diff --git a/CHANGELOG.md b/CHANGELOG.md index caa0e2d80..5b0b3b759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ [Full Changelog](https://github.com/parse-community/Parse-Swift/compare/4.9.3...main) * _Contributing to this repo? Add info about your change here to be included in the next release_ +__New features__ +- Add revertKeyPath() and revertObject() methods to ParseObject which allow developers to revert to original values of key paths or objects after mutating ParseObjects that already have an objectId ([#402](https://github.com/parse-community/Parse-Swift/pull/402)), thanks to [Corey Baker](https://github.com/cbaker6). + ### 4.9.3 [Full Changelog](https://github.com/parse-community/Parse-Swift/compare/4.9.2...4.9.3) diff --git a/Sources/ParseSwift/Objects/ParseObject.swift b/Sources/ParseSwift/Objects/ParseObject.swift index 604f0b587..6be1805e0 100644 --- a/Sources/ParseSwift/Objects/ParseObject.swift +++ b/Sources/ParseSwift/Objects/ParseObject.swift @@ -83,6 +83,7 @@ public protocol ParseObject: ParseTypeable, /** Determines if a `KeyPath` of the current `ParseObject` should be restored by comparing it to another `ParseObject`. + - parameter key: The `KeyPath` to check. - parameter original: The original `ParseObject`. - returns: Returns a **true** if the keyPath should be restored or **false** otherwise. */ @@ -140,6 +141,23 @@ public protocol ParseObject: ParseTypeable, use `shouldRestoreKey` to compare key modifications between objects. */ func merge(with object: Self) throws -> Self + + /** + Reverts the `KeyPath` of the `ParseObject` back to the original `KeyPath` + before mutations began. + - throws: An error of type `ParseError`. + - important: This reverts to the contents in `originalData`. This means `originalData` should have + been populated by calling `mergeable` or some other means. + */ + mutating func revertKeyPath(_ keyPath: WritableKeyPath) throws where W: Equatable + + /** + Reverts the `ParseObject` back to the original object before mutations began. + - throws: An error of type `ParseError`. + - important: This reverts to the contents in `originalData`. This means `originalData` should have + been populated by calling `mergeable` or some other means. + */ + mutating func revertObject() throws } // MARK: Default Implementations @@ -198,7 +216,7 @@ public extension ParseObject { } var updated = self if shouldRestoreKey(\.ACL, - original: object) { + original: object) { updated.ACL = object.ACL } return updated @@ -207,6 +225,45 @@ public extension ParseObject { func merge(with object: Self) throws -> Self { return try mergeParse(with: object) } + + mutating func revertKeyPath(_ keyPath: WritableKeyPath) throws where W: Equatable { + guard let originalData = originalData else { + throw ParseError(code: .unknownError, + message: "Missing original data to revert to") + } + let original = try ParseCoding.jsonDecoder().decode(Self.self, + from: originalData) + guard hasSameObjectId(as: original) else { + throw ParseError(code: .unknownError, + message: "The current object does not have the same objectId as the original") + } + if shouldRevertKey(keyPath, + original: original) { + self[keyPath: keyPath] = original[keyPath: keyPath] + } + } + + mutating func revertObject() throws { + guard let originalData = originalData else { + throw ParseError(code: .unknownError, + message: "Missing original data to revert to") + } + let original = try ParseCoding.jsonDecoder().decode(Self.self, + from: originalData) + guard hasSameObjectId(as: original) else { + throw ParseError(code: .unknownError, + message: "The current object does not have the same objectId as the original") + } + self = original + } +} + +// MARK: Default Implementations (Internal) +extension ParseObject { + func shouldRevertKey(_ key: KeyPath, + original: Self) -> Bool where W: Equatable { + original[keyPath: key] != self[keyPath: key] + } } // MARK: Batch Support diff --git a/Tests/ParseSwiftTests/ParseObjectTests.swift b/Tests/ParseSwiftTests/ParseObjectTests.swift index 663c25373..2bd28440e 100644 --- a/Tests/ParseSwiftTests/ParseObjectTests.swift +++ b/Tests/ParseSwiftTests/ParseObjectTests.swift @@ -427,6 +427,138 @@ class ParseObjectTests: XCTestCase { // swiftlint:disable:this type_body_length XCTAssertThrowsError(try score2.merge(with: score)) } + func testRevertObject() throws { + var score = GameScore(points: 19, name: "fire") + score.objectId = "yolo" + var mutableScore = score.mergeable + mutableScore.points = 50 + mutableScore.player = "ali" + XCTAssertNotEqual(mutableScore, score) + try mutableScore.revertObject() + XCTAssertEqual(mutableScore, score) + } + + func testRevertObjectMissingOriginal() throws { + var score = GameScore(points: 19, name: "fire") + score.objectId = "yolo" + var mutableScore = score + mutableScore.points = 50 + mutableScore.player = "ali" + XCTAssertNotEqual(mutableScore, score) + do { + try mutableScore.revertObject() + XCTFail("Should have thrown error") + } catch { + guard let parseError = error as? ParseError else { + XCTFail("Should have casted") + return + } + XCTAssertTrue(parseError.message.contains("Missing original")) + } + } + + func testRevertObjectDiffObjectId() throws { + var score = GameScore(points: 19, name: "fire") + score.objectId = "yolo" + var mutableScore = score.mergeable + mutableScore.points = 50 + mutableScore.player = "ali" + mutableScore.objectId = "nolo" + XCTAssertNotEqual(mutableScore, score) + do { + try mutableScore.revertObject() + XCTFail("Should have thrown error") + } catch { + guard let parseError = error as? ParseError else { + XCTFail("Should have casted") + return + } + XCTAssertTrue(parseError.message.contains("objectId as the original")) + } + } + + func testRevertKeyPath() throws { + var score = GameScore(points: 19, name: "fire") + score.objectId = "yolo" + var mutableScore = score.mergeable + mutableScore.points = 50 + mutableScore.player = "ali" + XCTAssertNotEqual(mutableScore, score) + try mutableScore.revertKeyPath(\.player) + XCTAssertNotEqual(mutableScore, score) + XCTAssertEqual(mutableScore.objectId, score.objectId) + XCTAssertNotEqual(mutableScore.points, score.points) + XCTAssertEqual(mutableScore.player, score.player) + } + + func testRevertKeyPathUpdatedNil() throws { + var score = GameScore(points: 19, name: "fire") + score.objectId = "yolo" + var mutableScore = score.mergeable + mutableScore.points = 50 + mutableScore.player = nil + XCTAssertNotEqual(mutableScore, score) + try mutableScore.revertKeyPath(\.player) + XCTAssertNotEqual(mutableScore, score) + XCTAssertEqual(mutableScore.objectId, score.objectId) + XCTAssertNotEqual(mutableScore.points, score.points) + XCTAssertEqual(mutableScore.player, score.player) + } + + func testRevertKeyPathOriginalNil() throws { + var score = GameScore(points: 19, name: "fire") + score.objectId = "yolo" + score.player = nil + var mutableScore = score.mergeable + mutableScore.points = 50 + mutableScore.player = "ali" + XCTAssertNotEqual(mutableScore, score) + try mutableScore.revertKeyPath(\.player) + XCTAssertNotEqual(mutableScore, score) + XCTAssertEqual(mutableScore.objectId, score.objectId) + XCTAssertNotEqual(mutableScore.points, score.points) + XCTAssertEqual(mutableScore.player, score.player) + } + + func testRevertKeyPathMissingOriginal() throws { + var score = GameScore(points: 19, name: "fire") + score.objectId = "yolo" + var mutableScore = score + mutableScore.points = 50 + mutableScore.player = "ali" + XCTAssertNotEqual(mutableScore, score) + do { + try mutableScore.revertKeyPath(\.player) + XCTFail("Should have thrown error") + } catch { + guard let parseError = error as? ParseError else { + XCTFail("Should have casted") + return + } + XCTAssertTrue(parseError.message.contains("Missing original")) + } + } + + func testRevertKeyPathDiffObjectId() throws { + var score = GameScore(points: 19, name: "fire") + score.objectId = "yolo" + var mutableScore = score.mergeable + mutableScore.points = 50 + mutableScore.player = "ali" + mutableScore.objectId = "nolo" + XCTAssertNotEqual(mutableScore, score) + do { + try mutableScore.revertKeyPath(\.player) + XCTFail("Should have thrown error") + } catch { + guard let parseError = error as? ParseError else { + XCTFail("Should have casted") + return + } + XCTAssertTrue(parseError.message.contains("objectId as the original")) + } + } + func testFetchCommand() { var score = GameScore(points: 10) let className = score.className