Skip to content

Commit da539b7

Browse files
authored
Merge pull request #38 from roanutil/feature/transactions
Feature/transactions (Depends on PR #37)
2 parents 3c59f6f + ab6b1d9 commit da539b7

29 files changed

Lines changed: 3195 additions & 1024 deletions

README.md

Lines changed: 92 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# CoreDataRepository
22

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

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

4141
### Model Bridging
4242

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

45-
#### RepositoryManagedModel
47+
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.
48+
49+
- `FetchableUnmanagedModel` for types that will be queried through 'fetch' endpoints.
50+
- `ReadableUnmanagedModel` for types that can be accessed individually. Inherits from `FetchableUnmanagedModel`.
51+
- `IdentifiedUnmanagedModel` for `ReadableUnmanagedModel` types that have a unique, hashable ID value.
52+
- `ManagedIdReferencable` for `ReadableUnmanagedModel` types that store their `NSManagedObjectID`.
53+
- `ManagedIdUrlReferencable` for `ReadableUnmanagedModel` types that store their `NSManagedObjectID` in `URL` form.
54+
- `WritableUnmanagedModel` for that types that need to write to the store via create, update, and delete operations.
55+
- `UnmanagedModel` for types that conform to both `ReadableUnmanagedModel` and `WritableUnmanagedModel`.
56+
57+
#### UnmanagedModel
4658

4759
```swift
4860
@objc(ManagedMovie)
4961
public final class ManagedMovie: NSManagedObject {
5062
@NSManaged var id: UUID?
5163
@NSManaged var title: String?
5264
@NSManaged var releaseDate: Date?
53-
@NSManaged var boxOffice: NSDecimalNumber?
54-
}
55-
56-
extension ManagedMovie: RepositoryManagedModel {
57-
public func create(from unmanaged: Movie) {
58-
update(from: unmanaged)
59-
}
60-
61-
public typealias Unmanaged = Movie
62-
public var asUnmanaged: Movie {
63-
Movie(
64-
id: id ?? UUID(),
65-
title: title ?? "",
66-
releaseDate: releaseDate ?? Date(),
67-
boxOffice: (boxOffice ?? 0) as Decimal,
68-
url: objectID.uriRepresentation()
69-
)
70-
}
71-
72-
public func update(from unmanaged: Movie) {
73-
id = unmanaged.id
74-
title = unmanaged.title
75-
releaseDate = unmanaged.releaseDate
76-
boxOffice = NSDecimalNumber(decimal: unmanaged.boxOffice)
77-
}
65+
@NSManaged var boxOffice: Decimal?
7866
}
79-
```
80-
81-
#### UnmanagedModel
8267

83-
```swift
84-
public struct Movie: Hashable {
68+
public struct Movie: Equatable, ManagedIdUrlReferencable, Sendable {
8569
public let id: UUID
8670
public var title: String = ""
8771
public var releaseDate: Date
8872
public var boxOffice: Decimal = 0
89-
public var url: URL?
73+
public var managedIdUrl: URL?
9074
}
9175

92-
extension Movie: UnmanagedModel {
93-
public var managedRepoUrl: URL? {
94-
get {
95-
url
96-
}
97-
set(newValue) {
98-
url = newValue
99-
}
76+
extension Movie: FetchableUnmanagedModel {
77+
public init(managed: ManagedMovie) {
78+
self.id = managed.id
79+
self.title = managed.title
80+
self.releaseDate = managed.releaseDate
81+
self.boxOffice = managed.boxOffice
82+
self.managedIdUrl = managed.objectID.uriRepresentation()
10083
}
84+
}
10185

102-
public func asManagedModel(in context: NSManagedObjectContext) -> ManagedMovie {
103-
let object = ManagedMovie(context: context)
104-
object.id = id
105-
object.title = title
106-
object.releaseDate = releaseDate
107-
object.boxOffice = boxOffice as NSDecimalNumber
108-
return object
86+
extension Movie: ReadableUnmanagedModel {}
87+
88+
extension Movie: WritableUnmanagedModel {
89+
public func updating(managed: ManagedMovie) throws {
90+
managed.id = id
91+
managed.title = title
92+
managed.releaseDate = releaseDate
93+
managed.boxOffice = boxOffice
10994
}
11095
}
96+
97+
extension Movie: UnmanagedModel {}
11198
```
11299

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

135122
### Fetch Subscription
136123

137-
Similar to a regular fe:
124+
Similar to a regular fetch:
138125

139126
```swift
140-
let result: AnyPublisher<[Movie], CoreDataError> = repository.fetchSubscription(fetchRequest)
141-
let cancellable = result.subscribe(on: userInitSerialQueue)
142-
.receive(on: mainQueue)
143-
.sink(receiveCompletion: { completion in
144-
switch completion {
145-
case .finished:
146-
os_log("Fetched a bunch of movies")
147-
default:
148-
fatalError("Failed to fetch all the movies!")
149-
}
150-
}, receiveValue: { value in
151-
os_log("Fetched \(value.items.count) movies")
152-
})
153-
...
154-
cancellable.cancel()
127+
let stream: AsyncThrowingStream<[Movie], any Error> = repository.fetchThrowingSubscription(fetchRequest)
128+
for try await movies in stream {
129+
os_log("Fetched \(movies.count) movies")
130+
}
155131
```
156132

157133
### Aggregate
@@ -185,7 +161,7 @@ let result: Result<NSBatchInsertResult, CoreDataError> = await repository.insert
185161
#### OR
186162

187163
```swift
188-
let movies: [[String: Any]] = [
164+
let movies: [Movie] = [
189165
Movie(id: UUID(), title: "A", releaseDate: Date()),
190166
Movie(id: UUID(), title: "B", releaseDate: Date()),
191167
Movie(id: UUID(), title: "C", releaseDate: Date()),
@@ -197,15 +173,58 @@ os_log("Created these movies: \(result.success)")
197173
os_log("Failed to create these movies: \(result.failed)")
198174
```
199175

200-
## TODO
176+
### Transactions
201177

202-
- Add a subscription feature for aggregate functions
203-
- Migrate subscription endpoints to AsyncSequence instead of Publisher
204-
- Simplify model protocols (require only one protocol for the value type)
205-
- Allow older platform support by working around the newer variants of `NSManagedObjectContext.perform` and `NSManagedObjectContext.performAndWait`
178+
Use `withTransaction` to group multiple operations together atomically:
179+
180+
```swift
181+
let newMovies = [
182+
Movie(id: UUID(), title: "Movie A", releaseDate: Date(), boxOffice: 1000),
183+
Movie(id: UUID(), title: "Movie B", releaseDate: Date(), boxOffice: 2000)
184+
]
185+
186+
// All operations within the transaction will succeed or fail together
187+
let result = try await repository.withTransaction(transactionAuthor: "BulkMovieImport") { transaction in
188+
var createdMovies: [Movie] = []
189+
190+
for movie in newMovies {
191+
let createResult = try await repository.create(movie).get()
192+
createdMovies.append(createResult)
193+
}
194+
195+
// Update existing movie
196+
let fetchRequest = Movie.managedFetchRequest
197+
fetchRequest.predicate = NSPredicate(format: "title == %@", "Old Movie")
198+
if let existingMovie = try await repository.fetch(fetchRequest).get().first {
199+
var updatedMovie = existingMovie
200+
updatedMovie.boxOffice = 5000
201+
_ = try await repository.update(updatedMovie).get()
202+
}
203+
204+
return createdMovies
205+
}
206+
207+
os_log("Transaction completed with \(result.count) new movies")
208+
```
209+
210+
**Important:** When using batch operations within transactions, don't specify `transactionAuthor` for individual operations as it's handled at the transaction level:
211+
212+
```swift
213+
// ✅ Correct - transactionAuthor only on withTransaction
214+
try await repository.withTransaction(transactionAuthor: "BatchUpdate") { _ in
215+
let request = NSBatchUpdateRequest(entityName: "ManagedMovie")
216+
request.propertiesToUpdate = ["boxOffice": 0]
217+
return await repository.update(request) // No transactionAuthor here
218+
}
219+
220+
// ❌ Incorrect - don't specify transactionAuthor on both
221+
try await repository.withTransaction(transactionAuthor: "BatchUpdate") { _ in
222+
let request = NSBatchUpdateRequest(entityName: "ManagedMovie")
223+
request.propertiesToUpdate = ["boxOffice": 0]
224+
return await repository.update(request, transactionAuthor: "BatchUpdate") // Ignored
225+
}
226+
```
206227

207228
## Contributing
208229

209230
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.
210-
211-
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.

Sources/CoreDataRepository/CoreDataError.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,19 @@ public enum CoreDataError: Error, Hashable, Sendable {
124124
)
125125
}
126126
}
127+
128+
@usableFromInline
129+
static func catching<T>(block: () async throws -> T) async throws(Self) -> T {
130+
do {
131+
return try await block()
132+
} catch let error as CoreDataError {
133+
throw error
134+
} catch let error as CocoaError {
135+
throw .cocoa(error)
136+
} catch {
137+
throw .unknown(error as NSError)
138+
}
139+
}
127140
}
128141

129142
extension CoreDataError: CustomNSError {

Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ extension CoreDataRepository {
3333
default:
3434
await Self.send(
3535
function: function,
36-
context: context,
36+
context: Transaction.current?.context ?? context,
3737
predicate: predicate,
3838
entityDesc: entityDesc,
3939
attributeDesc: attributeDesc,
@@ -55,7 +55,7 @@ extension CoreDataRepository {
5555
) async -> Result<Value, CoreDataError> where Value: Numeric, Value: Sendable {
5656
await Self.send(
5757
function: .average,
58-
context: context,
58+
context: Transaction.current?.context ?? context,
5959
predicate: predicate,
6060
entityDesc: entityDesc,
6161
attributeDesc: attributeDesc,
@@ -124,7 +124,7 @@ extension CoreDataRepository {
124124
entityDesc: NSEntityDescription,
125125
as _: Value.Type
126126
) async -> Result<Value, CoreDataError> where Value: Numeric, Value: Sendable {
127-
await context.performInScratchPad { scratchPad in
127+
await context.performInChild { scratchPad in
128128
do {
129129
let request = try NSFetchRequest<NSDictionary>
130130
.countRequest(predicate: predicate, entityDesc: entityDesc)
@@ -193,7 +193,7 @@ extension CoreDataRepository {
193193
) async -> Result<Value, CoreDataError> where Value: Numeric, Value: Sendable {
194194
await Self.send(
195195
function: .max,
196-
context: context,
196+
context: Transaction.current?.context ?? context,
197197
predicate: predicate,
198198
entityDesc: entityDesc,
199199
attributeDesc: attributeDesc,
@@ -268,7 +268,7 @@ extension CoreDataRepository {
268268
) async -> Result<Value, CoreDataError> where Value: Numeric, Value: Sendable {
269269
await Self.send(
270270
function: .min,
271-
context: context,
271+
context: Transaction.current?.context ?? context,
272272
predicate: predicate,
273273
entityDesc: entityDesc,
274274
attributeDesc: attributeDesc,
@@ -343,7 +343,7 @@ extension CoreDataRepository {
343343
) async -> Result<Value, CoreDataError> where Value: Numeric, Value: Sendable {
344344
await Self.send(
345345
function: .sum,
346-
context: context,
346+
context: Transaction.current?.context ?? context,
347347
predicate: predicate,
348348
entityDesc: entityDesc,
349349
attributeDesc: attributeDesc,
@@ -428,7 +428,7 @@ extension CoreDataRepository {
428428
guard entityDesc == attributeDesc.entity else {
429429
return .failure(.propertyDoesNotMatchEntity)
430430
}
431-
return await context.performInScratchPad { scratchPad in
431+
return await context.performInChild { scratchPad in
432432
let request = try NSFetchRequest<NSDictionary>.request(
433433
function: function,
434434
predicate: predicate,

Sources/CoreDataRepository/CoreDataRepository+BatchRequest.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ extension CoreDataRepository {
1313
_ request: NSBatchDeleteRequest,
1414
transactionAuthor: String? = nil
1515
) async -> Result<NSBatchDeleteResult, CoreDataError> {
16-
await context.performInScratchPad { [context] scratchPad in
16+
let context = Transaction.current?.context ?? context
17+
return await context.performInScratchPad { [context] scratchPad in
1718
context.transactionAuthor = transactionAuthor
1819
guard let result = try scratchPad.execute(request) as? NSBatchDeleteResult else {
1920
context.transactionAuthor = nil
@@ -30,7 +31,8 @@ extension CoreDataRepository {
3031
_ request: NSBatchInsertRequest,
3132
transactionAuthor: String? = nil
3233
) async -> Result<NSBatchInsertResult, CoreDataError> {
33-
await context.performInScratchPad { [context] scratchPad in
34+
let context = Transaction.current?.context ?? context
35+
return await context.performInScratchPad { [context] scratchPad in
3436
context.transactionAuthor = transactionAuthor
3537
guard let result = try scratchPad.execute(request) as? NSBatchInsertResult else {
3638
context.transactionAuthor = nil
@@ -47,7 +49,8 @@ extension CoreDataRepository {
4749
_ request: NSBatchUpdateRequest,
4850
transactionAuthor: String? = nil
4951
) async -> Result<NSBatchUpdateResult, CoreDataError> {
50-
await context.performInScratchPad { [context] scratchPad in
52+
let context = Transaction.current?.context ?? context
53+
return await context.performInScratchPad { [context] scratchPad in
5154
context.transactionAuthor = transactionAuthor
5255
guard let result = try scratchPad.execute(request) as? NSBatchUpdateResult else {
5356
context.transactionAuthor = nil

Sources/CoreDataRepository/CoreDataRepository+Create.swift

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,26 @@ extension CoreDataRepository {
1313
_ item: Model,
1414
transactionAuthor: String? = nil
1515
) async -> Result<Model, CoreDataError> where Model: WritableUnmanagedModel, Model: FetchableUnmanagedModel {
16-
await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in
16+
let context = Transaction.current?.context ?? context
17+
let notTransaction = Transaction.current == nil
18+
return await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in
1719
scratchPad.transactionAuthor = transactionAuthor
1820
let object = try item.asManagedModel(in: scratchPad)
1921
let tempObjectId = object.objectID
2022
try item.updating(managed: object)
2123
try scratchPad.save()
22-
try context.performAndWait {
23-
context.transactionAuthor = transactionAuthor
24-
do {
25-
try context.save()
26-
} catch {
27-
let parentContextObject = context.object(with: tempObjectId)
28-
context.delete(parentContextObject)
29-
throw error
24+
if notTransaction {
25+
try context.performAndWait {
26+
context.transactionAuthor = transactionAuthor
27+
do {
28+
try context.save()
29+
} catch {
30+
let parentContextObject = context.object(with: tempObjectId)
31+
context.delete(parentContextObject)
32+
throw error
33+
}
34+
context.transactionAuthor = nil
3035
}
31-
context.transactionAuthor = nil
3236
}
3337
try scratchPad.obtainPermanentIDs(for: [object])
3438
return try Model(managed: object)

0 commit comments

Comments
 (0)