Repository: parse-community/Parse-Swift (swift)

Issue:
Partially updating an object sends the full object to the server
### New Issue Checklist
<!--
    Check every following box [x] before submitting your issue.
    Click the "Preview" tab for better readability.
    Thanks for contributing to Parse Platform!
-->

- [x] I am not disclosing a [vulnerability](https://github.com/parse-community/Parse-Swift/security/policy).
- [x] I am not just asking a [question](https://github.com/parse-community/.github/blob/main/SUPPORT.md).
- [x] I have searched through [existing issues](https://github.com/parse-community/Parse-Swift/issues?q=is%3Aissue).
- [x] I can reproduce the issue with the latest versions of [Parse Server](https://github.com/parse-community/parse-server/releases) and the [Parse Swift SDK](https://github.com/parse-community/Parse-Swift/releases). <!-- We don't investigate issues for outdated releases. -->

### Issue Description
<!-- What is the specific issue? -->

When fetching a saved object and then calling `.save`, all keys are marked as `dirty` and saved to the backend, causing unnecessary traffic and database ops. 

### Steps to reproduce
<!-- How can someone else reproduce the issue? -->

1. Fetch an existing object
2. Have a Parse Cloud beforeSave trigger that logs `object.dirtyKeys()`. 

### Actual Outcome
<!-- What outcome did you get? -->

All fields are dirty. 

### Expected Outcome
<!-- What outcome did you expect? -->

No fields should be dirty unless mutated. 

### Environment
<!-- Be specific with versions, don't use "latest" or semver ranges like "~x.y.z" or "^x.y.z". -->

Client
- Parse Swift SDK version: `1.9.10`
- Xcode version: `10.15.7`
- Operating system (iOS, macOS, watchOS, etc.): `iOS`
- Operating system version: `14.7`

Server
- Parse Server version: `4.5.0`
- Operating system: `macOS`
- Local or remote host (AWS, Azure, Google Cloud, Heroku, Digital Ocean, etc): `localhost`

Database
- System (MongoDB or Postgres): `mongodb`
- Database version: `4.2`
- Local or remote host (MongoDB Atlas, mLab, AWS, Azure, Google Cloud, etc): `Atlas`

### Logs
<!-- Include relevant logs here. -->

Pull request:
### New Pull Request Checklist
<!--
    Please check the following boxes [x] before submitting your issue.
    Click the "Preview" tab for better readability.
    Thanks for contributing to Parse Platform!
-->

- [x] I am not disclosing a [vulnerability](https://github.com/parse-community/Parse-Swift/security/policy).
- [x] I am creating this PR in reference to an [issue](https://github.com/parse-community/Parse-Swift/issues?q=is%3Aissue).

### Issue Description
<!-- Add a brief description of the issue this PR solves. -->

Currently, the solution to only updating one field on an object is to use `emptyObject` #249, which requires the developer to add the `emptyObject` property to their `ParseObject` manually. 

Related issue: #242

### Approach
<!-- Add a description of the approach in this PR. -->

This adds a new simple operation of `.set`, so that individual keys can be updated without manually adding a property.

Although `.set` is perhaps an artifact of the old SDK, this would allow developers to use `.save` to save the whole object, and `.operation.set` to update the object, which in my opinion, is a good balance, and provides the reasonable distinction between object.save and object.operation.

~~If we agree on this approach, and I haven't missed any side-effects such as #247, I think we can revert `emptyObject`.~~

You can set multiple values at once (`score` and `levels`) and only save modified properties by doing the following:

```swift
struct GameScore: ParseObject {
        // Those are required for Object
        var objectId: String?
        var createdAt: Date?
        var updatedAt: Date?
        var ACL: ParseACL?

        // Your own properties
        var score: Int
        var members = [String]()
        var levels: [String]?
        var previous: [Level]?

        // custom initializers
        init() {
            self.score = 5
        }

        init(score: Int) {
            self.score = score
        }
}

let score = GameScore(score: 10)
do {
    // Perform any number of operations.
    let operations = try score.operation
            .set(("score", \.score), value: 15)
            .set(("levels", \.levels), value: ["hello"])

    // Use new async/await to save operations.
    let saved = try await operations.save()
} catch {
  print(error)
}
```

### TODOs before merging
<!--
    Add TODOs that need to be completed before merging this PR.
    Delete TODOs that do not apply to this PR.
-->

- [x] Add tests
- [x] Add entry to changelog
- [x] Add changes to documentation (guides, repository pages, in-code descriptions)

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc5313464..3dcedc664 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,15 @@
 # Parse-Swift Changelog
 
 ### main
-[Full Changelog](https://github.com/parse-community/Parse-Swift/compare/1.10.4...main)
+[Full Changelog](https://github.com/parse-community/Parse-Swift/compare/1.11.0...main)
 * _Contributing to this repo? Add info about your change here to be included in the next release_
 
+### 1.11.0
+[Full Changelog](https://github.com/parse-community/Parse-Swift/compare/1.10.4...1.11.0)
+
+__Improvements__
+- Added `operation` for `set` and `forceSet`, used for single key updates ([#248](https://github.com/parse-community/Parse-Swift/pull/248)), thanks to [Daniel Blyth](https://github.com/dblythy) and [Corey Baker](https://github.com/cbaker6).
+
 ### 1.10.4
 [Full Changelog](https://github.com/parse-community/Parse-Swift/compare/1.10.3...1.10.4)
 
diff --git a/Sources/ParseSwift/Operations/ParseOperation.swift b/Sources/ParseSwift/Operations/ParseOperation.swift
index 643cc0ca7..659ed042a 100644
--- a/Sources/ParseSwift/Operations/ParseOperation.swift
+++ b/Sources/ParseSwift/Operations/ParseOperation.swift
@@ -25,11 +25,53 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
         self.target = target
     }
 
+    /**
+     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.
+        - value: The value to set it to.
+        - returns: The updated operations.
+     */
+    public func set<W>(_ key: (String, WritableKeyPath<T, W>),
+                       value: W) throws -> Self where W: Encodable {
+        var mutableOperation = self
+        guard let target = self.target else {
+            throw ParseError(code: .unknownError, message: "Target shouldn't be nil")
+        }
+        if let currentValue = target[keyPath: key.1] as? NSObject,
+           let updatedValue = value as? NSObject {
+           if currentValue != updatedValue {
+               mutableOperation.operations[key.0] = value
+               mutableOperation.target?[keyPath: key.1] = value
+           }
+        } else {
+            mutableOperation.operations[key.0] = value
+            mutableOperation.target?[keyPath: key.1] = value
+        }
+        return mutableOperation
+    }
+
+    /**
+     An operation that force sets a field's value.
+     - Parameters:
+        - key: A tuple consisting of the key and the respective KeyPath of the object.
+        - value: The value to set it to.
+        - returns: The updated operations.
+     */
+    public func forceSet<W>(_ key: (String, WritableKeyPath<T, W>),
+                            value: W) throws -> Self where W: Encodable {
+        var mutableOperation = self
+        mutableOperation.operations[key.0] = value
+        mutableOperation.target?[keyPath: key.1] = value
+        return mutableOperation
+    }
+
     /**
      An operation that increases a numeric field's value by a given amount.
      - Parameters:
         - key: The key of the object.
         - amount: How much to increment by.
+        - returns: The updated operations.
      */
     public func increment(_ key: String, by amount: Int) -> Self {
         var mutableOperation = self
@@ -43,6 +85,7 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
      - Parameters:
         - key: The key of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func addUnique<W>(_ key: String, objects: [W]) -> Self where W: Encodable, W: Hashable {
         var mutableOperation = self
@@ -54,8 +97,9 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
      An operation that adds a new element to an array field,
      only if it wasn't already present.
      - Parameters:
-        - key: A tuple consisting of the key and KeyPath of the object.
+        - key: A tuple consisting of the key and the respective KeyPath of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func addUnique<V>(_ key: (String, WritableKeyPath<T, [V]>),
                              objects: [V]) throws -> Self where V: Encodable, V: Hashable {
@@ -74,8 +118,9 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
      An operation that adds a new element to an array field,
      only if it wasn't already present.
      - Parameters:
-        - key: A tuple consisting of the key and KeyPath of the object.
+        - key: A tuple consisting of the key and the respective KeyPath of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func addUnique<V>(_ key: (String, WritableKeyPath<T, [V]?>),
                              objects: [V]) throws -> Self where V: Encodable, V: Hashable {
@@ -95,6 +140,7 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
      - Parameters:
         - key: The key of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func add<W>(_ key: String, objects: [W]) -> Self where W: Encodable {
         var mutableOperation = self
@@ -105,8 +151,9 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
     /**
      An operation that adds a new element to an array field.
      - Parameters:
-        - key: A tuple consisting of the key and KeyPath of the object.
+        - key: A tuple consisting of the key and the respective KeyPath of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func add<V>(_ key: (String, WritableKeyPath<T, [V]>),
                        objects: [V]) throws -> Self where V: Encodable {
@@ -124,8 +171,9 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
     /**
      An operation that adds a new element to an array field.
      - Parameters:
-        - key: A tuple consisting of the key and KeyPath of the object.
+        - key: A tuple consisting of the key and the respective KeyPath of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func add<V>(_ key: (String, WritableKeyPath<T, [V]?>),
                        objects: [V]) throws -> Self where V: Encodable {
@@ -145,6 +193,7 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
      - Parameters:
         - key: The key of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func addRelation<W>(_ key: String, objects: [W]) throws -> Self where W: ParseObject {
         var mutableOperation = self
@@ -155,8 +204,9 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
     /**
      An operation that adds a new relation to an array field.
      - Parameters:
-        - key: A tuple consisting of the key and KeyPath of the object.
+        - key: A tuple consisting of the key and the respective KeyPath of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func addRelation<V>(_ key: (String, WritableKeyPath<T, [V]>),
                                objects: [V]) throws -> Self where V: ParseObject {
@@ -174,8 +224,9 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
     /**
      An operation that adds a new relation to an array field.
      - Parameters:
-        - key: A tuple consisting of the key and KeyPath of the object.
+        - key: A tuple consisting of the key and the respective KeyPath of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func addRelation<V>(_ key: (String, WritableKeyPath<T, [V]?>),
                                objects: [V]) throws -> Self where V: ParseObject {
@@ -196,6 +247,7 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
      - Parameters:
         - key: The key of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func remove<W>(_ key: String, objects: [W]) -> Self where W: Encodable {
         var mutableOperation = self
@@ -207,8 +259,9 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
      An operation that removes every instance of an element from
      an array field.
      - Parameters:
-        - key: A tuple consisting of the key and KeyPath of the object.
+        - key: A tuple consisting of the key and the respective KeyPath of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func remove<V>(_ key: (String, WritableKeyPath<T, [V]>),
                           objects: [V]) throws -> Self where V: Encodable, V: Hashable {
@@ -230,8 +283,9 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
      An operation that removes every instance of an element from
      an array field.
      - Parameters:
-        - key: A tuple consisting of the key and KeyPath of the object.
+        - key: A tuple consisting of the key and the respective KeyPath of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func remove<V>(_ key: (String, WritableKeyPath<T, [V]?>),
                           objects: [V]) throws -> Self where V: Encodable, V: Hashable {
@@ -255,6 +309,7 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
      - Parameters:
         - key: The key of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func removeRelation<W>(_ key: String, objects: [W]) throws -> Self where W: ParseObject {
         var mutableOperation = self
@@ -266,8 +321,9 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
      An operation that removes every instance of a relation from
      an array field.
      - Parameters:
-        - key: A tuple consisting of the key and KeyPath of the object.
+        - key: A tuple consisting of the key and the respective KeyPath of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func removeRelation<V>(_ key: (String, WritableKeyPath<T, [V]>),
                                   objects: [V]) throws -> Self where V: ParseObject {
@@ -289,8 +345,9 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
      An operation that removes every instance of a relation from
      an array field.
      - Parameters:
-        - key: A tuple consisting of the key and KeyPath of the object.
+        - key: A tuple consisting of the key and the respective KeyPath of the object.
         - objects: The field of objects.
+        - returns: The updated operations.
      */
     public func removeRelation<V>(_ key: (String, WritableKeyPath<T, [V]?>),
                                   objects: [V]) throws -> Self where V: ParseObject {
@@ -311,6 +368,7 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
     /**
      An operation where a field is deleted from the object.
      - parameter key: The key of the object.
+     - returns: The updated operations.
      */
     public func unset(_ key: String) -> Self {
         var mutableOperation = self
@@ -321,7 +379,8 @@ public struct ParseOperation<T>: Savable where T: ParseObject {
     /**
      An operation where a field is deleted from the object.
      - Parameters:
-        - key: A tuple consisting of the key and KeyPath of the object.
+        - key: A tuple consisting of the key and the respective KeyPath of the object.
+        - returns: The updated operations.
      */
     public func unset<V>(_ key: (String, WritableKeyPath<T, V?>)) -> Self where V: Encodable {
         var mutableOperation = self
diff --git a/Sources/ParseSwift/ParseConstants.swift b/Sources/ParseSwift/ParseConstants.swift
index 81e6f99eb..d9327a9dd 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 = "1.10.4"
+    static let version = "1.11.0"
     static let fileManagementDirectory = "parse/"
     static let fileManagementPrivateDocumentsDirectory = "Private Documents/"
     static let fileManagementLibraryDirectory = "Library/"


Test patch:
diff --git a/Tests/ParseSwiftTests/ParseOperationTests.swift b/Tests/ParseSwiftTests/ParseOperationTests.swift
index f4fb13cfd..4103a9717 100644
--- a/Tests/ParseSwiftTests/ParseOperationTests.swift
+++ b/Tests/ParseSwiftTests/ParseOperationTests.swift
@@ -139,6 +139,7 @@ class ParseOperationTests: XCTestCase {
             }
             XCTAssertEqual(savedUpdatedAt, originalUpdatedAt)
             XCTAssertEqual(saved.ACL, scoreOnServer.ACL)
+            XCTAssertEqual(saved.score+1, scoreOnServer.score)
         } catch {
             XCTFail(error.localizedDescription)
         }
@@ -188,6 +189,102 @@ class ParseOperationTests: XCTestCase {
                 }
                 XCTAssertEqual(savedUpdatedAt, originalUpdatedAt)
                 XCTAssertEqual(saved.ACL, scoreOnServer.ACL)
+                XCTAssertEqual(saved.score+1, scoreOnServer.score)
+            case .failure(let error):
+                XCTFail(error.localizedDescription)
+            }
+            expectation1.fulfill()
+        }
+        wait(for: [expectation1], timeout: 20.0)
+    }
+
+    func testSaveSet() throws { // swiftlint:disable:this function_body_length
+        var score = GameScore(score: 10)
+        score.objectId = "yarr"
+        let operations = try score.operation
+            .set(("score", \.score), value: 15)
+
+        var scoreOnServer = score
+        scoreOnServer.score = 15
+        scoreOnServer.updatedAt = Date()
+
+        let encoded: Data!
+        do {
+            encoded = try ParseCoding.jsonEncoder().encode(scoreOnServer)
+            //Get dates in correct format from ParseDecoding strategy
+            scoreOnServer = try scoreOnServer.getDecoder().decode(GameScore.self, from: encoded)
+        } catch {
+            XCTFail("Should encode/decode. Error \(error)")
+            return
+        }
+
+        MockURLProtocol.mockRequests { _ in
+            return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0)
+        }
+        do {
+            let saved = try operations.save()
+            XCTAssert(saved.hasSameObjectId(as: scoreOnServer))
+            guard let savedUpdatedAt = saved.updatedAt else {
+                    XCTFail("Should unwrap dates")
+                    return
+            }
+            guard let originalUpdatedAt = scoreOnServer.updatedAt else {
+                    XCTFail("Should unwrap dates")
+                    return
+            }
+            XCTAssertEqual(savedUpdatedAt, originalUpdatedAt)
+            XCTAssertEqual(saved.ACL, scoreOnServer.ACL)
+            XCTAssertEqual(saved.score, scoreOnServer.score)
+        } catch {
+            XCTFail(error.localizedDescription)
+        }
+    }
+
+    func testSaveSetAsyncMainQueue() throws {
+        var score = GameScore(score: 10)
+        score.objectId = "yarr"
+        let operations = try score.operation
+            .set(("score", \.score), value: 15)
+
+        var scoreOnServer = score
+        scoreOnServer.score = 15
+        scoreOnServer.createdAt = Date()
+        scoreOnServer.updatedAt = scoreOnServer.createdAt
+        scoreOnServer.ACL = nil
+        let encoded: Data!
+        do {
+            encoded = try ParseCoding.jsonEncoder().encode(scoreOnServer)
+            //Get dates in correct format from ParseDecoding strategy
+            scoreOnServer = try scoreOnServer.getDecoder().decode(GameScore.self, from: encoded)
+        } catch {
+            XCTFail("Should have encoded/decoded: Error: \(error)")
+            return
+        }
+        MockURLProtocol.mockRequests { _ in
+            return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0)
+        }
+
+        let expectation1 = XCTestExpectation(description: "Save object1")
+
+        operations.save(options: [], callbackQueue: .main) { result in
+
+            switch result {
+
+            case .success(let saved):
+                XCTAssert(saved.hasSameObjectId(as: scoreOnServer))
+                guard let savedUpdatedAt = saved.updatedAt else {
+                        XCTFail("Should unwrap dates")
+                        expectation1.fulfill()
+                        return
+                }
+                guard let originalUpdatedAt = scoreOnServer.updatedAt else {
+                        XCTFail("Should unwrap dates")
+                        expectation1.fulfill()
+                        return
+                }
+                XCTAssertEqual(savedUpdatedAt, originalUpdatedAt)
+                XCTAssertEqual(saved.ACL, scoreOnServer.ACL)
+                XCTAssertEqual(saved.score, scoreOnServer.score)
             case .failure(let error):
                 XCTFail(error.localizedDescription)
             }
@@ -392,8 +489,52 @@ class ParseOperationTests: XCTestCase {
         let decoded = try XCTUnwrap(String(data: encoded, encoding: .utf8))
         XCTAssertEqual(decoded, expected)
     }
+
+    func testSet() throws {
+        let score = GameScore(score: 10)
+        let operations = try score.operation.set(("score", \.score), value: 15)
+            .set(("levels", \.levels), value: ["hello"])
+        let expected = "{\"score\":15,\"levels\":[\"hello\"]}"
+        let encoded = try ParseCoding.parseEncoder()
+            .encode(operations)
+        let decoded = try XCTUnwrap(String(data: encoded, encoding: .utf8))
+        XCTAssertEqual(decoded, expected)
+        XCTAssertEqual(operations.target?.score, 15)
+    }
     #endif
 
+    func testObjectIdSet() throws {
+        var score = GameScore()
+        score.objectId = "test"
+        score.levels = nil
+        let operations = try score.operation.set(("objectId", \.objectId), value: "test")
+        let expected = "{}"
+        let encoded = try ParseCoding.parseEncoder()
+            .encode(operations)
+        let decoded = try XCTUnwrap(String(data: encoded, encoding: .utf8))
+        XCTAssertEqual(decoded, expected)
+    }
+
+    func testUnchangedSet() throws {
+        let score = GameScore(score: 10)
+        let operations = try score.operation.set(("score", \.score), value: 10)
+        let expected = "{}"
+        let encoded = try ParseCoding.parseEncoder()
+            .encode(operations)
+        let decoded = try XCTUnwrap(String(data: encoded, encoding: .utf8))
+        XCTAssertEqual(decoded, expected)
+    }
+
+    func testForceSet() throws {
+        let score = GameScore(score: 10)
+        let operations = try score.operation.forceSet(("score", \.score), value: 10)
+        let expected = "{\"score\":10}"
+        let encoded = try ParseCoding.parseEncoder()
+            .encode(operations)
+        let decoded = try XCTUnwrap(String(data: encoded, encoding: .utf8))
+        XCTAssertEqual(decoded, expected)
+    }
+
     func testUnset() throws {
         let score = GameScore(score: 10)
         let operations = score.operation