├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .spi.yml ├── .swiftformat ├── .swiftlint.yml ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── CoreDataRepository │ ├── CoreDataRepository+Aggregate.swift │ ├── CoreDataRepository+Batch.swift │ ├── CoreDataRepository+CRUD.swift │ ├── CoreDataRepository+Fetch.swift │ ├── CoreDataRepository.swift │ ├── CoreDataRepositoryError.swift │ ├── FetchSubscription.swift │ ├── NSManagedObject+CRUDHelpers.swift │ ├── NSManagedObjectContext+CRUDHelpers.swift │ ├── NSManagedObjectContext+Child.swift │ ├── NSManagedObjectContext+Scratchpad.swift │ ├── ReadSubscription.swift │ ├── RepositoryManagedModel.swift │ ├── Resources │ └── en.lproj │ │ └── Localizable.strings │ ├── Result+CRUDHelpers.swift │ ├── SubscriptionProvider.swift │ ├── UnmanagedModel.swift │ └── _Result.swift └── Tests └── CoreDataRepositoryTests ├── AggregateRepositoryTests.swift ├── BatchRepositoryTests.swift ├── CRUDRepositoryTests.swift ├── CoreDataStack.swift ├── CoreDataXCTestCase.swift ├── FetchRepositoryTests.swift └── Movie.swift /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "swift" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | reviewers: 8 | - "roanutil" 9 | versioning-strategy: "increase-if-necessary" 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | workflow_dispatch: 11 | 12 | jobs: 13 | lint: 14 | runs-on: macos-13 15 | environment: default 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Format lint 19 | run: swiftformat --lint . 20 | - name: Lint 21 | run: swiftlint . 22 | test: 23 | environment: default 24 | strategy: 25 | matrix: 26 | include: 27 | - os: macos-12 28 | xcode: 13.2.1 # Swift 5.5.2 29 | - os: macos-12 30 | xcode: 13.4.1 # Swift 5.6 31 | - os: macos-13 32 | xcode: 14.2 # Swift 5.7 33 | - os: macos-13 34 | xcode: 14.3 # Swift 5.8 35 | runs-on: ${{ matrix.os }} 36 | steps: 37 | - uses: actions/checkout@v3 38 | - name: Select Xcode ${{ matrix.xcode }} 39 | run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app 40 | - name: Run Tests 41 | run: swift test --enable-code-coverage 42 | - name: Swift Coverage Report 43 | run: xcrun llvm-cov export -format="lcov" .build/debug/CoreDataRepositoryPackageTests.xctest/Contents/MacOS/CoreDataRepositoryPackageTests -instr-profile .build/debug/codecov/default.profdata > coverage_report.lcov 44 | - uses: codecov/codecov-action@v3 45 | with: 46 | fail_ci_if_error: true # optional (default = false) 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /*.xcodeproj 5 | xcuserdata/ 6 | /.default.profraw -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [CoreDataRepository] -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --extensionacl on-declarations 2 | --redundanttype explicit 3 | --swiftversion 5.5 4 | --maxwidth 120 5 | --header "{file}\nCoreDataRepository\n\n\nMIT License\n\nCopyright © {year} Andrew Roan" 6 | --allman false 7 | --wraparguments before-first 8 | --wrapcollections before-first -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - multiple_closures_with_trailing_closure # by SwiftUI 3 | - trailing_comma # conflicts with SwiftFormat 4 | - opening_brace # conflicts with SwiftFormat 5 | excluded: # paths to ignore during linting. Takes precedence over `included`. 6 | - Carthage 7 | - Pods 8 | - .build/* 9 | - output 10 | - ./**/*Tests/* 11 | - Previews 12 | identifier_name: 13 | allowed_symbols: "_" 14 | excluded: # excluded via string array 15 | - id 16 | - to 17 | - vm 18 | - vc 19 | - _min 20 | - _max 21 | - or 22 | - by 23 | type_name: 24 | allowed_symbols: "_" 25 | excluded: 26 | - ID 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Andrew Roan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "CombineExt", 6 | "repositoryURL": "https://github.com/CombineCommunity/CombineExt.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "d7b896fa9ca8b47fa7bcde6b43ef9b70bf8c1f56", 10 | "version": "1.8.1" 11 | } 12 | }, 13 | { 14 | "package": "swift-custom-dump", 15 | "repositoryURL": "https://github.com/pointfreeco/swift-custom-dump.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "4a87bb75be70c983a9548597e8783236feb3401e", 19 | "version": "0.11.1" 20 | } 21 | }, 22 | { 23 | "package": "xctest-dynamic-overlay", 24 | "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", 25 | "state": { 26 | "branch": null, 27 | "revision": "50843cbb8551db836adec2290bb4bc6bac5c1865", 28 | "version": "0.9.0" 29 | } 30 | } 31 | ] 32 | }, 33 | "version": 1 34 | } 35 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CoreDataRepository", 8 | defaultLocalization: "en", 9 | platforms: [ 10 | .iOS(.v15), 11 | .macOS(.v12), 12 | .tvOS(.v15), 13 | .watchOS(.v8), 14 | ], 15 | products: [ 16 | .library( 17 | name: "CoreDataRepository", 18 | targets: ["CoreDataRepository"] 19 | ), 20 | ], 21 | dependencies: [ 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")), 24 | ], 25 | targets: [ 26 | .target( 27 | name: "CoreDataRepository", 28 | dependencies: ["CombineExt"] 29 | ), 30 | .testTarget( 31 | name: "CoreDataRepositoryTests", 32 | dependencies: [ 33 | "CoreDataRepository", 34 | .product(name: "CustomDump", package: "swift-custom-dump"), 35 | ] 36 | ), 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CoreDataRepository 2 | 3 | [![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) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Froanutil%2FCoreDataRepository%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/roanutil/CoreDataRepository) 6 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Froanutil%2FCoreDataRepository%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/roanutil/CoreDataRepository) 7 | 8 | ## :mega: Checkout the [discussion](https://github.com/roanutil/CoreDataRepository/discussions/15) for SwiftData 9 | ## :mega: Major changes are in progress for [v3.0](https://github.com/roanutil/CoreDataRepository/tree/3.0-preview) 10 | 11 | CoreDataRepository is a library 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. 12 | 13 | Since ```NSManagedObject```s are not thread safe, a value type model must exist for each ```NSMangaedObject``` subclass. 14 | 15 | ### [Documentation](https://swiftpackageindex.com/roanutil/CoreDataRepository/documentation/coredatarepository) 16 | 17 | ## Motivation 18 | 19 | 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. 20 | 21 | The goals of `CoreDataRepository` are: 22 | 23 | - Ease isolation of `CoreData` related code away from the rest of the app. 24 | - Improve ergonomics by providing an asynchronous API. 25 | - Improve usability of private contexts to relieve load from the main `DispatchQueue`. 26 | - Make local persistence with `CoreData` feel more 'Swift-like' by allowing the model layer to use value types. 27 | 28 | ### Mapping `NSManagedObject`s to value types 29 | 30 | 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. 31 | 32 | 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: 33 | 34 | > Q: How do dependencies work out? It seems like the greatest value of using values is in the model layer, yet that’s the layer at which you have the most dependencies across the rest of your app, which is probably in Objective-C. 35 | 36 | > Andy: In my experience, we had a CoreData stack, which is the opposite of isolation. Our strategy was putting a layer about the CoreData layer that would perform queries and return values. But where would we add functionality in the model layer? As far as using values in the view layer, we do a lot of that actually. We have a table view cell all the way down the stack that will render some icon and a label. The traditional thing to do would be to pass the ManagedObject for that content to the cell, but it doesn’t need that. There’s no reason to create this dependency between the cell and everything the model knows about, and so we make these lightweight little value types that the view needs. The owner of the view can populate that value type and give it to the view. We make these things called presenters that given some model can compute the view data. Then the thing which owns the presenter can pass the results into the view. 37 | 38 | 39 | ## Basic Usage 40 | 41 | ### Model Bridging 42 | 43 | There are two protocols that handle bridging between the value type and managed models. 44 | 45 | #### RepositoryManagedModel 46 | 47 | ```swift 48 | @objc(RepoMovie) 49 | public final class RepoMovie: NSManagedObject { 50 | @NSManaged var id: UUID? 51 | @NSManaged var title: String? 52 | @NSManaged var releaseDate: Date? 53 | @NSManaged var boxOffice: NSDecimalNumber? 54 | } 55 | 56 | extension RepoMovie: 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 | } 78 | 79 | static func fetchRequest() -> NSFetchRequest { 80 | let request = NSFetchRequest(entityName: "RepoMovie") 81 | return request 82 | } 83 | } 84 | ``` 85 | 86 | #### UnmanagedModel 87 | 88 | ```swift 89 | public struct Movie: Hashable { 90 | public let id: UUID 91 | public var title: String = "" 92 | public var releaseDate: Date 93 | public var boxOffice: Decimal = 0 94 | public var url: URL? 95 | } 96 | 97 | extension Movie: UnmanagedModel { 98 | public var managedRepoUrl: URL? { 99 | get { 100 | url 101 | } 102 | set(newValue) { 103 | url = newValue 104 | } 105 | } 106 | 107 | public func asRepoManaged(in context: NSManagedObjectContext) -> RepoMovie { 108 | let object = RepoMovie(context: context) 109 | object.id = id 110 | object.title = title 111 | object.releaseDate = releaseDate 112 | object.boxOffice = boxOffice as NSDecimalNumber 113 | return object 114 | } 115 | } 116 | ``` 117 | 118 | ### CRUD 119 | 120 | ```swift 121 | var movie = Movie(id: UUID(), title: "The Madagascar Penguins in a Christmas Caper", releaseDate: Date(), boxOffice: 100) 122 | let result: Result = await repository.create(movie) 123 | if case let .success(movie) = result { 124 | os_log("Created movie with title - \(movie.title)") 125 | } 126 | ``` 127 | 128 | ### Fetch 129 | 130 | ```swift 131 | let fetchRequest = NSFetchRequest(entityName: "RepoMovie") 132 | fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \RepoMovie.title, ascending: true)] 133 | fetchRequest.predicate = NSPredicate(value: true) 134 | let result: Result<[Movie], CoreDataRepositoryError> = await repository.fetch(fetchRequest) 135 | if case let .success(movies) = result { 136 | os_log("Fetched \(movies.count) movies") 137 | } 138 | ``` 139 | 140 | ### Fetch Subscription 141 | 142 | Similar to a regular fe: 143 | 144 | ```swift 145 | let result: AnyPublisher<[Movie], CoreDataRepositoryError> = repository.fetchSubscription(fetchRequest) 146 | let cancellable = result.subscribe(on: userInitSerialQueue) 147 | .receive(on: mainQueue) 148 | .sink(receiveCompletion: { completion in 149 | switch completion { 150 | case .finished: 151 | os_log("Fetched a bunch of movies") 152 | default: 153 | fatalError("Failed to fetch all the movies!") 154 | } 155 | }, receiveValue: { value in 156 | os_log("Fetched \(value.items.count) movies") 157 | }) 158 | ... 159 | cancellable.cancel() 160 | ``` 161 | 162 | ### Aggregate 163 | 164 | ```swift 165 | let result: Result<[[String: Decimal]], CoreDataRepositoryError> = await repository.sum( 166 | predicate: NSPredicate(value: true), 167 | entityDesc: RepoMovie.entity(), 168 | attributeDesc: RepoMovie.entity().attributesByName.values.first(where: { $0.name == "boxOffice" })! 169 | ) 170 | if case let .success(values) = result { 171 | os_log("The sum of all movies' boxOffice is \(values.first!.values.first!)") 172 | } 173 | ``` 174 | 175 | ### Batch 176 | 177 | ```swift 178 | let movies: [[String: Any]] = [ 179 | ["id": UUID(), "title": "A", "releaseDate": Date()], 180 | ["id": UUID(), "title": "B", "releaseDate": Date()], 181 | ["id": UUID(), "title": "C", "releaseDate": Date()], 182 | ["id": UUID(), "title": "D", "releaseDate": Date()], 183 | ["id": UUID(), "title": "E", "releaseDate": Date()] 184 | ] 185 | let request = NSBatchInsertRequest(entityName: RepoMovie.entity().name!, objects: movies) 186 | let result: Result = await repository.insert(request) 187 | 188 | ``` 189 | 190 | #### OR 191 | 192 | ```swift 193 | let movies: [[String: Any]] = [ 194 | Movie(id: UUID(), title: "A", releaseDate: Date()), 195 | Movie(id: UUID(), title: "B", releaseDate: Date()), 196 | Movie(id: UUID(), title: "C", releaseDate: Date()), 197 | Movie(id: UUID(), title: "D", releaseDate: Date()), 198 | Movie(id: UUID(), title: "E", releaseDate: Date()) 199 | ] 200 | let result: (success: [Movie], failed: [Movie]) = await repository.create(movies) 201 | os_log("Created these movies: \(result.success)") 202 | os_log("Failed to create these movies: \(result.failed)") 203 | ``` 204 | 205 | ## TODO 206 | 207 | - Add a subscription feature for aggregate functions 208 | - Migrate subscription endpoints to AsyncSequence instead of Publisher 209 | - Simplify model protocols (require only one protocol for the value type) 210 | - Allow older platform support by working around the newer variants of `NSManagedObjectContext.perform` and `NSManagedObjectContext.performAndWait` 211 | 212 | ## Contributing 213 | 214 | 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. 215 | 216 | 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. 217 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/CoreDataRepository+Aggregate.swift: -------------------------------------------------------------------------------- 1 | // CoreDataRepository+Aggregate.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Combine 10 | import CoreData 11 | 12 | extension CoreDataRepository { 13 | // MARK: Types 14 | 15 | /// The aggregate function to be calculated 16 | public enum AggregateFunction: String { 17 | case count 18 | case sum 19 | case average 20 | case min 21 | case max 22 | } 23 | 24 | // MARK: Private Functions 25 | 26 | private func request( 27 | function: AggregateFunction, 28 | predicate: NSPredicate, 29 | entityDesc: NSEntityDescription, 30 | attributeDesc: NSAttributeDescription, 31 | groupBy: NSAttributeDescription? = nil 32 | ) -> NSFetchRequest { 33 | let expDesc = NSExpressionDescription.aggregate(function: function, attributeDesc: attributeDesc) 34 | let request = NSFetchRequest(entityName: entityDesc.managedObjectClassName) 35 | request.predicate = predicate 36 | request.entity = entityDesc 37 | request.returnsObjectsAsFaults = false 38 | request.resultType = .dictionaryResultType 39 | if function == .count { 40 | request.propertiesToFetch = [attributeDesc.name, expDesc] 41 | } else { 42 | request.propertiesToFetch = [expDesc] 43 | } 44 | 45 | if let groupBy = groupBy { 46 | request.propertiesToGroupBy = [groupBy.name] 47 | } 48 | request.sortDescriptors = [NSSortDescriptor(key: attributeDesc.name, ascending: false)] 49 | return request 50 | } 51 | 52 | /// Calculates aggregate values 53 | /// - Parameters 54 | /// - function: Function 55 | /// - predicate: NSPredicate 56 | /// - entityDesc: NSEntityDescription 57 | /// - attributeDesc: NSAttributeDescription 58 | /// - groupBy: NSAttributeDescription? = nil 59 | /// - Returns 60 | /// - `[[String: Value]]` 61 | /// 62 | private static func aggregate( 63 | context: NSManagedObjectContext, 64 | request: NSFetchRequest 65 | ) throws -> [[String: Value]] { 66 | let result = try context.fetch(request) 67 | return result as? [[String: Value]] ?? [] 68 | } 69 | 70 | private static func send( 71 | context: NSManagedObjectContext, 72 | request: NSFetchRequest 73 | ) async -> Result<[[String: Value]], CoreDataRepositoryError> where Value: Numeric { 74 | await context.performInScratchPad { scratchPad in 75 | do { 76 | let result: [[String: Value]] = try Self.aggregate(context: scratchPad, request: request) 77 | return result 78 | } catch { 79 | throw CoreDataRepositoryError.coreData(error as NSError) 80 | } 81 | } 82 | } 83 | 84 | // MARK: Public Functions 85 | 86 | /// Calculate the count for a fetchRequest 87 | /// - Parameters: 88 | /// - predicate: NSPredicate 89 | /// - entityDesc: NSEntityDescription 90 | /// - Returns 91 | /// - Result<[[String: Value]], CoreDataRepositoryError> 92 | /// 93 | public func count( 94 | predicate: NSPredicate, 95 | entityDesc: NSEntityDescription 96 | ) async -> Result<[[String: Value]], CoreDataRepositoryError> { 97 | let _request = NSFetchRequest(entityName: entityDesc.name ?? "") 98 | _request.predicate = predicate 99 | _request 100 | .sortDescriptors = 101 | [NSSortDescriptor(key: entityDesc.attributesByName.values.first!.name, ascending: true)] 102 | return await context.performInScratchPad { scratchPad in 103 | do { 104 | let count = try scratchPad.count(for: _request) 105 | return [["countOf\(entityDesc.name ?? "")": Value(exactly: count) ?? Value.zero]] 106 | } catch { 107 | throw CoreDataRepositoryError.coreData(error as NSError) 108 | } 109 | } 110 | } 111 | 112 | /// Calculate the sum for a fetchRequest 113 | /// - Parameters: 114 | /// - predicate: NSPredicate 115 | /// - entityDesc: NSEntityDescription 116 | /// - attributeDesc: NSAttributeDescription 117 | /// - groupBy: NSAttributeDescription? = nil 118 | /// - Returns 119 | /// - Result<[[String: Value]], CoreDataRepositoryError> 120 | /// 121 | public func sum( 122 | predicate: NSPredicate, 123 | entityDesc: NSEntityDescription, 124 | attributeDesc: NSAttributeDescription, 125 | groupBy: NSAttributeDescription? = nil 126 | ) async -> Result<[[String: Value]], CoreDataRepositoryError> { 127 | let _request = request( 128 | function: .sum, 129 | predicate: predicate, 130 | entityDesc: entityDesc, 131 | attributeDesc: attributeDesc, 132 | groupBy: groupBy 133 | ) 134 | guard entityDesc == attributeDesc.entity else { 135 | return .failure(.propertyDoesNotMatchEntity) 136 | } 137 | return await Self.send(context: context, request: _request) 138 | } 139 | 140 | /// Calculate the average for a fetchRequest 141 | /// - Parameters: 142 | /// - predicate: NSPredicate 143 | /// - entityDesc: NSEntityDescription 144 | /// - attributeDesc: NSAttributeDescription 145 | /// - groupBy: NSAttributeDescription? = nil 146 | /// - Returns 147 | /// - Result<[[String: Value]], CoreDataRepositoryError> 148 | /// 149 | public func average( 150 | predicate: NSPredicate, 151 | entityDesc: NSEntityDescription, 152 | attributeDesc: NSAttributeDescription, 153 | groupBy: NSAttributeDescription? = nil 154 | ) async -> Result<[[String: Value]], CoreDataRepositoryError> { 155 | let _request = request( 156 | function: .average, 157 | predicate: predicate, 158 | entityDesc: entityDesc, 159 | attributeDesc: attributeDesc, 160 | groupBy: groupBy 161 | ) 162 | guard entityDesc == attributeDesc.entity else { 163 | return .failure(.propertyDoesNotMatchEntity) 164 | } 165 | return await Self.send(context: context, request: _request) 166 | } 167 | 168 | /// Calculate the min for a fetchRequest 169 | /// - Parameters: 170 | /// - predicate: NSPredicate 171 | /// - entityDesc: NSEntityDescription 172 | /// - attributeDesc: NSAttributeDescription 173 | /// - groupBy: NSAttributeDescription? = nil 174 | /// - Returns 175 | /// - Result<[[String: Value]], CoreDataRepositoryError> 176 | /// 177 | public func min( 178 | predicate: NSPredicate, 179 | entityDesc: NSEntityDescription, 180 | attributeDesc: NSAttributeDescription, 181 | groupBy: NSAttributeDescription? = nil 182 | ) async -> Result<[[String: Value]], CoreDataRepositoryError> { 183 | let _request = request( 184 | function: .min, 185 | predicate: predicate, 186 | entityDesc: entityDesc, 187 | attributeDesc: attributeDesc, 188 | groupBy: groupBy 189 | ) 190 | guard entityDesc == attributeDesc.entity else { 191 | return .failure(.propertyDoesNotMatchEntity) 192 | } 193 | return await Self.send(context: context, request: _request) 194 | } 195 | 196 | /// Calculate the max for a fetchRequest 197 | /// - Parameters: 198 | /// - predicate: NSPredicate 199 | /// - entityDesc: NSEntityDescription 200 | /// - attributeDesc: NSAttributeDescription 201 | /// - groupBy: NSAttributeDescription? = nil 202 | /// - Returns 203 | /// - Result<[[String: Value]], CoreDataRepositoryError> 204 | /// 205 | public func max( 206 | predicate: NSPredicate, 207 | entityDesc: NSEntityDescription, 208 | attributeDesc: NSAttributeDescription, 209 | groupBy: NSAttributeDescription? = nil 210 | ) async -> Result<[[String: Value]], CoreDataRepositoryError> { 211 | let _request = request( 212 | function: .max, 213 | predicate: predicate, 214 | entityDesc: entityDesc, 215 | attributeDesc: attributeDesc, 216 | groupBy: groupBy 217 | ) 218 | guard entityDesc == attributeDesc.entity else { 219 | return .failure(.propertyDoesNotMatchEntity) 220 | } 221 | return await Self.send(context: context, request: _request) 222 | } 223 | } 224 | 225 | // MARK: Extensions 226 | 227 | extension NSExpression { 228 | /// Convenience initializer for NSExpression that represent an aggregate function on a keypath 229 | fileprivate convenience init( 230 | function: CoreDataRepository.AggregateFunction, 231 | attributeDesc: NSAttributeDescription 232 | ) { 233 | let keyPathExp = NSExpression(forKeyPath: attributeDesc.name) 234 | self.init(forFunction: "\(function.rawValue):", arguments: [keyPathExp]) 235 | } 236 | } 237 | 238 | extension NSExpressionDescription { 239 | /// Convenience initializer for NSExpressionDescription that represent the properties to fetch in NSFetchRequest 240 | fileprivate static func aggregate( 241 | function: CoreDataRepository.AggregateFunction, 242 | attributeDesc: NSAttributeDescription 243 | ) -> NSExpressionDescription { 244 | let expression = NSExpression(function: function, attributeDesc: attributeDesc) 245 | let expDesc = NSExpressionDescription() 246 | expDesc.expression = expression 247 | expDesc.name = "\(function.rawValue)Of\(attributeDesc.name.capitalized)" 248 | expDesc.expressionResultType = attributeDesc.attributeType 249 | return expDesc 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/CoreDataRepository+Batch.swift: -------------------------------------------------------------------------------- 1 | // CoreDataRepository+Batch.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Combine 10 | import CoreData 11 | 12 | extension CoreDataRepository { 13 | // MARK: Functions 14 | 15 | /// Batch insert objects into CoreData 16 | /// - Parameters 17 | /// - _ request: NSBatchInsertRequest 18 | /// - transactionAuthor: String 19 | /// - Returns 20 | /// - Result 21 | public func insert( 22 | _ request: NSBatchInsertRequest, 23 | transactionAuthor: String? = nil 24 | ) async -> Result { 25 | await context.performInScratchPad { [context] scratchPad in 26 | context.transactionAuthor = transactionAuthor 27 | guard let result = try scratchPad.execute(request) as? NSBatchInsertResult else { 28 | context.transactionAuthor = nil 29 | throw CoreDataRepositoryError.fetchedObjectFailedToCastToExpectedType 30 | } 31 | context.transactionAuthor = nil 32 | return result 33 | } 34 | } 35 | 36 | /// Batch update objects in CoreData 37 | /// - Parameters 38 | /// - _ items: [Model] 39 | /// - transactionAuthor: String 40 | /// - Returns 41 | /// - (success: [Model, failed: [Model]) 42 | public func create( 43 | _ items: [Model], 44 | transactionAuthor: String? = nil 45 | ) async -> (success: [Model], failed: [Model]) { 46 | var successes = [Model]() 47 | var failures = [Model]() 48 | await withTaskGroup(of: _Result.self, body: { [weak self] group in 49 | guard let self = self else { 50 | group.cancelAll() 51 | return 52 | } 53 | for item in items { 54 | let added = group.addTaskUnlessCancelled { 55 | async let result: Result = self 56 | .create(item, transactionAuthor: transactionAuthor) 57 | switch await result { 58 | case let .success(created): 59 | return _Result.success(created) 60 | case .failure: 61 | return _Result.failure(item) 62 | } 63 | } 64 | if !added { 65 | return 66 | } 67 | } 68 | for await result in group { 69 | switch result { 70 | case let .success(success): 71 | successes.append(success) 72 | case let .failure(failure): 73 | failures.append(failure) 74 | } 75 | } 76 | }) 77 | return (success: successes, failed: failures) 78 | } 79 | 80 | /// Batch update objects in CoreData 81 | /// - Parameters 82 | /// - urls: [URL] 83 | /// - Returns 84 | /// - (success: [Model, failed: [Model]) 85 | @available(*, deprecated, message: "This method has an unused parameter for transactionAuthor.") 86 | public func read( 87 | urls: [URL], 88 | transactionAuthor _: String? = nil 89 | ) async -> (success: [Model], failed: [URL]) { 90 | await read(urls: urls) 91 | } 92 | 93 | /// Batch update objects in CoreData 94 | /// - Parameters 95 | /// - urls: [URL] 96 | /// - Returns 97 | /// - (success: [Model, failed: [Model]) 98 | public func read(urls: [URL]) async -> (success: [Model], failed: [URL]) { 99 | var successes = [Model]() 100 | var failures = [URL]() 101 | await withTaskGroup(of: _Result.self, body: { [weak self] group in 102 | guard let self = self else { 103 | group.cancelAll() 104 | return 105 | } 106 | for url in urls { 107 | let added = group.addTaskUnlessCancelled { 108 | async let result: Result = self.read(url) 109 | switch await result { 110 | case let .success(created): 111 | return _Result.success(created) 112 | case .failure: 113 | return _Result.failure(url) 114 | } 115 | } 116 | if !added { 117 | return 118 | } 119 | } 120 | for await result in group { 121 | switch result { 122 | case let .success(success): 123 | successes.append(success) 124 | case let .failure(failure): 125 | failures.append(failure) 126 | } 127 | } 128 | }) 129 | return (success: successes, failed: failures) 130 | } 131 | 132 | /// Batch update objects in CoreData 133 | /// - Parameters 134 | /// - _ request: NSBatchInsertRequest 135 | /// - transactionAuthor: String 136 | /// - Returns 137 | /// - Result 138 | public func update( 139 | _ request: NSBatchUpdateRequest, 140 | transactionAuthor: String? = nil 141 | ) async -> Result { 142 | await context.performInScratchPad { [context] scratchPad in 143 | context.transactionAuthor = transactionAuthor 144 | guard let result = try scratchPad.execute(request) as? NSBatchUpdateResult else { 145 | context.transactionAuthor = nil 146 | throw CoreDataRepositoryError.fetchedObjectFailedToCastToExpectedType 147 | } 148 | context.transactionAuthor = nil 149 | return result 150 | } 151 | } 152 | 153 | /// Batch update objects in CoreData 154 | /// - Parameters 155 | /// - _ items: [Model] 156 | /// - transactionAuthor: String 157 | /// - Returns 158 | /// - (success: [Model, failed: [Model]) 159 | public func update( 160 | _ items: [Model], 161 | transactionAuthor: String? = nil 162 | ) async -> (success: [Model], failed: [Model]) { 163 | var successes = [Model]() 164 | var failures = [Model]() 165 | await withTaskGroup(of: _Result.self, body: { [weak self] group in 166 | guard let self = self else { 167 | group.cancelAll() 168 | return 169 | } 170 | for item in items { 171 | let added = group.addTaskUnlessCancelled { 172 | guard let url = item.managedRepoUrl else { 173 | return _Result.failure(item) 174 | } 175 | async let result: Result = self 176 | .update(url, with: item, transactionAuthor: transactionAuthor) 177 | switch await result { 178 | case let .success(created): 179 | return _Result.success(created) 180 | case .failure: 181 | return _Result.failure(item) 182 | } 183 | } 184 | if !added { 185 | return 186 | } 187 | } 188 | for await result in group { 189 | switch result { 190 | case let .success(success): 191 | successes.append(success) 192 | case let .failure(failure): 193 | failures.append(failure) 194 | } 195 | } 196 | }) 197 | return (success: successes, failed: failures) 198 | } 199 | 200 | /// Batch delete objects from CoreData 201 | /// - Parameters 202 | /// - _ request: NSBatchInsertRequest 203 | /// - transactionAuthor: String 204 | /// - Returns 205 | /// - Result 206 | public func delete( 207 | _ request: NSBatchDeleteRequest, 208 | transactionAuthor: String? = nil 209 | ) async -> Result { 210 | await context.performInScratchPad { [context] scratchPad in 211 | context.transactionAuthor = transactionAuthor 212 | guard let result = try scratchPad.execute(request) as? NSBatchDeleteResult else { 213 | context.transactionAuthor = nil 214 | throw CoreDataRepositoryError.fetchedObjectFailedToCastToExpectedType 215 | } 216 | context.transactionAuthor = nil 217 | return result 218 | } 219 | } 220 | 221 | /// Batch update objects in CoreData 222 | /// - Parameters 223 | /// - _ items: [Model] 224 | /// - Returns 225 | /// - (success: [Model, failed: [Model]) 226 | public func delete( 227 | urls: [URL], 228 | transactionAuthor: String? = nil 229 | ) async -> (success: [URL], failed: [URL]) { 230 | var successes = [URL]() 231 | var failures = [URL]() 232 | await withTaskGroup(of: _Result.self, body: { [weak self] group in 233 | guard let self = self else { 234 | group.cancelAll() 235 | return 236 | } 237 | for url in urls { 238 | let added = group.addTaskUnlessCancelled { 239 | async let result: Result = self 240 | .delete(url, transactionAuthor: transactionAuthor) 241 | switch await result { 242 | case .success: 243 | return _Result.success(url) 244 | case .failure: 245 | return _Result.failure(url) 246 | } 247 | } 248 | if !added { 249 | return 250 | } 251 | } 252 | for await result in group { 253 | switch result { 254 | case let .success(success): 255 | successes.append(success) 256 | case let .failure(failure): 257 | failures.append(failure) 258 | } 259 | } 260 | }) 261 | return (success: successes, failed: failures) 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/CoreDataRepository+CRUD.swift: -------------------------------------------------------------------------------- 1 | // CoreDataRepository+CRUD.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Combine 10 | import CoreData 11 | 12 | extension CoreDataRepository { 13 | // MARK: Functions/Endpoints 14 | 15 | /// Create an instance of a NSManagedObject sub class from a corresponding value type. 16 | /// Supports specifying a transactionAuthor that is applied to the context before saving. 17 | /// - Types 18 | /// - Model: UnmanagedModel 19 | /// - Parameters 20 | /// - _ item: Model 21 | /// - transactionAuthor: String? = nil 22 | /// - Returns 23 | /// - Result 24 | /// 25 | public func create( 26 | _ item: Model, 27 | transactionAuthor: String? = nil 28 | ) async -> Result { 29 | await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in 30 | let object = Model.RepoManaged(context: scratchPad) 31 | object.create(from: item) 32 | try scratchPad.save() 33 | try context.performAndWait { 34 | context.transactionAuthor = transactionAuthor 35 | try context.save() 36 | context.transactionAuthor = nil 37 | } 38 | try scratchPad.obtainPermanentIDs(for: [object]) 39 | return object.asUnmanaged 40 | } 41 | } 42 | 43 | /// Read an instance of a NSManagedObject sub class as a corresponding value type 44 | /// - Types 45 | /// - Model: UnmanagedModel 46 | /// - Parameters 47 | /// - _ objectID: NSManagedObjectID 48 | /// - Returns 49 | /// - Result 50 | /// 51 | public func read(_ url: URL) async -> Result { 52 | await context.performInChild(schedule: .enqueued) { readContext in 53 | let id = try readContext.tryObjectId(from: url) 54 | let object = try readContext.notDeletedObject(for: id) 55 | let repoManaged: Model.RepoManaged = try object.asRepoManaged() 56 | return repoManaged.asUnmanaged 57 | } 58 | } 59 | 60 | /// Update an instance of a NSManagedObject sub class from a corresponding value type. 61 | /// Supports specifying a transactionAuthor that is applied to the context before saving. 62 | /// - Types 63 | /// - Model: UnmanagedModel 64 | /// - Parameters 65 | /// - objectID: NSManagedObjectID 66 | /// - with item: Model 67 | /// - transactionAuthor: String? = nil 68 | /// - Returns 69 | /// - Result 70 | /// 71 | public func update( 72 | _ url: URL, 73 | with item: Model, 74 | transactionAuthor: String? = nil 75 | ) async -> Result { 76 | await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in 77 | scratchPad.transactionAuthor = transactionAuthor 78 | let id = try scratchPad.tryObjectId(from: url) 79 | let object = try scratchPad.notDeletedObject(for: id) 80 | let repoManaged: Model.RepoManaged = try object.asRepoManaged() 81 | repoManaged.update(from: item) 82 | try scratchPad.save() 83 | try context.performAndWait { 84 | context.transactionAuthor = transactionAuthor 85 | try context.save() 86 | context.transactionAuthor = nil 87 | } 88 | return repoManaged.asUnmanaged 89 | } 90 | } 91 | 92 | /// Delete an instance of a NSManagedObject sub class. Supports specifying a 93 | /// transactionAuthor that is applied to the context before saving. 94 | /// - Types 95 | /// - Model: UnmanagedModel 96 | /// - Parameters 97 | /// - objectID: NSManagedObjectID 98 | /// - transactionAuthor: String? = nil 99 | /// - Returns 100 | /// - Result 101 | /// 102 | public func delete( 103 | _ url: URL, 104 | transactionAuthor: String? = nil 105 | ) async -> Result { 106 | await context.performInScratchPad(schedule: .enqueued) { [context] scratchPad in 107 | scratchPad.transactionAuthor = transactionAuthor 108 | let id = try scratchPad.tryObjectId(from: url) 109 | let object = try scratchPad.notDeletedObject(for: id) 110 | object.prepareForDeletion() 111 | scratchPad.delete(object) 112 | try scratchPad.save() 113 | try context.performAndWait { 114 | context.transactionAuthor = transactionAuthor 115 | try context.save() 116 | context.transactionAuthor = nil 117 | } 118 | return () 119 | } 120 | } 121 | 122 | /// Subscribe to updates for an instance of a NSManagedObject subclass. 123 | /// - Parameter publisher: Pub 124 | /// - Returns: AnyPublisher 125 | public func readSubscription(_ url: URL) -> AnyPublisher { 126 | let readContext = context.childContext() 127 | let readPublisher: AnyPublisher = readRepoManaged( 128 | url, 129 | readContext: readContext 130 | ) 131 | var subjectCancellable: AnyCancellable? 132 | return Publishers.Create { [weak self] subscriber in 133 | let subject = PassthroughSubject() 134 | subjectCancellable = subject.sink(receiveCompletion: subscriber.send, receiveValue: subscriber.send) 135 | 136 | let id = UUID() 137 | var subscription: SubscriptionProvider? 138 | self?.cancellables.insert(readPublisher.sink( 139 | receiveCompletion: { completion in 140 | if case .failure = completion { 141 | subject.send(completion: completion) 142 | } 143 | }, 144 | receiveValue: { repoManaged in 145 | let subscriptionProvider = ReadSubscription( 146 | id: id, 147 | objectId: repoManaged.objectID, 148 | context: readContext, 149 | subject: subject 150 | ) 151 | subscription = subscriptionProvider 152 | subscriptionProvider.start() 153 | if let _self = self, 154 | let _subjectCancellable = subjectCancellable 155 | { 156 | _self.subscriptions.append(subscriptionProvider) 157 | _self.cancellables.insert(_subjectCancellable) 158 | } else { 159 | subjectCancellable?.cancel() 160 | subscription?.cancel() 161 | } 162 | subscriptionProvider.manualFetch() 163 | } 164 | )) 165 | return AnyCancellable { 166 | subscription?.cancel() 167 | self?.subscriptions.removeAll(where: { $0.id == id as AnyHashable }) 168 | } 169 | }.eraseToAnyPublisher() 170 | } 171 | 172 | private static func getObjectId( 173 | fromUrl url: URL, 174 | context: NSManagedObjectContext 175 | ) -> Result { 176 | guard let objectId = context.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: url) else { 177 | return Result.failure(.failedToGetObjectIdFromUrl(url)) 178 | } 179 | return .success(objectId) 180 | } 181 | 182 | private func readRepoManaged( 183 | _ url: URL, 184 | readContext: NSManagedObjectContext 185 | ) -> AnyPublisher 186 | where T: RepositoryManagedModel 187 | { 188 | Future { promise in 189 | readContext.performAndWait { 190 | let result: Result = readContext.objectId(from: url) 191 | .mapToNSManagedObject(context: readContext) 192 | .map(to: T.self) 193 | .mapToRepoError() 194 | promise(result) 195 | } 196 | }.eraseToAnyPublisher() 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/CoreDataRepository+Fetch.swift: -------------------------------------------------------------------------------- 1 | // CoreDataRepository+Fetch.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Combine 10 | import CombineExt 11 | import CoreData 12 | 13 | extension CoreDataRepository { 14 | // MARK: Functions/Endpoints 15 | 16 | /// Fetch a single array of value types corresponding to a NSManagedObject sub class. 17 | /// - Parameters 18 | /// - _ request: NSFetchRequest 19 | /// - Returns 20 | /// - Result<[Model], CoreDataRepositoryError> 21 | /// 22 | public func fetch(_ request: NSFetchRequest) async 23 | -> Result<[Model], CoreDataRepositoryError> 24 | { 25 | await context.performInChild { fetchContext in 26 | try fetchContext.fetch(request).map(\.asUnmanaged) 27 | } 28 | } 29 | 30 | /// Fetch an array of value types corresponding to a NSManagedObject sub class and receive 31 | /// updates for changes in the context. 32 | /// - Parameters 33 | /// - _request: NSFetchRequest 34 | /// - Returns 35 | /// - AnyPublisher<[Model], CoreDataRepositoryError> 36 | public func fetchSubscription(_ request: NSFetchRequest) 37 | -> AnyPublisher<[Model], CoreDataRepositoryError> 38 | { 39 | let fetchContext = context.childContext() 40 | let fetchPublisher: AnyPublisher<[Model], CoreDataRepositoryError> = _fetch(request, fetchContext: fetchContext) 41 | var subjectCancellable: AnyCancellable? 42 | var fetchCancellable: AnyCancellable? 43 | return AnyPublisher.create { [weak self] subscriber in 44 | let subject = PassthroughSubject<[Model], CoreDataRepositoryError>() 45 | subjectCancellable = subject.sink(receiveCompletion: subscriber.send, receiveValue: subscriber.send) 46 | let id = UUID() 47 | var subscription: SubscriptionProvider? 48 | fetchCancellable = fetchPublisher.sink( 49 | receiveCompletion: { completion in 50 | if case .failure = completion { 51 | subject.send(completion: completion) 52 | } 53 | }, 54 | receiveValue: { value in 55 | let subscriptionProvider = FetchSubscription( 56 | id: id, 57 | request: request, 58 | context: fetchContext, 59 | success: { $0.map(\.asUnmanaged) }, 60 | subject: subject 61 | ) 62 | subscription = subscriptionProvider 63 | subscriptionProvider.start() 64 | if let _self = self, 65 | let _subjectCancellable = subjectCancellable, 66 | let _fetchCancellable = fetchCancellable 67 | { 68 | _self.subscriptions.append(subscriptionProvider) 69 | _self.cancellables.insert(_subjectCancellable) 70 | _self.cancellables.insert(_fetchCancellable) 71 | } else { 72 | subjectCancellable?.cancel() 73 | fetchCancellable?.cancel() 74 | subscription?.cancel() 75 | } 76 | subject.send(value) 77 | } 78 | ) 79 | return AnyCancellable { 80 | subscription?.cancel() 81 | self?.subscriptions.removeAll(where: { $0.id == id as AnyHashable }) 82 | } 83 | } 84 | } 85 | 86 | private func _fetch( 87 | _ request: NSFetchRequest, 88 | fetchContext: NSManagedObjectContext 89 | ) 90 | -> AnyPublisher<[Model], CoreDataRepositoryError> 91 | { 92 | Future { promise in 93 | fetchContext.perform { 94 | do { 95 | let items = try fetchContext.fetch(request).map(\.asUnmanaged) 96 | promise(.success(items)) 97 | } catch { 98 | promise(.failure(.coreData(error as NSError))) 99 | } 100 | } 101 | }.eraseToAnyPublisher() 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/CoreDataRepository.swift: -------------------------------------------------------------------------------- 1 | // CoreDataRepository.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Combine 10 | import CoreData 11 | import Foundation 12 | 13 | /// A CoreData repository with typical create, read, update, and delete endpoints 14 | public final class CoreDataRepository { 15 | // MARK: Properties 16 | 17 | /// CoreData context the repository uses 18 | public let context: NSManagedObjectContext 19 | var subscriptions = [SubscriptionProvider]() 20 | var cancellables = Set() 21 | 22 | // MARK: Init 23 | 24 | /// Initializes a CRUDRepository 25 | /// - Parameters 26 | /// - context: NSManagedObjectContext 27 | public init(context: NSManagedObjectContext) { 28 | self.context = context 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/CoreDataRepositoryError.swift: -------------------------------------------------------------------------------- 1 | // CoreDataRepositoryError.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Foundation 10 | 11 | public enum CoreDataRepositoryError: Error, Equatable, Hashable { 12 | case failedToGetObjectIdFromUrl(URL) 13 | case propertyDoesNotMatchEntity 14 | case fetchedObjectFailedToCastToExpectedType 15 | case fetchedObjectIsFlaggedAsDeleted 16 | case coreData(NSError) 17 | 18 | public var localizedDescription: String { 19 | switch self { 20 | case .failedToGetObjectIdFromUrl: 21 | return NSLocalizedString( 22 | "No NSManagedObjectID found that correlates to the provided URL.", 23 | bundle: .module, 24 | comment: "Error for when an ObjectID can't be found for the provided URL." 25 | ) 26 | case .propertyDoesNotMatchEntity: 27 | return NSLocalizedString( 28 | "There is a mismatch between a provided NSPropertyDescrption's entity and a NSEntityDescription. " 29 | + "When a property description is provided, it must match any related entity descriptions.", 30 | bundle: .module, 31 | comment: "Error for when the developer does not provide a valid pair of NSAttributeDescription " 32 | + "and NSPropertyDescription (or any of their child types)." 33 | ) 34 | case .fetchedObjectFailedToCastToExpectedType: 35 | return NSLocalizedString( 36 | "The object corresponding to the provided NSManagedObjectID is an incorrect Entity or " 37 | + "NSManagedObject subtype. It failed to cast to the requested type.", 38 | bundle: .module, 39 | comment: "Error for when an object is found for a given ObjectID but it is not the expected type." 40 | ) 41 | case .fetchedObjectIsFlaggedAsDeleted: 42 | return NSLocalizedString( 43 | "The object corresponding to the provided NSManagedObjectID is deleted and cannot be fetched.", 44 | bundle: .module, 45 | comment: "Error for when an object is fetched but is flagged as deleted and is no longer usable." 46 | ) 47 | case let .coreData(error): 48 | return error.localizedDescription 49 | } 50 | } 51 | } 52 | 53 | extension CoreDataRepositoryError: CustomNSError { 54 | public static let errorDomain: String = "CoreDataRepository" 55 | 56 | public var errorCode: Int { 57 | switch self { 58 | case .failedToGetObjectIdFromUrl: 59 | return 1 60 | case .propertyDoesNotMatchEntity: 61 | return 2 62 | case .fetchedObjectFailedToCastToExpectedType: 63 | return 3 64 | case .fetchedObjectIsFlaggedAsDeleted: 65 | return 4 66 | case .coreData: 67 | return 5 68 | } 69 | } 70 | 71 | public static let urlUserInfoKey: String = "ObjectIdUrl" 72 | 73 | public var errorUserInfo: [String: Any] { 74 | switch self { 75 | case let .failedToGetObjectIdFromUrl(url): 76 | return [Self.urlUserInfoKey: url] 77 | case .propertyDoesNotMatchEntity: 78 | return [:] 79 | case .fetchedObjectFailedToCastToExpectedType: 80 | return [:] 81 | case .fetchedObjectIsFlaggedAsDeleted: 82 | return [:] 83 | case let .coreData(error): 84 | return error.userInfo 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/FetchSubscription.swift: -------------------------------------------------------------------------------- 1 | // FetchSubscription.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Combine 10 | import CoreData 11 | import Foundation 12 | 13 | /// Re-fetches data as the context changes until canceled 14 | final class FetchSubscription< 15 | Success, 16 | Result: NSFetchRequestResult 17 | >: NSObject, NSFetchedResultsControllerDelegate, SubscriptionProvider { 18 | // MARK: Properties 19 | 20 | /// Enables easy cancellation and cleanup of a subscription as a repository may 21 | /// have multiple subscriptions running at once. 22 | let id: AnyHashable 23 | /// The fetch request to monitor 24 | private let request: NSFetchRequest 25 | /// Fetched results controller that notifies the context has changed 26 | private let frc: NSFetchedResultsController 27 | /// Subject that sends data as updates happen 28 | let subject: PassthroughSubject 29 | /// Closure to construct Success 30 | private let success: ([Result]) -> Success 31 | 32 | private var changeNotificationCancellable: AnyCancellable? 33 | 34 | // MARK: Init 35 | 36 | /// Initializes an instance of Subscription 37 | /// - Parameters 38 | /// - id: AnyHashable 39 | /// - request: NSFetchRequest 40 | /// - context: NSManagedObjectContext 41 | /// - success: @escaping ([Result]) -> Success 42 | /// - failure: @escaping (RepositoryErrors) -> Failure 43 | init( 44 | id: AnyHashable, 45 | request: NSFetchRequest, 46 | context: NSManagedObjectContext, 47 | success: @escaping ([Result]) -> Success, 48 | subject: PassthroughSubject = .init() 49 | ) { 50 | self.id = id 51 | self.request = request 52 | frc = NSFetchedResultsController( 53 | fetchRequest: request, 54 | managedObjectContext: context, 55 | sectionNameKeyPath: nil, 56 | cacheName: nil 57 | ) 58 | self.success = success 59 | self.subject = subject 60 | super.init() 61 | if request.resultType != .dictionaryResultType { 62 | frc.delegate = self 63 | } else { 64 | changeNotificationCancellable = NotificationCenter.default.publisher( 65 | for: .NSManagedObjectContextObjectsDidChange, 66 | object: context 67 | ).sink(receiveValue: { _ in 68 | self.fetch() 69 | }) 70 | } 71 | } 72 | 73 | // MARK: Private methods 74 | 75 | /// Get and send new data for fetch request 76 | private func fetch() { 77 | frc.managedObjectContext.perform { 78 | if (self.frc.fetchedObjects ?? []).isEmpty { 79 | self.start() 80 | } 81 | guard let items = self.frc.fetchedObjects else { return } 82 | self.subject.send(self.success(items)) 83 | } 84 | } 85 | 86 | // MARK: Public methods 87 | 88 | // MARK: NSFetchedResultsControllerDelegate conformance 89 | 90 | func controllerDidChangeContent(_: NSFetchedResultsController) { 91 | fetch() 92 | } 93 | 94 | func start() { 95 | do { 96 | try frc.performFetch() 97 | } catch { 98 | fail(.coreData(error as NSError)) 99 | } 100 | } 101 | 102 | /// Manually initiate a fetch and publish data 103 | func manualFetch() { 104 | fetch() 105 | } 106 | 107 | /// Cancel the subscription 108 | func cancel() { 109 | subject.send(completion: .finished) 110 | } 111 | 112 | /// Finish the subscription with a failure 113 | /// - Parameters 114 | /// - _ failure: Failure 115 | func fail(_ error: CoreDataRepositoryError) { 116 | subject.send(completion: .failure(error)) 117 | } 118 | 119 | // Helps me sleep at night 120 | deinit { 121 | self.subject.send(completion: .finished) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/NSManagedObject+CRUDHelpers.swift: -------------------------------------------------------------------------------- 1 | // NSManagedObject+CRUDHelpers.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import CoreData 10 | import Foundation 11 | 12 | extension NSManagedObject { 13 | func asRepoManaged() throws -> T where T: RepositoryManagedModel { 14 | guard let repoManaged = self as? T else { 15 | throw CoreDataRepositoryError.fetchedObjectFailedToCastToExpectedType 16 | } 17 | return repoManaged 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/NSManagedObjectContext+CRUDHelpers.swift: -------------------------------------------------------------------------------- 1 | // NSManagedObjectContext+CRUDHelpers.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import CoreData 10 | import Foundation 11 | 12 | extension NSManagedObjectContext { 13 | func tryObjectId(from url: URL) throws -> NSManagedObjectID { 14 | guard let objectId = persistentStoreCoordinator?.managedObjectID(forURIRepresentation: url) else { 15 | throw CoreDataRepositoryError.failedToGetObjectIdFromUrl(url) 16 | } 17 | return objectId 18 | } 19 | 20 | func objectId(from url: URL) -> Result { 21 | Result { 22 | try tryObjectId(from: url) 23 | } 24 | } 25 | 26 | func notDeletedObject(for id: NSManagedObjectID) throws -> NSManagedObject { 27 | let object: NSManagedObject = try existingObject(with: id) 28 | guard !object.isDeleted else { 29 | throw CoreDataRepositoryError.fetchedObjectIsFlaggedAsDeleted 30 | } 31 | return object 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/NSManagedObjectContext+Child.swift: -------------------------------------------------------------------------------- 1 | // NSManagedObjectContext+Child.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import CoreData 10 | import Foundation 11 | 12 | extension NSManagedObjectContext { 13 | func performInChild( 14 | schedule: NSManagedObjectContext.ScheduledTaskType = .immediate, 15 | _ block: @escaping (NSManagedObjectContext) throws -> Output 16 | ) async -> Result { 17 | let child = childContext() 18 | let output: Output 19 | do { 20 | output = try await child.perform(schedule: schedule) { try block(child) } 21 | } catch let error as CoreDataRepositoryError { 22 | return .failure(error) 23 | } catch let error as NSError { 24 | return .failure(.coreData(error)) 25 | } 26 | return .success(output) 27 | } 28 | 29 | func childContext() -> NSManagedObjectContext { 30 | let child = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) 31 | child.automaticallyMergesChangesFromParent = true 32 | child.parent = self 33 | return child 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/NSManagedObjectContext+Scratchpad.swift: -------------------------------------------------------------------------------- 1 | // NSManagedObjectContext+Scratchpad.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Combine 10 | import CoreData 11 | import Foundation 12 | 13 | extension NSManagedObjectContext { 14 | func performInScratchPad( 15 | promise: @escaping Future.Promise, 16 | _ block: @escaping (NSManagedObjectContext) -> Result 17 | ) { 18 | let scratchPad = scratchPadContext() 19 | scratchPad.perform { 20 | let result = block(scratchPad) 21 | if case .failure = result { 22 | scratchPad.rollback() 23 | } 24 | promise(result) 25 | } 26 | } 27 | 28 | func performInScratchPad( 29 | schedule: NSManagedObjectContext.ScheduledTaskType = .immediate, 30 | _ block: @escaping (NSManagedObjectContext) throws -> Output 31 | ) async -> Result { 32 | let scratchPad = scratchPadContext() 33 | let output: Output 34 | do { 35 | output = try await scratchPad.perform(schedule: schedule) { try block(scratchPad) } 36 | } catch let error as CoreDataRepositoryError { 37 | await scratchPad.perform { 38 | scratchPad.rollback() 39 | } 40 | return .failure(error) 41 | } catch let error as NSError { 42 | await scratchPad.perform { 43 | scratchPad.rollback() 44 | } 45 | return .failure(CoreDataRepositoryError.coreData(error)) 46 | } 47 | return .success(output) 48 | } 49 | 50 | func performAndWaitInScratchPad( 51 | promise: @escaping Future.Promise, 52 | _ block: @escaping (NSManagedObjectContext) -> Result 53 | ) throws { 54 | let scratchPad = scratchPadContext() 55 | scratchPad.performAndWait { 56 | let result = block(scratchPad) 57 | if case .failure = result { 58 | scratchPad.rollback() 59 | } 60 | promise(result) 61 | } 62 | } 63 | 64 | private func scratchPadContext() -> NSManagedObjectContext { 65 | let scratchPad = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) 66 | scratchPad.automaticallyMergesChangesFromParent = false 67 | scratchPad.parent = self 68 | return scratchPad 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/ReadSubscription.swift: -------------------------------------------------------------------------------- 1 | // ReadSubscription.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Combine 10 | import CoreData 11 | import Foundation 12 | 13 | final class ReadSubscription { 14 | let id: AnyHashable 15 | private let objectId: NSManagedObjectID 16 | private let context: NSManagedObjectContext 17 | let subject: PassthroughSubject 18 | private var cancellables: Set = [] 19 | 20 | init( 21 | id: AnyHashable, 22 | objectId: NSManagedObjectID, 23 | context: NSManagedObjectContext, 24 | subject: PassthroughSubject 25 | ) { 26 | self.id = id 27 | self.subject = subject 28 | self.objectId = objectId 29 | self.context = context 30 | } 31 | } 32 | 33 | extension ReadSubscription: SubscriptionProvider { 34 | func manualFetch() { 35 | context.perform { [weak self, context, objectId] in 36 | guard let object = context.object(with: objectId) as? Model.RepoManaged else { 37 | return 38 | } 39 | self?.subject.send(object.asUnmanaged) 40 | } 41 | } 42 | 43 | func cancel() { 44 | subject.send(completion: .finished) 45 | cancellables.forEach { $0.cancel() } 46 | } 47 | 48 | func start() { 49 | context.perform { [weak self, context, objectId] in 50 | guard let object = context.object(with: objectId) as? Model.RepoManaged else { 51 | return 52 | } 53 | let startCancellable = object.objectWillChange.sink { [weak self] _ in 54 | self?.subject.send(object.asUnmanaged) 55 | } 56 | self?.cancellables.insert(startCancellable) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/RepositoryManagedModel.swift: -------------------------------------------------------------------------------- 1 | // RepositoryManagedModel.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import CoreData 10 | 11 | /// A protocol for a CoreData NSManagedObject sub class that has a corresponding value type 12 | public protocol RepositoryManagedModel: NSManagedObject { 13 | associatedtype Unmanaged: UnmanagedModel where Unmanaged.RepoManaged == Self 14 | /// Returns a value type instance of `self` 15 | var asUnmanaged: Unmanaged { get } 16 | /// Create `self` from a corresponding instance of `UnmanagedModel`. Should not save the context. 17 | func create(from unmanaged: Unmanaged) 18 | /// Update `self` from a corresponding instance of `UnmanagedModel`. Should not save the context. 19 | func update(from unmanaged: Unmanaged) 20 | } 21 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roanutil/CoreDataRepository/c77247fcf06c477463f72418cf45da50806e2e06/Sources/CoreDataRepository/Resources/en.lproj/Localizable.strings -------------------------------------------------------------------------------- /Sources/CoreDataRepository/Result+CRUDHelpers.swift: -------------------------------------------------------------------------------- 1 | // Result+CRUDHelpers.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import CoreData 10 | import Foundation 11 | 12 | extension Result where Success == NSManagedObjectID, Failure == Error { 13 | func mapToNSManagedObject(context: NSManagedObjectContext) -> Result { 14 | flatMap { objectId -> Result in 15 | Result { 16 | try context.notDeletedObject(for: objectId) 17 | } 18 | } 19 | } 20 | } 21 | 22 | extension Result where Success == NSManagedObject, Failure == Error { 23 | func map(to _: T.Type) -> Result 24 | where T: RepositoryManagedModel 25 | { 26 | flatMap { object -> Result in 27 | Result { 28 | try object.asRepoManaged() 29 | } 30 | } 31 | } 32 | } 33 | 34 | extension Result where Failure == CoreDataRepositoryError { 35 | func save(context: NSManagedObjectContext) -> Result { 36 | flatMap { success in 37 | do { 38 | try context.save() 39 | if let parentContext = context.parent { 40 | var result: Result = .success(success) 41 | parentContext.performAndWait { 42 | do { 43 | try parentContext.save() 44 | } catch { 45 | result = .failure(.coreData(error as NSError)) 46 | } 47 | } 48 | return result 49 | } 50 | return .success(success) 51 | } catch { 52 | return .failure(.coreData(error as NSError)) 53 | } 54 | } 55 | } 56 | } 57 | 58 | extension Result where Failure == Error { 59 | func mapToRepoError() -> Result { 60 | mapError { error in 61 | if let repoError = error as? CoreDataRepositoryError { 62 | return repoError 63 | } else { 64 | return .coreData(error as NSError) 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/SubscriptionProvider.swift: -------------------------------------------------------------------------------- 1 | // SubscriptionProvider.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | public protocol SubscriptionProvider { 10 | var id: AnyHashable { get } 11 | func manualFetch() 12 | func cancel() 13 | func start() 14 | } 15 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/UnmanagedModel.swift: -------------------------------------------------------------------------------- 1 | // UnmanagedModel.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import CoreData 10 | import Foundation 11 | 12 | /// A protocol for a value type that corresponds to a RepositoryManagedModel 13 | public protocol UnmanagedModel: Equatable { 14 | associatedtype RepoManaged: RepositoryManagedModel where RepoManaged.Unmanaged == Self 15 | /// Keep an reference to the corresponding `RepositoryManagedModel` instance for getting it later. 16 | /// Optional since a new instance won't have a record in CoreData. 17 | var managedRepoUrl: URL? { get set } 18 | /// Returns a RepositoryManagedModel instance of `self` 19 | func asRepoManaged(in context: NSManagedObjectContext) -> RepoManaged 20 | } 21 | -------------------------------------------------------------------------------- /Sources/CoreDataRepository/_Result.swift: -------------------------------------------------------------------------------- 1 | // _Result.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Foundation 10 | 11 | /// Wrapper for success/failure output where failure does not confrom to `Error` 12 | enum _Result { 13 | case success(Success) 14 | case failure(Failure) 15 | } 16 | -------------------------------------------------------------------------------- /Tests/CoreDataRepositoryTests/AggregateRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | // AggregateRepositoryTests.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Combine 10 | import CoreData 11 | import CoreDataRepository 12 | import XCTest 13 | 14 | final class AggregateRepositoryTests: CoreDataXCTestCase { 15 | let fetchRequest: NSFetchRequest = { 16 | let request = NSFetchRequest(entityName: "RepoMovie") 17 | request.sortDescriptors = [NSSortDescriptor(keyPath: \RepoMovie.title, ascending: true)] 18 | return request 19 | }() 20 | 21 | let movies = [ 22 | Movie(id: UUID(), title: "A", releaseDate: Date(), boxOffice: 10), 23 | Movie(id: UUID(), title: "B", releaseDate: Date(), boxOffice: 20), 24 | Movie(id: UUID(), title: "C", releaseDate: Date(), boxOffice: 30), 25 | Movie(id: UUID(), title: "D", releaseDate: Date(), boxOffice: 40), 26 | Movie(id: UUID(), title: "E", releaseDate: Date(), boxOffice: 50), 27 | ] 28 | var objectIDs = [NSManagedObjectID]() 29 | 30 | override func setUpWithError() throws { 31 | try super.setUpWithError() 32 | try repositoryContext().performAndWait { 33 | objectIDs = try movies.map { try $0.asRepoManaged(in: self.repositoryContext()).objectID } 34 | try repositoryContext().save() 35 | } 36 | } 37 | 38 | override func tearDownWithError() throws { 39 | try super.tearDownWithError() 40 | objectIDs = [] 41 | } 42 | 43 | func testCountSuccess() async throws { 44 | let result: Result<[[String: Int]], CoreDataRepositoryError> = try await repository() 45 | .count(predicate: NSPredicate(value: true), entityDesc: RepoMovie.entity()) 46 | switch result { 47 | case let .success(values): 48 | let firstValue = try XCTUnwrap(values.first?.values.first) 49 | XCTAssertEqual(firstValue, 5, "Result value (count) should equal number of movies.") 50 | case .failure: 51 | XCTFail("Not expecting failure") 52 | } 53 | } 54 | 55 | func testSumSuccess() async throws { 56 | let result: Result<[[String: Decimal]], CoreDataRepositoryError> = try await repository().sum( 57 | predicate: NSPredicate(value: true), 58 | entityDesc: RepoMovie.entity(), 59 | attributeDesc: XCTUnwrap( 60 | RepoMovie.entity().attributesByName.values 61 | .first(where: { $0.name == "boxOffice" }) 62 | ) 63 | ) 64 | switch result { 65 | case let .success(values): 66 | let firstValue = try XCTUnwrap(values.first?.values.first) 67 | XCTAssertEqual(firstValue, 150, "Result value (sum) should equal number of movies.") 68 | case .failure: 69 | XCTFail("Not expecting failure") 70 | } 71 | } 72 | 73 | func testAverageSuccess() async throws { 74 | let result: Result<[[String: Decimal]], CoreDataRepositoryError> = try await repository().average( 75 | predicate: NSPredicate(value: true), 76 | entityDesc: RepoMovie.entity(), 77 | attributeDesc: XCTUnwrap( 78 | RepoMovie.entity().attributesByName.values 79 | .first(where: { $0.name == "boxOffice" }) 80 | ) 81 | ) 82 | switch result { 83 | case let .success(values): 84 | let firstValue = try XCTUnwrap(values.first?.values.first) 85 | XCTAssertEqual( 86 | firstValue, 87 | 30, 88 | "Result value should equal average of movies box office." 89 | ) 90 | case .failure: 91 | XCTFail("Not expecting failure") 92 | } 93 | } 94 | 95 | func testMinSuccess() async throws { 96 | let result: Result<[[String: Decimal]], CoreDataRepositoryError> = try await repository().min( 97 | predicate: NSPredicate(value: true), 98 | entityDesc: RepoMovie.entity(), 99 | attributeDesc: XCTUnwrap( 100 | RepoMovie.entity().attributesByName.values 101 | .first(where: { $0.name == "boxOffice" }) 102 | ) 103 | ) 104 | switch result { 105 | case let .success(values): 106 | let firstValue = try XCTUnwrap(values.first?.values.first) 107 | XCTAssertEqual( 108 | firstValue, 109 | 10, 110 | "Result value should equal min of movies box office." 111 | ) 112 | case .failure: 113 | XCTFail("Not expecting failure") 114 | } 115 | } 116 | 117 | func testMaxSuccess() async throws { 118 | let result: Result<[[String: Decimal]], CoreDataRepositoryError> = try await repository().max( 119 | predicate: NSPredicate(value: true), 120 | entityDesc: RepoMovie.entity(), 121 | attributeDesc: XCTUnwrap( 122 | RepoMovie.entity().attributesByName.values 123 | .first(where: { $0.name == "boxOffice" }) 124 | ) 125 | ) 126 | switch result { 127 | case let .success(values): 128 | let firstValue = try XCTUnwrap(values.first?.values.first) 129 | XCTAssertEqual( 130 | firstValue, 131 | 50, 132 | "Result value should equal max of movies box office." 133 | ) 134 | case .failure: 135 | XCTFail("Not expecting failure") 136 | } 137 | } 138 | 139 | func testCountWithPredicate() async throws { 140 | let result: Result<[[String: Int]], CoreDataRepositoryError> = try await repository() 141 | .count(predicate: NSComparisonPredicate( 142 | leftExpression: NSExpression(forKeyPath: \RepoMovie.title), 143 | rightExpression: NSExpression(forConstantValue: "A"), 144 | modifier: .direct, 145 | type: .notEqualTo 146 | ), entityDesc: RepoMovie.entity()) 147 | switch result { 148 | case let .success(values): 149 | let firstValue = try XCTUnwrap(values.first?.values.first) 150 | XCTAssertEqual(firstValue, 4, "Result value (count) should equal number of movies not titled 'A'.") 151 | case .failure: 152 | XCTFail("Not expecting failure") 153 | } 154 | } 155 | 156 | func testSumWithPredicate() async throws { 157 | let result: Result<[[String: Decimal]], CoreDataRepositoryError> = try await repository().sum( 158 | predicate: NSComparisonPredicate( 159 | leftExpression: NSExpression(forKeyPath: \RepoMovie.title), 160 | rightExpression: NSExpression(forConstantValue: "A"), 161 | modifier: .direct, 162 | type: .notEqualTo 163 | ), 164 | entityDesc: RepoMovie.entity(), 165 | attributeDesc: XCTUnwrap( 166 | RepoMovie.entity().attributesByName.values 167 | .first(where: { $0.name == "boxOffice" }) 168 | ) 169 | ) 170 | switch result { 171 | case let .success(values): 172 | let firstValue = try XCTUnwrap(values.first?.values.first) 173 | XCTAssertEqual( 174 | firstValue, 175 | 140, 176 | "Result value should equal sum of movies box office that are not titled 'A'." 177 | ) 178 | case .failure: 179 | XCTFail("Not expecting failure") 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /Tests/CoreDataRepositoryTests/BatchRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | // BatchRepositoryTests.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Combine 10 | import CoreData 11 | import CoreDataRepository 12 | import CustomDump 13 | import XCTest 14 | 15 | final class BatchRepositoryTests: CoreDataXCTestCase { 16 | let movies: [[String: Any]] = [ 17 | ["id": UUID(), "title": "A", "releaseDate": Date()], 18 | ["id": UUID(), "title": "B", "releaseDate": Date()], 19 | ["id": UUID(), "title": "C", "releaseDate": Date()], 20 | ["id": UUID(), "title": "D", "releaseDate": Date()], 21 | ["id": UUID(), "title": "E", "releaseDate": Date()], 22 | ] 23 | let failureInsertMovies: [[String: Any]] = [ 24 | ["id": "A", "title": 1, "releaseDate": "A"], 25 | ["id": "B", "title": 2, "releaseDate": "B"], 26 | ["id": "C", "title": 3, "releaseDate": "C"], 27 | ["id": "D", "title": 4, "releaseDate": "D"], 28 | ["id": "E", "title": 5, "releaseDate": "E"], 29 | ] 30 | let failureCreateMovies: [[String: Any]] = { 31 | let id = UUID() 32 | return [ 33 | ["id": id, "title": "A", "releaseDate": Date()], 34 | ["id": id, "title": "B", "releaseDate": Date()], 35 | ["id": id, "title": "C", "releaseDate": Date()], 36 | ["id": id, "title": "D", "releaseDate": Date()], 37 | ["id": id, "title": "E", "releaseDate": Date()], 38 | ] 39 | }() 40 | 41 | func mapDictToRepoMovie(_ dict: [String: Any]) throws -> RepoMovie { 42 | try mapDictToMovie(dict) 43 | .asRepoManaged(in: repositoryContext()) 44 | } 45 | 46 | func mapDictToMovie(_ dict: [String: Any]) throws -> Movie { 47 | let id = try XCTUnwrap(dict["id"] as? UUID) 48 | let title = try XCTUnwrap(dict["title"] as? String) 49 | let releaseDate = try XCTUnwrap(dict["releaseDate"] as? Date) 50 | return Movie(id: id, title: title, releaseDate: releaseDate) 51 | } 52 | 53 | func testInsertSuccess() async throws { 54 | let fetchRequest = NSFetchRequest(entityName: "RepoMovie") 55 | try await repositoryContext().perform { 56 | let count = try self.repositoryContext().count(for: fetchRequest) 57 | XCTAssertEqual(count, 0, "Count of objects in CoreData should be zero at the start of each test.") 58 | } 59 | 60 | let historyTimeStamp = Date() 61 | let transactionAuthor: String = #function 62 | 63 | let request = try NSBatchInsertRequest(entityName: XCTUnwrap(RepoMovie.entity().name), objects: movies) 64 | let result: Result = try await repository() 65 | .insert(request, transactionAuthor: transactionAuthor) 66 | 67 | switch result { 68 | case .success: 69 | XCTAssert(true) 70 | case .failure: 71 | XCTFail("Not expecting a failure result") 72 | } 73 | 74 | try await repositoryContext().perform { 75 | let data = try self.repositoryContext().fetch(fetchRequest) 76 | XCTAssertEqual( 77 | data.map { $0.title ?? "" }.sorted(), 78 | ["A", "B", "C", "D", "E"], 79 | "Inserted titles should match expectation" 80 | ) 81 | } 82 | 83 | try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) 84 | } 85 | 86 | func testInsertFailure() async throws { 87 | let fetchRequest = NSFetchRequest(entityName: "RepoMovie") 88 | try await repositoryContext().perform { 89 | let count = try self.repositoryContext().count(for: fetchRequest) 90 | XCTAssertEqual(count, 0, "Count of objects in CoreData should be zero at the start of each test.") 91 | } 92 | 93 | let request = try NSBatchInsertRequest( 94 | entityName: XCTUnwrap(RepoMovie.entity().name), 95 | objects: failureInsertMovies 96 | ) 97 | let result: Result = try await repository().insert(request) 98 | 99 | switch result { 100 | case .success: 101 | XCTFail("Not expecting a success result") 102 | case .failure: 103 | XCTAssert(true) 104 | } 105 | 106 | try await repositoryContext().perform { 107 | let data = try self.repositoryContext().fetch(fetchRequest) 108 | XCTAssertEqual(data.map { $0.title ?? "" }.sorted(), [], "There should be no inserted values.") 109 | } 110 | } 111 | 112 | func testCreateSuccess() async throws { 113 | let fetchRequest = NSFetchRequest(entityName: "RepoMovie") 114 | try await repositoryContext().perform { 115 | let count = try self.repositoryContext().count(for: fetchRequest) 116 | XCTAssertEqual(count, 0, "Count of objects in CoreData should be zero at the start of each test.") 117 | } 118 | 119 | let historyTimeStamp = Date() 120 | let transactionAuthor: String = #function 121 | 122 | let newMovies = try movies.map(mapDictToMovie(_:)) 123 | let result: (success: [Movie], failed: [Movie]) = try await repository() 124 | .create(newMovies, transactionAuthor: transactionAuthor) 125 | 126 | XCTAssertEqual(result.success.count, newMovies.count) 127 | XCTAssertEqual(result.failed.count, 0) 128 | 129 | for movie in result.success { 130 | try await verify(movie) 131 | } 132 | 133 | try await repositoryContext().perform { 134 | let data = try self.repositoryContext().fetch(fetchRequest) 135 | XCTAssertEqual( 136 | data.map { $0.title ?? "" }.sorted(), 137 | ["A", "B", "C", "D", "E"], 138 | "Inserted titles should match expectation" 139 | ) 140 | } 141 | 142 | try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) 143 | } 144 | 145 | func testDeprecatedReadSuccess() async throws { 146 | let fetchRequest = NSFetchRequest(entityName: "RepoMovie") 147 | var movies = [Movie]() 148 | try await repositoryContext().perform { 149 | let count = try self.repositoryContext().count(for: fetchRequest) 150 | XCTAssertEqual(count, 0, "Count of objects in CoreData should be zero at the start of each test.") 151 | 152 | let repoMovies = try self.movies 153 | .map(self.mapDictToRepoMovie(_:)) 154 | try self.repositoryContext().save() 155 | movies = repoMovies.map(\.asUnmanaged) 156 | } 157 | 158 | let result: (success: [Movie], failed: [URL]) = try await repository() 159 | .read(urls: movies.compactMap(\.url), transactionAuthor: "Unused") 160 | 161 | XCTAssertEqual(result.success.count, movies.count) 162 | XCTAssertEqual(result.failed.count, 0) 163 | 164 | XCTAssertEqual(Set(movies), Set(result.success)) 165 | } 166 | 167 | func testReadSuccess() async throws { 168 | let fetchRequest = NSFetchRequest(entityName: "RepoMovie") 169 | var movies = [Movie]() 170 | try await repositoryContext().perform { 171 | let count = try self.repositoryContext().count(for: fetchRequest) 172 | XCTAssertEqual(count, 0, "Count of objects in CoreData should be zero at the start of each test.") 173 | 174 | let repoMovies = try self.movies 175 | .map(self.mapDictToRepoMovie(_:)) 176 | try self.repositoryContext().save() 177 | movies = repoMovies.map(\.asUnmanaged) 178 | } 179 | 180 | let result: (success: [Movie], failed: [URL]) = try await repository().read(urls: movies.compactMap(\.url)) 181 | 182 | XCTAssertEqual(result.success.count, movies.count) 183 | XCTAssertEqual(result.failed.count, 0) 184 | 185 | XCTAssertEqual(Set(movies), Set(result.success)) 186 | } 187 | 188 | func testUpdateSuccess() async throws { 189 | let fetchRequest = NSFetchRequest(entityName: "RepoMovie") 190 | try await repositoryContext().perform { 191 | let count = try self.repositoryContext().count(for: fetchRequest) 192 | XCTAssertEqual(count, 0, "Count of objects in CoreData should be zero at the start of each test.") 193 | 194 | let _ = try self.movies 195 | .map(self.mapDictToRepoMovie(_:)) 196 | try self.repositoryContext().save() 197 | } 198 | 199 | let predicate = NSPredicate(value: true) 200 | let request = try NSBatchUpdateRequest(entityName: XCTUnwrap(RepoMovie.entity().name)) 201 | request.predicate = predicate 202 | request.propertiesToUpdate = ["title": "Updated!", "boxOffice": 1] 203 | 204 | let historyTimeStamp = Date() 205 | let transactionAuthor: String = #function 206 | 207 | let _: Result = try await repository() 208 | .update(request, transactionAuthor: transactionAuthor) 209 | 210 | try await repositoryContext().perform { 211 | let data = try self.repositoryContext().fetch(fetchRequest) 212 | XCTAssertEqual( 213 | data.map { $0.title ?? "" }.sorted(), 214 | ["Updated!", "Updated!", "Updated!", "Updated!", "Updated!"], 215 | "Updated titles should match request" 216 | ) 217 | } 218 | try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) 219 | } 220 | 221 | func testAltUpdateSuccess() async throws { 222 | let fetchRequest = NSFetchRequest(entityName: "RepoMovie") 223 | var movies = [Movie]() 224 | try await repositoryContext().perform { 225 | let count = try self.repositoryContext().count(for: fetchRequest) 226 | XCTAssertEqual(count, 0, "Count of objects in CoreData should be zero at the start of each test.") 227 | 228 | let repoMovies = try self.movies 229 | .map(self.mapDictToRepoMovie(_:)) 230 | try self.repositoryContext().save() 231 | movies = repoMovies.map(\.asUnmanaged) 232 | } 233 | 234 | var editedMovies = movies 235 | let newTitles = ["ZA", "ZB", "ZC", "ZD", "ZE"] 236 | newTitles.enumerated().forEach { index, title in editedMovies[index].title = title } 237 | 238 | let historyTimeStamp = Date() 239 | let transactionAuthor: String = #function 240 | 241 | let result: (success: [Movie], failed: [Movie]) = try await repository() 242 | .update(editedMovies, transactionAuthor: transactionAuthor) 243 | 244 | XCTAssertEqual(result.success.count, movies.count) 245 | XCTAssertEqual(result.failed.count, 0) 246 | 247 | XCTAssertEqual(Set(editedMovies), Set(result.success)) 248 | 249 | try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) 250 | } 251 | 252 | func testDeleteSuccess() async throws { 253 | let fetchRequest = NSFetchRequest(entityName: "RepoMovie") 254 | try await repositoryContext().perform { 255 | let count = try self.repositoryContext().count(for: fetchRequest) 256 | XCTAssertEqual(count, 0, "Count of objects in CoreData should be zero at the start of each test.") 257 | 258 | let _ = try self.movies 259 | .map(self.mapDictToRepoMovie(_:)) 260 | try self.repositoryContext().save() 261 | } 262 | 263 | let request = 264 | try NSBatchDeleteRequest(fetchRequest: NSFetchRequest(entityName: XCTUnwrap( 265 | RepoMovie 266 | .entity().name 267 | ))) 268 | 269 | let historyTimeStamp = Date() 270 | let transactionAuthor: String = #function 271 | 272 | let _: Result = try await repository() 273 | .delete(request, transactionAuthor: transactionAuthor) 274 | 275 | try await repositoryContext().perform { 276 | let data = try self.repositoryContext().fetch(fetchRequest) 277 | XCTAssertEqual(data.map { $0.title ?? "" }.sorted(), [], "There should be no remaining values.") 278 | } 279 | try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) 280 | } 281 | 282 | func testAltDeleteSuccess() async throws { 283 | let fetchRequest = NSFetchRequest(entityName: "RepoMovie") 284 | var movies = [Movie]() 285 | try await repositoryContext().perform { 286 | let count = try self.repositoryContext().count(for: fetchRequest) 287 | XCTAssertEqual(count, 0, "Count of objects in CoreData should be zero at the start of each test.") 288 | 289 | let repoMovies = try self.movies 290 | .map(self.mapDictToRepoMovie(_:)) 291 | try self.repositoryContext().save() 292 | movies = repoMovies.map(\.asUnmanaged) 293 | } 294 | 295 | let historyTimeStamp = Date() 296 | let transactionAuthor: String = #function 297 | 298 | let result: (success: [URL], failed: [URL]) = try await repository() 299 | .delete(urls: movies.compactMap(\.url), transactionAuthor: transactionAuthor) 300 | 301 | XCTAssertEqual(result.success.count, movies.count) 302 | XCTAssertEqual(result.failed.count, 0) 303 | 304 | try await repositoryContext().perform { 305 | let data = try self.repositoryContext().fetch(fetchRequest) 306 | XCTAssertEqual(data.map { $0.title ?? "" }.sorted(), [], "There should be no remaining values.") 307 | } 308 | try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /Tests/CoreDataRepositoryTests/CRUDRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | // CRUDRepositoryTests.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Combine 10 | import CoreData 11 | import CoreDataRepository 12 | import CustomDump 13 | import XCTest 14 | 15 | final class CRUDRepositoryTests: CoreDataXCTestCase { 16 | func testCreateSuccess() async throws { 17 | let historyTimeStamp = Date() 18 | let transactionAuthor: String = #function 19 | let movie = Movie(id: UUID(), title: "Create Success", releaseDate: Date(), boxOffice: 100) 20 | let result: Result = try await repository() 21 | .create(movie, transactionAuthor: transactionAuthor) 22 | guard case let .success(resultMovie) = result else { 23 | XCTFail("Not expecting a failed result") 24 | return 25 | } 26 | var tempResultMovie = resultMovie 27 | XCTAssertNotNil(tempResultMovie.url) 28 | tempResultMovie.url = nil 29 | XCTAssertNoDifference(tempResultMovie, movie) 30 | 31 | try await verify(resultMovie) 32 | try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) 33 | } 34 | 35 | func testReadSuccess() async throws { 36 | let movie = Movie(id: UUID(), title: "Read Success", releaseDate: Date(), boxOffice: 100) 37 | let createdMovie: Movie = try await repositoryContext().perform(schedule: .immediate) { 38 | let object = try RepoMovie(context: self.repositoryContext()) 39 | object.create(from: movie) 40 | try self.repositoryContext().save() 41 | return object.asUnmanaged 42 | } 43 | 44 | let result: Result = try await repository() 45 | .read(XCTUnwrap(createdMovie.url)) 46 | 47 | guard case let .success(resultMovie) = result else { 48 | XCTFail("Not expecting a failed result") 49 | return 50 | } 51 | 52 | var tempResultMovie = resultMovie 53 | 54 | XCTAssertNotNil(tempResultMovie.url) 55 | tempResultMovie.url = nil 56 | XCTAssertNoDifference(tempResultMovie, movie) 57 | 58 | try await verify(resultMovie) 59 | } 60 | 61 | func testReadFailure() async throws { 62 | let movie = Movie(id: UUID(), title: "Read Failure", releaseDate: Date(), boxOffice: 100) 63 | let createdMovie: Movie = try await repositoryContext().perform { 64 | let object = try RepoMovie(context: self.repositoryContext()) 65 | object.create(from: movie) 66 | try self.repositoryContext().save() 67 | return object.asUnmanaged 68 | } 69 | _ = try await repositoryContext().perform { 70 | let objectID = try self.repositoryContext().persistentStoreCoordinator? 71 | .managedObjectID(forURIRepresentation: XCTUnwrap(createdMovie.url)) 72 | let object = try self.repositoryContext().existingObject(with: XCTUnwrap(objectID)) 73 | try self.repositoryContext().delete(object) 74 | try self.repositoryContext().save() 75 | } 76 | 77 | let result: Result = try await repository() 78 | .read(XCTUnwrap(createdMovie.url)) 79 | 80 | switch result { 81 | case .success: 82 | XCTFail("Not expecting a successful result") 83 | case .failure: 84 | XCTAssert(true) 85 | } 86 | } 87 | 88 | func testUpdateSuccess() async throws { 89 | var movie = Movie(id: UUID(), title: "Update Success", releaseDate: Date(), boxOffice: 100) 90 | let createdMovie: Movie = try await repositoryContext().perform(schedule: .immediate) { 91 | let object = try RepoMovie(context: self.repositoryContext()) 92 | object.create(from: movie) 93 | try self.repositoryContext().save() 94 | return object.asUnmanaged 95 | } 96 | 97 | movie.title = "Update Success - Edited" 98 | 99 | let historyTimeStamp = Date() 100 | let transactionAuthor: String = #function 101 | 102 | let result: Result = try await repository() 103 | .update(XCTUnwrap(createdMovie.url), with: movie, transactionAuthor: transactionAuthor) 104 | 105 | guard case let .success(resultMovie) = result else { 106 | XCTFail("Not expecting a failed result") 107 | return 108 | } 109 | 110 | var tempResultMovie = resultMovie 111 | 112 | XCTAssertNotNil(tempResultMovie.url) 113 | tempResultMovie.url = nil 114 | XCTAssertNoDifference(tempResultMovie, movie) 115 | 116 | try await verify(resultMovie) 117 | try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) 118 | } 119 | 120 | func testUpdateFailure() async throws { 121 | var movie = Movie(id: UUID(), title: "Update Success", releaseDate: Date(), boxOffice: 100) 122 | let createdMovie: Movie = try await repositoryContext().perform(schedule: .immediate) { 123 | let object = try RepoMovie(context: self.repositoryContext()) 124 | object.create(from: movie) 125 | try self.repositoryContext().save() 126 | return object.asUnmanaged 127 | } 128 | 129 | _ = try await repositoryContext().perform { 130 | let objectID = try self.repositoryContext().persistentStoreCoordinator? 131 | .managedObjectID(forURIRepresentation: XCTUnwrap(createdMovie.url)) 132 | let object = try self.repositoryContext().existingObject(with: XCTUnwrap(objectID)) 133 | try self.repositoryContext().delete(object) 134 | try self.repositoryContext().save() 135 | } 136 | 137 | movie.title = "Update Success - Edited" 138 | 139 | let result: Result = try await repository() 140 | .update(XCTUnwrap(createdMovie.url), with: movie) 141 | 142 | switch result { 143 | case .success: 144 | XCTFail("Not expecting a successful result") 145 | case .failure: 146 | XCTAssert(true) 147 | } 148 | } 149 | 150 | func testDeleteSuccess() async throws { 151 | let movie = Movie(id: UUID(), title: "Delete Success", releaseDate: Date(), boxOffice: 100) 152 | let createdMovie: Movie = try await repositoryContext().perform(schedule: .immediate) { 153 | let object = try RepoMovie(context: self.repositoryContext()) 154 | object.create(from: movie) 155 | try self.repositoryContext().save() 156 | return object.asUnmanaged 157 | } 158 | 159 | let historyTimeStamp = Date() 160 | let transactionAuthor: String = #function 161 | 162 | let result: Result = try await repository() 163 | .delete(XCTUnwrap(createdMovie.url), transactionAuthor: transactionAuthor) 164 | 165 | switch result { 166 | case .success: 167 | XCTAssert(true) 168 | case .failure: 169 | XCTFail("Not expecting a failed result") 170 | } 171 | 172 | try verify(transactionAuthor: transactionAuthor, timeStamp: historyTimeStamp) 173 | } 174 | 175 | func testDeleteFailure() async throws { 176 | let movie = Movie(id: UUID(), title: "Delete Failure", releaseDate: Date(), boxOffice: 100) 177 | let createdMovie: Movie = try await repositoryContext().perform(schedule: .immediate) { 178 | let object = try RepoMovie(context: self.repositoryContext()) 179 | object.create(from: movie) 180 | try self.repositoryContext().save() 181 | return object.asUnmanaged 182 | } 183 | 184 | _ = try await repositoryContext().perform { 185 | let objectID = try self.repositoryContext().persistentStoreCoordinator? 186 | .managedObjectID(forURIRepresentation: XCTUnwrap(createdMovie.url)) 187 | let object = try self.repositoryContext().existingObject(with: XCTUnwrap(objectID)) 188 | try self.repositoryContext().delete(object) 189 | try self.repositoryContext().save() 190 | } 191 | 192 | let result: Result = try await repository() 193 | .delete(XCTUnwrap(createdMovie.url)) 194 | 195 | switch result { 196 | case .success: 197 | XCTFail("Not expecting a success result") 198 | case .failure: 199 | XCTAssert(true) 200 | } 201 | } 202 | 203 | func testReadSubscriptionSuccess() async throws { 204 | var movie = Movie(id: UUID(), title: "Read Success", releaseDate: Date(), boxOffice: 100) 205 | 206 | let count: Int = try await repositoryContext().perform { [self] in 207 | try repositoryContext().count(for: RepoMovie.fetchRequest()) 208 | } 209 | 210 | XCTAssertEqual(count, 0, "Count of objects in CoreData should be zero at the start of each test.") 211 | 212 | let repoMovieUrl: URL = try await repositoryContext().perform { [self] in 213 | let repoMovie = try movie.asRepoManaged(in: repositoryContext()) 214 | try repositoryContext().save() 215 | return repoMovie.objectID.uriRepresentation() 216 | } 217 | 218 | movie.url = repoMovieUrl 219 | let countAfterCreate: Int = try await repositoryContext().perform { 220 | try self.repositoryContext().count(for: RepoMovie.fetchRequest()) 221 | } 222 | XCTAssertEqual(countAfterCreate, 1, "Count of objects in CoreData should be 1 for read test.") 223 | 224 | var editedMovie = movie 225 | editedMovie.title = "New Title" 226 | 227 | let firstExp = expectation(description: "Read a movie from CoreData") 228 | let secondExp = expectation(description: "Read a movie again after CoreData context is updated") 229 | var resultCount = 0 230 | let result: AnyPublisher = try repository() 231 | .readSubscription(XCTUnwrap(movie.url)) 232 | result.subscribe(on: backgroundQueue) 233 | .receive(on: mainQueue) 234 | .sink(receiveCompletion: { completion in 235 | switch completion { 236 | case .finished: 237 | XCTFail("Not expecting completion since subscription finishes after subscriber cancel") 238 | case .failure: 239 | XCTFail("Not expecting failure") 240 | } 241 | }, receiveValue: { receiveMovie in 242 | resultCount += 1 243 | switch resultCount { 244 | case 1: 245 | XCTAssertEqual(receiveMovie, movie, "Success response should match local object.") 246 | firstExp.fulfill() 247 | case 2: 248 | XCTAssertEqual(receiveMovie, editedMovie, "Second success response should match local object.") 249 | secondExp.fulfill() 250 | default: 251 | XCTFail("Not expecting any values past the first two.") 252 | } 253 | 254 | }) 255 | .store(in: &cancellables) 256 | wait(for: [firstExp], timeout: 5) 257 | try repositoryContext().performAndWait { [self] in 258 | let coordinator = try XCTUnwrap(repositoryContext().persistentStoreCoordinator) 259 | let objectId = try XCTUnwrap(coordinator.managedObjectID(forURIRepresentation: XCTUnwrap(movie.url))) 260 | let object = try XCTUnwrap(repositoryContext().existingObject(with: objectId) as? RepoMovie) 261 | object.update(from: editedMovie) 262 | try repositoryContext().save() 263 | } 264 | wait(for: [secondExp], timeout: 5) 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /Tests/CoreDataRepositoryTests/CoreDataStack.swift: -------------------------------------------------------------------------------- 1 | // CoreDataStack.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import CoreData 10 | 11 | class CoreDataStack: NSObject { 12 | private static let model: NSManagedObjectModel = { 13 | let model = NSManagedObjectModel() 14 | model.entities = [MovieDescription] 15 | return model 16 | }() 17 | 18 | static var persistentContainer: NSPersistentContainer { 19 | let desc = NSPersistentStoreDescription() 20 | desc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) 21 | desc.type = NSSQLiteStoreType // NSInMemoryStoreType 22 | desc.shouldAddStoreAsynchronously = false 23 | let model = Self.model 24 | let container = NSPersistentContainer(name: "Model", managedObjectModel: model) 25 | container.persistentStoreDescriptions = [desc] 26 | container.loadPersistentStores { _, error in 27 | if let error = error { 28 | fatalError("Unable to load persistent stores: \(error)") 29 | } 30 | } 31 | container.viewContext.automaticallyMergesChangesFromParent = true 32 | return container 33 | } 34 | 35 | // Manually build model entities. Having trouble with the package loading the model from bundle. 36 | private static var MovieDescription: NSEntityDescription { 37 | let desc = NSEntityDescription() 38 | desc.name = "RepoMovie" 39 | desc.managedObjectClassName = NSStringFromClass(RepoMovie.self) 40 | desc.properties = [ 41 | movieIDDescription, 42 | movieTitleDescription, 43 | movieReleaseDateDescription, 44 | movieBoxOfficeDescription, 45 | ] 46 | desc.uniquenessConstraints = [[movieIDDescription]] 47 | return desc 48 | } 49 | 50 | private static var movieIDDescription: NSAttributeDescription { 51 | let desc = NSAttributeDescription() 52 | desc.name = "id" 53 | desc.attributeType = .UUIDAttributeType 54 | return desc 55 | } 56 | 57 | private static var movieTitleDescription: NSAttributeDescription { 58 | let desc = NSAttributeDescription() 59 | desc.name = "title" 60 | desc.attributeType = .stringAttributeType 61 | desc.defaultValue = "" 62 | return desc 63 | } 64 | 65 | private static var movieReleaseDateDescription: NSAttributeDescription { 66 | let desc = NSAttributeDescription() 67 | desc.name = "releaseDate" 68 | desc.attributeType = .dateAttributeType 69 | return desc 70 | } 71 | 72 | private static var movieBoxOfficeDescription: NSAttributeDescription { 73 | let desc = NSAttributeDescription() 74 | desc.name = "boxOffice" 75 | desc.attributeType = .decimalAttributeType 76 | desc.defaultValue = 0 77 | return desc 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Tests/CoreDataRepositoryTests/CoreDataXCTestCase.swift: -------------------------------------------------------------------------------- 1 | // CoreDataXCTestCase.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Combine 10 | import CoreData 11 | import CoreDataRepository 12 | import CustomDump 13 | import XCTest 14 | 15 | class CoreDataXCTestCase: XCTestCase { 16 | var cancellables: Set = [] 17 | var _container: NSPersistentContainer? 18 | var _repositoryContext: NSManagedObjectContext? 19 | var _repository: CoreDataRepository? 20 | let mainQueue = DispatchQueue.main 21 | let backgroundQueue = DispatchQueue(label: "background", qos: .userInitiated) 22 | 23 | func container() throws -> NSPersistentContainer { 24 | try XCTUnwrap(_container) 25 | } 26 | 27 | func repositoryContext() throws -> NSManagedObjectContext { 28 | try XCTUnwrap(_repositoryContext) 29 | } 30 | 31 | func repository() throws -> CoreDataRepository { 32 | try XCTUnwrap(_repository) 33 | } 34 | 35 | override func setUpWithError() throws { 36 | let container = CoreDataStack.persistentContainer 37 | _container = container 38 | backgroundQueue.sync { 39 | _repositoryContext = container.newBackgroundContext() 40 | _repositoryContext?.automaticallyMergesChangesFromParent = true 41 | } 42 | _repository = try CoreDataRepository(context: repositoryContext()) 43 | try super.setUpWithError() 44 | } 45 | 46 | override func tearDownWithError() throws { 47 | try super.tearDownWithError() 48 | _container = nil 49 | _repositoryContext = nil 50 | _repository = nil 51 | cancellables.forEach { $0.cancel() } 52 | } 53 | 54 | func verify(_ item: T) async throws where T: UnmanagedModel { 55 | guard let url = item.managedRepoUrl else { 56 | XCTFail("Failed to verify item in store because it has no URL") 57 | return 58 | } 59 | 60 | let context = try repositoryContext() 61 | let coordinator = try container().persistentStoreCoordinator 62 | context.performAndWait { 63 | guard let objectID = coordinator.managedObjectID(forURIRepresentation: url) else { 64 | XCTFail("Failed to verify item in store because no NSManagedObjectID found in viewContext from URL.") 65 | return 66 | } 67 | var _object: NSManagedObject? 68 | do { 69 | _object = try context.existingObject(with: objectID) 70 | } catch { 71 | XCTFail( 72 | "Failed to verify item in store because it was not found by its NSManagedObjectID. Error: \(error.localizedDescription)" 73 | ) 74 | return 75 | } 76 | 77 | guard let object = _object else { 78 | XCTFail("Failed to verify item in store because it was not found by its NSManagedObjectID") 79 | return 80 | } 81 | 82 | guard let managedItem = object as? T.RepoManaged else { 83 | XCTFail("Failed to verify item in store because it failed to cast to RepoManaged type.") 84 | return 85 | } 86 | XCTAssertNoDifference(item, managedItem.asUnmanaged) 87 | } 88 | } 89 | 90 | func verify(transactionAuthor: String?, timeStamp: Date) throws { 91 | let historyRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: timeStamp) 92 | try repositoryContext().performAndWait { 93 | let historyResult = try XCTUnwrap(repositoryContext().execute(historyRequest) as? NSPersistentHistoryResult) 94 | let history = try XCTUnwrap(historyResult.result as? [NSPersistentHistoryTransaction]) 95 | XCTAssertGreaterThan(history.count, 0) 96 | history.forEach { historyTransaction in 97 | XCTAssertEqual(historyTransaction.author, transactionAuthor) 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Tests/CoreDataRepositoryTests/FetchRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | // FetchRepositoryTests.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import Combine 10 | import CoreData 11 | import CoreDataRepository 12 | import XCTest 13 | 14 | final class FetchRepositoryTests: CoreDataXCTestCase { 15 | let fetchRequest: NSFetchRequest = { 16 | let request = NSFetchRequest(entityName: "RepoMovie") 17 | request.sortDescriptors = [NSSortDescriptor(keyPath: \RepoMovie.title, ascending: true)] 18 | return request 19 | }() 20 | 21 | let movies = [ 22 | Movie(id: UUID(), title: "A", releaseDate: Date()), 23 | Movie(id: UUID(), title: "B", releaseDate: Date()), 24 | Movie(id: UUID(), title: "C", releaseDate: Date()), 25 | Movie(id: UUID(), title: "D", releaseDate: Date()), 26 | Movie(id: UUID(), title: "E", releaseDate: Date()), 27 | ] 28 | var expectedMovies = [Movie]() 29 | 30 | override func setUpWithError() throws { 31 | try super.setUpWithError() 32 | expectedMovies = try repositoryContext().performAndWait { 33 | _ = try self.movies.map { try $0.asRepoManaged(in: repositoryContext()) } 34 | try self.repositoryContext().save() 35 | return try self.repositoryContext().fetch(fetchRequest).map(\.asUnmanaged) 36 | } 37 | } 38 | 39 | override func tearDownWithError() throws { 40 | try super.tearDownWithError() 41 | expectedMovies = [] 42 | } 43 | 44 | func testFetchSuccess() async throws { 45 | let result: Result<[Movie], CoreDataRepositoryError> = try await repository().fetch(fetchRequest) 46 | switch result { 47 | case let .success(movies): 48 | XCTAssertEqual(movies.count, 5, "Result items count should match expectation") 49 | XCTAssertEqual(movies, expectedMovies, "Result items should match expectations") 50 | case .failure: 51 | XCTFail("Not expecting failure") 52 | } 53 | } 54 | 55 | func testFetchSubscriptionSuccess() async throws { 56 | let firstExp = expectation(description: "Fetch movies from CoreData") 57 | let secondExp = expectation(description: "Fetch movies again after CoreData context is updated") 58 | var resultCount = 0 59 | let result: AnyPublisher<[Movie], CoreDataRepositoryError> = try repository().fetchSubscription(fetchRequest) 60 | result.subscribe(on: backgroundQueue) 61 | .receive(on: mainQueue) 62 | .sink(receiveCompletion: { completion in 63 | switch completion { 64 | case .finished: 65 | XCTFail("Not expecting completion since subscription finishes after subscriber cancel") 66 | default: 67 | XCTFail("Not expecting failure") 68 | } 69 | }, receiveValue: { items in 70 | resultCount += 1 71 | switch resultCount { 72 | case 1: 73 | XCTAssertEqual(items.count, 5, "Result items count should match expectation") 74 | XCTAssertEqual(items, self.expectedMovies, "Result items should match expectations") 75 | firstExp.fulfill() 76 | case 2: 77 | XCTAssertEqual(items.count, 4, "Result items count should match expectation") 78 | XCTAssertEqual(items, Array(self.expectedMovies[0 ... 3]), "Result items should match expectations") 79 | secondExp.fulfill() 80 | default: 81 | XCTFail("Not expecting any values past the first two.") 82 | } 83 | 84 | }) 85 | .store(in: &cancellables) 86 | wait(for: [firstExp], timeout: 5) 87 | let crudRepository = try CoreDataRepository(context: repositoryContext()) 88 | _ = try await repositoryContext().perform { [self] in 89 | let url = try XCTUnwrap(expectedMovies.last?.url) 90 | let coordinator = try XCTUnwrap(repositoryContext().persistentStoreCoordinator) 91 | let objectId = try XCTUnwrap(coordinator.managedObjectID(forURIRepresentation: url)) 92 | let object = try repositoryContext().existingObject(with: objectId) 93 | try repositoryContext().delete(object) 94 | try repositoryContext().save() 95 | } 96 | let _: Result = try await crudRepository 97 | .delete(XCTUnwrap(expectedMovies.last?.url)) 98 | wait(for: [secondExp], timeout: 5) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Tests/CoreDataRepositoryTests/Movie.swift: -------------------------------------------------------------------------------- 1 | // Movie.swift 2 | // CoreDataRepository 3 | // 4 | // 5 | // MIT License 6 | // 7 | // Copyright © 2023 Andrew Roan 8 | 9 | import CoreData 10 | import CoreDataRepository 11 | 12 | public struct Movie: Hashable { 13 | public let id: UUID 14 | public var title: String = "" 15 | public var releaseDate: Date 16 | public var boxOffice: Decimal = 0 17 | public var url: URL? 18 | } 19 | 20 | extension Movie: UnmanagedModel { 21 | public var managedRepoUrl: URL? { 22 | get { 23 | url 24 | } 25 | set(newValue) { 26 | url = newValue 27 | } 28 | } 29 | 30 | public func asRepoManaged(in context: NSManagedObjectContext) -> RepoMovie { 31 | let object = RepoMovie(context: context) 32 | object.id = id 33 | object.title = title 34 | object.releaseDate = releaseDate 35 | object.boxOffice = boxOffice as NSDecimalNumber 36 | return object 37 | } 38 | } 39 | 40 | @objc(RepoMovie) 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? 46 | } 47 | 48 | extension RepoMovie: RepositoryManagedModel { 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() 61 | ) 62 | } 63 | 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) 69 | } 70 | 71 | static func fetchRequest() -> NSFetchRequest { 72 | let request = NSFetchRequest(entityName: "RepoMovie") 73 | return request 74 | } 75 | } 76 | --------------------------------------------------------------------------------