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/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/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..b4fbc7e --- /dev/null +++ b/Sources/SwiftDataRepository/SwiftDataRepository.swift @@ -0,0 +1,187 @@ +// SwiftDataRepository.swift +// CoreDataRepository +// +// +// MIT License +// +// Copyright © 2023 Andrew Roan + +import Foundation +import SwiftData + +public actor SwiftDataRepository: ModelActor { + 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 + modelExecutor = DefaultSerialModelExecutor(modelContext: 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) -> 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) -> Result + where Proxy: PersistentModelProxy + { + guard let repoItem: Proxy.Persistent = context.model(for: identifier) as? Proxy.Persistent else { + return .failure(.noModelFoundForId(identifier)) + } + return .success(Proxy(persisted: repoItem)) + } + + 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) + } + 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) -> 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))) + } + } + } + + 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)) + } + } + } +} 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) } } diff --git a/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift b/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift new file mode 100644 index 0000000..d78c90e --- /dev/null +++ b/Tests/SwiftDataRepositoryTests/CRUDSwiftDataRepositoryTests.swift @@ -0,0 +1,119 @@ +// CRUDSwiftDataRepositoryTests.swift +// CoreDataRepository +// +// +// MIT License +// +// Copyright © 2023 Andrew Roan + +import CustomDump +import Foundation +import SwiftData +import SwiftDataRepository +import XCTest + +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() + + try await verify(createMovie) + } + + func testReadSuccess() async throws { + 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) + } + + 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 new file mode 100644 index 0000000..b66d48b --- /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.model(for: persistentId) as? Persistent { + updating(persisted: existingObject) + return existingObject + } else { + let object = RepoMovie(proxyID: id, title: title, releaseDate: releaseDate, boxOffice: boxOffice) + context.insert(object) + return object + } + } + + public func updating(persisted: RepoMovie) { + persisted.proxyID = id + persisted.title = title + persisted.releaseDate = releaseDate + persisted.boxOffice = boxOffice + } + + public init(persisted: RepoMovie) { + self.init( + id: persisted.proxyID, + title: persisted.title, + releaseDate: persisted.releaseDate, + boxOffice: persisted.boxOffice, + persistentId: persisted.persistentModelID + ) + } +} + +@Model +public final class RepoMovie: IdentifiableByProxy { + public var proxyID: UUID + public var title: String + public var releaseDate: Date + public var boxOffice: Decimal + + 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 new file mode 100644 index 0000000..a3e6ad6 --- /dev/null +++ b/Tests/SwiftDataRepositoryTests/SwiftDataXCTestCase.swift @@ -0,0 +1,74 @@ +// SwiftDataXCTestCase.swift +// CoreDataRepository +// +// +// MIT License +// +// Copyright © 2023 Andrew Roan + +import CustomDump +import SwiftData +import SwiftDataRepository +import XCTest + +@MainActor +class SwiftDataXCTestCase: XCTestCase { + var _repository: SwiftDataRepository? + + func container() async throws -> ModelContainer { + try repository().modelContainer + } + + func context() async throws -> ModelContext { + try await repository().modelExecutor.modelContext + } + + func repository() throws -> SwiftDataRepository { + try XCTUnwrap(_repository) + } + + override func setUp() async throws { + let container = try ModelContainer( + for: RepoMovie.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + _repository = SwiftDataRepository(container: container) + try await super.setUp() + } + + override func tearDown() async throws { + try await super.tearDown() + try await container().deleteAllData() + _repository = nil + } + + enum Failure: Error, Hashable, Sendable { + case noPersistentIdFoundOnProxy + case noItemFoundForPersistentId + } + + func verify(_ item: T) async throws where T: PersistentModelProxy { + guard let identifier = item.persistentId else { + throw Failure.noPersistentIdFoundOnProxy + } + + 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))) + } + + 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) async throws -> PersistentIdentifier where T: Identifiable, T: PersistentModelProxy, + 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?.persistentModelID) + } +}