diff --git a/Tests/ApolloInternalTestHelpers/MockOperation.swift b/Tests/ApolloInternalTestHelpers/MockOperation.swift index cfad662b1..7339fbb5c 100644 --- a/Tests/ApolloInternalTestHelpers/MockOperation.swift +++ b/Tests/ApolloInternalTestHelpers/MockOperation.swift @@ -66,14 +66,6 @@ open class AbstractMockSelectionSet: RootSelectionSet, Has public subscript(dynamicMember key: String) -> T? { __data[key] } - - public static func == (lhs: MockSelectionSet, rhs: MockSelectionSet) -> Bool { - lhs.__data == rhs.__data - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(__data) - } } public typealias MockSelectionSet = AbstractMockSelectionSet diff --git a/Tests/ApolloTests/SelectionSet_EqualityTests.swift b/Tests/ApolloTests/SelectionSet_EqualityTests.swift new file mode 100644 index 000000000..3697e43e4 --- /dev/null +++ b/Tests/ApolloTests/SelectionSet_EqualityTests.swift @@ -0,0 +1,1134 @@ +@testable import Apollo +import ApolloAPI +import ApolloInternalTestHelpers +import Nimble +import XCTest + +@MainActor +class SelectionSet_EqualityTests: XCTestCase { + + // MARK: Scalar tests + + func test__equatable__scalarString_givenOptionalityOpposedDataDictValue_sameValue_shouldBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("stringValue", String?.self) + ]} + + public var stringValue: String? { __data["stringValue"] } + + convenience init( + stringValue: String? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "stringValue": stringValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(stringValue: "Han Solo") + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "stringValue": "Han Solo" as String // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).to(equal(dataDictHero)) + expect(initializerHero.hashValue).to(equal(dataDictHero.hashValue)) + } + + func test__equatable__scalarString_givenOptionalityOpposedDataDictValue_differentValue_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("stringValue", String?.self) + ]} + + public var stringValue: String? { __data["stringValue"] } + + convenience init( + stringValue: String? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "stringValue": stringValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(stringValue: "Han Solo") + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "stringValue": "Darth Vader" as String // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__scalarString_givenOptionalityOpposedDataDictValue_nilValue_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("stringValue", String?.self) + ]} + + public var stringValue: String? { __data["stringValue"] } + + convenience init( + stringValue: String? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "stringValue": stringValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(stringValue: nil) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "stringValue": "Han Solo" as String // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__scalarStringList_givenOptionalityOpposedDataDictValue_sameValue_shouldBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("stringValue", [String?]?.self) + ]} + + public var stringValue: [String?]? { __data["stringValue"] } + + convenience init( + stringValue: [String?]? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "stringValue": stringValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(stringValue: ["Han Solo"]) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "stringValue": ["Han Solo"] as [String] // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).to(equal(dataDictHero)) + expect(initializerHero.hashValue).to(equal(dataDictHero.hashValue)) + } + + func test__equatable__scalarStringMultidimensionalList_givenOptionalityOpposedDataDictValue_sameValue_shouldBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("fieldValue", [[[String?]]].self) + ]} + + public var fieldValue: [[[String?]]] { __data["fieldValue"] } + + convenience init( + fieldValue: [[[String?]]] + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "fieldValue": fieldValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(fieldValue: [[["Han Solo"]]]) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "fieldValue": [[["Han Solo"]]] + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).to(equal(dataDictHero)) + expect(initializerHero.hashValue).to(equal(dataDictHero.hashValue)) + } + + func test__equatable__scalarStringMultidimensionalList_givenOptionalityOpposedDataDictValue_differentValue_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("fieldValue", [[[String?]]].self) + ]} + + public var fieldValue: [[[String?]]] { __data["fieldValue"] } + + convenience init( + fieldValue: [[[String?]]] + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "fieldValue": fieldValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(fieldValue: [[["Han Solo"]]]) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "fieldValue": [[["Luke Skywalker"]]] + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + XCTAssertNotEqual(initializerHero, dataDictHero) + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__scalarStringList_givenOptionalityOpposedDataDictValue_differentValue_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("stringValue", [String?]?.self) + ]} + + public var stringValue: [String?]? { __data["stringValue"] } + + convenience init( + stringValue: [String?]? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "stringValue": stringValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(stringValue: ["Han Solo"]) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "stringValue": ["Darth Vader"] as [String] // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__scalarStringList_givenOptionalityOpposedDataDictValue_nilValue_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("stringValue", [String?]?.self) + ]} + + public var stringValue: [String?]? { __data["stringValue"] } + + convenience init( + stringValue: [String?]? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "stringValue": stringValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(stringValue: nil) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "stringValue": ["Han Solo"] as [String] // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__scalarInt_givenOptionalityOpposedDataDictValue_sameValue_shouldBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("intValue", Int?.self) + ]} + + public var intValue: Int? { __data["intValue"] } + + convenience init( + intValue: Int? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "intValue": intValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(intValue: 1) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "intValue": 1 as Int // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).to(equal(dataDictHero)) + expect(initializerHero.hashValue).to(equal(dataDictHero.hashValue)) + } + + func test__equatable__scalarInt_givenOptionalityOpposedDataDictValue_differentValue_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("intValue", Int?.self) + ]} + + public var intValue: Int? { __data["intValue"] } + + convenience init( + intValue: Int? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "intValue": intValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(intValue: 1) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "intValue": 2 as Int // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__scalarInt_givenOptionalityOpposedDataDictValue_nilValue_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("intValue", Int?.self) + ]} + + public var intValue: Int? { __data["intValue"] } + + convenience init( + intValue: Int? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "intValue": intValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(intValue: nil) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "intValue": 2 as Int // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__scalarBool_givenOptionalityOpposedDataDictValue_sameValue_shouldBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("boolValue", Bool?.self) + ]} + + public var boolValue: Bool? { __data["boolValue"] } + + convenience init( + boolValue: Bool? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "boolValue": boolValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(boolValue: true) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "boolValue": true as Bool // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).to(equal(dataDictHero)) + expect(initializerHero.hashValue).to(equal(dataDictHero.hashValue)) + } + + func test__equatable__scalarBool_givenOptionalityOpposedDataDictValue_differentValue_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("boolValue", Bool?.self) + ]} + + public var boolValue: Bool? { __data["boolValue"] } + + convenience init( + boolValue: Bool? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "boolValue": boolValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(boolValue: true) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "boolValue": false as Bool // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__scalarBool_givenOptionalityOpposedDataDictValue_nilValue_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("boolValue", Bool?.self) + ]} + + public var boolValue: Bool? { __data["boolValue"] } + + convenience init( + boolValue: Bool? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "boolValue": boolValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(boolValue: nil) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "boolValue": true as Bool // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__scalarFloat_givenOptionalityOpposedDataDictValue_sameValue_shouldBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("floatValue", Float?.self) + ]} + + public var floatValue: Float? { __data["floatValue"] } + + convenience init( + floatValue: Float? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "floatValue": floatValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(floatValue: 1.1) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "floatValue": 1.1 as Float // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).to(equal(dataDictHero)) + expect(initializerHero.hashValue).to(equal(dataDictHero.hashValue)) + } + + func test__equatable__scalarFloat_givenOptionalityOpposedDataDictValue_differentValue_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("floatValue", Float?.self) + ]} + + public var floatValue: Float? { __data["floatValue"] } + + convenience init( + floatValue: Float? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "floatValue": floatValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(floatValue: 1.1) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "floatValue": 2.2 as Float // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__scalarFloat_givenOptionalityOpposedDataDictValue_nilValue_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("floatValue", Float?.self) + ]} + + public var floatValue: Float? { __data["floatValue"] } + + convenience init( + floatValue: Float? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "floatValue": floatValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(floatValue: nil) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "floatValue": 2.2 as Float // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__scalarDouble_givenOptionalityOpposedDataDictValue_sameValue_shouldBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("doubleValue", Double?.self) + ]} + + public var doubleValue: Double? { __data["doubleValue"] } + + convenience init( + doubleValue: Double? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "doubleValue": doubleValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(doubleValue: 1.1) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "doubleValue": 1.1 as Double // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).to(equal(dataDictHero)) + expect(initializerHero.hashValue).to(equal(dataDictHero.hashValue)) + } + + func test__equatable__scalarDouble_givenOptionalityOpposedDataDictValue_differentValue_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("doubleValue", Double?.self) + ]} + + public var doubleValue: Double? { __data["doubleValue"] } + + convenience init( + doubleValue: Double? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "doubleValue": doubleValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(doubleValue: 1.1) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "doubleValue": 2.2 as Double // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__scalarDouble_givenOptionalityOpposedDataDictValue_nilValue_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("doubleValue", Double?.self) + ]} + + public var doubleValue: Double? { __data["doubleValue"] } + + convenience init( + doubleValue: Double? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "doubleValue": doubleValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(doubleValue: nil) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "doubleValue": 2.2 as Double // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__scalar_givenDataDictValueOfDifferentTypeThatCannotCastToFieldType_shouldNotBeEqual() throws { + // given + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("fieldValue", String?.self) + ]} + + public var fieldValue: String? { __data["fieldValue"] } + + convenience init( + fieldValue: String? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "fieldValue": fieldValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(fieldValue: nil) // Muse be `nil` to test `as?` equality type cast behavior + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "fieldValue": 2 as Int + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__customScalar_givenOptionalityOpposedDataDictValue_sameValue_shouldBeEqual() throws { + // given + typealias GivenCustomScalar = MockCustomScalar + + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("fieldValue", GivenCustomScalar?.self) + ]} + + public var fieldValue: GivenCustomScalar? { __data["fieldValue"] } + + convenience init( + fieldValue: GivenCustomScalar? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "fieldValue": fieldValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(fieldValue: GivenCustomScalar(value: 989561700)) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "fieldValue": GivenCustomScalar(value: 989561700) // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).to(equal(dataDictHero)) + expect(initializerHero.hashValue).to(equal(dataDictHero.hashValue)) + } + + func test__equatable__customScalar_givenOptionalityOpposedDataDictValue_differentValue_shouldNotBeEqual() throws { + // given + typealias GivenCustomScalar = MockCustomScalar + + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("fieldValue", GivenCustomScalar?.self) + ]} + + public var fieldValue: GivenCustomScalar? { __data["fieldValue"] } + + convenience init( + fieldValue: GivenCustomScalar? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "fieldValue": fieldValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(fieldValue: GivenCustomScalar(value: 989561700)) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "fieldValue": GivenCustomScalar(value: 123) // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + func test__equatable__customScalar_givenOptionalityOpposedDataDictValue_nilValue_shouldNotBeEqual() throws { + // given + typealias GivenCustomScalar = MockCustomScalar + + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("fieldValue", GivenCustomScalar?.self) + ]} + + public var fieldValue: GivenCustomScalar? { __data["fieldValue"] } + + convenience init( + fieldValue: GivenCustomScalar? + ) { + self.init(_dataDict: DataDict(data: [ + "__typename": "Hero", + "fieldValue": fieldValue + ], fulfilledFragments: [ObjectIdentifier(Self.self)])) + } + } + + // when + let initializerHero = Hero(fieldValue: nil) + let dataDictHero = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "fieldValue": GivenCustomScalar(value: 123) // non-optional to oppose .field selection type + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(initializerHero).notTo(equal(dataDictHero)) + } + + // MARK: - Null/nil tests + + func test__equatable__optionalChildObject__isNullOnBoth_returns_true() { + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String?.self) + ]} + } + + // when + let selectionSet1 = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "name": NSNull() + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + let selectionSet2 = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "name": NSNull() + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(selectionSet1).to(equal(selectionSet2)) + expect(selectionSet1.hashValue).to(equal(selectionSet2.hashValue)) + } + + func test__equatable__optionalChildObject__isNullAndNil_returns_true() { + class Hero: MockSelectionSet { + typealias Schema = MockSchemaMetadata + + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String?.self) + ]} + } + + // when + let selectionSet1 = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "name": NSNull() + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + let selectionSet2 = Hero(_dataDict: DataDict( + data: [ + "__typename": "Hero", + "name": nil + ], + fulfilledFragments: [ObjectIdentifier(Hero.self)] + )) + + // then + expect(selectionSet1).to(equal(selectionSet2)) + expect(selectionSet1.hashValue).to(equal(selectionSet2.hashValue)) + } + + // MARK: - Integration Tests + + func test__equatable__givenQueryResponseFetchedFromStore() async throws { + // given + class GivenSelectionSet: MockSelectionSet { + override class var __selections: [Selection] { + [ + .field("hero", Hero.self) + ] + } + var hero: Hero { __data["hero"] } + + class Hero: MockSelectionSet { + override class var __selections: [Selection] { + [ + .field("__typename", String.self), + .field("name", String.self), + .field("friend", Friend.self), + ] + } + var friend: Friend { __data["friend"] } + + class Friend: MockSelectionSet { + override class var __selections: [Selection] { + [ + .field("__typename", String.self), + .field("name", String.self), + ] + } + var name: String { __data["name"] } + } + } + } + + let store = ApolloStore(cache: InMemoryNormalizedCache()) + store.publish(records: [ + "QUERY_ROOT": ["hero": CacheReference("hero")], + "hero": [ + "__typename": "Droid", + "name": "R2-D2", + "friend": CacheReference("1000"), + ], + "1000": ["__typename": "Human", "name": "Luke Skywalker"], + ]) + + let expected = try GivenSelectionSet(data: [ + "hero": [ + "__typename": "Droid", + "name": "R2-D2", + "friend": ["__typename": "Human", "name": "Luke Skywalker"] + ] + ]) + + // when + let updateCompletedExpectation = expectation(description: "Update completed") + + store.load(MockQuery()) { result in + defer { updateCompletedExpectation.fulfill() } + + XCTAssertSuccessResult(result) + let responseData = try! result.get().data + + expect(responseData).to(equal(expected)) + } + + await fulfillment(of: [updateCompletedExpectation], timeout: 1.0) + } + + func test__equatable__givenFailingModelFromCI_sameValue_shouldBeEqual() throws { + // given + class ReverseFriendsQuery: MockSelectionSet { + override class var __selections: [Selection] { [ + .field("hero", Hero?.self, arguments: ["id": .variable("id")]) + ]} + + var hero: Hero { __data["hero"] } + + class Hero: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("id", String.self), + .field("name", String.self), + .field("friendsConnection", FriendsConnection.self, arguments: [ + "first": .variable("first"), + "before": .variable("before"), + ]), + ]} + + var name: String { __data["name"] } + var id: String { __data["id"] } + var friendsConnection: FriendsConnection { __data["friendsConnection"] } + + class FriendsConnection: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("totalCount", Int.self), + .field("friends", [Character].self), + .field("pageInfo", PageInfo.self), + ]} + + var totalCount: Int { __data["totalCount"] } + var friends: [Character] { __data["friends"] } + var pageInfo: PageInfo { __data["pageInfo"] } + + class Character: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String.self), + .field("id", String.self), + ]} + + var name: String { __data["name"] } + var id: String { __data["id"] } + } + + class PageInfo: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("startCursor", Optional.self), + .field("hasPreviousPage", Bool.self), + ]} + + var startCursor: String? { __data["startCursor"] } + var hasPreviousPage: Bool { __data["hasPreviousPage"] } + } + } + } + } + + // when + let query = MockQuery() + query.__variables = ["id": "2001", "first": 2, "before": "Y3Vyc29yMw=="] + + let body: JSONObject = ["data": [ + "hero": [ + "__typename": "Droid", + "id": "2001", + "name": "R2-D2", + "friendsConnection": [ + "__typename": "FriendsConnection", + "totalCount": 3, + "friends": [ + [ + "__typename": "Human", + "name": "Han Solo", + "id": "1002", + ], + [ + "__typename": "Human", + "name": "Leia Organa", + "id": "1003", + ] + ], + "pageInfo": [ + "__typename": "PageInfo", + "startCursor": "Y3Vyc29yMg==", + "hasPreviousPage": true + ] + ] + ] + ]] + + let first = try GraphQLResponse( + operation: query, + body: body + ).parseResult().0.data + + let second = try GraphQLResponse( + operation: query, + body: body + ).parseResult().0.data + + // then + XCTAssertEqual(first, second) + expect(first).to(equal(second)) + } + +} diff --git a/apollo-ios/Sources/ApolloAPI/SelectionSet+Equatable.swift b/apollo-ios/Sources/ApolloAPI/SelectionSet+Equatable.swift new file mode 100644 index 000000000..06ed62514 --- /dev/null +++ b/apollo-ios/Sources/ApolloAPI/SelectionSet+Equatable.swift @@ -0,0 +1,279 @@ +import Foundation + +public extension SelectionSet { + + typealias FieldValue = any Hashable + + /// Creates a hash using a narrowly scoped algorithm that only combines fields in the underlying data + /// that are relevant to the `SelectionSet`. This ensures that hashes for a fragment do not + /// consider fields that are not included in the fragment, even if they are present in the data. + func hash(into hasher: inout Hasher) { + hasher.combine(self.fieldsForEquality()) + } + + /// Checks for equality using a narrowly scoped algorithm that only compares fields in the underlying data + /// that are relevant to the `SelectionSet`. This ensures that equality checks for a fragment do not + /// consider fields that are not included in the fragment, even if they are present in the data. + static func ==(lhs: Self, rhs: Self) -> Bool { + return Self.equatableCheck( + lhs.fieldsForEquality(), + rhs.fieldsForEquality() + ) + } + + @inlinable + internal static func equatableCheck( + _ lhs: [String: any Hashable], + _ rhs: [String: any Hashable] + ) -> Bool { + guard lhs.keys == rhs.keys else { return false } + + return lhs.allSatisfy { + guard let rhsValue = rhs[$0.key], + equatableCheck($0.value, rhsValue) else { + return false + } + return true + } + } + + @_disfavoredOverload + @inlinable + internal static func equatableCheck( + _ lhs: T, + _ rhs: any Hashable + ) -> Bool { + if let lhs = lhs as? [any Hashable], + let rhs = rhs as? [any Hashable] { + + return lhs.elementsEqual(rhs) { l, r in + equatableCheck(l, r) + } + } + + return lhs == rhs as? T + } + + private func fieldsForEquality() -> [String: FieldValue] { + var fields: [String: FieldValue] = [:] + if let asTypeCase = self as? any InlineFragment { + self.addFulfilledSelections(of: type(of: asTypeCase.asRootEntityType), to: &fields) + + } else { + self.addFulfilledSelections(of: type(of: self), to: &fields) + } + + return fields + } + + private func addFulfilledSelections( + of selectionSetType: any SelectionSet.Type, + to fields: inout [String: FieldValue] + ) { + guard self.__data.fragmentIsFulfilled(selectionSetType) else { + return + } + + for selection in selectionSetType.__selections { + switch selection { + case let .field(field): + add(field: field, to: &fields) + + case let .inlineFragment(typeCase): + self.addFulfilledSelections(of: typeCase, to: &fields) + + case let .conditional(_, selections): + self.addConditionalSelections(selections, to: &fields) + + case let .fragment(fragmentType): + self.addFulfilledSelections(of: fragmentType, to: &fields) + + case let .deferred(_, fragmentType, _): + self.addFulfilledSelections(of: fragmentType, to: &fields) + } + } + } + + private func add( + field: Selection.Field, + to fields: inout [String: FieldValue] + ) { + let nullableFieldData = (self.__data._data[field.responseKey]?.base as? FieldValue).asNullable + let fieldData: FieldValue + switch nullableFieldData { + case let .some(value): + fieldData = value + case .none, .null: + return + } + + addData(for: field.type) + + func addData(for type: Selection.Field.OutputType, inList: Bool = false) { + switch type { + case .scalar, .customScalar: + if inList { + guard let listData = fieldData as? [AnyHashable] else { + preconditionFailure("Expected list data for field: \(field)") + } + + fields[field.responseKey] = unwrapAnyHashable(list: listData) as FieldValue + } else { + fields[field.responseKey] = fieldData + } + + case let .nonNull(innerType): + addData(for: innerType, inList: inList) + + case let .list(innerType): + addData(for: innerType, inList: true) + + case let .object(selectionSetType): + switch inList { + case false: + guard let objectData = fieldData as? DataDict else { + preconditionFailure("Expected object data for object field: \(field)") + } + fields[field.responseKey] = selectionSetType.init(_dataDict: objectData) + + case true: + guard let listData = fieldData as? [FieldValue] else { + preconditionFailure("Expected list data for field: \(field)") + } + + fields[field.responseKey] = convertElements(of: listData, to: selectionSetType) as FieldValue + } + } + } + } + + private func convertElements( + of list: [FieldValue], + to selectionSetType: any RootSelectionSet.Type + ) -> [FieldValue] { + if let dataDictList = list as? [DataDict] { + return dataDictList.map { selectionSetType.init(_dataDict: $0) } + } + + if let nestedList = list as? [[FieldValue]] { + return nestedList.map { self.convertElements(of: $0, to: selectionSetType) as FieldValue } + } + + preconditionFailure("Expected list data to contain objects.") + } + + private func unwrapAnyHashable( + list: [AnyHashable] + ) -> [FieldValue] { + if let nestedList = list as? [[AnyHashable]] { + return nestedList.map { self.unwrapAnyHashable(list: $0) as FieldValue } + } + + return list.map { + guard let base = $0.base as? FieldValue else { + preconditionFailure("Expected list data to contain objects.") + } + return base + } + + } + + private func addConditionalSelections( + _ selections: [Selection], + to fields: inout [String: FieldValue] + ) { + for selection in selections { + switch selection { + case let .inlineFragment(typeCase): + self.addFulfilledSelections(of: typeCase, to: &fields) + + case let .fragment(fragment): + self.addFulfilledSelections(of: fragment, to: &fields) + + case let .deferred(_, fragment, _): + self.addFulfilledSelections(of: fragment, to: &fields) + + case let .conditional(_, selections): + addConditionalSelections(selections, to: &fields) + + case .field: + assertionFailure("Conditional selections should not directly include fields. They should use an InlineFragment instead.") + } + } + } + +} + +extension Hasher { + + @inlinable + public mutating func combine(_ optionalJSONValue: (any Hashable)?) { + if let value = optionalJSONValue { + self.combine(1 as UInt8) + self.combine(value) + } else { + // This mimics the implementation of combining a nil optional from the Swift language core + // Source reference at: + // https://github.com/swiftlang/swift/blob/main/stdlib/public/core/Optional.swift#L590 + self.combine(0 as UInt8) + } + } + + @inlinable + public mutating func combine( + _ dictionary: [T: any Hashable] + ) { + // From Dictionary's Hashable implementation + var commutativeHash = 0 + for (key, value) in dictionary { + var elementHasher = self + elementHasher.combine(key) + elementHasher.combine(AnyHashable(value)) + commutativeHash ^= elementHasher.finalize() + } + self.combine(commutativeHash) + } + + @inlinable + public mutating func combine( + _ dictionary: [T: any Hashable]? + ) { + if let value = dictionary { + self.combine(value) + } else { + self.combine(Optional<[T: any Hashable]>.none) + } + } +} + +fileprivate protocol AnyOptional {} + +@_spi(Internal) +extension Optional: AnyOptional { } + +fileprivate extension Optional { + + /// Converts the optional to a `GraphQLNullable. + /// + /// - Double nested optional (ie. `Optional.some(nil)`) -> `GraphQLNullable.null`. + /// - `Optional.none` -> `GraphQLNullable.none` + /// - `Optional.some` -> `GraphQLNullable.some` + var asNullable: GraphQLNullable { + unwrapAsNullable() + } + + private func unwrapAsNullable(nullIfNil: Bool = false) -> GraphQLNullable { + switch self { + case .none: return nullIfNil ? .null : .none + + case .some(let value as any AnyOptional): + return (value as! Self).unwrapAsNullable(nullIfNil: true) + + case .some(is NSNull): + return .null + + case .some(let value): + return .some(value) + } + } +} diff --git a/apollo-ios/Sources/ApolloAPI/SelectionSet.swift b/apollo-ios/Sources/ApolloAPI/SelectionSet.swift index c76744ff6..bfbe2fae3 100644 --- a/apollo-ios/Sources/ApolloAPI/SelectionSet.swift +++ b/apollo-ios/Sources/ApolloAPI/SelectionSet.swift @@ -110,14 +110,6 @@ extension SelectionSet { return T.init(_dataDict: __data) } - @inlinable public func hash(into hasher: inout Hasher) { - hasher.combine(__data) - } - - @inlinable public static func ==(lhs: Self, rhs: Self) -> Bool { - return lhs.__data == rhs.__data - } - public var debugDescription: String { return "\(self.__data._data as AnyObject)" }