From d05a4859143fbdc3adb83630b099f8838f2aa0d4 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Wed, 7 Jun 2023 22:18:44 -0500 Subject: [PATCH 1/5] WIP: Changes are not syncing across contexts swift-data --- Package.swift | 22 +++-- .../PersistentModelProxy.swift | 19 ++++ .../SwiftDataRepository.swift | 69 +++++++++++++++ .../CRUDSwiftDataRepositoryTests.swift | 37 ++++++++ Tests/SwiftDataRepositoryTests/Movie.swift | 78 +++++++++++++++++ .../SwiftDataXCTestCase.swift | 87 +++++++++++++++++++ 6 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 Sources/SwiftDataRepository/PersistentModelProxy.swift create mode 100644 Sources/SwiftDataRepository/SwiftDataRepository.swift create mode 100644 Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift create mode 100644 Tests/SwiftDataRepositoryTests/Movie.swift create mode 100644 Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift diff --git a/Package.swift b/Package.swift index ceda659..187e8d7 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -7,16 +7,20 @@ let package = Package( name: "CoreDataRepository", defaultLocalization: "en", platforms: [ - .iOS(.v15), - .macOS(.v12), - .tvOS(.v15), - .watchOS(.v8), + .iOS(.v17), + .macOS(.v14), + .tvOS(.v17), + .watchOS(.v10), ], products: [ .library( name: "CoreDataRepository", targets: ["CoreDataRepository"] ), + .library( + name: "SwiftDataRepository", + targets: ["SwiftDataRepository"] + ), ], dependencies: [ .package(url: "https://github.com/CombineCommunity/CombineExt.git", .upToNextMajor(from: "1.5.1")), @@ -34,5 +38,13 @@ let package = Package( .product(name: "CustomDump", package: "swift-custom-dump"), ] ), + .target(name: "SwiftDataRepository"), + .testTarget( + name: "SwiftDataRepositoryTests", + dependencies: [ + "SwiftDataRepository", + .product(name: "CustomDump", package: "swift-custom-dump"), + ] + ), ] ) diff --git a/Sources/SwiftDataRepository/PersistentModelProxy.swift b/Sources/SwiftDataRepository/PersistentModelProxy.swift new file mode 100644 index 0000000..e99d297 --- /dev/null +++ b/Sources/SwiftDataRepository/PersistentModelProxy.swift @@ -0,0 +1,19 @@ +// PersistentModelProxy.swift +// CoreDataRepository +// +// +// MIT License +// +// Copyright © 2023 Andrew Roan + +import Foundation +import SwiftData + +public protocol PersistentModelProxy: Equatable { + associatedtype Persistent: PersistentModel + + var persistentId: PersistentIdentifier? { get set } + func asPersistentModel(in context: ModelContext) -> Persistent + init(persisted: Persistent) + func updating(persisted: Persistent) +} diff --git a/Sources/SwiftDataRepository/SwiftDataRepository.swift b/Sources/SwiftDataRepository/SwiftDataRepository.swift new file mode 100644 index 0000000..0d5830f --- /dev/null +++ b/Sources/SwiftDataRepository/SwiftDataRepository.swift @@ -0,0 +1,69 @@ +// SwiftDataRepository.swift +// CoreDataRepository +// +// +// MIT License +// +// Copyright © 2023 Andrew Roan + +import Foundation +import SwiftData + +public actor SwiftDataRepository: ModelActor { + public let executor: any ModelExecutor + + public init(container: ModelContainer) { + let context = ModelContext(container) + executor = DefaultModelExecutor(context: context) + } + + public enum Failure: Error, Equatable, Hashable, Sendable { + case unknown(NSError) + case swiftData(SwiftDataError) + case noPersistentId + case noModelFoundForId(PersistentIdentifier) + + public var localizedDescription: String { + switch self { + case let .unknown(nsError): + return nsError.localizedDescription + case let .swiftData(swiftDataError): + return swiftDataError.localizedDescription + case .noPersistentId: + return "PersistentIdentifier required but not found on proxy." + case let .noModelFoundForId(id): + return "No model found in context for id \(id.id) and entity \(id.entityName)." + } + } + } + + public func create(_ item: Proxy) async -> Result where Proxy: PersistentModelProxy { + await Task { + do { + var item = item + let repoItem = item.asPersistentModel(in: context) + context.insert(repoItem) + try context.save() + item.persistentId = repoItem.objectID + return .success(item) + } catch let error as SwiftDataError { + context.undo() + return .failure(.swiftData(error)) + } catch { + context.undo() + return .failure(.unknown(error as NSError)) + } + }.value + } + + public func read(identifier: PersistentIdentifier, as _: Proxy.Type) async -> Result + where Proxy: PersistentModelProxy + { + await Task { + guard let repoItem: Proxy.Persistent = context.registeredObject(for: identifier) else { + return .failure(.noModelFoundForId(identifier)) + } + return .success(Proxy(persisted: repoItem)) + }.value + } +} diff --git a/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift b/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift new file mode 100644 index 0000000..1505231 --- /dev/null +++ b/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift @@ -0,0 +1,37 @@ +// CRUDRepositoryTests.swift +// CoreDataRepository +// +// +// MIT License +// +// Copyright © 2023 Andrew Roan + +import CustomDump +import Foundation +import SwiftData +import SwiftDataRepository +import XCTest + +@MainActor +final class CRUDSwiftDataRepositoryTests: SwiftDataXCTestCase { + func testCreateSuccess() async throws { + let movie = Movie(id: UUID(), title: "Create Success", releaseDate: Date(), boxOffice: 100) + let createMovie = try await repository().create(movie).get() + let readMovie = try await repository().read(identifier: XCTUnwrap(createMovie.persistentId), as: Movie.self) + .get() + XCTAssertEqual(movie.id, readMovie.id) + XCTAssertNotNil(readMovie.persistentId) + XCTAssertNotNil(createMovie.persistentId) + XCTAssertEqual(createMovie.persistentId, readMovie.persistentId) + } + + func testReadSuccess() async throws { + let movie = Movie(id: UUID(), title: "Create Success", releaseDate: Date(), boxOffice: 100) + try context().insert(movie.asPersistentModel(in: context())) + try context().save() + let identifier = try identifier(for: movie) + let readMovie: Movie = try await repository().read(identifier: identifier, as: Movie.self).get() + XCTAssertNotNil(readMovie.persistentId) + XCTAssertEqual(movie.id, readMovie.id) + } +} diff --git a/Tests/SwiftDataRepositoryTests/Movie.swift b/Tests/SwiftDataRepositoryTests/Movie.swift new file mode 100644 index 0000000..1ed6e61 --- /dev/null +++ b/Tests/SwiftDataRepositoryTests/Movie.swift @@ -0,0 +1,78 @@ +// Movie.swift +// CoreDataRepository +// +// +// MIT License +// +// Copyright © 2023 Andrew Roan + +import Foundation +import SwiftData +import SwiftDataRepository + +public struct Movie: Hashable, Identifiable, Codable { + public let id: UUID + public var title: String = "" + public var releaseDate: Date + public var boxOffice: Decimal = 0 + public var persistentId: PersistentIdentifier? + + public init( + id: UUID, + title: String, + releaseDate: Date, + boxOffice: Decimal, + persistentId: PersistentIdentifier? = nil + ) { + self.id = id + self.title = title + self.releaseDate = releaseDate + self.boxOffice = boxOffice + self.persistentId = persistentId + } +} + +extension Movie: PersistentModelProxy { + public func asPersistentModel(in context: ModelContext) -> RepoMovie { + if let persistentId, let existingObject: Persistent = context.registeredObject(for: persistentId) { + updating(persisted: existingObject) + return existingObject + } else { + let object = RepoMovie(id: id, title: title, releaseDate: releaseDate, boxOffice: boxOffice) + context.insert(object) + return object + } + } + + public func updating(persisted: RepoMovie) { + persisted.id = id + persisted.title = title + persisted.releaseDate = releaseDate + persisted.boxOffice = boxOffice + } + + public init(persisted: RepoMovie) { + self.init( + id: persisted.id, + title: persisted.title, + releaseDate: persisted.releaseDate, + boxOffice: persisted.boxOffice, + persistentId: persisted.objectID + ) + } +} + +@Model +public final class RepoMovie: Identifiable { + public var id: UUID + public var title: String + public var releaseDate: Date + public var boxOffice: Decimal + + public init(id: UUID, title: String, releaseDate: Date, boxOffice: Decimal) { + self.id = id + self.title = title + self.releaseDate = releaseDate + self.boxOffice = boxOffice + } +} diff --git a/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift b/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift new file mode 100644 index 0000000..fddd0c3 --- /dev/null +++ b/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift @@ -0,0 +1,87 @@ +// SwiftDataXCTestCase.swift +// CoreDataRepository +// +// +// MIT License +// +// Copyright © 2023 Andrew Roan + +import CustomDump +import SwiftData +import SwiftDataRepository +import XCTest + +@MainActor +class SwiftDataXCTestCase: XCTestCase { + var _container: ModelContainer? + var _context: ModelContext? + var _repository: SwiftDataRepository? + + func container() throws -> ModelContainer { + try XCTUnwrap(_container) + } + + func context() throws -> ModelContext { + try XCTUnwrap(_context) + } + + func repository() throws -> SwiftDataRepository { + try XCTUnwrap(_repository) + } + + override func setUp() async throws { + let container = try ModelContainer( + for: RepoMovie.self, + ModelConfiguration(inMemory: true) + ) + _container = container + _context = container.mainContext + _repository = SwiftDataRepository(container: container) + try await super.setUp() + } + + override func tearDown() async throws { + try await super.tearDown() + _container?.destroy() + _container = nil + _context = nil + _repository = nil + } + + enum Failure: Error, Hashable, Sendable { + case noPersistentIdFoundOnProxy + case noItemFoundForPersistentId + } + + func verify(_ item: T) throws where T: PersistentModelProxy { + guard let identifier = item.persistentId else { + throw Failure.noPersistentIdFoundOnProxy + } + + guard let _object: T.Persistent = try context().registeredObject(for: identifier) else { + throw Failure.noItemFoundForPersistentId + } + XCTAssertNoDifference(item, try T(persisted: XCTUnwrap(_object))) + } + + func verifyDoesNotExist(_ item: T) throws where T: PersistentModelProxy { + guard let identifier = item.persistentId else { + throw Failure.noPersistentIdFoundOnProxy + } + + try context().transaction { + let model: T.Persistent? = try context().registeredObject(for: identifier) + XCTAssertNil(model) + } + } + + func identifier(for item: T) throws -> PersistentIdentifier where T: Identifiable, T: PersistentModelProxy, + T.Persistent: Identifiable, T.ID == T.Persistent.ID, T: Codable + { + let predicate = #Predicate { model in + model.id == item.id + } + let first = try XCTUnwrap(context().fetchIdentifiers(FetchDescriptor(predicate: predicate)).first) + return first + } +} From 15dbd7b6323103d777f5b7798b2027b1d3f3a07b Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Thu, 6 Jul 2023 20:27:17 -0500 Subject: [PATCH 2/5] Basic CRUD operations with happy path tests swift-data --- .../IdentifiableByProxy.swift | 15 ++++++ .../SwiftDataRepository.swift | 45 ++++++++++++++++++ .../CRUDSwiftDataRepositoryTests.swift | 46 ++++++++++++++----- Tests/SwiftDataRepositoryTests/Movie.swift | 14 +++--- .../SwiftDataXCTestCase.swift | 45 +++++++----------- 5 files changed, 117 insertions(+), 48 deletions(-) create mode 100644 Sources/SwiftDataRepository/IdentifiableByProxy.swift diff --git a/Sources/SwiftDataRepository/IdentifiableByProxy.swift b/Sources/SwiftDataRepository/IdentifiableByProxy.swift new file mode 100644 index 0000000..330b6f1 --- /dev/null +++ b/Sources/SwiftDataRepository/IdentifiableByProxy.swift @@ -0,0 +1,15 @@ +// IdentifiableByProxy.swift +// CoreDataRepository +// +// +// MIT License +// +// Copyright © 2023 Andrew Roan + +import Foundation + +public protocol IdentifiableByProxy { + associatedtype ProxID: Hashable + + var proxyID: ProxID { get } +} diff --git a/Sources/SwiftDataRepository/SwiftDataRepository.swift b/Sources/SwiftDataRepository/SwiftDataRepository.swift index 0d5830f..2c0b320 100644 --- a/Sources/SwiftDataRepository/SwiftDataRepository.swift +++ b/Sources/SwiftDataRepository/SwiftDataRepository.swift @@ -14,6 +14,7 @@ public actor SwiftDataRepository: ModelActor { public init(container: ModelContainer) { let context = ModelContext(container) + context.autosaveEnabled = false executor = DefaultModelExecutor(context: context) } @@ -66,4 +67,48 @@ public actor SwiftDataRepository: ModelActor { return .success(Proxy(persisted: repoItem)) }.value } + + public func update(_ item: Proxy) async -> Result where Proxy: PersistentModelProxy { + guard let persistentId = item.persistentId else { + return .failure(.noPersistentId) + } + return await Task { + guard let object = context.object(with: persistentId) as? Proxy.Persistent else { + return .failure(.noModelFoundForId(persistentId)) + } + item.updating(persisted: object) + + do { + try context.save() + return .success(Proxy(persisted: object)) + } catch let error as SwiftDataError { + context.undo() + return .failure(.swiftData(error)) + } catch { + context.undo() + return .failure(.unknown(error as NSError)) + } + }.value + } + + public func delete(identifier: PersistentIdentifier) async -> Result { + await Task { + let object = context.object(with: identifier) + context.delete(object) + context.delete(object: object) + if !object.isDeleted() { + fatalError() + } + do { + try context.save() + return .success(()) + } catch let error as SwiftDataError { + context.undo() + return .failure(.swiftData(error)) + } catch { + context.undo() + return .failure(.unknown(error as NSError)) + } + }.value + } } diff --git a/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift b/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift index 1505231..a2ad682 100644 --- a/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift +++ b/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift @@ -1,4 +1,4 @@ -// CRUDRepositoryTests.swift +// CRUDSwiftDataRepositoryTests.swift // CoreDataRepository // // @@ -12,26 +12,48 @@ import SwiftData import SwiftDataRepository import XCTest -@MainActor final class CRUDSwiftDataRepositoryTests: SwiftDataXCTestCase { func testCreateSuccess() async throws { let movie = Movie(id: UUID(), title: "Create Success", releaseDate: Date(), boxOffice: 100) let createMovie = try await repository().create(movie).get() - let readMovie = try await repository().read(identifier: XCTUnwrap(createMovie.persistentId), as: Movie.self) - .get() - XCTAssertEqual(movie.id, readMovie.id) - XCTAssertNotNil(readMovie.persistentId) - XCTAssertNotNil(createMovie.persistentId) - XCTAssertEqual(createMovie.persistentId, readMovie.persistentId) + + try await verify(createMovie) } func testReadSuccess() async throws { - let movie = Movie(id: UUID(), title: "Create Success", releaseDate: Date(), boxOffice: 100) - try context().insert(movie.asPersistentModel(in: context())) - try context().save() - let identifier = try identifier(for: movie) + let movie = Movie(id: UUID(), title: "Read Success", releaseDate: Date(), boxOffice: 100) + try await context().insert(movie.asPersistentModel(in: context())) + try await context().save() + let identifier = try await identifier(for: movie) let readMovie: Movie = try await repository().read(identifier: identifier, as: Movie.self).get() XCTAssertNotNil(readMovie.persistentId) XCTAssertEqual(movie.id, readMovie.id) } + + func testUpdateSuccess() async throws { + let movie = Movie( + id: UUID(), + title: "Update Success", + releaseDate: Date(timeIntervalSinceReferenceDate: 0), + boxOffice: 100 + ) + try await context().insert(movie.asPersistentModel(in: context())) + try await context().save() + var _updatedMovie = movie + _updatedMovie.releaseDate = Date.distantFuture + _updatedMovie.persistentId = try await identifier(for: movie) + + let updatedMovie = try await repository().update(_updatedMovie).get() + XCTAssertEqual(_updatedMovie, updatedMovie) + } + + func testDeleteSuccess() async throws { + var movie = Movie(id: UUID(), title: "Delete Success", releaseDate: Date(), boxOffice: 100) + try await context().insert(movie.asPersistentModel(in: context())) + try await context().save() + let identifier = try await identifier(for: movie) + movie.persistentId = identifier + _ = try await repository().delete(identifier: identifier).get() + try await verifyDoesNotExist(movie) + } } diff --git a/Tests/SwiftDataRepositoryTests/Movie.swift b/Tests/SwiftDataRepositoryTests/Movie.swift index 1ed6e61..de40f3a 100644 --- a/Tests/SwiftDataRepositoryTests/Movie.swift +++ b/Tests/SwiftDataRepositoryTests/Movie.swift @@ -38,14 +38,14 @@ extension Movie: PersistentModelProxy { updating(persisted: existingObject) return existingObject } else { - let object = RepoMovie(id: id, title: title, releaseDate: releaseDate, boxOffice: boxOffice) + let object = RepoMovie(proxyID: id, title: title, releaseDate: releaseDate, boxOffice: boxOffice) context.insert(object) return object } } public func updating(persisted: RepoMovie) { - persisted.id = id + persisted.proxyID = id persisted.title = title persisted.releaseDate = releaseDate persisted.boxOffice = boxOffice @@ -53,7 +53,7 @@ extension Movie: PersistentModelProxy { public init(persisted: RepoMovie) { self.init( - id: persisted.id, + id: persisted.proxyID, title: persisted.title, releaseDate: persisted.releaseDate, boxOffice: persisted.boxOffice, @@ -63,14 +63,14 @@ extension Movie: PersistentModelProxy { } @Model -public final class RepoMovie: Identifiable { - public var id: UUID +public final class RepoMovie: IdentifiableByProxy { + public var proxyID: UUID public var title: String public var releaseDate: Date public var boxOffice: Decimal - public init(id: UUID, title: String, releaseDate: Date, boxOffice: Decimal) { - self.id = id + public init(proxyID: UUID, title: String, releaseDate: Date, boxOffice: Decimal) { + self.proxyID = proxyID self.title = title self.releaseDate = releaseDate self.boxOffice = boxOffice diff --git a/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift b/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift index fddd0c3..5469a30 100644 --- a/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift +++ b/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift @@ -13,16 +13,14 @@ import XCTest @MainActor class SwiftDataXCTestCase: XCTestCase { - var _container: ModelContainer? - var _context: ModelContext? var _repository: SwiftDataRepository? - func container() throws -> ModelContainer { - try XCTUnwrap(_container) + func container() async throws -> ModelContainer { + try await repository().container } - func context() throws -> ModelContext { - try XCTUnwrap(_context) + func context() async throws -> ModelContext { + try await repository().executor.context } func repository() throws -> SwiftDataRepository { @@ -34,17 +32,13 @@ class SwiftDataXCTestCase: XCTestCase { for: RepoMovie.self, ModelConfiguration(inMemory: true) ) - _container = container - _context = container.mainContext _repository = SwiftDataRepository(container: container) try await super.setUp() } override func tearDown() async throws { try await super.tearDown() - _container?.destroy() - _container = nil - _context = nil + try await container().destroy() _repository = nil } @@ -53,35 +47,28 @@ class SwiftDataXCTestCase: XCTestCase { case noItemFoundForPersistentId } - func verify(_ item: T) throws where T: PersistentModelProxy { + func verify(_ item: T) async throws where T: PersistentModelProxy { guard let identifier = item.persistentId else { throw Failure.noPersistentIdFoundOnProxy } - guard let _object: T.Persistent = try context().registeredObject(for: identifier) else { + guard let _object: T.Persistent = try await context().registeredObject(for: identifier) else { throw Failure.noItemFoundForPersistentId } XCTAssertNoDifference(item, try T(persisted: XCTUnwrap(_object))) } - func verifyDoesNotExist(_ item: T) throws where T: PersistentModelProxy { - guard let identifier = item.persistentId else { - throw Failure.noPersistentIdFoundOnProxy - } - - try context().transaction { - let model: T.Persistent? = try context().registeredObject(for: identifier) - XCTAssertNil(model) - } + func verifyDoesNotExist(_ item: T) async throws where T: Identifiable, T: PersistentModelProxy, + T.Persistent: IdentifiableByProxy, T.ID == T.Persistent.ProxID + { + let object = try await context().fetch(FetchDescriptor()).first(where: { $0.proxyID == item.id }) + XCTAssertNil(object) } - func identifier(for item: T) throws -> PersistentIdentifier where T: Identifiable, T: PersistentModelProxy, - T.Persistent: Identifiable, T.ID == T.Persistent.ID, T: Codable + func identifier(for item: T) async throws -> PersistentIdentifier where T: Identifiable, T: PersistentModelProxy, + T.Persistent: IdentifiableByProxy, T.ID == T.Persistent.ProxID { - let predicate = #Predicate { model in - model.id == item.id - } - let first = try XCTUnwrap(context().fetchIdentifiers(FetchDescriptor(predicate: predicate)).first) - return first + let first = try await context().fetch(FetchDescriptor()).first(where: { $0.proxyID == item.id }) + return try XCTUnwrap(first?.objectID) } } From b2fda26843f163a681f00883009fda94735a5673 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Tue, 25 Jul 2023 23:24:06 -0500 Subject: [PATCH 3/5] Update for Xcode 15 beta 5 swift-data --- .../SwiftDataRepository.swift | 17 ++++++++--------- Tests/SwiftDataRepositoryTests/Movie.swift | 2 +- .../SwiftDataXCTestCase.swift | 2 +- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/Sources/SwiftDataRepository/SwiftDataRepository.swift b/Sources/SwiftDataRepository/SwiftDataRepository.swift index 2c0b320..8ff0709 100644 --- a/Sources/SwiftDataRepository/SwiftDataRepository.swift +++ b/Sources/SwiftDataRepository/SwiftDataRepository.swift @@ -48,10 +48,10 @@ public actor SwiftDataRepository: ModelActor { item.persistentId = repoItem.objectID return .success(item) } catch let error as SwiftDataError { - context.undo() + context.rollback() return .failure(.swiftData(error)) } catch { - context.undo() + context.rollback() return .failure(.unknown(error as NSError)) } }.value @@ -61,7 +61,7 @@ public actor SwiftDataRepository: ModelActor { where Proxy: PersistentModelProxy { await Task { - guard let repoItem: Proxy.Persistent = context.registeredObject(for: identifier) else { + guard let repoItem: Proxy.Persistent = context.object(with: identifier) as? Proxy.Persistent else { return .failure(.noModelFoundForId(identifier)) } return .success(Proxy(persisted: repoItem)) @@ -82,20 +82,19 @@ public actor SwiftDataRepository: ModelActor { try context.save() return .success(Proxy(persisted: object)) } catch let error as SwiftDataError { - context.undo() + context.rollback() return .failure(.swiftData(error)) } catch { - context.undo() + context.rollback() return .failure(.unknown(error as NSError)) } }.value } public func delete(identifier: PersistentIdentifier) async -> Result { - await Task { + await Task { [context] in let object = context.object(with: identifier) context.delete(object) - context.delete(object: object) if !object.isDeleted() { fatalError() } @@ -103,10 +102,10 @@ public actor SwiftDataRepository: ModelActor { try context.save() return .success(()) } catch let error as SwiftDataError { - context.undo() + context.rollback() return .failure(.swiftData(error)) } catch { - context.undo() + context.rollback() return .failure(.unknown(error as NSError)) } }.value diff --git a/Tests/SwiftDataRepositoryTests/Movie.swift b/Tests/SwiftDataRepositoryTests/Movie.swift index de40f3a..a8fc6d9 100644 --- a/Tests/SwiftDataRepositoryTests/Movie.swift +++ b/Tests/SwiftDataRepositoryTests/Movie.swift @@ -34,7 +34,7 @@ public struct Movie: Hashable, Identifiable, Codable { extension Movie: PersistentModelProxy { public func asPersistentModel(in context: ModelContext) -> RepoMovie { - if let persistentId, let existingObject: Persistent = context.registeredObject(for: persistentId) { + if let persistentId, let existingObject: Persistent = context.object(with: persistentId) as? Persistent { updating(persisted: existingObject) return existingObject } else { diff --git a/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift b/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift index 5469a30..fa556ef 100644 --- a/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift +++ b/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift @@ -52,7 +52,7 @@ class SwiftDataXCTestCase: XCTestCase { throw Failure.noPersistentIdFoundOnProxy } - guard let _object: T.Persistent = try await context().registeredObject(for: identifier) else { + guard let _object: T.Persistent = try await context().object(with: identifier) as? T.Persistent else { throw Failure.noItemFoundForPersistentId } XCTAssertNoDifference(item, try T(persisted: XCTUnwrap(_object))) From 4e535a99477ecc127fdb65e1a8d08d08ce9d24c4 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Mon, 18 Sep 2023 21:32:41 -0500 Subject: [PATCH 4/5] Update for Xcode 15 release. Start work on read subscriptions swift-data --- .../SwiftDataRepository.swift | 194 ++++++++++++------ .../CRUDSwiftDataRepositoryTests.swift | 60 ++++++ Tests/SwiftDataRepositoryTests/Movie.swift | 4 +- .../SwiftDataXCTestCase.swift | 12 +- 4 files changed, 202 insertions(+), 68 deletions(-) diff --git a/Sources/SwiftDataRepository/SwiftDataRepository.swift b/Sources/SwiftDataRepository/SwiftDataRepository.swift index 8ff0709..b4fbc7e 100644 --- a/Sources/SwiftDataRepository/SwiftDataRepository.swift +++ b/Sources/SwiftDataRepository/SwiftDataRepository.swift @@ -10,12 +10,20 @@ import Foundation import SwiftData public actor SwiftDataRepository: ModelActor { - public let executor: any ModelExecutor + public nonisolated var modelContainer: ModelContainer { + modelExecutor.modelContext.container + } + + public var context: ModelContext { + modelExecutor.modelContext + } + + public let modelExecutor: ModelExecutor public init(container: ModelContainer) { let context = ModelContext(container) context.autosaveEnabled = false - executor = DefaultModelExecutor(context: context) + modelExecutor = DefaultSerialModelExecutor(modelContext: context) } public enum Failure: Error, Equatable, Hashable, Sendable { @@ -38,76 +46,142 @@ public actor SwiftDataRepository: ModelActor { } } - public func create(_ item: Proxy) async -> Result where Proxy: PersistentModelProxy { - await Task { - do { - var item = item - let repoItem = item.asPersistentModel(in: context) - context.insert(repoItem) - try context.save() - item.persistentId = repoItem.objectID - return .success(item) - } catch let error as SwiftDataError { - context.rollback() - return .failure(.swiftData(error)) - } catch { - context.rollback() - return .failure(.unknown(error as NSError)) - } - }.value + public func create(_ item: Proxy) -> Result where Proxy: PersistentModelProxy { + do { + var item = item + let repoItem = item.asPersistentModel(in: context) + context.insert(repoItem) + try context.save() + item.persistentId = repoItem.persistentModelID + return .success(item) + } catch let error as SwiftDataError { + context.rollback() + return .failure(.swiftData(error)) + } catch { + context.rollback() + return .failure(.unknown(error as NSError)) + } } - public func read(identifier: PersistentIdentifier, as _: Proxy.Type) async -> Result + public func read(identifier: PersistentIdentifier, as _: Proxy.Type) -> Result where Proxy: PersistentModelProxy { - await Task { - guard let repoItem: Proxy.Persistent = context.object(with: identifier) as? Proxy.Persistent else { - return .failure(.noModelFoundForId(identifier)) - } - return .success(Proxy(persisted: repoItem)) - }.value + guard let repoItem: Proxy.Persistent = context.model(for: identifier) as? Proxy.Persistent else { + return .failure(.noModelFoundForId(identifier)) + } + return .success(Proxy(persisted: repoItem)) } - public func update(_ item: Proxy) async -> Result where Proxy: PersistentModelProxy { + public func readSubscription(identifier: PersistentIdentifier, as _: Proxy.Type) -> AsyncStream> where Proxy: PersistentModelProxy { + guard let repoItem: Proxy.Persistent = context.model(for: identifier) as? Proxy.Persistent else { + return AsyncStream(unfolding: { .failure(.noModelFoundForId(identifier)) }) + } + return repoItem.subscription() + } + + public func readThrowingSubscription( + identifier: PersistentIdentifier, + as _: Proxy.Type + ) -> AsyncThrowingStream where Proxy: PersistentModelProxy { + guard let repoItem: Proxy.Persistent = context.model(for: identifier) as? Proxy.Persistent else { + return AsyncThrowingStream(unfolding: { throw Failure.noModelFoundForId(identifier) }) + } + return repoItem.throwingSubscription() + } + + public func update(_ item: Proxy) -> Result where Proxy: PersistentModelProxy { guard let persistentId = item.persistentId else { return .failure(.noPersistentId) } - return await Task { - guard let object = context.object(with: persistentId) as? Proxy.Persistent else { - return .failure(.noModelFoundForId(persistentId)) - } - item.updating(persisted: object) - - do { - try context.save() - return .success(Proxy(persisted: object)) - } catch let error as SwiftDataError { - context.rollback() - return .failure(.swiftData(error)) - } catch { - context.rollback() - return .failure(.unknown(error as NSError)) - } - }.value + guard let object = context.model(for: persistentId) as? Proxy.Persistent else { + return .failure(.noModelFoundForId(persistentId)) + } + item.updating(persisted: object) + + do { + try context.save() + return .success(Proxy(persisted: object)) + } catch let error as SwiftDataError { + context.rollback() + return .failure(.swiftData(error)) + } catch { + context.rollback() + return .failure(.unknown(error as NSError)) + } } - public func delete(identifier: PersistentIdentifier) async -> Result { - await Task { [context] in - let object = context.object(with: identifier) - context.delete(object) - if !object.isDeleted() { - fatalError() + public func delete(identifier: PersistentIdentifier) -> Result { + let object = context.model(for: identifier) + context.delete(object) + if !object.isDeleted { + fatalError() + } + do { + try context.save() + context.processPendingChanges() + return .success(()) + } catch let error as SwiftDataError { + context.rollback() + return .failure(.swiftData(error)) + } catch { + context.rollback() + return .failure(.unknown(error as NSError)) + } + } + + public func fetch(_ request: FetchDescriptor) + -> Result<[Proxy], Failure> + { + do { + return try .success(context.fetch(request).map(Proxy.init(persisted:))) + } catch let error as SwiftDataError { + return .failure(.swiftData(error)) + } catch { + return .failure(.unknown(error as NSError)) + } + } + + public func fetch( + _ request: FetchDescriptor, + batchSize: Int + ) -> Result<[Proxy], Failure> { + do { + return try .success(context.fetch(request, batchSize: batchSize).map(Proxy.init(persisted:))) + } catch let error as SwiftDataError { + return .failure(.swiftData(error)) + } catch { + return .failure(.unknown(error as NSError)) + } + } +} + +extension PersistentModel { + func subscription() -> AsyncStream> where Proxy: PersistentModelProxy, + Proxy.Persistent == Self, Failure: Error + { + AsyncStream { continuation in + continuation.yield(.success(Proxy(persisted: self))) + withObservationTracking { + _ = self.hasChanges + } onChange: { + continuation.yield(.success(Proxy(persisted: self))) } - do { - try context.save() - return .success(()) - } catch let error as SwiftDataError { - context.rollback() - return .failure(.swiftData(error)) - } catch { - context.rollback() - return .failure(.unknown(error as NSError)) + } + } + + func throwingSubscription() -> AsyncThrowingStream where Proxy: PersistentModelProxy, + Proxy.Persistent == Self + { + AsyncThrowingStream { continuation in + continuation.yield(Proxy(persisted: self)) + withObservationTracking { + _ = self.hasChanges + } onChange: { + continuation.yield(Proxy(persisted: self)) } - }.value + } } } diff --git a/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift b/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift index a2ad682..d78c90e 100644 --- a/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift +++ b/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift @@ -56,4 +56,64 @@ final class CRUDSwiftDataRepositoryTests: SwiftDataXCTestCase { _ = try await repository().delete(identifier: identifier).get() try await verifyDoesNotExist(movie) } + + func testReadSubscriptionSuccess() async throws { + var movie = Movie(id: UUID(), title: "Read Success", releaseDate: Date(), boxOffice: 100) + + let count = try await context().fetchCount(FetchDescriptor()) + + XCTAssertEqual(count, 0, "Count of objects in CoreData should be zero at the start of each test.") + + let repoMoviePersistentId: PersistentIdentifier = try await { [self] in + let repoMovie = try await movie.asPersistentModel(in: context()) + try await context().save() + return repoMovie.persistentModelID + }() + + movie.persistentId = repoMoviePersistentId + let countAfterCreate: Int = try await context().fetchCount(FetchDescriptor()) + XCTAssertEqual(countAfterCreate, 1, "Count of objects in CoreData should be 1 for read test.") + + var editedMovie = movie + editedMovie.title = "New Title" + + let firstExp = expectation(description: "Read a movie from CoreData") + let secondExp = expectation(description: "Read a movie again after CoreData context is updated") + + let subscriptionTask = Task { + var resultCount = 0 + for await receiveResult in try await repository().readSubscription( + identifier: repoMoviePersistentId, + as: Movie.self + ) { + let receiveMovie = try receiveResult.get() + resultCount += 1 + switch resultCount { + case 1: + XCTAssertEqual(receiveMovie, movie, "Success response should match local object.") + firstExp.fulfill() + case 2: + XCTAssertEqual(receiveMovie, editedMovie, "Second success response should match local object.") + secondExp.fulfill() + default: + XCTFail("Not expecting any values past the first two.") + } + } + } + await fulfillment(of: [firstExp], timeout: 5) + guard let editedRepoMovie = try await context().model(for: repoMoviePersistentId) as? RepoMovie else { + XCTFail() + return + } + editedMovie.updating(persisted: editedRepoMovie) + try await repository().saveContext() + await fulfillment(of: [secondExp], timeout: 5) + subscriptionTask.cancel() + } +} + +extension SwiftDataRepository { + func saveContext() async throws { + try context.save() + } } diff --git a/Tests/SwiftDataRepositoryTests/Movie.swift b/Tests/SwiftDataRepositoryTests/Movie.swift index a8fc6d9..b66d48b 100644 --- a/Tests/SwiftDataRepositoryTests/Movie.swift +++ b/Tests/SwiftDataRepositoryTests/Movie.swift @@ -34,7 +34,7 @@ public struct Movie: Hashable, Identifiable, Codable { extension Movie: PersistentModelProxy { public func asPersistentModel(in context: ModelContext) -> RepoMovie { - if let persistentId, let existingObject: Persistent = context.object(with: persistentId) as? Persistent { + if let persistentId, let existingObject: Persistent = context.model(for: persistentId) as? Persistent { updating(persisted: existingObject) return existingObject } else { @@ -57,7 +57,7 @@ extension Movie: PersistentModelProxy { title: persisted.title, releaseDate: persisted.releaseDate, boxOffice: persisted.boxOffice, - persistentId: persisted.objectID + persistentId: persisted.persistentModelID ) } } diff --git a/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift b/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift index fa556ef..a3e6ad6 100644 --- a/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift +++ b/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift @@ -16,11 +16,11 @@ class SwiftDataXCTestCase: XCTestCase { var _repository: SwiftDataRepository? func container() async throws -> ModelContainer { - try await repository().container + try repository().modelContainer } func context() async throws -> ModelContext { - try await repository().executor.context + try await repository().modelExecutor.modelContext } func repository() throws -> SwiftDataRepository { @@ -30,7 +30,7 @@ class SwiftDataXCTestCase: XCTestCase { override func setUp() async throws { let container = try ModelContainer( for: RepoMovie.self, - ModelConfiguration(inMemory: true) + configurations: ModelConfiguration(isStoredInMemoryOnly: true) ) _repository = SwiftDataRepository(container: container) try await super.setUp() @@ -38,7 +38,7 @@ class SwiftDataXCTestCase: XCTestCase { override func tearDown() async throws { try await super.tearDown() - try await container().destroy() + try await container().deleteAllData() _repository = nil } @@ -52,7 +52,7 @@ class SwiftDataXCTestCase: XCTestCase { throw Failure.noPersistentIdFoundOnProxy } - guard let _object: T.Persistent = try await context().object(with: identifier) as? T.Persistent else { + guard let _object: T.Persistent = try await context().model(for: identifier) as? T.Persistent else { throw Failure.noItemFoundForPersistentId } XCTAssertNoDifference(item, try T(persisted: XCTUnwrap(_object))) @@ -69,6 +69,6 @@ class SwiftDataXCTestCase: XCTestCase { T.Persistent: IdentifiableByProxy, T.ID == T.Persistent.ProxID { let first = try await context().fetch(FetchDescriptor()).first(where: { $0.proxyID == item.id }) - return try XCTUnwrap(first?.objectID) + return try XCTUnwrap(first?.persistentModelID) } } From 38b86c6d4c4d064e0a82d073671b8ae1957048e8 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Mon, 18 Sep 2023 21:33:06 -0500 Subject: [PATCH 5/5] Fix warnings in CoreDataRepositoryTests swift-data --- Tests/CoreDataRepositoryTests/BatchRepositoryTests.swift | 2 +- Tests/CoreDataRepositoryTests/CRUDRepositoryTests.swift | 6 +++--- Tests/CoreDataRepositoryTests/FetchRepositoryTests.swift | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/CoreDataRepositoryTests/BatchRepositoryTests.swift b/Tests/CoreDataRepositoryTests/BatchRepositoryTests.swift index ad5124e..bfbee78 100644 --- a/Tests/CoreDataRepositoryTests/BatchRepositoryTests.swift +++ b/Tests/CoreDataRepositoryTests/BatchRepositoryTests.swift @@ -156,7 +156,7 @@ final class BatchRepositoryTests: CoreDataXCTestCase { } let result: (success: [Movie], failed: [URL]) = try await repository() - .read(urls: movies.compactMap(\.url), transactionAuthor: "Unused") + .read(urls: movies.compactMap(\.url)) XCTAssertEqual(result.success.count, movies.count) XCTAssertEqual(result.failed.count, 0) diff --git a/Tests/CoreDataRepositoryTests/CRUDRepositoryTests.swift b/Tests/CoreDataRepositoryTests/CRUDRepositoryTests.swift index 5769ad7..57f4b5e 100644 --- a/Tests/CoreDataRepositoryTests/CRUDRepositoryTests.swift +++ b/Tests/CoreDataRepositoryTests/CRUDRepositoryTests.swift @@ -239,7 +239,7 @@ final class CRUDRepositoryTests: CoreDataXCTestCase { XCTFail("Not expecting failure") } }, receiveValue: { receiveMovie in - resultCount += 1 + switch resultCount { case 1: XCTAssertEqual(receiveMovie, movie, "Success response should match local object.") @@ -253,7 +253,7 @@ final class CRUDRepositoryTests: CoreDataXCTestCase { }) .store(in: &cancellables) - wait(for: [firstExp], timeout: 5) + await fulfillment(of: [firstExp], timeout: 5) try repositoryContext().performAndWait { [self] in let coordinator = try XCTUnwrap(repositoryContext().persistentStoreCoordinator) let objectId = try XCTUnwrap(coordinator.managedObjectID(forURIRepresentation: XCTUnwrap(movie.url))) @@ -261,6 +261,6 @@ final class CRUDRepositoryTests: CoreDataXCTestCase { object.update(from: editedMovie) try repositoryContext().save() } - wait(for: [secondExp], timeout: 5) + await fulfillment(of: [secondExp], timeout: 5) } } diff --git a/Tests/CoreDataRepositoryTests/FetchRepositoryTests.swift b/Tests/CoreDataRepositoryTests/FetchRepositoryTests.swift index 3931fba..f4b23b9 100644 --- a/Tests/CoreDataRepositoryTests/FetchRepositoryTests.swift +++ b/Tests/CoreDataRepositoryTests/FetchRepositoryTests.swift @@ -83,7 +83,7 @@ final class FetchRepositoryTests: CoreDataXCTestCase { }) .store(in: &cancellables) - wait(for: [firstExp], timeout: 5) + await fulfillment(of: [firstExp], timeout: 5) let crudRepository = try CoreDataRepository(context: repositoryContext()) _ = try await repositoryContext().perform { [self] in let url = try XCTUnwrap(expectedMovies.last?.url) @@ -95,6 +95,6 @@ final class FetchRepositoryTests: CoreDataXCTestCase { } let _: Result = try await crudRepository .delete(XCTUnwrap(expectedMovies.last?.url)) - wait(for: [secondExp], timeout: 5) + await fulfillment(of: [secondExp], timeout: 5) } }