Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 92 additions & 73 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# CoreDataRepository

[![CI](https://github.com/roanutil/CoreDataRepository/actions/workflows/ci.yml/badge.svg)](https://github.com/roanutil/CoreDataRepository/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/roanutil/CoreDataRepository/branch/main/graph/badge.svg?token=WRO4CXYWRG)](https://codecov.io/gh/roanutil/CoreDataRepository)
[![codecov](https://codecov.io/gh/roanutil/CoreDataRepository/branch/main/graph/badge.svg?token=WRO4CXYWRG)](https://codecov.io/gh/roanutil/CoreDataRepository)
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Froanutil%2FCoreDataRepository%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/roanutil/CoreDataRepository)
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Froanutil%2FCoreDataRepository%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/roanutil/CoreDataRepository)

Expand Down Expand Up @@ -40,74 +40,61 @@ To give some weight to this idea, here's a quote from the Q&A portion of [this](

### Model Bridging

There are two protocols that handle bridging between the value type and managed models.
There are various protocols for defining how a value type should
bridge to the corresponding `NSManagedObject` subclass. Each
protocol is intended for a general pattern of use.

#### RepositoryManagedModel
A single value type can conform to multiple protocols to combine their supported functionality. A single `NSManagedObject` subclass can be bridged to by multiple value types.

- `FetchableUnmanagedModel` for types that will be queried through 'fetch' endpoints.
- `ReadableUnmanagedModel` for types that can be accessed individually. Inherits from `FetchableUnmanagedModel`.
- `IdentifiedUnmanagedModel` for `ReadableUnmanagedModel` types that have a unique, hashable ID value.
- `ManagedIdReferencable` for `ReadableUnmanagedModel` types that store their `NSManagedObjectID`.
- `ManagedIdUrlReferencable` for `ReadableUnmanagedModel` types that store their `NSManagedObjectID` in `URL` form.
- `WritableUnmanagedModel` for that types that need to write to the store via create, update, and delete operations.
- `UnmanagedModel` for types that conform to both `ReadableUnmanagedModel` and `WritableUnmanagedModel`.

#### UnmanagedModel

```swift
@objc(ManagedMovie)
public final class ManagedMovie: NSManagedObject {
@NSManaged var id: UUID?
@NSManaged var title: String?
@NSManaged var releaseDate: Date?
@NSManaged var boxOffice: NSDecimalNumber?
}

extension ManagedMovie: RepositoryManagedModel {
public func create(from unmanaged: Movie) {
update(from: unmanaged)
}

public typealias Unmanaged = Movie
public var asUnmanaged: Movie {
Movie(
id: id ?? UUID(),
title: title ?? "",
releaseDate: releaseDate ?? Date(),
boxOffice: (boxOffice ?? 0) as Decimal,
url: objectID.uriRepresentation()
)
}

public func update(from unmanaged: Movie) {
id = unmanaged.id
title = unmanaged.title
releaseDate = unmanaged.releaseDate
boxOffice = NSDecimalNumber(decimal: unmanaged.boxOffice)
}
@NSManaged var boxOffice: Decimal?
}
```

#### UnmanagedModel

```swift
public struct Movie: Hashable {
public struct Movie: Equatable, ManagedIdUrlReferencable, Sendable {
public let id: UUID
public var title: String = ""
public var releaseDate: Date
public var boxOffice: Decimal = 0
public var url: URL?
public var managedIdUrl: URL?
}

extension Movie: UnmanagedModel {
public var managedRepoUrl: URL? {
get {
url
}
set(newValue) {
url = newValue
}
extension Movie: FetchableUnmanagedModel {
public init(managed: ManagedMovie) {
self.id = managed.id
self.title = managed.title
self.releaseDate = managed.releaseDate
self.boxOffice = managed.boxOffice
self.managedIdUrl = managed.objectID.uriRepresentation()
}
}

public func asManagedModel(in context: NSManagedObjectContext) -> ManagedMovie {
let object = ManagedMovie(context: context)
object.id = id
object.title = title
object.releaseDate = releaseDate
object.boxOffice = boxOffice as NSDecimalNumber
return object
extension Movie: ReadableUnmanagedModel {}

extension Movie: WritableUnmanagedModel {
public func updating(managed: ManagedMovie) throws {
managed.id = id
managed.title = title
managed.releaseDate = releaseDate
managed.boxOffice = boxOffice
}
}

extension Movie: UnmanagedModel {}
```

### CRUD
Expand All @@ -134,24 +121,13 @@ if case let .success(movies) = result {

### Fetch Subscription

Similar to a regular fe:
Similar to a regular fetch:

```swift
let result: AnyPublisher<[Movie], CoreDataError> = repository.fetchSubscription(fetchRequest)
let cancellable = result.subscribe(on: userInitSerialQueue)
.receive(on: mainQueue)
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
os_log("Fetched a bunch of movies")
default:
fatalError("Failed to fetch all the movies!")
}
}, receiveValue: { value in
os_log("Fetched \(value.items.count) movies")
})
...
cancellable.cancel()
let stream: AsyncThrowingStream<[Movie], any Error> = repository.fetchThrowingSubscription(fetchRequest)
for try await movies in stream {
os_log("Fetched \(movies.count) movies")
}
```

### Aggregate
Expand Down Expand Up @@ -185,7 +161,7 @@ let result: Result<NSBatchInsertResult, CoreDataError> = await repository.insert
#### OR

```swift
let movies: [[String: Any]] = [
let movies: [Movie] = [
Movie(id: UUID(), title: "A", releaseDate: Date()),
Movie(id: UUID(), title: "B", releaseDate: Date()),
Movie(id: UUID(), title: "C", releaseDate: Date()),
Expand All @@ -197,15 +173,58 @@ os_log("Created these movies: \(result.success)")
os_log("Failed to create these movies: \(result.failed)")
```

## TODO
### Transactions

- Add a subscription feature for aggregate functions
- Migrate subscription endpoints to AsyncSequence instead of Publisher
- Simplify model protocols (require only one protocol for the value type)
- Allow older platform support by working around the newer variants of `NSManagedObjectContext.perform` and `NSManagedObjectContext.performAndWait`
Use `withTransaction` to group multiple operations together atomically:

```swift
let newMovies = [
Movie(id: UUID(), title: "Movie A", releaseDate: Date(), boxOffice: 1000),
Movie(id: UUID(), title: "Movie B", releaseDate: Date(), boxOffice: 2000)
]

// All operations within the transaction will succeed or fail together
let result = try await repository.withTransaction(transactionAuthor: "BulkMovieImport") { transaction in
var createdMovies: [Movie] = []

for movie in newMovies {
let createResult = try await repository.create(movie).get()
createdMovies.append(createResult)
}

// Update existing movie
let fetchRequest = Movie.managedFetchRequest
fetchRequest.predicate = NSPredicate(format: "title == %@", "Old Movie")
if let existingMovie = try await repository.fetch(fetchRequest).get().first {
var updatedMovie = existingMovie
updatedMovie.boxOffice = 5000
_ = try await repository.update(updatedMovie).get()
}

return createdMovies
}

os_log("Transaction completed with \(result.count) new movies")
```

**Important:** When using batch operations within transactions, don't specify `transactionAuthor` for individual operations as it's handled at the transaction level:

```swift
// ✅ Correct - transactionAuthor only on withTransaction
try await repository.withTransaction(transactionAuthor: "BatchUpdate") { _ in
let request = NSBatchUpdateRequest(entityName: "ManagedMovie")
request.propertiesToUpdate = ["boxOffice": 0]
return await repository.update(request) // No transactionAuthor here
}

// ❌ Incorrect - don't specify transactionAuthor on both
try await repository.withTransaction(transactionAuthor: "BatchUpdate") { _ in
let request = NSBatchUpdateRequest(entityName: "ManagedMovie")
request.propertiesToUpdate = ["boxOffice": 0]
return await repository.update(request, transactionAuthor: "BatchUpdate") // Ignored
}
```

## Contributing

I welcome any feedback or contributions. It's probably best to create an issue where any possible changes can be discussed before doing the work and creating a PR.

The above [TODO](#todo) section is a good place to start if you would like to contribute but don't already have a change in mind.
13 changes: 13 additions & 0 deletions Sources/CoreDataRepository/CoreDataError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,19 @@ public enum CoreDataError: Error, Hashable, Sendable {
)
}
}

@usableFromInline
static func catching<T>(block: () async throws -> T) async throws(Self) -> T {
do {
return try await block()
} catch let error as CoreDataError {
throw error
} catch let error as CocoaError {
throw .cocoa(error)
} catch {
throw .unknown(error as NSError)
}
}
}

extension CoreDataError: CustomNSError {
Expand Down
14 changes: 7 additions & 7 deletions Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ extension CoreDataRepository {
default:
await Self.send(
function: function,
context: context,
context: Transaction.current?.context ?? context,
predicate: predicate,
entityDesc: entityDesc,
attributeDesc: attributeDesc,
Expand All @@ -55,7 +55,7 @@ extension CoreDataRepository {
) async -> Result<Value, CoreDataError> where Value: Numeric, Value: Sendable {
await Self.send(
function: .average,
context: context,
context: Transaction.current?.context ?? context,
predicate: predicate,
entityDesc: entityDesc,
attributeDesc: attributeDesc,
Expand Down Expand Up @@ -124,7 +124,7 @@ extension CoreDataRepository {
entityDesc: NSEntityDescription,
as _: Value.Type
) async -> Result<Value, CoreDataError> where Value: Numeric, Value: Sendable {
await context.performInScratchPad { scratchPad in
await context.performInChild { scratchPad in
do {
let request = try NSFetchRequest<NSDictionary>
.countRequest(predicate: predicate, entityDesc: entityDesc)
Expand Down Expand Up @@ -193,7 +193,7 @@ extension CoreDataRepository {
) async -> Result<Value, CoreDataError> where Value: Numeric, Value: Sendable {
await Self.send(
function: .max,
context: context,
context: Transaction.current?.context ?? context,
predicate: predicate,
entityDesc: entityDesc,
attributeDesc: attributeDesc,
Expand Down Expand Up @@ -268,7 +268,7 @@ extension CoreDataRepository {
) async -> Result<Value, CoreDataError> where Value: Numeric, Value: Sendable {
await Self.send(
function: .min,
context: context,
context: Transaction.current?.context ?? context,
predicate: predicate,
entityDesc: entityDesc,
attributeDesc: attributeDesc,
Expand Down Expand Up @@ -343,7 +343,7 @@ extension CoreDataRepository {
) async -> Result<Value, CoreDataError> where Value: Numeric, Value: Sendable {
await Self.send(
function: .sum,
context: context,
context: Transaction.current?.context ?? context,
predicate: predicate,
entityDesc: entityDesc,
attributeDesc: attributeDesc,
Expand Down Expand Up @@ -428,7 +428,7 @@ extension CoreDataRepository {
guard entityDesc == attributeDesc.entity else {
return .failure(.propertyDoesNotMatchEntity)
}
return await context.performInScratchPad { scratchPad in
return await context.performInChild { scratchPad in
let request = try NSFetchRequest<NSDictionary>.request(
function: function,
predicate: predicate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ extension CoreDataRepository {
_ request: NSBatchDeleteRequest,
transactionAuthor: String? = nil
) async -> Result<NSBatchDeleteResult, CoreDataError> {
await context.performInScratchPad { [context] scratchPad in
let context = Transaction.current?.context ?? context
return await context.performInScratchPad { [context] scratchPad in
context.transactionAuthor = transactionAuthor
guard let result = try scratchPad.execute(request) as? NSBatchDeleteResult else {
context.transactionAuthor = nil
Expand All @@ -30,7 +31,8 @@ extension CoreDataRepository {
_ request: NSBatchInsertRequest,
transactionAuthor: String? = nil
) async -> Result<NSBatchInsertResult, CoreDataError> {
await context.performInScratchPad { [context] scratchPad in
let context = Transaction.current?.context ?? context
return await context.performInScratchPad { [context] scratchPad in
context.transactionAuthor = transactionAuthor
guard let result = try scratchPad.execute(request) as? NSBatchInsertResult else {
context.transactionAuthor = nil
Expand All @@ -47,7 +49,8 @@ extension CoreDataRepository {
_ request: NSBatchUpdateRequest,
transactionAuthor: String? = nil
) async -> Result<NSBatchUpdateResult, CoreDataError> {
await context.performInScratchPad { [context] scratchPad in
let context = Transaction.current?.context ?? context
return await context.performInScratchPad { [context] scratchPad in
context.transactionAuthor = transactionAuthor
guard let result = try scratchPad.execute(request) as? NSBatchUpdateResult else {
context.transactionAuthor = nil
Expand Down
24 changes: 14 additions & 10 deletions Sources/CoreDataRepository/CoreDataRepository+Create.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,26 @@ extension CoreDataRepository {
_ item: Model,
transactionAuthor: String? = nil
) async -> Result<Model, CoreDataError> where Model: WritableUnmanagedModel, Model: FetchableUnmanagedModel {
await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in
let context = Transaction.current?.context ?? context
let notTransaction = Transaction.current == nil
return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in
scratchPad.transactionAuthor = transactionAuthor
let object = try item.asManagedModel(in: scratchPad)
let tempObjectId = object.objectID
try item.updating(managed: object)
try scratchPad.save()
try context.performAndWait {
context.transactionAuthor = transactionAuthor
do {
try context.save()
} catch {
let parentContextObject = context.object(with: tempObjectId)
context.delete(parentContextObject)
throw error
if notTransaction {
try context.performAndWait {
context.transactionAuthor = transactionAuthor
do {
try context.save()
} catch {
let parentContextObject = context.object(with: tempObjectId)
context.delete(parentContextObject)
throw error
}
context.transactionAuthor = nil
}
context.transactionAuthor = nil
}
try scratchPad.obtainPermanentIDs(for: [object])
return try Model(managed: object)
Expand Down
Loading