From 8245f9e1d356ceafde46822d8cc7456f16877147 Mon Sep 17 00:00:00 2001 From: Corey Date: Fri, 9 Sep 2022 14:31:39 -0400 Subject: [PATCH] feat: add set() and auto merge updated properties with original (#406) * wip * updates * feat: add set() and auto merge updated properties with original * updates * update documentation and tests --- CHANGELOG.md | 6 + ParseSwift.xcodeproj/project.pbxproj | 10 -- Sources/ParseSwift/API/API+Command.swift | 16 +- .../Objects/ParseInstallation.swift | 24 ++- Sources/ParseSwift/Objects/ParseObject.swift | 162 +++++++++++++----- Sources/ParseSwift/Objects/ParseUser.swift | 24 ++- Sources/ParseSwift/ParseConstants.swift | 2 +- .../Types/ParseOperation+keyPath.swift | 49 ------ Sources/ParseSwift/Types/ParseOperation.swift | 74 ++++++++ .../ParseInstallationAsyncTests.swift | 51 +++++- .../ParseInstallationTests.swift | 51 +++++- .../ParseObjectAsyncTests.swift | 86 ++++++++++ Tests/ParseSwiftTests/ParseObjectTests.swift | 39 ++++- .../ParseSwiftTests/ParseOperationTests.swift | 2 + .../ParseSwiftTests/ParseUserAsyncTests.swift | 50 ++++++ Tests/ParseSwiftTests/ParseUserTests.swift | 53 ++++++ 16 files changed, 556 insertions(+), 143 deletions(-) delete mode 100644 Sources/ParseSwift/Types/ParseOperation+keyPath.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 37eabc22a..b48906285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ [Full Changelog](https://github.com/parse-community/Parse-Swift/compare/4.10.0...main) * _Contributing to this repo? Add info about your change here to be included in the next release_ +### 4.11.0 +[Full Changelog](https://github.com/parse-community/Parse-Swift/compare/4.10.0...4.11.0) + +__New features__ +- Add a set method that developers can call on their ParseObjects which automatically sends updated properties to a Parse Server and merges those updates with the original ParseObject locally. The feature removes the requirement to call mergeable and implement merge(), but comes at additional computational overhead ([#406](https://github.com/parse-community/Parse-Swift/pull/406)), thanks to [Corey Baker](https://github.com/cbaker6). + ### 4.10.0 [Full Changelog](https://github.com/parse-community/Parse-Swift/compare/4.9.3...4.10.0) diff --git a/ParseSwift.xcodeproj/project.pbxproj b/ParseSwift.xcodeproj/project.pbxproj index b7bec1e77..15c6ed524 100644 --- a/ParseSwift.xcodeproj/project.pbxproj +++ b/ParseSwift.xcodeproj/project.pbxproj @@ -451,10 +451,6 @@ 7085DDB326D1EC7F0033B977 /* ParseAuthenticationCombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7085DDB226D1EC7F0033B977 /* ParseAuthenticationCombineTests.swift */; }; 7085DDB426D1EC7F0033B977 /* ParseAuthenticationCombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7085DDB226D1EC7F0033B977 /* ParseAuthenticationCombineTests.swift */; }; 7085DDB526D1EC7F0033B977 /* ParseAuthenticationCombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7085DDB226D1EC7F0033B977 /* ParseAuthenticationCombineTests.swift */; }; - 7087A93C28C558CA00656E93 /* ParseOperation+keyPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7087A93B28C558CA00656E93 /* ParseOperation+keyPath.swift */; }; - 7087A93D28C558CA00656E93 /* ParseOperation+keyPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7087A93B28C558CA00656E93 /* ParseOperation+keyPath.swift */; }; - 7087A93E28C558CA00656E93 /* ParseOperation+keyPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7087A93B28C558CA00656E93 /* ParseOperation+keyPath.swift */; }; - 7087A93F28C558CA00656E93 /* ParseOperation+keyPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7087A93B28C558CA00656E93 /* ParseOperation+keyPath.swift */; }; 708CADCF2872263D0066C279 /* ParseKeychainAccessGroupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708CADCE2872263D0066C279 /* ParseKeychainAccessGroupTests.swift */; }; 708CADD02872263D0066C279 /* ParseKeychainAccessGroupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708CADCE2872263D0066C279 /* ParseKeychainAccessGroupTests.swift */; }; 708CADD12872263D0066C279 /* ParseKeychainAccessGroupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 708CADCE2872263D0066C279 /* ParseKeychainAccessGroupTests.swift */; }; @@ -1273,7 +1269,6 @@ 7085DD9326CBF3A70033B977 /* Documentation.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Documentation.docc; sourceTree = ""; }; 7085DDA226CC8A470033B977 /* ParseHealth+combine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ParseHealth+combine.swift"; sourceTree = ""; }; 7085DDB226D1EC7F0033B977 /* ParseAuthenticationCombineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseAuthenticationCombineTests.swift; sourceTree = ""; }; - 7087A93B28C558CA00656E93 /* ParseOperation+keyPath.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ParseOperation+keyPath.swift"; sourceTree = ""; }; 708CADCE2872263D0066C279 /* ParseKeychainAccessGroupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseKeychainAccessGroupTests.swift; sourceTree = ""; }; 708D035125215F9B00646C70 /* Deletable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Deletable.swift; sourceTree = ""; }; 709A147C283949D100BF85E5 /* ParseSchema.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseSchema.swift; sourceTree = ""; }; @@ -2140,7 +2135,6 @@ F97B464024D9C78B00F4A88B /* ParseOperation.swift */, 703B091026BD992E005A112F /* ParseOperation+async.swift */, 7044C19E25C4FA870011F6E7 /* ParseOperation+combine.swift */, - 7087A93B28C558CA00656E93 /* ParseOperation+keyPath.swift */, 91285B1B26990D7F0051B544 /* ParsePolygon.swift */, 705025BC284C610C008D6624 /* ParsePush.swift */, 705025C1284C7841008D6624 /* ParsePush+async.swift */, @@ -2703,7 +2697,6 @@ 91285B1C26990D7F0051B544 /* ParsePolygon.swift in Sources */, 91BB8FCA2690AC99005A6BA5 /* QueryViewModel.swift in Sources */, 7085DD9426CBF3A70033B977 /* Documentation.docc in Sources */, - 7087A93C28C558CA00656E93 /* ParseOperation+keyPath.swift in Sources */, 705025EB285153BC008D6624 /* ParsePushApplePayloadable.swift in Sources */, 705025A928441C96008D6624 /* ParseFieldOptions.swift in Sources */, F97B45D624D9C6F200F4A88B /* ParseEncoder.swift in Sources */, @@ -3013,7 +3006,6 @@ 91285B1D26990D7F0051B544 /* ParsePolygon.swift in Sources */, 91BB8FCB2690AC99005A6BA5 /* QueryViewModel.swift in Sources */, 7085DD9526CBF3A70033B977 /* Documentation.docc in Sources */, - 7087A93D28C558CA00656E93 /* ParseOperation+keyPath.swift in Sources */, 705025EC285153BC008D6624 /* ParsePushApplePayloadable.swift in Sources */, 705025AA28441C96008D6624 /* ParseFieldOptions.swift in Sources */, F97B45D724D9C6F200F4A88B /* ParseEncoder.swift in Sources */, @@ -3455,7 +3447,6 @@ 91679D67268E596300F71809 /* ParseVersion.swift in Sources */, 91285B1F26990D7F0051B544 /* ParsePolygon.swift in Sources */, 91BB8FCD2690AC99005A6BA5 /* QueryViewModel.swift in Sources */, - 7087A93F28C558CA00656E93 /* ParseOperation+keyPath.swift in Sources */, 705025EE285153BC008D6624 /* ParsePushApplePayloadable.swift in Sources */, 705025AC28441C96008D6624 /* ParseFieldOptions.swift in Sources */, 7085DD9726CBF3A70033B977 /* Documentation.docc in Sources */, @@ -3642,7 +3633,6 @@ 91679D66268E596300F71809 /* ParseVersion.swift in Sources */, 91285B1E26990D7F0051B544 /* ParsePolygon.swift in Sources */, 91BB8FCC2690AC99005A6BA5 /* QueryViewModel.swift in Sources */, - 7087A93E28C558CA00656E93 /* ParseOperation+keyPath.swift in Sources */, 705025ED285153BC008D6624 /* ParsePushApplePayloadable.swift in Sources */, 705025AB28441C96008D6624 /* ParseFieldOptions.swift in Sources */, 7085DD9626CBF3A70033B977 /* Documentation.docc in Sources */, diff --git a/Sources/ParseSwift/API/API+Command.swift b/Sources/ParseSwift/API/API+Command.swift index 1d725d6ad..94b2d55d3 100644 --- a/Sources/ParseSwift/API/API+Command.swift +++ b/Sources/ParseSwift/API/API+Command.swift @@ -430,17 +430,17 @@ internal extension API.Command { let mapper = { (mapperData: Data) -> T in var updatedObject = object updatedObject.originalData = nil - let object = try ParseCoding + updatedObject = try ParseCoding .jsonDecoder() .decode(ReplaceResponse.self, from: mapperData) .apply(to: updatedObject) guard let originalData = data, let original = try? ParseCoding.jsonDecoder().decode(T.self, from: originalData), - original.hasSameObjectId(as: object) else { - return object + original.hasSameObjectId(as: updatedObject) else { + return updatedObject } - return try object.merge(with: original) + return try updatedObject.merge(with: original) } return API.Command(method: .PUT, path: object.endpoint, @@ -456,17 +456,17 @@ internal extension API.Command { let mapper = { (mapperData: Data) -> T in var updatedObject = object updatedObject.originalData = nil - let object = try ParseCoding + updatedObject = try ParseCoding .jsonDecoder() .decode(UpdateResponse.self, from: mapperData) .apply(to: updatedObject) guard let originalData = data, let original = try? ParseCoding.jsonDecoder().decode(T.self, from: originalData), - original.hasSameObjectId(as: object) else { - return object + original.hasSameObjectId(as: updatedObject) else { + return updatedObject } - return try object.merge(with: original) + return try updatedObject.merge(with: original) } return API.Command(method: .PATCH, path: object.endpoint, diff --git a/Sources/ParseSwift/Objects/ParseInstallation.swift b/Sources/ParseSwift/Objects/ParseInstallation.swift index f65e10b6e..dda76297e 100644 --- a/Sources/ParseSwift/Objects/ParseInstallation.swift +++ b/Sources/ParseSwift/Objects/ParseInstallation.swift @@ -153,7 +153,11 @@ public extension ParseInstallation { } func merge(with object: Self) throws -> Self { - try mergeParse(with: object) + do { + return try mergeAutomatically(object) + } catch { + return try mergeParse(with: object) + } } } @@ -773,15 +777,16 @@ extension ParseInstallation { let mapper = { (data: Data) -> Self in var updatedObject = self updatedObject.originalData = nil - let object = try ParseCoding.jsonDecoder().decode(ReplaceResponse.self, from: data).apply(to: updatedObject) + updatedObject = try ParseCoding.jsonDecoder().decode(ReplaceResponse.self, + from: data).apply(to: updatedObject) // MARK: The lines below should be removed when server supports PATCH. guard let originalData = self.originalData, let original = try? ParseCoding.jsonDecoder().decode(Self.self, from: originalData), - original.hasSameObjectId(as: object) else { - return object + original.hasSameObjectId(as: updatedObject) else { + return updatedObject } - return try object.merge(with: original) + return try updatedObject.merge(with: original) } return API.Command(method: .PUT, path: endpoint, @@ -797,14 +802,15 @@ extension ParseInstallation { let mapper = { (data: Data) -> Self in var updatedObject = self updatedObject.originalData = nil - let object = try ParseCoding.jsonDecoder().decode(UpdateResponse.self, from: data).apply(to: updatedObject) + updatedObject = try ParseCoding.jsonDecoder().decode(UpdateResponse.self, + from: data).apply(to: updatedObject) guard let originalData = self.originalData, let original = try? ParseCoding.jsonDecoder().decode(Self.self, from: originalData), - original.hasSameObjectId(as: object) else { - return object + original.hasSameObjectId(as: updatedObject) else { + return updatedObject } - return try object.merge(with: original) + return try updatedObject.merge(with: original) } return API.Command(method: .PATCH, path: endpoint, diff --git a/Sources/ParseSwift/Objects/ParseObject.swift b/Sources/ParseSwift/Objects/ParseObject.swift index f94d8ca1e..eb24758fa 100644 --- a/Sources/ParseSwift/Objects/ParseObject.swift +++ b/Sources/ParseSwift/Objects/ParseObject.swift @@ -14,22 +14,23 @@ import Foundation Objects that conform to the `ParseObject` protocol have a local representation of data persisted to the Parse Server. This is the main protocol that is used to interact with objects in your app. - The Swift SDK is designed for your `ParseObject`s to be **value types (structs)**. + The Swift SDK is designed for your `ParseObject`s to be **value types (structures)**. Since you are using value types the compiler will assist you with conforming to the `ParseObject` protocol. - After a `ParseObject`is saved/created to a Parse Server. It is recommended to conduct any updates on your updates - to a `mergeable` copy of your `ParseObject`. This allows a subset of the fields to be updated (PATCH) of an object - as oppose to replacing all of the fields of an object (PUT). This reduces the amount of data - sent between client and server when using `save`, `saveAll`, `update`, + After a `ParseObject`is saved/created to a Parse Server. It is recommended to conduct any updates on a + `mergeable` copy of your `ParseObject`. This can be accomplished by calling the `mergeable` property + of your `ParseObject` or by calling the `set()` method on your `ParseObject`. This allows a subset + of the fields to be updated (PATCH) of an object as oppose to replacing all of the fields of an object (PUT). + This reduces the amount of data sent between client and server when using `save`, `saveAll`, `update`, `updateAll`, `replace`, `replaceAll`, to update objects. - - important: It is required that all of your `ParseObject`'s be **value types(structs)** and all added + - important: It is required that all of your `ParseObject`'s be **value types (structures)** and all added properties be optional so they can eventually be used as Parse `Pointer`'s. If a developer really wants to have a required key, they should require it on the server-side or create methods to check the respective properties on the client-side before saving objects. See [here](https://github.com/parse-community/Parse-Swift/pull/315#issuecomment-1014701003) for more information on the reasons why. See the [Playgrounds](https://github.com/parse-community/Parse-Swift/blob/c119033f44b91570997ad24f7b4b5af8e4d47b64/ParseSwift.playground/Pages/1%20-%20Your%20first%20Object.xcplaygroundpage/Contents.swift#L32-L66) for an example. - - important: To take advantage of `mergeable`, the developer should implement the `merge` method in every - `ParseObject`. + - important: A developer can take advantage of `mergeable` updates in two ways: 1) By calling the `set()` method when starting + to mutate a saved `ParseObject`, or 2) implement the `merge` method in each of your`ParseObject` models. - note: If you plan to use custom encoding/decoding, be sure to add `objectId`, `createdAt`, `updatedAt`, and `ACL` to your `ParseObject`'s `CodingKeys`. - warning: This SDK is not designed to use **reference types(classes)** for `ParseObject`'s. Doing so is at your @@ -69,6 +70,9 @@ public protocol ParseObject: ParseTypeable, ([memberwise initializer](https://docs.swift.org/swift-book/LanguageGuide/Initialization.html)) as long as you declare all properties as **optional** (see **Warning** section) and you declare all other initializers in an **extension**. See the [Playgrounds](https://github.com/parse-community/Parse-Swift/blob/c119033f44b91570997ad24f7b4b5af8e4d47b64/ParseSwift.playground/Pages/1%20-%20Your%20first%20Object.xcplaygroundpage/Contents.swift#L32-L66) for an example. + - attention: This initilizer **should remain empty and no properties should be implemented inside of it**. The SDK needs + this initializer to create new instances of your `ParseObject` when saving, updating, and converting to/from Parse Pointers. If you need + to initiaze properties, create additional initializers. - warning: It is required that all added properties be optional properties so they can eventually be used as Parse `Pointer`'s. If a developer really wants to have a required key, they should require it on the server-side or create methods to check the respective properties on the client-side before saving objects. See @@ -138,31 +142,6 @@ 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. - */ - func revertKeyPath(_ keyPath: WritableKeyPath) throws -> Self 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. - */ - func revertObject() throws -> Self - - /** - Get the unwrapped property value. - - parameter key: The `KeyPath` of the value to get. - - throws: An error of type `ParseError` when the value is **nil**. - - returns: The unwrapped value. - */ - func get(_ keyPath: KeyPath) throws -> W where W: Equatable } // MARK: Default Implementations @@ -173,18 +152,19 @@ public extension ParseObject { } /** - A computed property that is the same value as `objectId` and makes it easy to use `ParseObject`'s + A computed property that is the same value as `objectId` and makes it easy to use `ParseObject`'s as models in MVVM and SwiftUI. - - note: `id` allows `ParseObjects`'s to be used even if they are unsaved and do not have an `objectId`. + - note: `id` allows `ParseObjects`'s to be used even when they are unsaved and do not have an `objectId`. */ var id: String { - guard let objectId = self.objectId else { - return UUID().uuidString - } - return objectId + objectId ?? UUID().uuidString } var mergeable: Self { + guard isSaved, + originalData == nil else { + return self + } var object = Self() object.objectId = objectId object.createdAt = createdAt @@ -203,7 +183,7 @@ public extension ParseObject { /** Converts this `ParseObject` to a Parse Pointer. - - returns: Pointer + - returns: The pointer version of the `ParseObject`, Pointer. */ func toPointer() throws -> Pointer { return try Pointer(self) @@ -228,10 +208,55 @@ public extension ParseObject { } func merge(with object: Self) throws -> Self { - return try mergeParse(with: object) + do { + return try mergeAutomatically(object) + } catch { + return try mergeParse(with: object) + } + } +} + +// MARK: Default Implementations (Internal) +extension ParseObject { + func shouldRevertKey(_ key: KeyPath, + original: Self) -> Bool where W: Equatable { + original[keyPath: key] != self[keyPath: key] } +} +// MARK: Helper Methods +public extension ParseObject { + /** + 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 the `set` method. + */ + @available(*, deprecated, renamed: "revert") func revertKeyPath(_ keyPath: WritableKeyPath) throws -> Self where W: Equatable { + try revert(keyPath) + } + + /** + 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 the `set` method. + */ + @available(*, deprecated, renamed: "revert") + func revertObject() throws -> Self { + try revert() + } + + /** + 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 the `set` method. + */ + func revert(_ keyPath: WritableKeyPath) throws -> Self where W: Equatable { guard let originalData = originalData else { throw ParseError(code: .unknownError, message: "Missing original data to revert to") @@ -250,7 +275,13 @@ public extension ParseObject { return updated } - func revertObject() throws -> Self { + /** + 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 the `set` method. + */ + func revert() throws -> Self { guard let originalData = originalData else { throw ParseError(code: .unknownError, message: "Missing original data to revert to") @@ -264,6 +295,12 @@ public extension ParseObject { return original } + /** + Get the unwrapped property value. + - parameter key: The `KeyPath` of the value to get. + - throws: An error of type `ParseError` when the value is **nil**. + - returns: The unwrapped value. + */ @discardableResult func get(_ keyPath: KeyPath) throws -> W where W: Equatable { guard let value = self[keyPath: keyPath] else { @@ -271,13 +308,46 @@ public extension ParseObject { } return value } + + /** + Set the value of a specific `KeyPath` on a `ParseObject`. + - parameter key: The `KeyPath` of the value to set. + - parameter to: The value to set the `KeyPath` to. + - returns: The updated `ParseObject`. + - important: This method should be used when updating a `ParseObject` that has already been saved to + a Parse Server. You can also use this method on a new `ParseObject`'s that has not been saved to a Parse Server + as long as the `objectId` of the respective `ParseObject` is **nil**. + - attention: If you are using the `set()` method, you do not need to implement `merge()`. Using `set()` + may perform slower than implementing `merge()` after saving the updated `ParseObject` to a Parse Server. + This is due to neccesary overhead required to determine what keys have been updated. If a developer finds decoding + updated `ParseObjects`'s to be slow, implementing `merge()` may speed up the process. + - warning: This method should always be used when making the very first update/mutation to your `ParseObject`. + Any subsequent mutations can modify the `ParseObject` property directly or use the `set()` method. + */ + func set(_ keyPath: WritableKeyPath, to value: W) -> Self where W: Equatable { + var updated = self.mergeable + updated[keyPath: keyPath] = value + return updated + } } -// MARK: Default Implementations (Internal) +// MARK: Helper Methods (Internal) extension ParseObject { - func shouldRevertKey(_ key: KeyPath, - original: Self) -> Bool where W: Equatable { - original[keyPath: key] != self[keyPath: key] + + func mergeAutomatically(_ originalObject: Self) throws -> Self { + let updatedEncoded = try ParseCoding.jsonEncoder().encode(self) + let originalData = try ParseCoding.jsonEncoder().encode(originalObject) + guard let updated = try JSONSerialization.jsonObject(with: updatedEncoded) as? [String: AnyObject], + var original = try JSONSerialization.jsonObject(with: originalData) as? [String: AnyObject] else { + throw ParseError(code: .unknownError, + message: "Could not encode/decode necessary objects") + } + updated.forEach { (key, value) in + original[key] = value + } + let mergedEncoded = try JSONSerialization.data(withJSONObject: original) + return try ParseCoding.jsonDecoder().decode(Self.self, + from: mergedEncoded) } } diff --git a/Sources/ParseSwift/Objects/ParseUser.swift b/Sources/ParseSwift/Objects/ParseUser.swift index 76356fe29..121b48220 100644 --- a/Sources/ParseSwift/Objects/ParseUser.swift +++ b/Sources/ParseSwift/Objects/ParseUser.swift @@ -70,7 +70,11 @@ public extension ParseUser { } func merge(with object: Self) throws -> Self { - try mergeParse(with: object) + do { + return try mergeAutomatically(object) + } catch { + return try mergeParse(with: object) + } } } @@ -1264,15 +1268,16 @@ extension ParseUser { let mapper = { (data: Data) -> Self in var updatedObject = self updatedObject.originalData = nil - let object = try ParseCoding.jsonDecoder().decode(ReplaceResponse.self, from: data).apply(to: updatedObject) + updatedObject = try ParseCoding.jsonDecoder().decode(ReplaceResponse.self, + from: data).apply(to: updatedObject) // MARK: The lines below should be removed when server supports PATCH. guard let originalData = self.originalData, let original = try? ParseCoding.jsonDecoder().decode(Self.self, from: originalData), - original.hasSameObjectId(as: object) else { - return object + original.hasSameObjectId(as: updatedObject) else { + return updatedObject } - return try object.merge(with: original) + return try updatedObject.merge(with: original) } return API.Command(method: .PUT, path: endpoint, @@ -1303,14 +1308,15 @@ extension ParseUser { let mapper = { (data: Data) -> Self in var updatedObject = self updatedObject.originalData = nil - let object = try ParseCoding.jsonDecoder().decode(UpdateResponse.self, from: data).apply(to: updatedObject) + updatedObject = try ParseCoding.jsonDecoder().decode(UpdateResponse.self, + from: data).apply(to: updatedObject) guard let originalData = self.originalData, let original = try? ParseCoding.jsonDecoder().decode(Self.self, from: originalData), - original.hasSameObjectId(as: object) else { - return object + original.hasSameObjectId(as: updatedObject) else { + return updatedObject } - return try object.merge(with: original) + return try updatedObject.merge(with: original) } return API.Command(method: .PATCH, path: endpoint, diff --git a/Sources/ParseSwift/ParseConstants.swift b/Sources/ParseSwift/ParseConstants.swift index 11a0903d9..fa8f121a4 100644 --- a/Sources/ParseSwift/ParseConstants.swift +++ b/Sources/ParseSwift/ParseConstants.swift @@ -10,7 +10,7 @@ import Foundation enum ParseConstants { static let sdk = "swift" - static let version = "4.10.0" + static let version = "4.11.0" static let fileManagementDirectory = "parse/" static let fileManagementPrivateDocumentsDirectory = "Private Documents/" static let fileManagementLibraryDirectory = "Library/" diff --git a/Sources/ParseSwift/Types/ParseOperation+keyPath.swift b/Sources/ParseSwift/Types/ParseOperation+keyPath.swift deleted file mode 100644 index 279d0d812..000000000 --- a/Sources/ParseSwift/Types/ParseOperation+keyPath.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// ParseOperation+keyPath.swift -// ParseSwift -// -// Created by Corey Baker on 9/4/22. -// Copyright © 2022 Parse Community. All rights reserved. -// - -import Foundation - -extension ParseOperation { - - func setOriginalDataIfNeeded(_ operation: Self) -> Self { - var mutableOperation = operation - if mutableOperation.target.originalData == nil { - mutableOperation.target = mutableOperation.target.mergeable - } - return mutableOperation - } - - /** - An operation that sets a field's value. - - Parameters: - - keyPath: The respective `KeyPath` of the object. - - value: The value to set the `KeyPath` to. - - returns: The updated operations. - - warning: Do not combine operations using this method with other operations that - do not use this method to **set** all operations. If you need to combine multiple types - of operations such as: add, increment, forceSet, etc., use - `func set(_ key: (String, WritableKeyPath), value: W?)` - instead. - */ - public func set(_ keyPath: WritableKeyPath, - value: W) throws -> Self where W: Encodable & Equatable { - guard operations.isEmpty, - keysToNull.isEmpty else { - throw ParseError(code: .unknownError, - message: """ - Cannot combine other operations such as: add, increment, - forceSet, etc., with this method. Use the \"set\" method that takes - the (String, WritableKeyPath) tuple as an argument instead to - combine multiple types of operations. - """) - } - var mutableOperation = setOriginalDataIfNeeded(self) - mutableOperation.target[keyPath: keyPath] = value - return mutableOperation - } -} diff --git a/Sources/ParseSwift/Types/ParseOperation.swift b/Sources/ParseSwift/Types/ParseOperation.swift index 5be8e2de5..e9aaa7d15 100644 --- a/Sources/ParseSwift/Types/ParseOperation.swift +++ b/Sources/ParseSwift/Types/ParseOperation.swift @@ -26,6 +26,53 @@ public struct ParseOperation: Savable where T: ParseObject { self.target = target } + /** + An operation that sets a field's value. + - Parameters: + - keyPath: The respective `KeyPath` of the object. + - value: The value to set the `KeyPath` to. + - returns: The updated operations. + - warning: Do not combine operations using this method with other operations that + do not use this method to **set** all operations. If you need to combine multiple types + of operations such as: add, increment, forceSet, etc., use + `func set(_ key: (String, WritableKeyPath), value: W?)` + instead. + */ + @available(*, deprecated, message: "replace \"value\" with \"to\"") + public func set(_ keyPath: WritableKeyPath, + value: W) throws -> Self where W: Encodable & Equatable { + try set(keyPath, to: value) + } + + /** + An operation that sets a field's value. + - Parameters: + - keyPath: The respective `KeyPath` of the object. + - to: The value to set the `KeyPath` to. + - returns: The updated operations. + - warning: Do not combine operations using this method with other operations that + do not use this method to **set** all operations. If you need to combine multiple types + of operations such as: add, increment, forceSet, etc., use + `func set(_ key: (String, WritableKeyPath), value: W?)` + instead. + */ + public func set(_ keyPath: WritableKeyPath, + to value: W) throws -> Self where W: Encodable & Equatable { + guard operations.isEmpty, + keysToNull.isEmpty else { + throw ParseError(code: .unknownError, + message: """ + Cannot combine other operations such as: add, increment, + forceSet, etc., with this method. Use the \"set\" method that takes + the (String, WritableKeyPath) tuple as an argument instead to + combine multiple types of operations. + """) + } + var mutableOperation = self + mutableOperation.target = mutableOperation.target.set(keyPath, to: value) + return mutableOperation + } + /** An operation that sets a field's value if it has changed from its previous value. - Parameters: @@ -34,8 +81,22 @@ public struct ParseOperation: Savable where T: ParseObject { - returns: The updated operations. - Note: Set the value to "nil" if you want it to be "null" on the Parse Server. */ + @available(*, deprecated, message: "replace \"value\" with \"to\"") public func set(_ key: (String, WritableKeyPath), value: W?) -> Self where W: Encodable & Equatable { + set(key, to: value) + } + + /** + An operation that sets a field's value if it has changed from its previous value. + - Parameters: + - key: A tuple consisting of the key and the respective `KeyPath` of the object. + - to: The value to set the `KeyPath` to. + - returns: The updated operations. + - Note: Set the value to "nil" if you want it to be "null" on the Parse Server. + */ + public func set(_ key: (String, WritableKeyPath), + to value: W?) -> Self where W: Encodable & Equatable { var mutableOperation = self if value == nil && target[keyPath: key.1] != nil { mutableOperation.keysToNull.insert(key.0) @@ -57,6 +118,19 @@ public struct ParseOperation: Savable where T: ParseObject { */ public func forceSet(_ key: (String, WritableKeyPath), value: W?) -> Self where W: Encodable { + forceSet(key, to: value) + } + + /** + An operation that force sets a field's value. + - Parameters: + - key: A tuple consisting of the key and the respective `KeyPath` of the object. + - to: The value to set the `KeyPath` to. + - returns: The updated operations. + - Note: Set the value to "nil" if you want it to be "null" on the Parse Server. + */ + public func forceSet(_ key: (String, WritableKeyPath), + to value: W?) -> Self where W: Encodable { var mutableOperation = self if value != nil { mutableOperation.operations[key.0] = value diff --git a/Tests/ParseSwiftTests/ParseInstallationAsyncTests.swift b/Tests/ParseSwiftTests/ParseInstallationAsyncTests.swift index 3442edd37..45855659d 100644 --- a/Tests/ParseSwiftTests/ParseInstallationAsyncTests.swift +++ b/Tests/ParseSwiftTests/ParseInstallationAsyncTests.swift @@ -98,6 +98,26 @@ class ParseInstallationAsyncTests: XCTestCase { // swiftlint:disable:this type_b } } + struct InstallationDefaultMerge: ParseInstallation { + var installationId: String? + var deviceType: String? + var deviceToken: String? + var badge: Int? + var timeZone: String? + var channels: [String]? + var appName: String? + var appIdentifier: String? + var appVersion: String? + var parseVersion: String? + var localeIdentifier: String? + var objectId: String? + var createdAt: Date? + var updatedAt: Date? + var ACL: ParseACL? + var originalData: Data? + var customKey: String? + } + struct InstallationDefault: ParseInstallation { var installationId: String? var deviceType: String? @@ -444,7 +464,7 @@ class ParseInstallationAsyncTests: XCTestCase { // swiftlint:disable:this type_b MockURLProtocol.mockRequests { _ in do { let encoded = try serverResponse.getEncoder().encode(serverResponse, skipKeys: .none) - //Get dates in correct format from ParseDecoding strategy + // Get dates in correct format from ParseDecoding strategy serverResponse = try serverResponse.getDecoder().decode(Installation.self, from: encoded) return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0) } catch { @@ -458,6 +478,35 @@ class ParseInstallationAsyncTests: XCTestCase { // swiftlint:disable:this type_b XCTAssertEqual(saved.updatedAt, serverResponse.updatedAt) } + @MainActor + func testUpdateDefaultMerge() async throws { + try saveCurrentInstallation() + MockURLProtocol.removeAll() + + var installation = InstallationDefaultMerge() + installation.objectId = "yolo" + installation.installationId = "123" + + var serverResponse = installation + serverResponse.updatedAt = Date() + serverResponse.customKey = "newValue" + + MockURLProtocol.mockRequests { _ in + do { + let encoded = try serverResponse.getEncoder().encode(serverResponse, skipKeys: .none) + // Get dates in correct format from ParseDecoding strategy + serverResponse = try serverResponse.getDecoder().decode(InstallationDefaultMerge.self, from: encoded) + return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0) + } catch { + return nil + } + } + + installation = installation.set(\.customKey, to: "newValue") + let saved = try await installation.update() + XCTAssertEqual(saved, serverResponse) + } + func testUpdateMutableMergeCurrentInstallation() async throws { // Save current Installation try saveCurrentInstallation() diff --git a/Tests/ParseSwiftTests/ParseInstallationTests.swift b/Tests/ParseSwiftTests/ParseInstallationTests.swift index 3805e96bb..2109b7f75 100644 --- a/Tests/ParseSwiftTests/ParseInstallationTests.swift +++ b/Tests/ParseSwiftTests/ParseInstallationTests.swift @@ -94,6 +94,26 @@ class ParseInstallationTests: XCTestCase { // swiftlint:disable:this type_body_l } } + struct InstallationDefaultMerge: ParseInstallation { + var installationId: String? + var deviceType: String? + var deviceToken: String? + var badge: Int? + var timeZone: String? + var channels: [String]? + var appName: String? + var appIdentifier: String? + var appVersion: String? + var parseVersion: String? + var localeIdentifier: String? + var objectId: String? + var createdAt: Date? + var updatedAt: Date? + var ACL: ParseACL? + var originalData: Data? + var customKey: String? + } + let testInstallationObjectId = "yarr" override func setUpWithError() throws { @@ -337,6 +357,35 @@ class ParseInstallationTests: XCTestCase { // swiftlint:disable:this type_body_l XCTAssertEqual(merged.updatedAt, updated.updatedAt) } + func testMergeDefaultImplementation() throws { + guard let currentInstallation = Installation.current else { + XCTFail("Should have unwrapped") + return + } + var original = InstallationDefaultMerge() + original.installationId = currentInstallation.installationId + original.objectId = "yolo" + original.createdAt = Date() + original.updatedAt = Date() + original.badge = 10 + original.deviceToken = "bruh" + original.channels = ["halo"] + var acl = ParseACL() + acl.publicRead = true + original.ACL = acl + + var updated = original.set(\.customKey, to: "newKey") + updated.updatedAt = Calendar.current.date(byAdding: .init(day: 1), to: Date()) + original.updatedAt = updated.updatedAt + original.customKey = updated.customKey + var merged = try updated.merge(with: original) + merged.originalData = nil + // Get dates in correct format from ParseDecoding strategy + let encoded = try ParseCoding.jsonEncoder().encode(original) + original = try ParseCoding.jsonDecoder().decode(InstallationDefaultMerge.self, from: encoded) + XCTAssertEqual(merged, original) + } + func testMergeDifferentObjectId() throws { var installation = Installation() installation.objectId = "yolo" @@ -360,7 +409,7 @@ class ParseInstallationTests: XCTestCase { // swiftlint:disable:this type_body_l let encoded: Data! do { encoded = try installationOnServer.getEncoder().encode(installationOnServer, skipKeys: .none) - //Get dates in correct format from ParseDecoding strategy + // Get dates in correct format from ParseDecoding strategy installationOnServer = try installationOnServer.getDecoder().decode(Installation.self, from: encoded) } catch { XCTFail("Should encode/decode. Error \(error)") diff --git a/Tests/ParseSwiftTests/ParseObjectAsyncTests.swift b/Tests/ParseSwiftTests/ParseObjectAsyncTests.swift index 34c8e81d1..23cd91f5f 100644 --- a/Tests/ParseSwiftTests/ParseObjectAsyncTests.swift +++ b/Tests/ParseSwiftTests/ParseObjectAsyncTests.swift @@ -59,6 +59,54 @@ class ParseObjectAsyncTests: XCTestCase { // swiftlint:disable:this type_body_le } } + struct Level: ParseObject { + var objectId: String? + + var createdAt: Date? + + var updatedAt: Date? + + var ACL: ParseACL? + + var name: String? + + var originalData: Data? + + init() { + } + } + + struct GameScoreDefaultMerge: ParseObject { + //: These are required by ParseObject + var objectId: String? + var createdAt: Date? + var updatedAt: Date? + var ACL: ParseACL? + var originalData: Data? + + //: Your own properties + var points: Int? + var player: String? + var level: Level? + var levels: [Level]? + var nextLevel: Level? + + //: custom initializers + init() {} + + init(objectId: String?) { + self.objectId = objectId + } + init(points: Int) { + self.points = points + self.player = "Jen" + } + init(points: Int, name: String) { + self.points = points + self.player = name + } + } + struct GameScoreDefault: ParseObject { //: These are required by ParseObject @@ -371,6 +419,44 @@ class ParseObjectAsyncTests: XCTestCase { // swiftlint:disable:this type_body_le XCTAssertEqual(saved.ACL, scoreOnServer.ACL) } + @MainActor + func testUpdateDefaultMerge() async throws { + var score = GameScoreDefaultMerge(points: 10) + score.objectId = "yarr" + var level = Level() + level.name = "next" + level.objectId = "yolo" + score.level = level + + var scoreOnServer = score + scoreOnServer.updatedAt = Date() + scoreOnServer.ACL = nil + scoreOnServer.points = 50 + scoreOnServer.player = "Ali" + level.objectId = "nolo" + scoreOnServer.level = level + + let encoded: Data! + do { + encoded = try ParseCoding.jsonEncoder().encode(scoreOnServer) + // Get dates in correct format from ParseDecoding strategy + scoreOnServer = try scoreOnServer.getDecoder().decode(GameScoreDefaultMerge.self, from: encoded) + } catch { + XCTFail("Should encode/decode. Error \(error)") + return + } + + MockURLProtocol.mockRequests { _ in + return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0) + } + + score = score.set(\.player, to: "Ali") + .set(\.points, to: 50) + .set(\.level, to: level) + let saved = try await score.update() + XCTAssertEqual(saved, scoreOnServer) + } + @MainActor func testUpdateClientMissingObjectId() async throws { let score = GameScore(points: 10) diff --git a/Tests/ParseSwiftTests/ParseObjectTests.swift b/Tests/ParseSwiftTests/ParseObjectTests.swift index cc3271324..9e64b6482 100644 --- a/Tests/ParseSwiftTests/ParseObjectTests.swift +++ b/Tests/ParseSwiftTests/ParseObjectTests.swift @@ -106,6 +106,21 @@ class ParseObjectTests: XCTestCase { // swiftlint:disable:this type_body_length } } + struct GameDefaultMerge: ParseObject { + //: These are required by ParseObject + var objectId: String? + var createdAt: Date? + var updatedAt: Date? + var ACL: ParseACL? + var originalData: Data? + + //: Your own properties + var gameScore: GameScore? + var gameScores: [GameScore]? + var name: String? + var profilePicture: ParseFile? + } + struct Game2: ParseObject { //: These are required by ParseObject var objectId: String? @@ -366,11 +381,15 @@ class ParseObjectTests: XCTestCase { // swiftlint:disable:this type_body_length func testParseObjectMutable() throws { var score = GameScore(points: 19, name: "fire") + XCTAssertEqual(score, score.mergeable) score.objectId = "yolo" score.createdAt = Date() - let empty = score.mergeable + var empty = score.mergeable + XCTAssertNotNil(empty.originalData) XCTAssertTrue(score.hasSameObjectId(as: empty)) XCTAssertEqual(score.createdAt, empty.createdAt) + empty.player = "Ali" + XCTAssertEqual(empty.originalData, empty.mergeable.originalData) } func testMerge() throws { @@ -405,18 +424,20 @@ class ParseObjectTests: XCTestCase { // swiftlint:disable:this type_body_length } func testMergeDefaultImplementation() throws { - var score = Game() + var score = GameDefaultMerge() score.objectId = "yolo" score.createdAt = Date() score.updatedAt = Date() - var updated = score.mergeable + var updated = score.set(\.name, to: "moreFire") updated.updatedAt = Calendar.current.date(byAdding: .init(day: 1), to: Date()) - updated.name = "moreFire" - let merged = try updated.merge(with: score) - XCTAssertEqual(merged.name, updated.name) - XCTAssertEqual(merged.gameScore, score.gameScore) - XCTAssertEqual(merged.gameScores, score.gameScores) - XCTAssertEqual(merged.profilePicture, updated.profilePicture) + score.updatedAt = updated.updatedAt + score.name = updated.name + var merged = try updated.merge(with: score) + merged.originalData = nil + // Get dates in correct format from ParseDecoding strategy + let encoded = try ParseCoding.jsonEncoder().encode(score) + score = try ParseCoding.jsonDecoder().decode(GameDefaultMerge.self, from: encoded) + XCTAssertEqual(merged, score) } func testMergeDifferentObjectId() throws { diff --git a/Tests/ParseSwiftTests/ParseOperationTests.swift b/Tests/ParseSwiftTests/ParseOperationTests.swift index 907bf8a08..2be19bb89 100644 --- a/Tests/ParseSwiftTests/ParseOperationTests.swift +++ b/Tests/ParseSwiftTests/ParseOperationTests.swift @@ -691,10 +691,12 @@ class ParseOperationTests: XCTestCase { func testSetKeyPath() throws { var score = GameScore() score.points = 10 + score.objectId = "yolo" var operations = try score.operation.set(\.points, value: 15) .set(\.levels, value: ["hello"]) var expected = GameScore() expected.points = 15 + expected.objectId = "yolo" expected.levels = ["hello"] XCTAssertNotNil(operations.target.originalData) XCTAssertNotEqual(operations.target, expected) diff --git a/Tests/ParseSwiftTests/ParseUserAsyncTests.swift b/Tests/ParseSwiftTests/ParseUserAsyncTests.swift index db3e314f3..93994693d 100644 --- a/Tests/ParseSwiftTests/ParseUserAsyncTests.swift +++ b/Tests/ParseSwiftTests/ParseUserAsyncTests.swift @@ -46,6 +46,26 @@ class ParseUserAsyncTests: XCTestCase { // swiftlint:disable:this type_body_leng } } + struct UserDefaultMerge: ParseUser { + + //: These are required by ParseObject + var objectId: String? + var createdAt: Date? + var updatedAt: Date? + var ACL: ParseACL? + var originalData: Data? + + // These are required by ParseUser + var username: String? + var email: String? + var emailVerified: Bool? + var password: String? + var authData: [String: [String: String]?]? + + // Your custom keys + var customKey: String? + } + struct UserDefault: ParseUser { //: These are required by ParseObject @@ -927,6 +947,36 @@ class ParseUserAsyncTests: XCTestCase { // swiftlint:disable:this type_body_leng XCTAssertEqual(saved.updatedAt, serverResponse.updatedAt) } + @MainActor + func testUpdateDefaultMerge() async throws { + login() + MockURLProtocol.removeAll() + XCTAssertNotNil(User.current?.objectId) + + var user = UserDefaultMerge() + user.username = "stop" + user.objectId = "yolo" + + var serverResponse = user + serverResponse.updatedAt = Date() + serverResponse.customKey = "be" + + MockURLProtocol.mockRequests { _ in + do { + let encoded = try serverResponse.getEncoder().encode(serverResponse, skipKeys: .none) + serverResponse = try serverResponse.getDecoder().decode(UserDefaultMerge.self, from: encoded) + return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0) + } catch { + return nil + } + } + + user = user.set(\.customKey, to: "be") + let saved = try await user.update() + XCTAssertEqual(saved.objectId, serverResponse.objectId) + XCTAssertEqual(saved.updatedAt, serverResponse.updatedAt) + } + @MainActor func testUpdateClientMissingObjectId() async throws { var user = User() diff --git a/Tests/ParseSwiftTests/ParseUserTests.swift b/Tests/ParseSwiftTests/ParseUserTests.swift index 3fca61e97..2d7d42868 100644 --- a/Tests/ParseSwiftTests/ParseUserTests.swift +++ b/Tests/ParseSwiftTests/ParseUserTests.swift @@ -42,6 +42,26 @@ class ParseUserTests: XCTestCase { // swiftlint:disable:this type_body_length } } + struct UserDefaultMerge: ParseUser { + + //: These are required by ParseObject + var objectId: String? + var createdAt: Date? + var updatedAt: Date? + var ACL: ParseACL? + var originalData: Data? + + // These are required by ParseUser + var username: String? + var email: String? + var emailVerified: Bool? + var password: String? + var authData: [String: [String: String]?]? + + // Your custom keys + var customKey: String? + } + struct LoginSignupResponse: ParseUser { var objectId: String? @@ -175,6 +195,39 @@ class ParseUserTests: XCTestCase { // swiftlint:disable:this type_body_length XCTAssertEqual(merged.updatedAt, updated.updatedAt) } + func testMergeDefaultImplementation() throws { + // Signup current User + XCTAssertNil(User.current?.objectId) + try userSignUp() + XCTAssertNotNil(User.current?.objectId) + + guard let currentUser = User.current else { + XCTFail("Should have unwrapped") + return + } + var original = UserDefaultMerge() + original.username = currentUser.username + original.email = currentUser.email + original.customKey = currentUser.customKey + original.objectId = "yolo" + original.createdAt = Date() + original.updatedAt = Date() + var acl = ParseACL() + acl.publicRead = true + original.ACL = acl + + var updated = original.set(\.customKey, to: "newKey") + updated.updatedAt = Calendar.current.date(byAdding: .init(day: 1), to: Date()) + original.customKey = updated.customKey + original.updatedAt = updated.updatedAt + var merged = try updated.merge(with: original) + merged.originalData = nil + // Get dates in correct format from ParseDecoding strategy + let encoded = try ParseCoding.jsonEncoder().encode(original) + original = try ParseCoding.jsonDecoder().decode(UserDefaultMerge.self, from: encoded) + XCTAssertEqual(merged, original) + } + func testMergeDifferentObjectId() throws { var user = User() user.objectId = "yolo"