Skip to content

Commit 36560e8

Browse files
authored
Merge pull request #1 from roanutil/v1-preview
Major Refactor and improvements - Change to storing the URL instead of the `NSManagedObjectID` on value types - Add batch operations by NSManagedObjectID URL in addition to NSBatch*Request - Add subscription for reading a single item - Make all edits to the store in a 'scratchpad' context that can be thrown out if there's a problem - Move all functionality into a single repository type `CoreDataRepository` which helps with internal code reuse and only needing the one repository for all uses. - Add `CombineCommunity/CombineExt` as dependency and avoid inlining the `Create` publisher manually - Overhaul Failure types to concrete CoreDataRepositoryError - Cleanup various internal code for less possible error branches and readability - Fix lots of wrong doc comments - Fix sporadic test failures
2 parents c479529 + 8010926 commit 36560e8

37 files changed

Lines changed: 1693 additions & 1458 deletions

.github/workflows/ci.yml

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,13 @@ on:
1010

1111
jobs:
1212
library:
13-
runs-on: macos-11.0
13+
runs-on: macos-11
1414
environment: default
1515
strategy:
1616
matrix:
17-
xcode:
18-
- '12.4'
19-
- '12.5.1'
20-
- '13.2'
17+
platform: [macOS]
18+
xcode: [12.4, 12.5.1, 13.2]
19+
# Swift: 5.3, 5.4.2, 5.5.2
2120
steps:
2221
- uses: actions/checkout@v2
2322
- name: Select Xcode ${{ matrix.xcode }}

.swiftformat

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@
33
--swiftversion 5.5
44
--maxwidth 120
55
--header "{file}\nCoreDataRepository\n\n\nMIT License\n\nCopyright © {year} Andrew Roan"
6-
--allman false
6+
--allman false
7+
--wraparguments before-first
8+
--wrapcollections before-first

.swiftpm/xcode/xcshareddata/xcschemes/CoreDataRepository.xcscheme

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,29 @@
5454
buildConfiguration = "Debug"
5555
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
5656
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
57-
shouldUseLaunchSchemeArgsEnv = "YES">
57+
shouldUseLaunchSchemeArgsEnv = "NO">
58+
<CommandLineArguments>
59+
<CommandLineArgument
60+
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
61+
isEnabled = "YES">
62+
</CommandLineArgument>
63+
<CommandLineArgument
64+
argument = "-com.apple.CoreData.SQLDebug 1"
65+
isEnabled = "YES">
66+
</CommandLineArgument>
67+
</CommandLineArguments>
68+
<EnvironmentVariables>
69+
<EnvironmentVariable
70+
key = "OS_ACTIVITY_MODE"
71+
value = "${DEBUG_ACTIVITY_MODE}"
72+
isEnabled = "YES">
73+
</EnvironmentVariable>
74+
</EnvironmentVariables>
5875
<Testables>
5976
<TestableReference
60-
skipped = "NO">
77+
skipped = "NO"
78+
parallelizable = "YES"
79+
testExecutionOrdering = "random">
6180
<BuildableReference
6281
BuildableIdentifier = "primary"
6382
BlueprintIdentifier = "CoreDataRepositoryTests"

Package.resolved

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,35 +5,33 @@ import PackageDescription
55

66
let package = Package(
77
name: "CoreDataRepository",
8+
defaultLocalization: "en",
89
platforms: [
910
.iOS(.v13),
1011
.macOS(.v10_15),
1112
.tvOS(.v13),
1213
.watchOS(.v6),
1314
],
1415
products: [
15-
// Products define the executables and libraries a package produces, and make them visible to other packages.
1616
.library(
1717
name: "CoreDataRepository",
1818
targets: ["CoreDataRepository"]
1919
),
2020
],
2121
dependencies: [
22-
// Dependencies declare other packages that this package depends on.
23-
// .package(url: /* package url */, from: "1.0.0"),
22+
.package(url: "https://github.com/CombineCommunity/CombineExt.git", .upToNextMajor(from: "1.5.1")),
23+
.package(url: "https://github.com/pointfreeco/swift-custom-dump.git", .upToNextMajor(from: "0.4.0")),
2424
],
2525
targets: [
26-
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
27-
// Targets can depend on other targets in this package, and on products in packages this package depends on.
2826
.target(
2927
name: "CoreDataRepository",
30-
dependencies: []
28+
dependencies: ["CombineExt"]
3129
),
3230
.testTarget(
3331
name: "CoreDataRepositoryTests",
34-
dependencies: ["CoreDataRepository"],
35-
resources: [
36-
.process("Model.xcdatamodeld"),
32+
dependencies: [
33+
"CoreDataRepository",
34+
.product(name: "CustomDump", package: "swift-custom-dump"),
3735
]
3836
),
3937
]

README.md

Lines changed: 80 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,24 @@
44
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Froanutil%2FCoreDataRepository%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/roanutil/CoreDataRepository)
55
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Froanutil%2FCoreDataRepository%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/roanutil/CoreDataRepository)
66

7-
CoreDataRepository is a reactive library (Combine) for using CoreData on a background queue. It features endpoints for CRUD, batch, fetch multiple, and aggregate operations. Also, it offers a stream like subscription function for wrapping a fetch multiple call that will send updates that match the fetch request.
7+
CoreDataRepository is a reactive library (Combine) for using CoreData on a background queue. It features endpoints for CRUD, batch, fetch, and aggregate operations. Also, it offers a stream like subscription for fetch and read.
88

99
Since ```NSManagedObject```s are not thread safe, a value type model must exist for each ```NSMangaedObject``` subclass.
1010

1111

12-
## Why the hell did you make this?
13-
When I started learning more about application architecture, I ran into things like Clean Architecture that insist that the models, business logic, and views should be far away from platform specific frameworks. Your view should have no concern over the implementation details of persistence. When I compared that to how things are usually done on iOS, I noticed a big difference.
12+
## Motivation
1413

15-
After some time passed I came across Composable Architecture which is a Swift library and seemingly meant for iOS. I was really confused how anybody could take it seriously since all of the app state is value types and the Apple frameworks are object oriented. Finally I found somebody discussing CoreData and ComposableArchitecture on the Swift Forums and they seemed to be mapping NSManagedObjects to structs which seemed insane but clever. After reading that, I did my best to suppress my inner rage at the inefficiency of it all and got to work.
14+
CoreData is a great framework for local persistence on Apple's platforms. However, it can be tempting to create strong dependencies on it throughout an app. Even worse, the `viewContext` runs on the main `DispatchQueue` along with the UI. Even fetching data from the store can be enough to cause performance problems.
1615

17-
The result is this library which in some form is actually used in production for my app. Going forward, when given the choice, I will always use this library rather than the old way. NSManagedObjects can be tricky. Fetching any real number of them on the main queue freezes the UI.
16+
The goals of `CoreDataRepository` are:
17+
- Ease isolation of `CoreData` related code away from the rest of the app.
18+
- Improve ergonomics by providing an asynchronous API with `Combine`.
19+
- Improve usability of private contexts to relieve load from the main `DispatchQueue`.
20+
- Make local persistence with `CoreData` feel more 'Swift-like' by allowing the model layer to use value types.
21+
22+
### Mapping `NSManagedObject`s to value types
23+
24+
It may feel convoluted to add this layer of abstraction over local persistence and the overhead of mapping between objects and value types. Similar to the motivation for only exposing views to the minimum data they need, why should the model layer be concerned with the details of the persistence layer? `NSManagedObject`s are complicated types that really should be isolated as much as possible.
1825

1926
To give some weight to this idea, here's a quote from the Q&A portion of [this](https://academy.realm.io/posts/andy-matuschak-controlling-complexity/) talk by Andy Matuschak:
2027

@@ -31,47 +38,62 @@ There are two protocols that handle bridging between the value type and managed
3138
#### RepositoryManagedModel
3239
```swift
3340
@objc(RepoMovie)
34-
final class RepoMovie: NSManagedObject {
35-
@NSManaged var id: UUID
36-
@NSManaged var title: String
37-
@NSManaged var releaseDate: Date
38-
@NSManaged var boxOffice: NSDecimalNumber
41+
public final class RepoMovie: NSManagedObject {
42+
@NSManaged var id: UUID?
43+
@NSManaged var title: String?
44+
@NSManaged var releaseDate: Date?
45+
@NSManaged var boxOffice: NSDecimalNumber?
3946
}
4047

4148
extension RepoMovie: RepositoryManagedModel {
42-
var asUnmanaged: Movie {
43-
return Movie(
44-
id: id,
45-
title: title,
46-
releaseDate: releaseDate,
47-
boxOffice: boxOffice as Decimal,
48-
objectID: objectID
49+
public func create(from unmanaged: Movie) {
50+
update(from: unmanaged)
51+
}
52+
53+
public typealias Unmanaged = Movie
54+
public var asUnmanaged: Movie {
55+
Movie(
56+
id: id ?? UUID(),
57+
title: title ?? "",
58+
releaseDate: releaseDate ?? Date(),
59+
boxOffice: (boxOffice ?? 0) as Decimal,
60+
url: objectID.uriRepresentation()
4961
)
5062
}
5163

52-
func update(from unmanaged: Movie) {
53-
self.id = unmanaged.id
54-
self.title = unmanaged.title
55-
self.releaseDate = unmanaged.releaseDate
56-
self.boxOffice = unmanaged.boxOffice as NSDecimalNumber
64+
public func update(from unmanaged: Movie) {
65+
id = unmanaged.id
66+
title = unmanaged.title
67+
releaseDate = unmanaged.releaseDate
68+
boxOffice = NSDecimalNumber(decimal: unmanaged.boxOffice)
5769
}
5870

5971
static func fetchRequest() -> NSFetchRequest<RepoMovie> {
60-
NSFetchRequest<RepoMovie>(entityName: "RepoMovie")
72+
let request = NSFetchRequest<RepoMovie>(entityName: "RepoMovie")
73+
return request
6174
}
6275
}
6376
```
6477
#### UnmanagedModel
6578
```swift
66-
public struct Movie {
79+
public struct Movie: Hashable {
6780
public let id: UUID
6881
public var title: String = ""
6982
public var releaseDate: Date
7083
public var boxOffice: Decimal = 0
71-
public var objectID: NSManagedObjectID?
84+
public var url: URL?
7285
}
7386

7487
extension Movie: UnmanagedModel {
88+
public var managedRepoUrl: URL? {
89+
get {
90+
url
91+
}
92+
set(newValue) {
93+
url = newValue
94+
}
95+
}
96+
7597
public func asRepoManaged(in context: NSManagedObjectContext) -> RepoMovie {
7698
let object = RepoMovie(context: context)
7799
object.id = id
@@ -112,7 +134,7 @@ _ = repository.create(movie).subscribe(on: self.userInitSerialQueue)
112134
let fetchRequest = NSFetchRequest<RepoMovie>(entityName: "RepoMovie")
113135
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \RepoMovie.title, ascending: true)]
114136
fetchRequest.predicate = NSPredicate(value: true)
115-
let result: AnyPublisher<Success, Failure> = repository.fetch(fetchRequest)
137+
let result: AnyPublisher<[Movie], Error> = repository.fetch(fetchRequest)
116138
let cancellable = result.subscribe(on: userInitSerialQueue)
117139
.receive(on: mainQueue)
118140
.sink(receiveCompletion: { completion in
@@ -130,14 +152,14 @@ let cancellable = result.subscribe(on: userInitSerialQueue)
130152
Similar to a regular fetch:
131153
```swift
132154
...
133-
let result: AnyPublisher<Success, Failure> = repository.fetch(fetchRequest).subscription(repository)
155+
let result: AnyPublisher<[Movie], Error> = repository.fetchSubscription(fetchRequest)
134156
...
135157
cancellable.cancel()
136158
```
137159

138160
### Aggregate
139161
```swift
140-
let result: AnyPublisher<Success<Decimal>, Failure> = repository.sum(
162+
let result: AnyPublisher<[[String: Decimal]], Error> = repository.sum(
141163
predicate: NSPredicate(value: true),
142164
entityDesc: RepoMovie.entity(),
143165
attributeDesc: RepoMovie.entity().attributesByName.values.first(where: { $0.name == "boxOffice" })!
@@ -201,10 +223,39 @@ _ = self.repository.insert(request)
201223
)
202224
```
203225

226+
#### OR
227+
228+
```swift
229+
let movies: [[String: Any]] = [
230+
Movie(id: UUID(), title: "A", releaseDate: Date()),
231+
Movie(id: UUID(), title: "B", releaseDate: Date()),
232+
Movie(id: UUID(), title: "C", releaseDate: Date()),
233+
Movie(id: UUID(), title: "D", releaseDate: Date()),
234+
Movie(id: UUID(), title: "E", releaseDate: Date())
235+
]
236+
let publisher: AnyPublisher<(success: [Movie], failed: [Movie]), Never> = repository.create(movies)
237+
_ = publisher
238+
.subscribe(on: backgroundQueue)
239+
.receive(on: mainQueue)
240+
.sink(
241+
receiveCompletion: { completion in
242+
switch completion {
243+
case .finished:
244+
os_log("Finished inserting A LOT of movies")
245+
default:
246+
fatalError("Failed to insert a lot of movies")
247+
}
248+
},
249+
receiveValue: { createdMovies in
250+
os_log("Created these movies: \(createdMovies)")
251+
}
252+
)_
253+
```
254+
204255

205256
## TODO
206257
- Add a subscription feature for aggregate functions
207258

208259

209260
## Contributing
210-
I welcome any feedback or contributions. I'm not eager to mess with the API a lot but let's be honest, it could probably be better. As always more tests wouldn't hurt.
261+
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.

0 commit comments

Comments
 (0)