diff --git a/DemoCore/Sources/DemoCore/Features/ScreenMachines.swift b/DemoCore/Sources/DemoCore/Features/ScreenMachines.swift index 3c7ac3bcd..2acb305fb 100644 --- a/DemoCore/Sources/DemoCore/Features/ScreenMachines.swift +++ b/DemoCore/Sources/DemoCore/Features/ScreenMachines.swift @@ -386,7 +386,7 @@ public final class TaskFormSheetMachine { _Concurrency.Task { do { - let payload = try DemoSyncPayload(dictionary: requestBody) + let payload = try SyncJSON(dictionary: requestBody) switch mode { case .create(let projectID): try await syncEngine.createTask(body: payload, projectID: projectID) diff --git a/DemoCore/Sources/DemoCore/Networking/DemoAPI.swift b/DemoCore/Sources/DemoCore/Networking/DemoAPI.swift index a01c10ea7..e2d930721 100644 --- a/DemoCore/Sources/DemoCore/Networking/DemoAPI.swift +++ b/DemoCore/Sources/DemoCore/Networking/DemoAPI.swift @@ -1,5 +1,6 @@ import DemoBackend import Foundation +import SwiftSync public typealias DemoSeedData = DemoBackend.DemoSeedData public typealias DemoServerSimulator = DemoBackend.DemoServerSimulator @@ -83,45 +84,45 @@ public final class FakeDemoAPIClient { } } - public func getProjects() async throws -> [DemoSyncPayload] { + public func getProjects() async throws -> [SyncJSON] { try await networkGate(endpoint: "GET /projects") return try backend.getProjectsPayload().map(Self.makePayload) } - public func getProjectTasks(projectID: String) async throws -> [DemoSyncPayload] { + public func getProjectTasks(projectID: String) async throws -> [SyncJSON] { try await networkGate(endpoint: "GET /projects/{id}/tasks") return try backend.getProjectTasksPayload(projectID: projectID).map(Self.makePayload) } - public func getUsers() async throws -> [DemoSyncPayload] { + public func getUsers() async throws -> [SyncJSON] { try await networkGate(endpoint: "GET /users") return try backend.getUsersPayload().map(Self.makePayload) } - public func getTaskDetail(taskID: String) async throws -> DemoSyncPayload? { + public func getTaskDetail(taskID: String) async throws -> SyncJSON? { try await networkGate(endpoint: "GET /tasks/{id}") guard let payload = try backend.getTaskDetailPayload(publicID: taskID) else { return nil } return try Self.makePayload(payload) } - public func getTaskStateOptions() async throws -> [DemoSyncPayload] { + public func getTaskStateOptions() async throws -> [SyncJSON] { try await networkGate(endpoint: "GET /task-state-options") return try backend.getTaskStateOptionsPayload().map(Self.makePayload) } - public func patchTaskState(taskID: String, state: String) async throws -> DemoSyncPayload? { + public func patchTaskState(taskID: String, state: String) async throws -> SyncJSON? { try await networkGate(endpoint: "PATCH /tasks/{id} (state)") guard let payload = try backend.patchTaskState(publicID: taskID, state: state) else { return nil } return try Self.makePayload(payload) } - public func patchTaskAssignee(taskID: String, assigneeID: String?) async throws -> DemoSyncPayload? { + public func patchTaskAssignee(taskID: String, assigneeID: String?) async throws -> SyncJSON? { try await networkGate(endpoint: "PATCH /tasks/{id} (assignee_id)") guard let payload = try backend.patchTaskAssignee(publicID: taskID, assigneeID: assigneeID) else { return nil } return try Self.makePayload(payload) } - public func replaceTaskReviewers(taskID: String, reviewerIDs: [String]) async throws -> DemoSyncPayload? { + public func replaceTaskReviewers(taskID: String, reviewerIDs: [String]) async throws -> SyncJSON? { try await networkGate(endpoint: "PUT /tasks/{id}/reviewers") guard let payload = try backend.replaceTaskReviewers(publicID: taskID, reviewerIDs: reviewerIDs) else { return nil @@ -129,7 +130,7 @@ public final class FakeDemoAPIClient { return try Self.makePayload(payload) } - public func replaceTaskWatchers(taskID: String, watcherIDs: [String]) async throws -> DemoSyncPayload? { + public func replaceTaskWatchers(taskID: String, watcherIDs: [String]) async throws -> SyncJSON? { try await networkGate(endpoint: "PUT /tasks/{id}/watchers") guard let payload = try backend.replaceTaskWatchers(publicID: taskID, watcherIDs: watcherIDs) else { return nil @@ -137,13 +138,13 @@ public final class FakeDemoAPIClient { return try Self.makePayload(payload) } - public func createTask(body: DemoSyncPayload) async throws -> DemoSyncPayload { + public func createTask(body: SyncJSON) async throws -> SyncJSON { try await networkGate(endpoint: "POST /tasks") let created = try backend.createTask(body: body.toSyncPayloadDictionary()) return try Self.makePayload(created) } - public func updateTask(taskID: String, body: DemoSyncPayload) async throws -> DemoSyncPayload? { + public func updateTask(taskID: String, body: SyncJSON) async throws -> SyncJSON? { try await networkGate(endpoint: "PUT /tasks/{id}") let updated = try backend.updateTask(publicID: taskID, body: body.toSyncPayloadDictionary()) return try Self.makePayload(updated) @@ -203,9 +204,9 @@ public final class FakeDemoAPIClient { try await _Concurrency.Task.sleep(nanoseconds: (baseDelayMS + jitter + mutationExtra) * 1_000_000) } - private static func makePayload(_ dictionary: [String: Any]) throws -> DemoSyncPayload { + private static func makePayload(_ dictionary: [String: Any]) throws -> SyncJSON { do { - return try DemoSyncPayload(dictionary: dictionary) + return try SyncJSON(dictionary: dictionary) } catch { throw DemoAPIError.invalidPayload(error.localizedDescription) } diff --git a/DemoCore/Sources/DemoCore/Networking/DemoSyncPayload.swift b/DemoCore/Sources/DemoCore/Networking/DemoSyncPayload.swift deleted file mode 100644 index 4de22644b..000000000 --- a/DemoCore/Sources/DemoCore/Networking/DemoSyncPayload.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Foundation -import SwiftSync - -public enum DemoSyncPayloadError: LocalizedError { - case unsupportedValue(path: String, type: String) - case expectedObject(path: String) - - public var errorDescription: String? { - switch self { - case let .unsupportedValue(path, type): - return "Unsupported payload value at \(path): \(type)." - case let .expectedObject(path): - return "Expected object payload at \(path)." - } - } -} - -public struct DemoSyncPayload: Sendable, SyncPayloadConvertible { - public let values: [String: DemoSyncValue] - - public init(values: [String: DemoSyncValue]) { - self.values = values - } - - public init(dictionary: [String: Any]) throws { - self.values = try dictionary.reduce(into: [:]) { partialResult, element in - partialResult[element.key] = try DemoSyncValue(anyValue: element.value, path: element.key) - } - } - - public func toSyncPayloadDictionary() -> [String: Any] { - values.mapValues { $0.foundationValue } - } - - public func string(_ key: String) -> String? { - values[key]?.stringValue - } - - public func objectArray(_ key: String) -> [DemoSyncPayload]? { - values[key]?.objectArrayValue - } -} - -public enum DemoSyncValue: Sendable { - case string(String) - case int(Int) - case double(Double) - case bool(Bool) - case object([String: DemoSyncValue]) - case array([DemoSyncValue]) - case null - - fileprivate init(anyValue: Any, path: String) throws { - switch anyValue { - case let value as String: - self = .string(value) - case let value as Int: - self = .int(value) - case let value as Bool: - self = .bool(value) - case let value as Double: - self = .double(value) - case let value as Float: - self = .double(Double(value)) - case let value as NSNumber: - if CFGetTypeID(value) == CFBooleanGetTypeID() { - self = .bool(value.boolValue) - } else if value.doubleValue.rounded(.towardZero) == value.doubleValue { - self = .int(value.intValue) - } else { - self = .double(value.doubleValue) - } - case let value as [String: Any]: - self = .object(try value.reduce(into: [:]) { partialResult, element in - let nestedPath = "\(path).\(element.key)" - partialResult[element.key] = try DemoSyncValue(anyValue: element.value, path: nestedPath) - }) - case let value as [Any]: - self = .array(try value.enumerated().map { index, element in - try DemoSyncValue(anyValue: element, path: "\(path)[\(index)]") - }) - case _ as NSNull: - self = .null - default: - throw DemoSyncPayloadError.unsupportedValue(path: path, type: String(describing: type(of: anyValue))) - } - } - - fileprivate var foundationValue: Any { - switch self { - case let .string(value): - return value - case let .int(value): - return value - case let .double(value): - return value - case let .bool(value): - return value - case let .object(value): - return value.mapValues { $0.foundationValue } - case let .array(value): - return value.map { $0.foundationValue } - case .null: - return NSNull() - } - } - - fileprivate var stringValue: String? { - guard case let .string(value) = self else { return nil } - return value - } - - fileprivate var objectArrayValue: [DemoSyncPayload]? { - guard case let .array(value) = self else { return nil } - return value.compactMap { item in - guard case let .object(objectValue) = item else { return nil } - return DemoSyncPayload(values: objectValue) - } - } -} diff --git a/DemoCore/Sources/DemoCore/Sync/DemoSyncEngine.swift b/DemoCore/Sources/DemoCore/Sync/DemoSyncEngine.swift index 32a9c3c79..b393cfe16 100644 --- a/DemoCore/Sources/DemoCore/Sync/DemoSyncEngine.swift +++ b/DemoCore/Sources/DemoCore/Sync/DemoSyncEngine.swift @@ -89,7 +89,7 @@ public final class DemoSyncEngine { } } - public func createTask(body: DemoSyncPayload, projectID: String) async throws { + public func createTask(body: SyncJSON, projectID: String) async throws { if isOffline { guard let project = try project(withID: projectID) else { throw SyncTaskDetailError.missingProject(projectID) @@ -107,7 +107,7 @@ public final class DemoSyncEngine { } } - public func updateTask(taskID: String, projectID: String?, body: DemoSyncPayload) async throws { + public func updateTask(taskID: String, projectID: String?, body: SyncJSON) async throws { // A row the server doesn't have yet (offline-created, or a rejected insert) can't be PUT — it // must be (re)sent as an upsert. Apply locally so it stays a pending change; online, push it. let neverSynced = isNeverPushed(taskID) @@ -275,7 +275,7 @@ public final class DemoSyncEngine { // hard-deleted row when a delete loses) and treat the row as resolved, not a failure. if let server = result["server"] as? [String: Any] { try? await syncContainer.sync( - item: DemoSyncPayload(dictionary: server), as: Task.self) + item: SyncJSON(dictionary: server), as: Task.self) } case ("upsert", "applied"), ("delete", "applied"): break @@ -305,7 +305,7 @@ public final class DemoSyncEngine { /// Apply a payload to the local store as a *local* edit (default author), so it's tracked as a /// pending change — unlike `syncContainer.sync(item:)`, which stamps writes as inbound (pulled). /// Reuses the `@Syncable`-generated `make`/`apply`, so no field mapping is duplicated here. - private func applyLocalTask(_ body: DemoSyncPayload, project: Project? = nil) throws { + private func applyLocalTask(_ body: SyncJSON, project: Project? = nil) throws { let values = body.toSyncPayloadDictionary() let payload = SyncPayload(values: values, keyStyle: syncContainer.keyStyle) let context = syncContainer.mainContext @@ -449,7 +449,7 @@ public final class DemoSyncEngine { try await syncItemsIfPresent(in: payload, taskID: taskID) } - private func syncTaskDetailItem(_ payload: DemoSyncPayload) async throws { + private func syncTaskDetailItem(_ payload: SyncJSON) async throws { guard let projectID = payload.string("project_id"), !projectID.isEmpty else { throw SyncTaskDetailError.missingProjectID } @@ -491,7 +491,7 @@ public final class DemoSyncEngine { ).first } - private func syncItemsIfPresent(in payload: DemoSyncPayload, taskID: String) async throws { + private func syncItemsIfPresent(in payload: SyncJSON, taskID: String) async throws { guard let itemPayload = payload.objectArray("items") else { return } guard let resolvedTask = try task(withID: taskID) else { return } nonisolated(unsafe) let task = resolvedTask diff --git a/DemoCore/Tests/DemoCoreTests/OfflinePushTests.swift b/DemoCore/Tests/DemoCoreTests/OfflinePushTests.swift index 758be7ef4..c9b5099bb 100644 --- a/DemoCore/Tests/DemoCoreTests/OfflinePushTests.swift +++ b/DemoCore/Tests/DemoCoreTests/OfflinePushTests.swift @@ -114,7 +114,7 @@ final class OfflinePushTests: XCTestCase { body["items"] = items try await engine.updateTask( - taskID: taskID, projectID: projectID, body: try DemoSyncPayload(dictionary: body)) + taskID: taskID, projectID: projectID, body: try SyncJSON(dictionary: body)) let updated = try XCTUnwrap(fetchTask(id: taskID, in: syncContainer.mainContext)) let titles = updated.items.map(\.title) @@ -170,7 +170,7 @@ final class OfflinePushTests: XCTestCase { var dictionary = syncContainer.export(task) dictionary["title"] = String(repeating: "A", count: 100) try await engine.updateTask( - taskID: taskID, projectID: projectID, body: try DemoSyncPayload(dictionary: dictionary)) + taskID: taskID, projectID: projectID, body: try SyncJSON(dictionary: dictionary)) // Reconnect + push: the server rejects, and the failure is recorded on the row. engine.isOffline = false @@ -207,7 +207,7 @@ final class OfflinePushTests: XCTestCase { var dictionary = syncContainer.export(task) dictionary["title"] = String(repeating: "A", count: 100) try await engine.updateTask( - taskID: taskID, projectID: projectID, body: try DemoSyncPayload(dictionary: dictionary)) + taskID: taskID, projectID: projectID, body: try SyncJSON(dictionary: dictionary)) engine.isOffline = false _ = try await engine.pushPendingChanges() XCTAssertNotNil(try XCTUnwrap(fetchTask(id: taskID, in: syncContainer.mainContext)).syncFailureReason) @@ -240,7 +240,7 @@ final class OfflinePushTests: XCTestCase { createDictionary["title"] = String(repeating: "A", count: 100) createDictionary.removeValue(forKey: "items") try await engine.createTask( - body: try DemoSyncPayload(dictionary: createDictionary), projectID: projectID) + body: try SyncJSON(dictionary: createDictionary), projectID: projectID) engine.isOffline = false _ = try await engine.pushPendingChanges() @@ -253,7 +253,7 @@ final class OfflinePushTests: XCTestCase { fixDictionary["title"] = "Fixed" fixDictionary.removeValue(forKey: "items") try await engine.updateTask( - taskID: id, projectID: projectID, body: try DemoSyncPayload(dictionary: fixDictionary)) + taskID: id, projectID: projectID, body: try SyncJSON(dictionary: fixDictionary)) let fixed = try XCTUnwrap(fetchTask(id: id, in: syncContainer.mainContext)) XCTAssertNil(fixed.syncFailureReason, "the failure is resolved") @@ -281,7 +281,7 @@ final class OfflinePushTests: XCTestCase { badDictionary["title"] = String(repeating: "A", count: 100) badDictionary.removeValue(forKey: "items") try await engine.updateTask( - taskID: taskID, projectID: projectID, body: try DemoSyncPayload(dictionary: badDictionary)) + taskID: taskID, projectID: projectID, body: try SyncJSON(dictionary: badDictionary)) engine.isOffline = false _ = try await engine.pushPendingChanges() @@ -294,7 +294,7 @@ final class OfflinePushTests: XCTestCase { fixDictionary["title"] = "Fixed online" fixDictionary.removeValue(forKey: "items") try await engine.updateTask( - taskID: taskID, projectID: projectID, body: try DemoSyncPayload(dictionary: fixDictionary)) + taskID: taskID, projectID: projectID, body: try SyncJSON(dictionary: fixDictionary)) let fixed = try XCTUnwrap(fetchTask(id: taskID, in: syncContainer.mainContext)) XCTAssertNil(fixed.syncFailureReason, "the failure clears once the corrected edit saves") @@ -325,7 +325,7 @@ final class OfflinePushTests: XCTestCase { localEdit["title"] = "Local edit" localEdit.removeValue(forKey: "items") try await engine.updateTask( - taskID: taskID, projectID: projectID, body: try DemoSyncPayload(dictionary: localEdit)) + taskID: taskID, projectID: projectID, body: try SyncJSON(dictionary: localEdit)) // Then another client advances the server's copy (timestamp T2 > T1), so our edit loses LWW. let serverTitle = "Server wins \(UUID().uuidString.prefix(6))" @@ -371,7 +371,7 @@ final class OfflinePushTests: XCTestCase { editDictionary.removeValue(forKey: "items") try await engine.updateTask( taskID: "OFFLINE-EDIT-1", projectID: projectID, - body: try DemoSyncPayload(dictionary: editDictionary)) + body: try SyncJSON(dictionary: editDictionary)) let edited = try XCTUnwrap(fetchTask(id: "OFFLINE-EDIT-1", in: syncContainer.mainContext)) XCTAssertEqual(edited.title, "Renamed offline", "the edit updates the local row's title") @@ -405,7 +405,7 @@ final class OfflinePushTests: XCTestCase { var body = syncContainer.export(task) body["reviewer_ids"] = newReviewers try await engine.updateTask( - taskID: taskID, projectID: projectID, body: try DemoSyncPayload(dictionary: body)) + taskID: taskID, projectID: projectID, body: try SyncJSON(dictionary: body)) let offline = try XCTUnwrap(fetchTask(id: taskID, in: syncContainer.mainContext)) XCTAssertEqual( @@ -588,7 +588,7 @@ final class OfflinePushTests: XCTestCase { engine.isOffline = true let task = try XCTUnwrap(fetchTask(id: taskID, in: syncContainer.mainContext)) - let body = try mutating(try DemoSyncPayload(dictionary: syncContainer.export(task))) { + let body = try mutating(try SyncJSON(dictionary: syncContainer.export(task))) { $0["title"] = "edited offline" } try await engine.updateTask(taskID: taskID, projectID: projectID, body: body) @@ -622,7 +622,7 @@ final class OfflinePushTests: XCTestCase { engine.isOffline = true let task = try XCTUnwrap(fetchTask(id: taskID, in: syncContainer.mainContext)) let invalidTitle = String(repeating: "A", count: 100) - let body = try mutating(try DemoSyncPayload(dictionary: syncContainer.export(task))) { + let body = try mutating(try SyncJSON(dictionary: syncContainer.export(task))) { $0["title"] = invalidTitle } try await engine.updateTask(taskID: taskID, projectID: projectID, body: body) @@ -650,7 +650,7 @@ final class OfflinePushTests: XCTestCase { // Pollute: offline edit to an invalid (too-long) title the server rejects on push. engine.isOffline = true let task = try XCTUnwrap(fetchTask(id: taskID, in: syncContainer.mainContext)) - let invalidBody = try mutating(try DemoSyncPayload(dictionary: syncContainer.export(task))) { + let invalidBody = try mutating(try SyncJSON(dictionary: syncContainer.export(task))) { $0["title"] = String(repeating: "A", count: 100) } try await engine.updateTask(taskID: taskID, projectID: projectID, body: invalidBody) @@ -690,7 +690,7 @@ final class OfflinePushTests: XCTestCase { engine.isOffline = true let polluted = try XCTUnwrap(fetchTask(id: pollutedID, in: syncContainer.mainContext)) let invalidTitle = String(repeating: "A", count: 100) - let invalidBody = try mutating(try DemoSyncPayload(dictionary: syncContainer.export(polluted))) { + let invalidBody = try mutating(try SyncJSON(dictionary: syncContainer.export(polluted))) { $0["title"] = invalidTitle } try await engine.updateTask(taskID: pollutedID, projectID: projectID, body: invalidBody) @@ -710,24 +710,24 @@ final class OfflinePushTests: XCTestCase { XCTAssertEqual(siblingRow.title, siblingTitle, "a sibling row still refreshes — the pull isn't blocked") } - private func mutating(_ body: DemoSyncPayload, _ transform: (inout [String: Any]) -> Void) throws - -> DemoSyncPayload + private func mutating(_ body: SyncJSON, _ transform: (inout [String: Any]) -> Void) throws + -> SyncJSON { var dictionary = body.toSyncPayloadDictionary() transform(&dictionary) - return try DemoSyncPayload(dictionary: dictionary) + return try SyncJSON(dictionary: dictionary) } @MainActor private func createBody(from templateID: String, newID: String, in syncContainer: SyncContainer) throws - -> DemoSyncPayload + -> SyncJSON { let template = try XCTUnwrap(fetchTask(id: templateID, in: syncContainer.mainContext)) var dictionary = syncContainer.export(template) dictionary["id"] = newID dictionary["title"] = "Offline task" dictionary.removeValue(forKey: "items") - return try DemoSyncPayload(dictionary: dictionary) + return try SyncJSON(dictionary: dictionary) } @MainActor diff --git a/README.md b/README.md index 2a17d7275..293e5172f 100644 --- a/README.md +++ b/README.md @@ -513,6 +513,22 @@ Use these annotations when you need them: See [Property Mapping Contract](docs/project/property-mapping-contract.md) for the complete mapping rules. +## Sendable Payloads + +`sync(payload:)` takes `[String: Any]`, which is fine when you decode and sync on the same actor. But `[String: Any]` is not `Sendable`, so if a payload has to cross an actor boundary — e.g. you decode a response on one actor and `sync` it on another — Swift 6 will flag the hop. + +`SyncJSON` is the carrier for that case: a `Sendable`, structured JSON value that conforms to `SyncPayloadConvertible`, so it feeds `sync` directly. Box your JSON once, carry it across actors, and read it back with keyed accessors: + +```swift +let payload = try SyncJSON(dictionary: responseDictionary) // Sendable; crosses actors freely +try await syncContainer.sync(payload: [payload], as: User.self) + +payload.string("id") // typed read +payload.objectArray("items") // nested objects +``` + +It preserves `null` (so a sync can clear a field) and the underlying value shapes, exactly like the dictionary would. + ## Reactive Reads SwiftSync is built around local reactive reads. That means your views do not fetch directly from the network and then hold onto that response as UI state. Instead, sync writes backend changes into SwiftData, and the UI reads from SwiftData as its source of truth. diff --git a/SwiftSync/Sources/SwiftSync/SyncJSON.swift b/SwiftSync/Sources/SwiftSync/SyncJSON.swift new file mode 100644 index 000000000..57a472c6d --- /dev/null +++ b/SwiftSync/Sources/SwiftSync/SyncJSON.swift @@ -0,0 +1,79 @@ +import Foundation + +/// A `Sendable`, structured JSON value. +/// +/// `sync(...)` runs off the main actor, so a payload crossing into it must be `Sendable` — which a raw +/// `[String: Any]` is not. `SyncJSON` is the carrier: box your JSON once with `init(dictionary:)`, hand it +/// to `sync` across actor boundaries, and read it back with the keyed accessors. It conforms to +/// `SyncPayloadConvertible`, so it feeds `sync(payload:)` / `sync(item:)` directly. +public enum SyncJSON: Sendable, SyncPayloadConvertible { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case object([String: SyncJSON]) + case array([SyncJSON]) + case null + + /// Boxes an arbitrary JSON value. `NSNumber` is matched first so a JSON boolean isn't mistaken for `1` + /// (a `Bool` bridges to `NSNumber` and would otherwise satisfy `as? Int`). + public init(_ value: Any) throws { + switch value { + case let value as SyncJSON: + self = value + case is NSNull: + self = .null + case let value as NSNumber: + if CFGetTypeID(value) == CFBooleanGetTypeID() { + self = .bool(value.boolValue) + } else if value.doubleValue.rounded(.towardZero) == value.doubleValue { + self = .int(value.intValue) + } else { + self = .double(value.doubleValue) + } + case let value as String: + self = .string(value) + case let value as [String: Any]: + self = .object(try value.mapValues { try SyncJSON($0) }) + case let value as [Any]: + self = .array(try value.map { try SyncJSON($0) }) + default: + throw SyncError.invalidPayload( + model: "SyncJSON", reason: "unsupported value of type \(type(of: value))") + } + } + + /// Boxes a JSON object. + public init(dictionary: [String: Any]) throws { + self = .object(try dictionary.mapValues { try SyncJSON($0) }) + } + + public func toSyncPayloadDictionary() -> [String: Any] { + guard case .object(let members) = self else { return [:] } + return members.mapValues(\.foundationValue) + } + + /// The string at `key` when this is an object whose value there is a string. + public func string(_ key: String) -> String? { + guard case .object(let members) = self, case .string(let value)? = members[key] else { return nil } + return value + } + + /// The object elements of the array at `key` when this is an object. + public func objectArray(_ key: String) -> [SyncJSON]? { + guard case .object(let members) = self, case .array(let elements)? = members[key] else { return nil } + return elements.filter { if case .object = $0 { return true } else { return false } } + } + + var foundationValue: Any { + switch self { + case .string(let value): return value + case .int(let value): return value + case .double(let value): return value + case .bool(let value): return value + case .object(let value): return value.mapValues(\.foundationValue) + case .array(let value): return value.map(\.foundationValue) + case .null: return NSNull() + } + } +} diff --git a/SwiftSync/Tests/SwiftSyncTests/SyncJSONTests.swift b/SwiftSync/Tests/SwiftSyncTests/SyncJSONTests.swift new file mode 100644 index 000000000..2df4f3a1c --- /dev/null +++ b/SwiftSync/Tests/SwiftSyncTests/SyncJSONTests.swift @@ -0,0 +1,45 @@ +import SwiftData +import SwiftSync +import XCTest + +final class SyncJSONTests: XCTestCase { + func testRoundTripsAStructuredDictionary() throws { + let json = try SyncJSON(dictionary: [ + "id": 7, + "full_name": "Ada", + "active": true, + "score": 3.5, + "tags": ["a", "b"], + "nested": ["k": "v"], + "cleared": NSNull(), + ]) + let dict = json.toSyncPayloadDictionary() + + XCTAssertEqual(dict["id"] as? Int, 7) + XCTAssertEqual(dict["full_name"] as? String, "Ada") + XCTAssertEqual(dict["active"] as? Bool, true) + XCTAssertEqual(dict["score"] as? Double, 3.5) + XCTAssertEqual(dict["tags"] as? [String], ["a", "b"]) + XCTAssertEqual((dict["nested"] as? [String: Any])?["k"] as? String, "v") + XCTAssertTrue(dict["cleared"] is NSNull, "explicit null is preserved (so a sync can clear)") + } + + func testKeyedAccessors() throws { + let json = try SyncJSON(dictionary: [ + "project_id": "P1", + "items": [["id": "i1"], ["id": "i2"]], + ]) + XCTAssertEqual(json.string("project_id"), "P1") + XCTAssertEqual(json.objectArray("items")?.count, 2) + XCTAssertEqual(json.objectArray("items")?.first?.string("id"), "i1") + } + + @MainActor + func testSyncsAsAPayloadConvertibleValue() async throws { + let container = try SyncContainer(for: User.self, configurations: .init(isStoredInMemoryOnly: true)) + try await container.sync(payload: [SyncJSON(dictionary: ["id": 1, "full_name": "Ada"])], as: User.self) + + let users = try container.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(users.map(\.fullName), ["Ada"]) + } +}