├── .codecov.yml ├── .github └── workflows │ └── CI.yml ├── .gitignore ├── .spi.yml ├── Assets ├── stores-dark.png ├── stores-light.png └── stores.drawio ├── LICENSE ├── Package.swift ├── README.md ├── Sources ├── Blueprints │ ├── AnyMultiObjectStore.swift │ ├── AnySingleObjectStore.swift │ ├── Documentation.docc │ │ └── Blueprints.md │ ├── Logger.swift │ ├── MultiObjectStore.swift │ └── SingleObjectStore.swift ├── CoreData │ ├── Database.swift │ ├── Documentation.docc │ │ └── CoreData.md │ ├── Entity.swift │ ├── MultiCoreDataStore.swift │ └── SingleCoreDataStore.swift ├── FileSystem │ ├── Documentation.docc │ │ └── FileSystem.md │ ├── MultiFileSystemStore.swift │ └── SingleFileSystemStore.swift ├── Keychain │ ├── Documentation.docc │ │ └── Keychain.md │ ├── KeychainAccessibility.swift │ ├── KeychainError.swift │ ├── MultiKeychainStore.swift │ └── SingleKeychainStore.swift ├── Stores │ ├── Documentation.docc │ │ ├── Articles │ │ │ ├── Installation.md │ │ │ ├── Motivation.md │ │ │ └── Usage.md │ │ └── Stores.md │ └── Stores.swift └── UserDefaults │ ├── Documentation.docc │ └── UserDefaults.md │ ├── MultiUserDefaultsStore.swift │ └── SingleUserDefaultsStore.swift └── Tests ├── Blueprints ├── AnyMultiObjectStoreTests.swift ├── AnySingleObjectStoreTests.swift ├── MultiObjectStoreTests.swift └── SingleObjectStoreTests.swift ├── CoreData ├── DatabaseTests.swift ├── MultiCoreDataStoreTests.swift └── SingleCoreDataStoreTests.swift ├── FileSystem ├── MultiFileSystemStoreTests.swift └── SingleFileSystemStoreTests.swift ├── Keychain ├── KeychainErrorTests.swift ├── MultiKeychainStoreTests.swift └── SingleKeychainStoreTests.swift ├── UserDefaults ├── MultiUserDefaultsStoreTests.swift └── SingleUserDefaultsStoreTests.swift └── Utils ├── Documentation.docc └── TestUtils.md ├── MultiObjectStoreFake.swift ├── SingleObjectStoreFake.swift ├── StoresError.swift └── User.swift /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | round: down 4 | range: 70...100 5 | 6 | status: 7 | project: true 8 | patch: true 9 | changes: true -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: Stores 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: macos-latest 14 | 15 | steps: 16 | - name: Get Sources 17 | uses: actions/checkout@v2 18 | 19 | - name: Build Package 20 | run: swift build -v 21 | 22 | - name: Run tests 23 | run: swift test --enable-code-coverage -v 24 | 25 | - name: Gather code coverage 26 | run: xcrun llvm-cov export -format="lcov" .build/debug/StoresPackageTests.xctest/Contents/MacOS/StoresPackageTests -instr-profile .build/debug/codecov/default.profdata > coverage_report.lcov 27 | 28 | - name: Upload coverage to Codecov 29 | uses: codecov/codecov-action@v2 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | fail_ci_if_error: fail 33 | files: ./coverage_report.lcov 34 | verbose: true 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: 5 | - Stores 6 | - Blueprints 7 | - UserDefaultsStore 8 | - FileSystemStore 9 | - CoreDataStore 10 | - KeychainStore 11 | - TestUtils 12 | -------------------------------------------------------------------------------- /Assets/stores-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/omaralbeik/Stores/638291752db347e40ea4932be424585d47447d32/Assets/stores-dark.png -------------------------------------------------------------------------------- /Assets/stores-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/omaralbeik/Stores/638291752db347e40ea4932be424585d47447d32/Assets/stores-light.png -------------------------------------------------------------------------------- /Assets/stores.drawio: -------------------------------------------------------------------------------- 1 | 7V1bc9o4FP41PNLxBVN4DCFpZ7eZnVna3fZpR9jCdiMsVxYB+utXsiV8kx3SiTF1lJlS62bpnPPpO0LSSUb27fbwgYA4eMAeRCPL8A4jezmyLNuaTdh/POeY5ZjObJ7l+CT0RF6esQp/QpFpiNxd6MGkVJFijGgYlzNdHEXQpaU8QAjel6ttMCr3GgMf1jJWLkD13H9DjwZZ7swx8vyPMPQD2bNpiJItkJVFRhIAD+8LWfbdyL4lGNPsaXu4hYhrT+ola3ffUHoaGIERPaeBFRz/my7+OTw8/EB39h/zb1Pv01i85QmgnRBYDJYepQZ8gnexqAYJhQeV3sFaVjfq4zJP0jKcQLyFlBxZFfGiuWghEGI5Tpbe5+qevhd1goKqHdkQCBP7p1fnWmAPQhFqpWzuPv7YL/c7iMcLtEefpyYOx6b1vFb2QUjhKgYuT+8Z+Ef2IqBb1s/SZI9MZ5EHPZFKHiF1A5EIgBvsCPzAmywnLGODI3oPtiHiGrghLn+zSxPW3xLsmLiQiEorvCNpfwGlDP6WY9+wDyYq/+AVknc+xj6CIA6Tdy7epgVukla932RdsMdSJ461qHaTTUJmSXsBiCuSDkvVTdsKqipa6hDo0cYThY2niHIDscFOff60opgw8hH5rJ+8qAYIJiqtYAAm4U8xMVJd7ihOMmXyYoBCP2LPLlNmqnuur5Axz40o2IaexxsvYhwy2/JunMXIWXIzhQjdYoRZs2WEI14poQQ/wkpmDkPjt4bhqwNPOiijxjWmo8ChaXWGQ+dFDPzLKmhi6kbVTMq0PJnUNOXMVDPWeAVNOZ5lzp21NzM8sDFNc2zOWmdrQVnTHzvuUBdbQPyQzSIGTWMSH9incfrkeJL18vmcvY4jj68TMmLP6/2N15hiVvKAI1xszoZIwZg3GyfELbXJ5gcfApsbDTMjnRenWVGeE/mMUA0XbLnPidZJXBi+6OdcqdTTvHfpFszKMUlJr12uCiULRBCOCLmSlO82m20eE9iOoQw0WXKM4IYV3kwagWSM2b9VGDFN/LX+zoRN3UhhyGl/3Q/hYceWya83gtIUkp9yCEYGkVTpvOb0mWm2JgpTXkgvN9HxCqzDRnGugSruoLpOoDguLCey3heY1dqg9PsGXyyUfSpb4Fb8/UeIniB/a+syVn6D4S6Zze/QFc8IrCFaAPfRTxsoVyby25MphyIEyt4VURBG3PXX18t1x9fuSJ93/vK76Fzh/eeX9f7Nbs16djb+glurv7BDspcuc1Fzmed71wt7u4vq50xn+CWBZAk3gJFFUqWJJp9oXbFPbJSn6BNyB9YmvmbJDllSlFqzOkuednmKLDnriiRVuzGaJDVJFoW/DxFcHRMKt0OgyAZp1ATZLLqmx+7p0bbK+ySmUd8oUdOl3RFd2hemyzfPh70Q3i2b6kvW8RDoTimLmuyaxNZUdwGqm1ZO6sy+V4YtZzia6gZDdX/CoxswuA+B6mQPSQyiZ5fSZfELImatC2eU7aTZcaeafntZadpm/aLEZelXdXSp6Xdo9JtdgvgME/qFhuicE7mBEXB2QtPGhPfg8fUp+GXdahK+7G6ovBZh9b0Gfl+zNfR8KDWFCQ2wjyOA7vLcionyOp8wh0Wa+R1SehSm5xeXyjaGh5B+LTx/40Z554jU8iBslCaOMhExcb8WE4VWPJk3S1OyXQWkVRyU7O7uyNNJrheCIJHXmtouoFDGOLD1bVk9boNWPBGIAA2fipU6AMdMg+OawGFdFTjmGhzXBA77qsAh93Y0Oq4DHZPrQoep0XFN6HD6Qof6DrLqtm4FLp1HlVR2Sy4bVqJUi2q3uqKVNxlVwkvl17+RZd+nP6NqREOxRBH+0Ail52/Mz9RA6g8oLftqOjTl2rDchN7XB2QfMSvKAb5vxacOxhjW/q8OxtDBGDoYQ2+snypf4VpNHbNo2fW7eN0FeCgH3BK32MkRqb67/PvdXdYBHjrA43dn3urlFEW4eGfHosrxzjXxauLVQSM6aGS4lFu5jm1OZmdy7itEnqgHbFyYdN88q+rQEx16ognz7N2B6pFg34tUeYyuCXPQhKkDWHQAiybxbla9tjPvm8QtTeJvgMR1GIwOg9FU3ume8bT39bhdQ4y+1vqrUBqprqSejZryzdi2a0jFy7HNZ7C93YWdaEgND1LzXiHlaEgND1Jmw6/jvhCmphpTA8RUv2EgOjR5iJiyusEUS+Z/CyctK/xJIfvufw== -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Omar Albeik 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.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Stores", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v10_15), 10 | .tvOS(.v13), 11 | .watchOS(.v6) 12 | ], 13 | products: [ 14 | .library(name: "Stores", targets: ["Stores"]), 15 | .library(name: "Blueprints", targets: ["Blueprints"]), 16 | .library(name: "UserDefaultsStore", targets: ["UserDefaultsStore"]), 17 | .library(name: "FileSystemStore", targets: ["FileSystemStore"]), 18 | .library(name: "CoreDataStore", targets: ["CoreDataStore"]), 19 | .library(name: "KeychainStore", targets: ["KeychainStore"]), 20 | .library(name: "StoresTestUtils", targets: ["TestUtils"]), 21 | ], 22 | targets: [ 23 | // MARK: - Stores 24 | .target( 25 | name: "Stores", 26 | dependencies: [ 27 | "Blueprints", 28 | "UserDefaultsStore", 29 | "FileSystemStore", 30 | "CoreDataStore", 31 | "KeychainStore" 32 | ], 33 | path: "Sources/Stores" 34 | ), 35 | // MARK: - Blueprints 36 | .target( 37 | name: "Blueprints", 38 | path: "Sources/Blueprints" 39 | ), 40 | .testTarget( 41 | name: "BlueprintsTests", 42 | dependencies: [ 43 | "Blueprints", 44 | "TestUtils" 45 | ], 46 | path: "Tests/Blueprints" 47 | ), 48 | // MARK: - UserDefaults 49 | .target( 50 | name: "UserDefaultsStore", 51 | dependencies: [ 52 | "Blueprints" 53 | ], 54 | path: "Sources/UserDefaults" 55 | ), 56 | .testTarget( 57 | name: "UserDefaultsStoreTests", 58 | dependencies: [ 59 | "UserDefaultsStore", 60 | "TestUtils" 61 | ], 62 | path: "Tests/UserDefaults" 63 | ), 64 | // MARK: - FileSystem 65 | .target( 66 | name: "FileSystemStore", 67 | dependencies: [ 68 | "Blueprints" 69 | ], 70 | path: "Sources/FileSystem" 71 | ), 72 | .testTarget( 73 | name: "FileSystemStoreTests", 74 | dependencies: [ 75 | "FileSystemStore", 76 | "TestUtils" 77 | ], 78 | path: "Tests/FileSystem" 79 | ), 80 | // MARK: - CoreData 81 | .target( 82 | name: "CoreDataStore", 83 | dependencies: [ 84 | "Blueprints", 85 | ], 86 | path: "Sources/CoreData" 87 | ), 88 | .testTarget( 89 | name: "CoreDataStoreTests", 90 | dependencies: [ 91 | "CoreDataStore", 92 | "TestUtils", 93 | ], 94 | path: "Tests/CoreData" 95 | ), 96 | // MARK: - Keychain 97 | .target( 98 | name: "KeychainStore", 99 | dependencies: [ 100 | "Blueprints", 101 | ], 102 | path: "Sources/Keychain" 103 | ), 104 | .testTarget( 105 | name: "KeychainStoreTests", 106 | dependencies: [ 107 | "KeychainStore", 108 | "TestUtils", 109 | ], 110 | path: "Tests/Keychain" 111 | ), 112 | // MARK: - TestUtils 113 | .target( 114 | name: "TestUtils", 115 | dependencies: [ 116 | "Blueprints" 117 | ], 118 | path: "Tests/Utils" 119 | ), 120 | ] 121 | ) 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🗂 Stores 2 | 3 | [![Stores](https://github.com/omaralbeik/Stores/actions/workflows/CI.yml/badge.svg)](https://github.com/omaralbeik/Stores/actions/workflows/CI.yml) 4 | [![codecov](https://codecov.io/gh/omaralbeik/Stores/branch/main/graph/badge.svg?token=iga0JA6Mwo)](https://codecov.io/gh/omaralbeik/Stores) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fomaralbeik%2FStores%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/omaralbeik/Stores) 6 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fomaralbeik%2FStores%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/omaralbeik/Stores) 7 | [![License](https://img.shields.io/badge/License-MIT-red.svg)](https://opensource.org/licenses/MIT) 8 | 9 | A typed key-value storage solution to store `Codable` types in various persistence layers like User Defaults, File System, Core Data, Keychain, and more with a few lines of code! 10 | 11 | ## Features 12 | 13 | - [x] macOS Catalina+, iOS 13+, tvOS 13+, watchOS 6+, any Linux supporting Swift 5.4+. 14 | - [x] Store any `Codable` type. 15 | - [x] Single API with implementations using User Default, file system, Core Data, Keychain, and fakes for testing. 16 | - [x] Thread-safe implementations. 17 | - [x] Swappable implementations with a type-erased store. 18 | - [x] [Modular library](#installation), add all, or only what you need. 19 | 20 | --- 21 | 22 | ## Motivation 23 | 24 | When working on an app that uses the [composable architecture](https://github.com/pointfreeco/swift-composable-architecture), I fell in love with how reducers use an environment type that holds any dependencies the feature needs, such as API clients, analytics clients, and more. 25 | 26 | **Stores** tries to abstract the concept of a store and provide various implementations that can be injected in such environment and swapped easily when running tests or based on a remote flag. 27 | 28 | It all boils down to the two protocols [`SingleObjectStore`](https://github.com/omaralbeik/Stores/blob/main/Sources/Blueprints/SingleObjectStore.swift) and [`MultiObjectStore`](https://github.com/omaralbeik/Stores/blob/main/Sources/Blueprints/MultiObjectStore.swift) defined in the [**Blueprints**](https://github.com/omaralbeik/Stores/tree/main/Sources/Blueprints) layer, which provide the abstract concepts of stores that can store a single or multiple objects of a generic `Codable` type. 29 | 30 | The two protocols are then implemented in the different modules as explained in the chart below: 31 | 32 | ![Modules chart](https://raw.githubusercontent.com/omaralbeik/Stores/main/Assets/stores-light.png#gh-light-mode-only) 33 | ![Modules chart](https://raw.githubusercontent.com/omaralbeik/Stores/main/Assets/stores-dark.png#gh-dark-mode-only) 34 | 35 | --- 36 | 37 | ## Usage 38 | 39 | Let's say you have a `User` struct defined as below: 40 | 41 | ```swift 42 | struct User: Codable { 43 | let id: Int 44 | let name: String 45 | } 46 | ``` 47 | 48 | Here's how you store it using Stores: 49 | 50 |
51 | 1. Conform to Identifiable 52 | 53 | This is required to make the store associate an object with its id. 54 | 55 | ```swift 56 | extension User: Identifiable {} 57 | ``` 58 | 59 | The property `id` can be on any `Hashable` type. [Read more](https://developer.apple.com/documentation/swift/identifiable). 60 | 61 |
62 | 63 |
64 | 2. Create a store 65 | 66 | Stores comes pre-equipped with the following stores: 67 | 68 | 141 | 142 | You can create a custom store by implementing the protocols in [`Blueprints`](https://github.com/omaralbeik/Stores/tree/main/Sources/Blueprints) 143 | 144 | 182 |
183 | 184 | 185 |
186 | 3. Inject the store 187 | 188 | Assuming we have a view model that uses a store to fetch data: 189 | 190 | ```swift 191 | struct UsersViewModel { 192 | let store: AnyMultiObjectStore 193 | } 194 | ``` 195 | 196 | Inject the appropriate store implementation: 197 | 198 | ```swift 199 | let coreDataStore = MultiCoreDataStore(databaseName: "users") 200 | let prodViewModel = UsersViewModel(store: coreDataStore.eraseToAnyStore()) 201 | ``` 202 | 203 | or: 204 | 205 | ```swift 206 | let fakeStore = MultiObjectStoreFake() 207 | let testViewModel = UsersViewModel(store: fakeStore.eraseToAnyStore()) 208 | ``` 209 | 210 |
211 | 212 |
213 | 4. Save, retrieve, update, or remove objects 214 | 215 | ```swift 216 | let john = User(id: 1, name: "John Appleseed") 217 | 218 | // Save an object to a store 219 | try store.save(john) 220 | 221 | // Save an array of objects to a store 222 | try store.save([jane, steve, jessica]) 223 | 224 | // Get an object from store 225 | let user = store.object(withId: 1) 226 | 227 | // Get an array of object in store 228 | let users = store.objects(withIds: [1, 2, 3]) 229 | 230 | // Get an array of all objects in store 231 | let allUsers = store.allObjects() 232 | 233 | // Check if store has an object 234 | print(store.containsObject(withId: 10)) // false 235 | 236 | // Remove an object from a store 237 | try store.remove(withId: 1) 238 | 239 | // Remove multiple objects from a store 240 | try store.remove(withIds: [1, 2, 3]) 241 | 242 | // Remove all objects in a store 243 | try store.removeAll() 244 | ``` 245 | 246 |
247 | 248 | --- 249 | 250 | ## Documentation 251 | 252 | Read the full documentation at [Swift Package Index](https://swiftpackageindex.com/omaralbeik/Stores/documentation). 253 | 254 | --- 255 | 256 | ## Installation 257 | 258 | You can add Stores to an Xcode project by adding it as a package dependency. 259 | 260 | 1. From the **File** menu, select **Add Packages...** 261 | 2. Enter "https://github.com/omaralbeik/Stores" into the package repository URL text field 262 | 3. Depending on what you want to use Stores for, add the following target(s) to your app: 263 | - `Stores`: the entire library with all stores. 264 | - `UserDefaultsStore`: use User Defaults to persist data. 265 | - `FileSystemStore`: persist data by saving it to the file system. 266 | - `CoreDataStore`: use a Core Data database to persist data. 267 | - `KeychainStore`: persist data securely in the Keychain. 268 | - `Blueprints`: protocols only, this is a good option if you do not want to use any of the provided stores and build yours. 269 | - `StoresTestUtils` to use the fakes in your tests target. 270 | 271 | --- 272 | 273 | ## Credits and thanks 274 | 275 | - [Tom Harrington](https://twitter.com/atomicbird) for writing ["Core Data Using Only Code"](https://www.atomicbird.com/blog/core-data-code-only/). 276 | - [Keith Harrison](https://twitter.com/kharrison) for writing ["Testing Core Data In A Swift Package"](https://useyourloaf.com/blog/testing-core-data-in-a-swift-package/). 277 | - [Riccardo Cipolleschi](https://twitter.com/cipolleschir) for writing [Retrieve multiple values from Keychain](https://medium.com/macoclock/retrieve-multiple-values-from-keychain-77641248f4a1). 278 | --- 279 | 280 | ## License 281 | 282 | Stores is released under the MIT license. See [LICENSE](https://github.com/omaralbeik/Stores/blob/main/LICENSE) for more information. 283 | -------------------------------------------------------------------------------- /Sources/Blueprints/AnyMultiObjectStore.swift: -------------------------------------------------------------------------------- 1 | /// A type-erased ``MultiObjectStore``. 2 | public final class AnyMultiObjectStore< 3 | Object: Codable & Identifiable 4 | >: MultiObjectStore { 5 | /// Create any store from a given store. 6 | /// - Parameter store: store to erase its type. 7 | public init( 8 | _ store: Store 9 | ) where Store.Object == Object { 10 | _store = store 11 | _save = { try store.save($0) } 12 | _saveOptional = { try store.save($0) } 13 | _saveObjects = { try store.save($0) } 14 | _objectsCount = { store.objectsCount } 15 | _containsObject = { store.containsObject(withId: $0) } 16 | _object = { store.object(withId: $0) } 17 | _objects = { store.objects(withIds: $0) } 18 | _allObjects = { store.allObjects() } 19 | _remove = { try store.remove(withId: $0) } 20 | _removeMultiple = { try store.remove(withIds: $0) } 21 | _removeAll = { try store.removeAll() } 22 | } 23 | 24 | private let _store: any MultiObjectStore 25 | private let _save: (Object) throws -> Void 26 | private let _saveOptional: (Object?) throws -> Void 27 | private let _saveObjects: ([Object]) throws -> Void 28 | private let _objectsCount: () -> Int 29 | private let _containsObject: (Object.ID) -> Bool 30 | private let _object: (Object.ID) -> Object? 31 | private let _objects: ([Object.ID]) -> [Object] 32 | private let _allObjects: () -> [Object] 33 | private let _remove: (Object.ID) throws -> Void 34 | private let _removeMultiple: ([Object.ID]) throws -> Void 35 | private let _removeAll: () throws -> Void 36 | 37 | /// Saves an object to store. 38 | /// - Parameter object: object to be saved. 39 | /// - Throws error: any error that might occur during the save operation. 40 | public func save(_ object: Object) throws { 41 | try _save(object) 42 | } 43 | 44 | /// Saves an optional object to store —if not nil—. 45 | /// - Parameter object: optional object to be saved. 46 | /// - Throws error: any error that might occur during the save operation. 47 | public func save(_ object: Object?) throws { 48 | try _saveOptional(object) 49 | } 50 | 51 | /// Saves an array of objects to store. 52 | /// - Parameter objects: array of objects to be saved. 53 | /// - Throws error: any error that might occur during the save operation. 54 | public func save(_ objects: [Object]) throws { 55 | try _saveObjects(objects) 56 | } 57 | 58 | /// The number of all objects stored in the store. 59 | public var objectsCount: Int { 60 | return _objectsCount() 61 | } 62 | 63 | /// Whether the store contains a saved object with the given id. 64 | /// - Parameter id: object id. 65 | /// - Returns: true if store contains an object with the given id. 66 | public func containsObject(withId id: Object.ID) -> Bool { 67 | return _containsObject(id) 68 | } 69 | 70 | /// Returns an object for the given id, or `nil` if no object is found. 71 | /// - Parameter id: object id. 72 | /// - Returns: object with the given id, or `nil` if no object with the given id is found. 73 | public func object(withId id: Object.ID) -> Object? { 74 | return _object(id) 75 | } 76 | 77 | /// Returns objects for given ids, and ignores any ids that does not represent an object in the store. 78 | /// - Parameter ids: object ids. 79 | /// - Returns: array of objects with the given ids. 80 | public func objects(withIds ids: [Object.ID]) -> [Object] { 81 | return _objects(ids) 82 | } 83 | 84 | /// Returns all objects in the store. 85 | /// - Returns: collection containing all objects stored in store without a given order. 86 | public func allObjects() -> [Object] { 87 | return _allObjects() 88 | } 89 | 90 | /// Removes object with the given id —if found—. 91 | /// - Parameter id: id for the object to be removed. 92 | /// - Throws error: any error that might occur during the removal operation. 93 | public func remove(withId id: Object.ID) throws { 94 | try _remove(id) 95 | } 96 | 97 | /// Removes objects with given ids —if found—, and ignore any ids that does not represent objects stored 98 | /// in the store. 99 | /// - Parameter ids: ids for the objects to be deleted. 100 | /// - Throws error: any error that might occur during the removal operation. 101 | public func remove(withIds ids: [Object.ID]) throws { 102 | try _removeMultiple(ids) 103 | } 104 | 105 | /// Removes all objects in store. 106 | /// - Throws error: any errors that might occur during the removal operation. 107 | public func removeAll() throws { 108 | try _removeAll() 109 | } 110 | } 111 | 112 | public extension MultiObjectStore { 113 | /// Create a type erased store. 114 | /// - Returns: ``AnyMultiObjectStore``. 115 | func eraseToAnyStore() -> AnyMultiObjectStore { 116 | if let anyStore = self as? AnyMultiObjectStore { 117 | return anyStore 118 | } 119 | return .init(self) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Sources/Blueprints/AnySingleObjectStore.swift: -------------------------------------------------------------------------------- 1 | /// A type-erased ``SingleObjectStore``. 2 | public final class AnySingleObjectStore: SingleObjectStore { 3 | /// Create any store from a given store. 4 | /// - Parameter store: store to erase its type. 5 | public init( 6 | _ store: Store 7 | ) where Store.Object == Object { 8 | _store = store 9 | _save = { try store.save($0) } 10 | _saveOptional = { try store.save($0) } 11 | _object = { store.object() } 12 | _remove = { try store.remove() } 13 | } 14 | 15 | private let _store: any SingleObjectStore 16 | private let _save: (Object) throws -> Void 17 | private let _saveOptional: (Object?) throws -> Void 18 | private let _object: () -> Object? 19 | private let _remove: () throws -> Void 20 | 21 | /// Saves an object to store. 22 | /// - Parameter object: object to be saved. 23 | /// - Throws error: any error that might occur during the save operation. 24 | public func save(_ object: Object) throws { 25 | try _save(object) 26 | } 27 | 28 | /// Saves an optional object to store or remove currently saved object if `nil`. 29 | /// - Parameter object: object to be saved. 30 | /// - Throws error: any error that might occur during the save operation. 31 | public func save(_ object: Object?) throws { 32 | try _saveOptional(object) 33 | } 34 | 35 | /// Returns the object saved in the store 36 | /// - Returns: object saved in the store. `nil` if no object is saved in store. 37 | public func object() -> Object? { 38 | return _object() 39 | } 40 | 41 | /// Removes any saved object in the store. 42 | /// - Throws error: any error that might occur during the removal operation. 43 | public func remove() throws { 44 | try _remove() 45 | } 46 | } 47 | 48 | public extension SingleObjectStore { 49 | /// Create a type erased store. 50 | /// - Returns: ``AnySingleObjectStore``. 51 | func eraseToAnyStore() -> AnySingleObjectStore { 52 | if let anyStore = self as? AnySingleObjectStore { 53 | return anyStore 54 | } 55 | return .init(self) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Blueprints/Documentation.docc/Blueprints.md: -------------------------------------------------------------------------------- 1 | # ``Blueprints`` 2 | 3 | This layer contains the protocols required to build convenient and type-safe stores to store and retrieve `Codable` objects. 4 | -------------------------------------------------------------------------------- /Sources/Blueprints/Logger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A helper class used to log errors to console and perform actions that might throw an error. 4 | public final class Logger { 5 | /// Create a new logger. 6 | public init() {} 7 | 8 | /// Last console output. 9 | public private(set) var lastOutput: String? 10 | 11 | var error: Error? 12 | 13 | /// Log an error to console. 14 | /// - Parameters: 15 | /// - error: error to be logged to console. 16 | /// - fileName: file name of where the error occurred. 17 | /// - functionName: function name of where the error occurred. 18 | /// - Returns: the string that was printed to console. 19 | @discardableResult public func log( 20 | _ error: Error, 21 | fileName: String = #file, 22 | functionName: String = #function 23 | ) -> String { 24 | let file = fileName 25 | .split(separator: "/") 26 | .last? 27 | .replacingOccurrences(of: ".swift", with: "") ?? fileName 28 | let location = "`\(file).\(functionName)`" 29 | let errorDescription = error.localizedDescription 30 | let message = "An error occurred in \(location). Error: \(errorDescription)" 31 | #if DEBUG 32 | print(message) 33 | #endif 34 | lastOutput = message 35 | return message 36 | } 37 | 38 | /// Perform an action and return its result. 39 | /// - Returns: Action to be performed. 40 | @discardableResult public func perform( 41 | _ action: @autoclosure () throws -> Output 42 | ) throws -> Output { 43 | if let error = error { 44 | throw error 45 | } 46 | let result = try action() 47 | return result 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/Blueprints/MultiObjectStore.swift: -------------------------------------------------------------------------------- 1 | /// An API for a key-value store that offers a convenient and type-safe way to store and retrieve a collection of 2 | /// `Codable` and `Identifiable` objects. 3 | public protocol MultiObjectStore { 4 | // Storable object. 5 | associatedtype Object: Codable & Identifiable 6 | 7 | /// Saves an object to store. 8 | /// - Parameter object: object to be saved. 9 | /// - Throws error: any error that might occur during the save operation. 10 | func save(_ object: Object) throws 11 | 12 | /// Saves an optional object to store —if not nil—. 13 | /// **This method has a default implementation.** 14 | /// - Parameter object: optional object to be saved. 15 | /// - Throws error: any error that might occur during the save operation. 16 | func save(_ object: Object?) throws 17 | 18 | /// Saves an array of objects to store. 19 | /// **This method has a default implementation.** 20 | /// - Parameter objects: array of objects to be saved. 21 | /// - Throws error: any error that might occur during the save operation. 22 | func save(_ objects: [Object]) throws 23 | 24 | /// The number of all objects stored in store. 25 | var objectsCount: Int { get } 26 | 27 | /// Whether the store contains a saved object with the given id. 28 | /// - Parameter id: object id. 29 | /// - Returns: true if store contains an object with the given id. 30 | func containsObject(withId id: Object.ID) -> Bool 31 | 32 | /// Returns an object for the given id, or `nil` if no object is found. 33 | /// - Parameter id: object id. 34 | /// - Returns: object with the given id, or `nil` if no object with the given id is found. 35 | func object(withId id: Object.ID) -> Object? 36 | 37 | /// Returns objects for given ids, and ignores any id that does not represent an object in the store. 38 | /// **This method has a default implementation.** 39 | /// - Parameter ids: object ids. 40 | /// - Returns: array of objects with the given ids. 41 | func objects(withIds ids: [Object.ID]) -> [Object] 42 | 43 | /// Returns all objects in the store. 44 | /// - Returns: collection containing all objects stored in the store without a given order. 45 | func allObjects() -> [Object] 46 | 47 | /// Removes object with the given id —if found—. 48 | /// - Parameter id: id for the object to be removed. 49 | /// - Throws error: any error that might occur during the removal operation. 50 | func remove(withId id: Object.ID) throws 51 | 52 | /// Removes objects with given ids —if found—, and ignore any ids that does not represent objects stored 53 | /// in the store. 54 | /// **This method has a default implementation.** 55 | /// - Parameter ids: ids for the objects to be deleted. 56 | /// - Throws error: any error that might occur during the removal operation. 57 | func remove(withIds ids: [Object.ID]) throws 58 | 59 | /// Removes all objects in store. 60 | /// - Throws error: any errors that might occur during the removal operation. 61 | func removeAll() throws 62 | } 63 | 64 | public extension MultiObjectStore { 65 | /// Saves an optional object to store —if not nil—. 66 | /// - Parameter object: optional object to be saved. 67 | /// - Throws error: any error that might occur during the save operation. 68 | func save(_ object: Object?) throws { 69 | if let object = object { 70 | try save(object) 71 | } 72 | } 73 | 74 | /// Saves an array of objects to store. 75 | /// - Parameter objects: array of objects to be saved. 76 | /// - Throws error: any error that might occur during the save operation. 77 | func save(_ objects: [Object]) throws { 78 | try objects.forEach(save) 79 | } 80 | 81 | /// Returns objects for given ids, and ignores any ids that does not represent an object in the store. 82 | /// - Parameter ids: object ids. 83 | /// - Returns: array of objects with the given ids. 84 | func objects(withIds ids: [Object.ID]) -> [Object] { 85 | return ids.compactMap(object(withId:)) 86 | } 87 | 88 | /// Removes objects with given ids —if found—, and ignore any ids that does not represent objects stored 89 | /// in the store. 90 | /// - Parameter ids: ids for the objects to be deleted. 91 | /// - Throws error: any error that might occur during the removal operation. 92 | func remove(withIds ids: [Object.ID]) throws { 93 | try ids.forEach(remove(withId:)) 94 | } 95 | } 96 | 97 | public extension MultiObjectStore where Object: Hashable { 98 | /// Saves a set of objects to store. 99 | /// - Parameter objects: array of objects to be saved. 100 | /// - Throws error: any error that might occur during the save operation. 101 | func save(_ objects: Set) throws { 102 | try save(Array(objects)) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/Blueprints/SingleObjectStore.swift: -------------------------------------------------------------------------------- 1 | /// An API for a single object store offers a convenient and type-safe way to store and retrieve a single 2 | /// `Codable` object. 3 | public protocol SingleObjectStore { 4 | // Storable object. 5 | associatedtype Object: Codable 6 | 7 | /// Saves an object to store. 8 | /// - Parameter object: object to be saved. 9 | /// - Throws error: any error that might occur during the save operation. 10 | func save(_ object: Object) throws 11 | 12 | /// Saves an optional object to store or remove currently saved object if `nil`. 13 | /// **This method has a default implementation.** 14 | /// - Parameter object: object to be saved. 15 | /// - Throws error: any error that might occur during the save operation. 16 | func save(_ object: Object?) throws 17 | 18 | /// Returns the object saved in the store 19 | /// - Returns: object saved in the store. `nil` if no object is saved in store. 20 | func object() -> Object? 21 | 22 | /// Removes any saved object in the store. 23 | /// - Throws error: any error that might occur during the removal operation. 24 | func remove() throws 25 | } 26 | 27 | public extension SingleObjectStore { 28 | /// Saves an optional object to store or remove currently saved object if `nil`. 29 | /// - Parameter object: object to be saved. 30 | /// - Throws error: any error that might occur during the save operation. 31 | func save(_ object: Object?) throws { 32 | if let object = object { 33 | try save(object) 34 | } else { 35 | try remove() 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/CoreData/Database.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreData) 2 | 3 | import CoreData 4 | import Foundation 5 | 6 | final class Container: NSPersistentContainer { 7 | override class func defaultDirectoryURL() -> URL { 8 | super.defaultDirectoryURL().appendingPathComponent("CoreDataStore") 9 | } 10 | 11 | required init(name: String) { 12 | super.init(name: name, managedObjectModel: Database.entityModel) 13 | } 14 | } 15 | 16 | final class Database { 17 | let context: NSManagedObjectContext 18 | private(set) var url: URL? 19 | 20 | init(name: String, container: NSPersistentContainer) { 21 | context = container.viewContext 22 | container.loadPersistentStores { description, error in 23 | if let error = error { 24 | preconditionFailure( 25 | "Failed to load store with error: \(error.localizedDescription)." 26 | ) 27 | } 28 | self.url = description.url 29 | } 30 | } 31 | 32 | static let entityModel: NSManagedObjectModel = { 33 | let entity = NSEntityDescription() 34 | entity.name = "Entity" 35 | entity.managedObjectClassName = "Entity" 36 | 37 | let idAttribute = NSAttributeDescription() 38 | idAttribute.name = "id" 39 | idAttribute.attributeType = .stringAttributeType 40 | idAttribute.isOptional = false 41 | entity.properties.append(idAttribute) 42 | 43 | let dataAttribute = NSAttributeDescription() 44 | dataAttribute.name = "data" 45 | dataAttribute.attributeType = .binaryDataAttributeType 46 | dataAttribute.isOptional = false 47 | entity.properties.append(dataAttribute) 48 | 49 | let lastUpdatedAttribute = NSAttributeDescription() 50 | lastUpdatedAttribute.name = "lastUpdated" 51 | lastUpdatedAttribute.attributeType = .dateAttributeType 52 | lastUpdatedAttribute.isOptional = false 53 | entity.properties.append(lastUpdatedAttribute) 54 | 55 | let model = NSManagedObjectModel() 56 | model.entities = [entity] 57 | 58 | return model 59 | }() 60 | 61 | let entitiesFetchRequest: () -> NSFetchRequest = { 62 | let request = NSFetchRequest(entityName: "Entity") 63 | request.sortDescriptors = [ 64 | .init(key: "lastUpdated", ascending: true), 65 | ] 66 | return request 67 | } 68 | 69 | let entityFetchRequest: (String) -> NSFetchRequest = { id in 70 | let request = NSFetchRequest(entityName: "Entity") 71 | request.predicate = NSPredicate(format: "id == %@", id) 72 | request.fetchLimit = 1 73 | return request 74 | } 75 | } 76 | 77 | #endif 78 | -------------------------------------------------------------------------------- /Sources/CoreData/Documentation.docc/CoreData.md: -------------------------------------------------------------------------------- 1 | # ``CoreDataStore`` 2 | 3 | An implementation of **Blueprints** that offers a convenient and type-safe way to store and retrieve objects in a Core Data database. -------------------------------------------------------------------------------- /Sources/CoreData/Entity.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreData) 2 | 3 | import CoreData 4 | import Foundation 5 | 6 | @objc(Entity) 7 | final class Entity: NSManagedObject { 8 | @NSManaged var id: String? 9 | @NSManaged var data: Data? 10 | @NSManaged var lastUpdated: Date? 11 | } 12 | 13 | #endif 14 | -------------------------------------------------------------------------------- /Sources/CoreData/MultiCoreDataStore.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreData) 2 | 3 | import Blueprints 4 | import CoreData 5 | import Foundation 6 | 7 | /// The multi object core data store is an implementation of `MultiObjectStore` that offers a 8 | /// convenient and type-safe way to store and retrieve a collection of `Codable` and `Identifiable` 9 | /// objects in a core data database. 10 | /// 11 | /// > Thread safety: This is a thread-safe class. 12 | public final class MultiCoreDataStore< 13 | Object: Codable & Identifiable 14 | >: MultiObjectStore { 15 | let encoder = JSONEncoder() 16 | let decoder = JSONDecoder() 17 | let lock = NSRecursiveLock() 18 | let database: Database 19 | let logger = Logger() 20 | 21 | /// Store's database name. 22 | /// 23 | /// > Important: Never use the same name for multiple stores with different object types, 24 | /// doing this might cause stores to have corrupted data. 25 | public let databaseName: String 26 | 27 | /// Initialize store with given database name. 28 | /// 29 | /// > Important: Never use the same name for multiple stores with different object types, 30 | /// doing this might cause stores to have corrupted data. 31 | /// 32 | /// - Parameter databaseName: store's database name. 33 | /// - Parameter containerProvider: Optional closure that can be used to provide a custom 34 | /// provider for the given entity model. Defaults to `nil` which uses the default container. 35 | /// 36 | /// > Important: If you decided to use a custom container, do not forget to set containers's 37 | /// `managedObjectModel` to the provided model. 38 | /// 39 | /// > Note: Creating a store is a fairly cheap operation, you can create multiple instances of the same store 40 | /// with a same database name. 41 | public init( 42 | databaseName: String, 43 | containerProvider: ((NSManagedObjectModel) -> NSPersistentContainer)? = nil 44 | ) { 45 | self.databaseName = databaseName 46 | let container = containerProvider?(Database.entityModel) 47 | ?? Container(name: databaseName) 48 | database = .init(name: databaseName, container: container) 49 | } 50 | 51 | /// URL for where the core data SQLite database is stored. 52 | public var databaseURL: URL? { 53 | database.url 54 | } 55 | 56 | // MARK: - MultiObjectStore 57 | 58 | /// Saves an object to store. 59 | /// - Parameter object: object to be saved. 60 | /// - Throws error: any error that might occur during the save operation. 61 | public func save(_ object: Object) throws { 62 | try sync { 63 | let data = try encoder.encode(object) 64 | let key = key(for: object) 65 | let request = database.entityFetchRequest(key) 66 | if let savedEntity = try database.context.fetch(request).first { 67 | savedEntity.data = data 68 | savedEntity.lastUpdated = Date() 69 | } else { 70 | let newEntity = Entity(context: database.context) 71 | newEntity.id = key 72 | newEntity.data = data 73 | newEntity.lastUpdated = Date() 74 | } 75 | try database.context.save() 76 | } 77 | } 78 | 79 | /// Saves an array of objects to store. 80 | /// - Parameter objects: array of objects to be saved. 81 | /// - Throws error: any error that might occur during the save operation. 82 | public func save(_ objects: [Object]) throws { 83 | try sync { 84 | let pairs = try objects.map { object -> (key: String, data: Data) in 85 | let key = key(for: object) 86 | let data = try encoder.encode(object) 87 | return (key, data) 88 | } 89 | try pairs.forEach { pair in 90 | let request = database.entityFetchRequest(pair.key) 91 | if let savedEntity = try database.context.fetch(request).first { 92 | savedEntity.data = pair.data 93 | savedEntity.lastUpdated = Date() 94 | } else { 95 | let newEntity = Entity(context: database.context) 96 | newEntity.id = pair.key 97 | newEntity.data = pair.data 98 | newEntity.lastUpdated = Date() 99 | } 100 | } 101 | try database.context.save() 102 | } 103 | } 104 | 105 | /// The number of all objects stored in store. 106 | /// 107 | /// > Note: Errors thrown out by performing the Core Data requests will be ignored and logged out to 108 | /// console in DEBUG. 109 | public var objectsCount: Int { 110 | let request = database.entitiesFetchRequest() 111 | do { 112 | let count = try logger.perform(database.context.count(for: request)) 113 | return count 114 | } catch { 115 | logger.log(error) 116 | return 0 117 | } 118 | } 119 | 120 | /// Whether the store contains a saved object with the given id. 121 | /// 122 | /// > Note: Errors thrown out by performing the Core Data requests will be ignored and logged out to 123 | /// console in DEBUG. 124 | /// 125 | /// - Parameter id: object id. 126 | /// - Returns: true if store contains an object with the given id. 127 | public func containsObject(withId id: Object.ID) -> Bool { 128 | let key = key(for: id) 129 | let request = database.entityFetchRequest(key) 130 | do { 131 | let count = try logger.perform(database.context.count(for: request)) 132 | return count != 0 133 | } catch { 134 | logger.log(error) 135 | return false 136 | } 137 | } 138 | 139 | /// Returns an object for the given id, or `nil` if no object is found. 140 | /// 141 | /// > Note: Errors thrown out by performing the Core Data requests will be ignored and logged out to 142 | /// console in DEBUG. 143 | /// 144 | /// - Parameter id: object id. 145 | /// - Returns: object with the given id, or `nil` if no object with the given id is found. 146 | public func object(withId id: Object.ID) -> Object? { 147 | let request = database.entityFetchRequest(key(for: id)) 148 | do { 149 | guard let data = try database.context.fetch(request).first?.data else { 150 | return nil 151 | } 152 | return try decoder.decode(Object.self, from: data) 153 | } catch { 154 | logger.log(error) 155 | return nil 156 | } 157 | } 158 | 159 | /// Returns all objects in the store. 160 | /// 161 | /// > Note: Errors thrown out by performing the Core Data requests will be ignored and logged out to 162 | /// console in DEBUG. 163 | /// 164 | /// - Returns: collection containing all objects stored in store. 165 | public func allObjects() -> [Object] { 166 | let request = database.entitiesFetchRequest() 167 | do { 168 | return try logger.perform( 169 | database.context.fetch(request) 170 | .compactMap(\.data) 171 | .compactMap { 172 | do { 173 | return try decoder.decode(Object.self, from: $0) 174 | } catch { 175 | logger.log(error) 176 | return nil 177 | } 178 | } 179 | ) 180 | } catch { 181 | logger.log(error) 182 | return [] 183 | } 184 | } 185 | 186 | /// Removes object with the given id —if found—. 187 | /// - Parameter id: id for the object to be deleted. 188 | public func remove(withId id: Object.ID) throws { 189 | try sync { 190 | let request = database.entityFetchRequest(key(for: id)) 191 | if let object = try database.context.fetch(request).first { 192 | database.context.delete(object) 193 | } 194 | try database.context.save() 195 | } 196 | } 197 | 198 | /// Removes objects with given ids —if found—. 199 | /// - Parameter ids: ids for the objects to be deleted. 200 | public func remove(withIds ids: [Object.ID]) throws { 201 | try sync { 202 | try ids.forEach(remove(withId:)) 203 | } 204 | } 205 | 206 | /// Removes all objects in store. 207 | public func removeAll() throws { 208 | try sync { 209 | let request = database.entitiesFetchRequest() 210 | let entities = try database.context.fetch(request) 211 | for entity in entities { 212 | database.context.delete(entity) 213 | } 214 | try database.context.save() 215 | } 216 | } 217 | } 218 | 219 | // MARK: - Helpers 220 | 221 | extension MultiCoreDataStore { 222 | func sync(action: () throws -> Void) rethrows { 223 | lock.lock() 224 | defer { lock.unlock() } 225 | try action() 226 | } 227 | 228 | func key(for object: Object) -> String { 229 | return key(for: object.id) 230 | } 231 | 232 | func key(for id: Object.ID) -> String { 233 | return "\(databaseName)-\(id)" 234 | } 235 | } 236 | 237 | #endif 238 | -------------------------------------------------------------------------------- /Sources/CoreData/SingleCoreDataStore.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreData) 2 | 3 | import Blueprints 4 | import CoreData 5 | import Foundation 6 | 7 | /// The single core data object store is an implementation of `SingleObjectStore` that offers a 8 | /// convenient and type-safe way to store and retrieve a single `Codable` object by saving it in a core data 9 | /// database. 10 | /// 11 | /// > Thread safety: This is a thread-safe class. 12 | public final class SingleCoreDataStore: SingleObjectStore { 13 | let encoder = JSONEncoder() 14 | let decoder = JSONDecoder() 15 | let lock = NSRecursiveLock() 16 | let database: Database 17 | let key = "object" 18 | let logger = Logger() 19 | 20 | /// Store's database name. 21 | /// 22 | /// > Important: Never use the same name for multiple stores with different object types, 23 | /// doing this might cause stores to have corrupted data. 24 | public let databaseName: String 25 | 26 | /// Initialize store with given database name. 27 | /// 28 | /// > Important: Never use the same name for multiple stores with different object types, 29 | /// doing this might cause stores to have corrupted data. 30 | /// 31 | /// - Parameter databaseName: store's database name. 32 | /// - Parameter containerProvider: Optional closure that can be used to provide a custom 33 | /// provider for the given entity model. Defaults to `nil` which uses the default container. 34 | /// 35 | /// > Important: If you decided to use a custom container, do not forget to set containers's 36 | /// `managedObjectModel` to the provided model. 37 | /// 38 | /// > Note: Creating a store is a fairly cheap operation, you can create multiple instances of the same store 39 | /// with a same database name. 40 | public init( 41 | databaseName: String, 42 | containerProvider: ((NSManagedObjectModel) -> NSPersistentContainer)? = nil 43 | ) { 44 | self.databaseName = databaseName 45 | let container = containerProvider?(Database.entityModel) 46 | ?? Container(name: databaseName) 47 | database = .init(name: databaseName, container: container) 48 | } 49 | 50 | /// URL for where the core data SQLite database is stored. 51 | public var databaseURL: URL? { 52 | database.url 53 | } 54 | 55 | // MARK: - SingleObjectStore 56 | 57 | /// Saves an object to store. 58 | /// - Parameter object: object to be saved. 59 | /// - Throws error: any error that might occur during the save operation. 60 | public func save(_ object: Object) throws { 61 | try sync { 62 | let data = try encoder.encode(object) 63 | let request = database.entitiesFetchRequest() 64 | if let savedEntity = try database.context.fetch(request).first { 65 | savedEntity.data = data 66 | savedEntity.lastUpdated = Date() 67 | } else { 68 | let newEntity = Entity(context: database.context) 69 | newEntity.id = key 70 | newEntity.data = data 71 | newEntity.lastUpdated = Date() 72 | } 73 | try database.context.save() 74 | } 75 | } 76 | 77 | /// Returns the object saved in the store. 78 | /// 79 | /// > Note: Errors thrown out by performing the Core Data requests will be ignored and logged out to 80 | /// console in DEBUG. 81 | /// 82 | /// - Returns: object saved in the store. `nil` if no object is saved in store. 83 | public func object() -> Object? { 84 | let request = database.entitiesFetchRequest() 85 | 86 | do { 87 | let result = try database.context.fetch(request) 88 | guard let data = result.first?.data else { return nil } 89 | return try decoder.decode(Object.self, from: data) 90 | } catch { 91 | logger.log(error) 92 | return nil 93 | } 94 | } 95 | 96 | /// Removes any saved object in the store. 97 | /// - Throws error: any error that might occur during the removal operation. 98 | public func remove() throws { 99 | try sync { 100 | let request = database.entitiesFetchRequest() 101 | let entities = try database.context.fetch(request) 102 | for entity in entities { 103 | database.context.delete(entity) 104 | } 105 | try database.context.save() 106 | } 107 | } 108 | } 109 | 110 | extension SingleCoreDataStore { 111 | func sync(action: () throws -> Void) rethrows { 112 | lock.lock() 113 | defer { lock.unlock() } 114 | try action() 115 | } 116 | } 117 | 118 | #endif 119 | -------------------------------------------------------------------------------- /Sources/FileSystem/Documentation.docc/FileSystem.md: -------------------------------------------------------------------------------- 1 | # ``FileSystemStore`` 2 | 3 | An implementation of **Blueprints** that offers a convenient and type-safe way to store and retrieve objects in the file system. -------------------------------------------------------------------------------- /Sources/FileSystem/MultiFileSystemStore.swift: -------------------------------------------------------------------------------- 1 | import Blueprints 2 | import Foundation 3 | 4 | /// The multi object file system store is an implementation of `MultiObjectStore` that offers a 5 | /// convenient and type-safe way to store and retrieve a collection of `Codable` and `Identifiable` 6 | /// objects as json files using the file system. 7 | /// 8 | /// > Thread safety: This is a thread-safe class. 9 | public final class MultiFileSystemStore< 10 | Object: Codable & Identifiable 11 | >: MultiObjectStore { 12 | let encoder = JSONEncoder() 13 | let decoder = JSONDecoder() 14 | let manager = FileManager.default 15 | let lock = NSRecursiveLock() 16 | let logger = Logger() 17 | 18 | /// Directory where the store folder is created. 19 | public let directory: FileManager.SearchPathDirectory 20 | 21 | /// Store's path. 22 | /// 23 | /// > Note: This is used to create the directory where files are saved: 24 | /// > 25 | /// > `{directory}/Stores/MultiObjects/{path}/{ID}.json` 26 | /// 27 | /// > Important: Never use the same path for multiple stores with different object types, 28 | /// doing this might cause stores to have corrupted data. 29 | public let path: String 30 | 31 | /// Initialize store with given directory and path. 32 | /// 33 | /// > Important: Never use the same path for multiple stores with different object types, 34 | /// doing this might cause stores to have corrupted data. 35 | /// 36 | /// - Parameter directory: directory where the store folder is created. 37 | /// Defaults to `.applicationSupportDirectory` 38 | /// - Parameter path: store's path. 39 | /// 40 | /// > Note: Directory and path are used to create the directory where files are saved: 41 | /// > 42 | /// > `{directory}/Stores/MultiObjects/{path}/{ID}.json` 43 | /// > 44 | /// > Creating a store is a fairly cheap operation, you can create multiple instances of the same store 45 | /// with a same directory and path. 46 | public required init( 47 | directory: FileManager.SearchPathDirectory = .applicationSupportDirectory, 48 | path: String 49 | ) { 50 | self.directory = directory 51 | self.path = path 52 | } 53 | 54 | // MARK: - Deprecated 55 | 56 | /// Deprecated: Store's unique identifier. 57 | @available(*, deprecated, renamed: "path") 58 | public var identifier: String { path } 59 | 60 | /// Deprecated: Initialize store with given identifier and directory. 61 | @available(*, deprecated, renamed: "init(directory:path:)") 62 | public required init( 63 | identifier: String, 64 | directory: FileManager.SearchPathDirectory = .applicationSupportDirectory 65 | ) { 66 | self.directory = directory 67 | self.path = identifier 68 | } 69 | 70 | // MARK: - MultiObjectStore 71 | 72 | /// Saves an object to store. 73 | /// - Parameter object: object to be saved. 74 | /// - Throws error: any error that might occur during the save operation. 75 | public func save(_ object: Object) throws { 76 | try sync { 77 | _ = try storeURL().path 78 | let newURL = try url(forObject: object) 79 | let data = try encoder.encode(object) 80 | manager.createFile(atPath: newURL.path, contents: data) 81 | } 82 | } 83 | 84 | /// Saves an array of objects to store. 85 | /// - Parameter objects: array of objects to be saved. 86 | /// - Throws error: any error that might occur during the save operation. 87 | public func save(_ objects: [Object]) throws { 88 | try sync { 89 | let pairs = try objects.map { object -> (url: URL, data: Data) in 90 | let url = try url(forObject: object) 91 | let data = try encoder.encode(object) 92 | return (url, data) 93 | } 94 | pairs.forEach { pair in 95 | manager.createFile(atPath: pair.url.path, contents: pair.data) 96 | } 97 | } 98 | } 99 | 100 | /// The number of all objects stored in store. 101 | /// 102 | /// > Note: Errors thrown out by file manager during reading files will be ignored and logged out to console 103 | /// in DEBUG. 104 | public var objectsCount: Int { 105 | do { 106 | let storeURL = try logger.perform(storeURL()) 107 | let items = try logger.perform( 108 | manager.contentsOfDirectory( 109 | at: storeURL, 110 | includingPropertiesForKeys: nil, 111 | options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] 112 | ) 113 | ) 114 | return items.count 115 | } catch { 116 | logger.log(error) 117 | return 0 118 | } 119 | } 120 | 121 | /// Whether the store contains a saved object with the given id. 122 | /// 123 | /// > Note: Errors thrown out by file manager during reading files will be ignored and logged out to console 124 | /// in DEBUG. 125 | /// 126 | /// - Parameter id: object id. 127 | /// - Returns: true if store contains an object with the given id. 128 | public func containsObject(withId id: Object.ID) -> Bool { 129 | do { 130 | let path = try logger.perform(url(forObjectWithId: id).path) 131 | return manager.fileExists(atPath: path) 132 | } catch { 133 | logger.log(error) 134 | return false 135 | } 136 | } 137 | 138 | /// Returns an object for the given id, or `nil` if no object is found. 139 | /// 140 | /// > Note: Errors thrown out by file manager during reading files will be ignored and logged out to console 141 | /// in DEBUG. 142 | /// 143 | /// - Parameter id: object id. 144 | /// - Returns: object with the given id, or `nil` if no object with the given id is found. 145 | public func object(withId id: Object.ID) -> Object? { 146 | do { 147 | let path = try url(forObjectWithId: id).path 148 | guard let data = manager.contents(atPath: path) else { return nil } 149 | return try decoder.decode(Object.self, from: data) 150 | } catch { 151 | logger.log(error) 152 | return nil 153 | } 154 | } 155 | 156 | /// Returns all objects in the store. 157 | /// 158 | /// > Note: Errors thrown out by file manager during reading files will be ignored and logged out to console 159 | /// in DEBUG. 160 | /// 161 | /// - Returns: collection containing all objects stored in store. 162 | public func allObjects() -> [Object] { 163 | do { 164 | let storePath = try logger.perform(storeURL().path) 165 | return try logger.perform( 166 | manager.contentsOfDirectory(atPath: storePath) 167 | .compactMap(url(forObjectPath:)) 168 | .map(\.path) 169 | .compactMap { 170 | do { 171 | return try logger.perform(object(atPath: $0)) 172 | } catch { 173 | logger.log(error) 174 | return nil 175 | } 176 | } 177 | ) 178 | } catch { 179 | logger.log(error) 180 | return [] 181 | } 182 | } 183 | 184 | /// Removes object with the given id —if found—. 185 | /// - Parameter id: id for the object to be deleted. 186 | public func remove(withId id: Object.ID) throws { 187 | try sync { 188 | let path = try url(forObjectWithId: id).path 189 | if manager.fileExists(atPath: path) { 190 | try manager.removeItem(atPath: path) 191 | } 192 | } 193 | } 194 | 195 | /// Removes all objects in store. 196 | public func removeAll() throws { 197 | try sync { 198 | let storePath = try storeURL().path 199 | if manager.fileExists(atPath: storePath) { 200 | try manager.removeItem(atPath: storePath) 201 | } 202 | } 203 | } 204 | } 205 | 206 | // MARK: - Helpers 207 | 208 | extension MultiFileSystemStore { 209 | func sync(action: () throws -> Void) rethrows { 210 | lock.lock() 211 | defer { lock.unlock() } 212 | try action() 213 | } 214 | 215 | func object(atPath path: String) throws -> Object? { 216 | guard let data = manager.contents(atPath: path) else { return nil } 217 | return try decoder.decode(Object.self, from: data) 218 | } 219 | 220 | func storeURL() throws -> URL { 221 | let url = try manager 222 | .url( 223 | for: directory, 224 | in: .userDomainMask, 225 | appropriateFor: nil, 226 | create: true 227 | ) 228 | .appendingPathComponent("Stores", isDirectory: true) 229 | .appendingPathComponent("MultiObjects", isDirectory: true) 230 | .appendingPathComponent(path, isDirectory: true) 231 | if manager.fileExists(atPath: url.path) == false { 232 | try manager.createDirectory( 233 | atPath: url.path, 234 | withIntermediateDirectories: true 235 | ) 236 | } 237 | return url 238 | } 239 | 240 | func url(forObjectWithId id: Object.ID) throws -> URL { 241 | return try storeURL() 242 | .appendingPathComponent("\(id)") 243 | .appendingPathExtension("json") 244 | } 245 | 246 | func url(forObject object: Object) throws -> URL { 247 | return try url(forObjectWithId: object.id) 248 | } 249 | 250 | func url(forObjectPath objectPath: String) throws -> URL { 251 | return try storeURL() 252 | .appendingPathComponent(objectPath, isDirectory: false) 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /Sources/FileSystem/SingleFileSystemStore.swift: -------------------------------------------------------------------------------- 1 | import Blueprints 2 | import Foundation 3 | 4 | /// The single file system object store is an implementation of `SingleObjectStore` that offers a 5 | /// convenient and type-safe way to store and retrieve a single `Codable` object by saving it as a json file 6 | /// using the file system. 7 | /// 8 | /// > Thread safety: This is a thread-safe class. 9 | public final class SingleFileSystemStore: SingleObjectStore { 10 | let encoder = JSONEncoder() 11 | let decoder = JSONDecoder() 12 | let manager = FileManager.default 13 | let lock = NSRecursiveLock() 14 | let logger = Logger() 15 | 16 | /// Directory where the store folder is created. 17 | public let directory: FileManager.SearchPathDirectory 18 | 19 | /// Store's path. 20 | /// 21 | /// > Note: This is used to create the directory where the file is saved: 22 | /// > 23 | /// > `{directory}/Stores/SingleObject/{path}/object.json` 24 | /// 25 | /// > Important: Never use the same path for multiple stores with different object types, 26 | /// doing this might cause stores to have corrupted data. 27 | public let path: String 28 | 29 | /// Initialize store with given directory and path. 30 | /// 31 | /// > Important: Never use the same path for multiple stores with different object types, 32 | /// doing this might cause stores to have corrupted data. 33 | /// 34 | /// - Parameter directory: directory where the store folder is created. 35 | /// Defaults to `.applicationSupportDirectory` 36 | /// - Parameter path: store's path. 37 | /// 38 | /// > Note: Directory and path are used to create the directory where the file is saved: 39 | /// > 40 | /// > `{directory}/Stores/SingleObject/{path}/object.json` 41 | /// > 42 | /// > Creating a store is a fairly cheap operation, you can create multiple instances of the same store 43 | /// with a same directory and path. 44 | public required init( 45 | directory: FileManager.SearchPathDirectory = .applicationSupportDirectory, 46 | path: String 47 | ) { 48 | self.directory = directory 49 | self.path = path 50 | } 51 | 52 | // MARK: - Deprecated 53 | 54 | /// Deprecated: Store's unique identifier. 55 | @available(*, deprecated, renamed: "name") 56 | public var identifier: String { path } 57 | 58 | /// Deprecated: Initialize store with given identifier and directory. 59 | @available(*, deprecated, renamed: "init(directory:name:)") 60 | public required init( 61 | identifier: String, 62 | directory: FileManager.SearchPathDirectory = .applicationSupportDirectory 63 | ) { 64 | self.directory = directory 65 | self.path = identifier 66 | } 67 | 68 | // MARK: - SingleObjectStore 69 | 70 | /// Saves an object to store. 71 | /// - Parameter object: object to be saved. 72 | /// - Throws error: any error that might occur during the save operation. 73 | public func save(_ object: Object) throws { 74 | try sync { 75 | let data = try encoder.encode(object) 76 | _ = try storeURL() 77 | manager.createFile(atPath: try fileURL().path, contents: data) 78 | } 79 | } 80 | 81 | /// Returns the object saved in the store 82 | /// 83 | /// > Note: Errors thrown out by file manager during reading files will be ignored and logged out to console 84 | /// in DEBUG. 85 | /// 86 | /// - Returns: object saved in the store. `nil` if no object is saved in store. 87 | public func object() -> Object? { 88 | do { 89 | let path = try fileURL().path 90 | guard let data = manager.contents(atPath: path) else { return nil } 91 | return try decoder.decode(Object.self, from: data) 92 | } catch { 93 | logger.log(error) 94 | return nil 95 | } 96 | } 97 | 98 | /// Removes any saved object in the store. 99 | /// - Throws error: any errors that might occur if the object was not removed. 100 | public func remove() throws { 101 | try sync { 102 | let path = try storeURL().path 103 | try manager.removeItem(atPath: path) 104 | } 105 | } 106 | } 107 | 108 | // MARK: - Helpers 109 | 110 | extension SingleFileSystemStore { 111 | func sync(action: () throws -> Void) rethrows { 112 | lock.lock() 113 | defer { lock.unlock() } 114 | try action() 115 | } 116 | 117 | func storeURL() throws -> URL { 118 | let url = try manager 119 | .url( 120 | for: directory, 121 | in: .userDomainMask, 122 | appropriateFor: nil, 123 | create: true 124 | ) 125 | .appendingPathComponent("Stores", isDirectory: true) 126 | .appendingPathComponent("SingleObject", isDirectory: true) 127 | .appendingPathComponent(path, isDirectory: true) 128 | if manager.fileExists(atPath: url.path) == false { 129 | try manager.createDirectory( 130 | atPath: url.path, 131 | withIntermediateDirectories: true 132 | ) 133 | } 134 | return url 135 | } 136 | 137 | func fileURL() throws -> URL { 138 | return try storeURL() 139 | .appendingPathComponent("object") 140 | .appendingPathExtension("json") 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Sources/Keychain/Documentation.docc/Keychain.md: -------------------------------------------------------------------------------- 1 | # ``KeychainStore`` 2 | 3 | An implementation of **Blueprints** that offers a convenient and type-safe way to store and retrieve objects securely in the Keychain. -------------------------------------------------------------------------------- /Sources/Keychain/KeychainAccessibility.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Security) 2 | 3 | import Security 4 | 5 | /// An object representing keychain accessibility level. 6 | public struct KeychainAccessibility: Equatable { 7 | let attribute: CFString 8 | 9 | /// The data in the keychain item cannot be accessed after a restart until the device has been unlocked 10 | /// once by the user. 11 | public static let afterFirstUnlock = Self( 12 | attribute: kSecAttrAccessibleAfterFirstUnlock 13 | ) 14 | 15 | /// The data in the keychain item can be accessed only while the device is unlocked by the user. 16 | public static let whenUnlocked = Self( 17 | attribute: kSecAttrAccessibleWhenUnlocked 18 | ) 19 | 20 | /// The data in the keychain item can be accessed only while the device is unlocked by the user. 21 | public static let whenUnlockedThisDeviceOnly = Self( 22 | attribute: kSecAttrAccessibleWhenUnlockedThisDeviceOnly 23 | ) 24 | 25 | /// The data in the keychain can only be accessed when the device is unlocked. 26 | /// Only available if a passcode is set on the device. 27 | public static let whenPasscodeSetThisDeviceOnly = Self( 28 | attribute: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly 29 | ) 30 | 31 | /// The data in the keychain item cannot be accessed after a restart until the device has been 32 | /// unlocked once by the user. 33 | public static let afterFirstUnlockThisDeviceOnly = Self( 34 | attribute: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly 35 | ) 36 | } 37 | 38 | #endif 39 | -------------------------------------------------------------------------------- /Sources/Keychain/KeychainError.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Security) 2 | 3 | import Security 4 | import Foundation 5 | 6 | enum KeychainError: LocalizedError { 7 | case keychain(OSStatus) 8 | case invalidResult 9 | 10 | var errorDescription: String? { 11 | switch self { 12 | case .keychain(let oSStatus): 13 | return "Keychain Error: OSStatus=\(oSStatus)." 14 | case .invalidResult: 15 | return "Keychain Error: Invalid result." 16 | } 17 | } 18 | } 19 | 20 | #endif 21 | -------------------------------------------------------------------------------- /Sources/Keychain/MultiKeychainStore.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Security) 2 | 3 | import Blueprints 4 | import Foundation 5 | import Security 6 | 7 | /// The multi object keychain store is an implementation of `MultiObjectStore` that offers a 8 | /// convenient and type-safe way to store and retrieve a collection of `Codable` and `Identifiable` 9 | /// objects securely in the keychain. 10 | /// 11 | /// > Thread safety: This is a thread-safe class. 12 | public final class MultiKeychainStore< 13 | Object: Codable & Identifiable 14 | >: MultiObjectStore { 15 | let encoder = JSONEncoder() 16 | let decoder = JSONDecoder() 17 | let lock = NSRecursiveLock() 18 | let logger = Logger() 19 | 20 | /// Store's unique identifier. 21 | /// 22 | /// Note: This is used to create the underlying service name `kSecAttrService` where objects are 23 | /// stored. 24 | /// 25 | /// `com.omaralbeik.stores.multi.{identifier}` 26 | /// 27 | /// > Important: Never use the same identifier for multiple stores with different object types, 28 | /// doing this might cause stores to have corrupted data. 29 | public let identifier: String 30 | 31 | /// Store's accessibility level. 32 | public let accessibility: KeychainAccessibility 33 | 34 | /// Initialize store. 35 | /// 36 | /// Note: This is used to create the underlying service name `kSecAttrService` where objects are 37 | /// stored. 38 | /// 39 | /// `com.omaralbeik.stores.multi.{identifier}` 40 | /// 41 | /// > Important: Never use the same identifier for multiple stores with different object types, 42 | /// doing this might cause stores to have corrupted data. 43 | /// 44 | /// - Parameters: 45 | /// - identifier: store's unique identifier. 46 | /// - accessibility: store's accessibility level. Defaults to `.whenUnlockedThisDeviceOnly` 47 | /// 48 | /// > Note: Creating a store is a fairly cheap operation, you can create multiple instances of the same store 49 | /// with same parameters. 50 | required public init( 51 | identifier: String, 52 | accessibility: KeychainAccessibility = .whenUnlockedThisDeviceOnly 53 | ) { 54 | self.identifier = identifier 55 | self.accessibility = accessibility 56 | } 57 | 58 | // MARK: - MultiObjectStore 59 | 60 | /// Saves an object to store. 61 | /// - Parameter object: object to be saved. 62 | /// - Throws error: any error that might occur during the save operation. 63 | public func save(_ object: Object) throws { 64 | try sync { 65 | let data = try encoder.encode(object) 66 | try addOrUpdate(data: data, for: object.id) 67 | } 68 | } 69 | 70 | /// Saves an array of objects to store. 71 | /// - Parameter objects: array of objects to be saved. 72 | /// - Throws error: any error that might occur during the save operation. 73 | public func save(_ objects: [Object]) throws { 74 | try sync { 75 | let pairs = try objects.map { (try encoder.encode($0), $0.id) } 76 | try pairs.forEach(addOrUpdate) 77 | } 78 | } 79 | 80 | /// The number of all objects stored in store. 81 | /// 82 | /// > Note: Errors thrown while performing the security query will be ignored and logged out to console 83 | /// in DEBUG. 84 | public var objectsCount: Int { 85 | let query = generateQuery { 86 | $0[kSecMatchLimit] = kSecMatchLimitAll 87 | } 88 | var result: AnyObject? 89 | let status = SecItemCopyMatching(query as CFDictionary, &result) 90 | switch status { 91 | case errSecSuccess: 92 | break 93 | case errSecItemNotFound: 94 | return 0 95 | default: 96 | logger.log(KeychainError.keychain(status)) 97 | return 0 98 | } 99 | guard let array = result as? NSArray else { 100 | logger.log(KeychainError.invalidResult) 101 | return 0 102 | } 103 | return array.count 104 | } 105 | 106 | /// Whether the store contains a saved object with the given id. 107 | /// 108 | /// > Note: Errors thrown while performing the security query will be ignored and logged out to console 109 | /// in DEBUG. 110 | /// 111 | /// - Parameter id: object id. 112 | /// - Returns: true if store contains an object with the given id. 113 | public func containsObject(withId id: Object.ID) -> Bool { 114 | let query = generateQuery(id: id) { 115 | $0[kSecMatchLimit] = kSecMatchLimitOne 116 | } 117 | let status = SecItemCopyMatching(query as CFDictionary, nil) 118 | switch status { 119 | case errSecSuccess: 120 | return true 121 | case errSecItemNotFound: 122 | return false 123 | default: 124 | logger.log(KeychainError.keychain(status)) 125 | return false 126 | } 127 | } 128 | 129 | /// Returns an object for the given id, or `nil` if no object is found. 130 | /// 131 | /// > Note: Errors thrown while performing the security query will be ignored and logged out to console 132 | /// in DEBUG. 133 | /// 134 | /// - Parameter id: object id. 135 | /// - Returns: object with the given id, or `nil` if no object with the given id is found. 136 | public func object(withId id: Object.ID) -> Object? { 137 | let query = generateQuery(id: id) { 138 | $0[kSecReturnData] = kCFBooleanTrue 139 | $0[kSecMatchLimit] = kSecMatchLimitOne 140 | } 141 | var result: AnyObject? 142 | SecItemCopyMatching(query as CFDictionary, &result) 143 | guard let data = result as? Data else { 144 | logger.log(KeychainError.invalidResult) 145 | return nil 146 | } 147 | do { 148 | return try decoder.decode(Object.self, from: data) 149 | } catch { 150 | logger.log(error) 151 | return nil 152 | } 153 | } 154 | 155 | /// Returns all objects in the store. 156 | /// 157 | /// > Note: Errors thrown while performing the security query will be ignored and logged out to console 158 | /// in DEBUG. 159 | /// 160 | /// - Returns: collection containing all objects stored in store. 161 | public func allObjects() -> [Object] { 162 | let query = generateQuery { 163 | $0[kSecReturnAttributes] = kCFBooleanTrue 164 | $0[kSecMatchLimit] = kSecMatchLimitAll 165 | } 166 | var result: AnyObject? 167 | let status = SecItemCopyMatching(query as CFDictionary, &result) 168 | switch status { 169 | case errSecSuccess: 170 | break 171 | case errSecItemNotFound: 172 | return [] 173 | default: 174 | logger.log(KeychainError.keychain(status)) 175 | return [] 176 | } 177 | guard let items = result as? [NSDictionary] else { 178 | logger.log(KeychainError.invalidResult) 179 | return [] 180 | } 181 | return items.compactMap(extractObject) 182 | } 183 | 184 | /// Removes object with the given id —if found—. 185 | /// - Parameter id: id for the object to be deleted. 186 | public func remove(withId id: Object.ID) throws { 187 | try sync { 188 | let query = generateQuery(id: id) 189 | let status = SecItemDelete(query as CFDictionary) 190 | switch status { 191 | case errSecSuccess, errSecItemNotFound: 192 | return 193 | default: 194 | throw KeychainError.keychain(status) 195 | } 196 | } 197 | } 198 | 199 | /// Removes objects with given ids —if found—. 200 | /// - Parameter ids: ids for the objects to be deleted. 201 | public func remove(withIds ids: [Object.ID]) throws { 202 | for id in ids where containsObject(withId: id) { 203 | try remove(withId: id) 204 | } 205 | } 206 | 207 | /// Removes all objects in store. 208 | public func removeAll() throws { 209 | try sync { 210 | let query = generateQuery() { 211 | $0[kSecMatchLimit] = kSecMatchLimitAll 212 | } 213 | let status = SecItemDelete(query as CFDictionary) 214 | switch status { 215 | case errSecSuccess, errSecItemNotFound: 216 | return 217 | default: 218 | throw KeychainError.keychain(status) 219 | } 220 | } 221 | } 222 | } 223 | 224 | // MARK: - Helpers 225 | 226 | extension MultiKeychainStore { 227 | func sync(action: () throws -> Void) rethrows { 228 | lock.lock() 229 | defer { lock.unlock() } 230 | try action() 231 | } 232 | 233 | func serviceName() -> String { 234 | return "com.omaralbeik.stores.multi.\(identifier)" 235 | } 236 | 237 | func key(for object: Object) -> String { 238 | return key(for: object.id) 239 | } 240 | 241 | func key(for id: Object.ID) -> String { 242 | return "\(identifier)-\(id)" 243 | } 244 | 245 | typealias Query = Dictionary 246 | 247 | func addOrUpdate(data: Data, for id: Object.ID) throws { 248 | let query = generateQuery(id: id) { 249 | $0[kSecValueData] = data 250 | $0[kSecAttrAccessible] = self.accessibility.attribute 251 | } 252 | let status = SecItemAdd(query as CFDictionary, nil) 253 | switch status { 254 | case errSecSuccess: 255 | return 256 | case errSecDuplicateItem: 257 | try update(data: data, for: id) 258 | default: 259 | throw KeychainError.keychain(status) 260 | } 261 | } 262 | 263 | func update(data: Data, for id: Object.ID) throws { 264 | let query = generateQuery(id: id) { 265 | $0[kSecAttrAccessible] = self.accessibility.attribute 266 | } 267 | let updateQuery = [kSecValueData: data] 268 | let status = SecItemUpdate( 269 | query as CFDictionary, 270 | updateQuery as CFDictionary 271 | ) 272 | switch status { 273 | case errSecSuccess: 274 | return 275 | default: 276 | throw KeychainError.keychain(status) 277 | } 278 | } 279 | 280 | func extractObject(from dictionary: NSDictionary) -> Object? { 281 | let query = generateQuery { 282 | $0[kSecReturnData] = kCFBooleanTrue 283 | $0[kSecMatchLimit] = kSecMatchLimitOne 284 | $0[kSecAttrAccount] = dictionary[kSecAttrAccount] 285 | } 286 | var result: AnyObject? 287 | SecItemCopyMatching(query as CFDictionary, &result) 288 | guard let data = result as? Data else { 289 | logger.log(KeychainError.invalidResult) 290 | return nil 291 | } 292 | do { 293 | return try decoder.decode(Object.self, from: data) 294 | } catch { 295 | logger.log(error) 296 | return nil 297 | } 298 | } 299 | 300 | func generateQuery( 301 | with mutator: ((inout Query) -> Void)? = nil 302 | ) -> Query { 303 | var query: Query = [ 304 | kSecClass: kSecClassGenericPassword, 305 | kSecAttrService: serviceName(), 306 | ] 307 | mutator?(&query) 308 | return query 309 | } 310 | 311 | func generateQuery( 312 | id: Object.ID, 313 | with mutator: ((inout Query) -> Void)? = nil 314 | ) -> Query { 315 | let key = key(for: id) 316 | return generateQuery { 317 | $0[kSecAttrAccount] = key 318 | mutator?(&$0) 319 | } 320 | } 321 | } 322 | 323 | #endif 324 | -------------------------------------------------------------------------------- /Sources/Keychain/SingleKeychainStore.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Security) 2 | 3 | import Blueprints 4 | import Foundation 5 | import Security 6 | 7 | /// The single keychain object store is an implementation of `SingleObjectStore` that offers a 8 | /// convenient and type-safe way to store and retrieve a single `Codable` object securely in and from the keychain. 9 | /// 10 | /// > Thread safety: This is a thread-safe class. 11 | public final class SingleKeychainStore: SingleObjectStore { 12 | let encoder = JSONEncoder() 13 | let decoder = JSONDecoder() 14 | let lock = NSRecursiveLock() 15 | let logger = Logger() 16 | let key = "object" 17 | 18 | /// Store's unique identifier. 19 | /// 20 | /// Note: This is used to create the underlying service name `kSecAttrService` where the object is 21 | /// stored. 22 | /// 23 | /// `com.omaralbeik.stores.single.{identifier}` 24 | /// 25 | /// > Important: Never use the same identifier for multiple stores with different object types, 26 | /// doing this might cause stores to have corrupted data. 27 | public let identifier: String 28 | 29 | /// Store's accessibility level. 30 | public let accessibility: KeychainAccessibility 31 | 32 | /// Initialize store. 33 | /// 34 | /// Note: This is used to create the underlying service name `kSecAttrService` where the object is 35 | /// stored. 36 | /// 37 | /// `com.omaralbeik.stores.single.{identifier}` 38 | /// 39 | /// > Important: Never use the same identifier for multiple stores with different object types, 40 | /// doing this might cause stores to have corrupted data. 41 | /// 42 | /// - Parameters: 43 | /// - identifier: store's unique identifier. 44 | /// - accessibility: store's accessibility level. Defaults to `.whenUnlockedThisDeviceOnly` 45 | /// 46 | /// > Note: Creating a store is a fairly cheap operation, you can create multiple instances of the same store 47 | /// with same parameters. 48 | required public init( 49 | identifier: String, 50 | accessibility: KeychainAccessibility = .whenUnlockedThisDeviceOnly 51 | ) { 52 | self.identifier = identifier 53 | self.accessibility = accessibility 54 | } 55 | 56 | // MARK: - SingleObjectStore 57 | 58 | /// Saves an object to store. 59 | /// - Parameter object: object to be saved. 60 | /// - Throws error: any error that might occur during the save operation. 61 | public func save(_ object: Object) throws { 62 | try sync { 63 | let data = try encoder.encode(object) 64 | try addOrUpdate(data) 65 | } 66 | } 67 | 68 | /// Returns the object saved in the store. 69 | /// 70 | /// > Note: Errors thrown while performing the security query will be ignored and logged out to console 71 | /// in DEBUG. 72 | /// 73 | /// - Returns: object saved in the store. `nil` if no object is saved in store. 74 | public func object() -> Object? { 75 | let query = generateQuery { 76 | $0[kSecReturnData] = kCFBooleanTrue 77 | $0[kSecMatchLimit] = kSecMatchLimitOne 78 | } 79 | var result: AnyObject? 80 | let status = SecItemCopyMatching(query as CFDictionary, &result) 81 | switch status { 82 | case errSecSuccess: 83 | break 84 | default: 85 | logger.log(KeychainError.keychain(status)) 86 | return nil 87 | } 88 | guard let data = result as? Data else { 89 | logger.log(KeychainError.invalidResult) 90 | return nil 91 | } 92 | do { 93 | return try decoder.decode(Object.self, from: data) 94 | } catch { 95 | logger.log(error) 96 | return nil 97 | } 98 | } 99 | 100 | /// Removes any saved object in the store. 101 | public func remove() throws { 102 | try sync { 103 | let query = generateQuery() 104 | let status = SecItemDelete(query as CFDictionary) 105 | switch status { 106 | case errSecSuccess, errSecItemNotFound: 107 | return 108 | default: 109 | throw KeychainError.keychain(status) 110 | } 111 | } 112 | } 113 | } 114 | 115 | // MARK: - Helpers 116 | 117 | extension SingleKeychainStore { 118 | func sync(action: () throws -> Void) rethrows { 119 | lock.lock() 120 | defer { lock.unlock() } 121 | try action() 122 | } 123 | 124 | typealias Query = Dictionary 125 | 126 | func addOrUpdate(_ data: Data) throws { 127 | let query = generateQuery { $0[kSecValueData] = data } 128 | let status = SecItemAdd(query as CFDictionary, nil) 129 | switch status { 130 | case errSecSuccess: 131 | return 132 | case errSecDuplicateItem: 133 | try update(data) 134 | default: 135 | throw KeychainError.keychain(status) 136 | } 137 | } 138 | 139 | func update(_ data: Data) throws { 140 | let query = generateQuery { $0[kSecValueData] = data } 141 | let status = SecItemUpdate( 142 | query as CFDictionary, 143 | query as CFDictionary 144 | ) 145 | switch status { 146 | case errSecSuccess: 147 | return 148 | default: 149 | throw KeychainError.keychain(status) 150 | } 151 | } 152 | 153 | func serviceName() -> String { 154 | return "com.omaralbeik.stores.single.\(identifier)" 155 | } 156 | 157 | func generateQuery(with mutator: ((inout Query) -> Void)? = nil) -> Query { 158 | var query: Query = [ 159 | kSecClass: kSecClassGenericPassword, 160 | kSecAttrService: serviceName(), 161 | kSecAttrAccessible: accessibility.attribute, 162 | kSecAttrAccount: key, 163 | ] 164 | mutator?(&query) 165 | return query 166 | } 167 | } 168 | 169 | #endif 170 | -------------------------------------------------------------------------------- /Sources/Stores/Documentation.docc/Articles/Installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | How to add Stores to your project 4 | 5 | --- 6 | 7 | You can add Stores to an Xcode project by adding it as a package dependency. 8 | 9 | 1. From the **File** menu, select **Add Packages...** 10 | 2. Enter "**https://github.com/omaralbeik/Stores**" into the package repository URL text field 11 | 3. Depending on what you want to use Stores for, add the following target(s) to your app: 12 | - `Stores`: the entire library with all stores. 13 | - `UserDefaultsStore`: use User Defaults to persist data. 14 | - `FileSystemStore`: persist data by saving it to the file system. 15 | - `CoreDataStore`: use a Core Data database to persist data. 16 | - `KeychainStore`: persist data securely in the Keychain. 17 | - `Blueprints`: protocols only, this is a good option if you do not want to use any of the provided stores and build yours. 18 | - `StoresTestUtils` to use the fakes in your tests target. 19 | -------------------------------------------------------------------------------- /Sources/Stores/Documentation.docc/Articles/Motivation.md: -------------------------------------------------------------------------------- 1 | # Motivation 2 | 3 | Why was Stores created? 4 | 5 | --- 6 | 7 | When working on an app that uses the [composable architecture](https://github.com/pointfreeco/swift-composable-architecture), I fell in love with how reducers use an environment type that holds any dependencies the feature needs, such as API clients, analytics clients, and more. 8 | 9 | **Stores** tries to abstract the concept of a store and provide various implementations that can be injected in such environment and swapped easily when running tests or based on a remote flag. 10 | 11 | It all boils down to the two protocols ``SingleObjectStore`` and ``MultiObjectStore`` defined in the **Blueprints** layer, which provide the abstract concepts of stores that can store a single or multiple objects of a generic `Codable` type. 12 | 13 | The two protocols are then implemented in the different modules as explained in the chart below: 14 | 15 | ![Modules chart](https://raw.githubusercontent.com/omaralbeik/Stores/main/Assets/stores-light.png) 16 | -------------------------------------------------------------------------------- /Sources/Stores/Documentation.docc/Articles/Usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | How to use Stores in your project 4 | 5 | --- 6 | 7 | Let's say you have a `User` struct defined as below: 8 | 9 | ```swift 10 | struct User: Codable { 11 | let id: Int 12 | let name: String 13 | } 14 | ``` 15 | 16 | Here's how you store it using Stores: 17 | 18 | ## 1. Conform to `Identifiable` 19 | 20 | This is required to make the store associate an object with its id. 21 | 22 | ```swift 23 | extension User: Identifiable {} 24 | ``` 25 | 26 | The property `id` can be on any `Hashable` type. [Read more](https://developer.apple.com/documentation/swift/identifiable). 27 | 28 | ## 2. Create a store 29 | 30 | Stores comes pre-equipped with the following stores: 31 | 32 | #### a. UserDefaults 33 | 34 | - ``MultiUserDefaultsStore`` 35 | - ``SingleUserDefaultsStore`` 36 | 37 | ```swift 38 | // Store for multiple objects 39 | let store = MultiUserDefaultsStore(identifier: "users") 40 | 41 | // Store for a single object 42 | let store = SingleUserDefaultsStore(identifier: "users") 43 | ``` 44 | 45 | #### b. FileSystem 46 | 47 | - ``MultiFileSystemStore`` 48 | - ``SingleFileSystemStore`` 49 | 50 | ```swift 51 | // Store for multiple objects 52 | let store = MultiFileSystemStore(identifier: "users") 53 | 54 | // Store for a single object 55 | let store = SingleFileSystemStore(identifier: "users") 56 | ``` 57 | 58 | #### c. CoreData 59 | 60 | - ``MultiCoreDataStore`` 61 | - ``SingleCoreDataStore`` 62 | 63 | ```swift 64 | // Store for multiple objects 65 | let store = MultiCoreDataStore(identifier: "users") 66 | 67 | // Store for a single object 68 | let store = SingleCoreDataStore(identifier: "users") 69 | ``` 70 | 71 | #### d. Keychain 72 | 73 | - ``MultiKeychainStore`` 74 | - ``SingleKeychainStore`` 75 | 76 | ```swift 77 | // Store for multiple objects 78 | let store = MultiKeychainStore(identifier: "users") 79 | 80 | // Store for a single object 81 | let store = SingleKeychainStore(identifier: "users") 82 | ``` 83 | 84 | #### e. Fakes (for testing) 85 | 86 | ```swift 87 | // Store for multiple objects 88 | let store = MultiObjectStoreFake() 89 | 90 | // Store for a single object 91 | let store = SingleObjectStoreFake() 92 | ``` 93 | 94 | You can create a custom store by implementing the protocols in [`Blueprints`](https://github.com/omaralbeik/Stores/tree/main/Sources/Blueprints) 95 | 96 | #### f. Realm 97 | 98 | ```swift 99 | // Store for multiple objects 100 | final class MultiRealmStore: MultiObjectStore { 101 | // ... 102 | } 103 | 104 | // Store for a single object 105 | final class SingleRealmStore: SingleObjectStore { 106 | // ... 107 | } 108 | ``` 109 | 110 | #### g. SQLite 111 | 112 | ```swift 113 | // Store for multiple objects 114 | final class MultiSQLiteStore: MultiObjectStore { 115 | // ... 116 | } 117 | 118 | // Store for a single object 119 | final class SingleSQLiteStore: SingleObjectStore { 120 | // ... 121 | } 122 | ``` 123 | 124 | ## 3. Inject the store 125 | 126 | Assuming we have a view model that uses a store to fetch data: 127 | 128 | ```swift 129 | struct UsersViewModel { 130 | let store: AnyMultiObjectStore 131 | } 132 | ``` 133 | 134 | Inject the appropriate store implementation: 135 | 136 | ```swift 137 | let coreDataStore = MultiCoreDataStore(databaseName: "users") 138 | let prodViewModel = UsersViewModel(store: coreDataStore.eraseToAnyStore()) 139 | ``` 140 | 141 | or: 142 | 143 | ```swift 144 | let fakeStore = MultiObjectStoreFake() 145 | let testViewModel = UsersViewModel(store: fakeStore.eraseToAnyStore()) 146 | ``` 147 | 148 | ## 4. Save, retrieve, update, or remove objects 149 | 150 | ```swift 151 | let john = User(id: 1, name: "John Appleseed") 152 | 153 | // Save an object to a store 154 | try store.save(john) 155 | 156 | // Save an array of objects to a store 157 | try store.save([jane, steve, jessica]) 158 | 159 | // Get an object from store 160 | let user = store.object(withId: 1) 161 | 162 | // Get an array of object in store 163 | let users = store.objects(withIds: [1, 2, 3]) 164 | 165 | // Get an array of all objects in store 166 | let allUsers = store.allObjects() 167 | 168 | // Check if store has an object 169 | print(store.containsObject(withId: 10)) // false 170 | 171 | // Remove an object from a store 172 | try store.remove(withId: 1) 173 | 174 | // Remove multiple objects from a store 175 | try store.remove(withIds: [1, 2, 3]) 176 | 177 | // Remove all objects in a store 178 | try store.removeAll() 179 | ``` 180 | -------------------------------------------------------------------------------- /Sources/Stores/Documentation.docc/Stores.md: -------------------------------------------------------------------------------- 1 | # ``Stores`` 2 | 3 | A typed key-value storage solution to store Codable types in various persistence layers like User Defaults, File System, Core Data, Keychain, and more with a few lines of code! 4 | 5 | ## Overview 6 | 7 | Stores tries to abstract the concept of a store and provide various implementations that can be injected in such environment and swapped easily when running tests or based on a remote flag. 8 | 9 | ## Topics 10 | 11 | ### Essentials 12 | 13 | - 14 | - 15 | - 16 | -------------------------------------------------------------------------------- /Sources/Stores/Stores.swift: -------------------------------------------------------------------------------- 1 | @_exported import Blueprints 2 | 3 | #if canImport(UserDefaultsStore) 4 | @_exported import UserDefaultsStore 5 | #endif 6 | 7 | #if canImport(FileSystemStore) 8 | @_exported import FileSystemStore 9 | #endif 10 | 11 | #if canImport(CoreData) && canImport(CoreDataStore) 12 | @_exported import CoreDataStore 13 | #endif 14 | 15 | #if canImport(Security) && canImport(KeychainStore) 16 | @_exported import KeychainStore 17 | #endif 18 | -------------------------------------------------------------------------------- /Sources/UserDefaults/Documentation.docc/UserDefaults.md: -------------------------------------------------------------------------------- 1 | # ``UserDefaultsStore`` 2 | 3 | An implementation of **Blueprints** that offers a convenient and type-safe way to store and retrieve objects using User Defaults. -------------------------------------------------------------------------------- /Sources/UserDefaults/MultiUserDefaultsStore.swift: -------------------------------------------------------------------------------- 1 | import Blueprints 2 | import Foundation 3 | 4 | /// The multi object user defaults store is an implementation of `MultiObjectStore` that offers a 5 | /// convenient and type-safe way to store and retrieve a collection of `Codable` and `Identifiable` 6 | /// objects in a user defaults suite. 7 | /// 8 | /// > Thread safety: This is a thread-safe class. 9 | public final class MultiUserDefaultsStore< 10 | Object: Codable & Identifiable 11 | >: MultiObjectStore { 12 | let store: UserDefaults 13 | let encoder = JSONEncoder() 14 | let decoder = JSONDecoder() 15 | let lock = NSRecursiveLock() 16 | 17 | /// Store's suite name. 18 | /// 19 | /// > Note: This is used as the suite name for the underlying UserDefaults store. 20 | /// 21 | /// > Important: Never use the same suite name for multiple stores with different object types, 22 | /// doing this might cause stores to have corrupted data. 23 | public let suiteName: String 24 | 25 | /// Initialize store with given suite name. 26 | /// 27 | /// > Note: This is used as the suite name for the underlying UserDefaults store. Using an invalid name like 28 | /// `default` will cause a precondition failure. 29 | /// 30 | /// > Important: Never use the same suite name for multiple stores with different object types, 31 | /// doing this might cause stores to have corrupted data. 32 | /// 33 | /// - Parameter suiteName: store's suite name. 34 | /// 35 | /// > Note: Creating a store is a fairly cheap operation, you can create multiple instances of the same store 36 | /// with a same suiteName. 37 | public required init(suiteName: String) { 38 | guard let store = UserDefaults(suiteName: suiteName) else { 39 | preconditionFailure( 40 | "Can not create store with suiteName: '\(suiteName)'." 41 | ) 42 | } 43 | self.suiteName = suiteName 44 | self.store = store 45 | } 46 | 47 | // MARK: - Deprecated 48 | 49 | /// Deprecated: Store's unique identifier. 50 | @available(*, deprecated, renamed: "suiteName") 51 | public var identifier: String { suiteName } 52 | 53 | /// Deprecated: Initialize store with given identifier. 54 | @available(*, deprecated, renamed: "init(suiteName:)") 55 | public required init(identifier: String) { 56 | guard let store = UserDefaults(suiteName: identifier) else { 57 | preconditionFailure( 58 | "Can not create store with identifier: '\(identifier)'." 59 | ) 60 | } 61 | self.suiteName = identifier 62 | self.store = store 63 | } 64 | 65 | // MARK: - MultiObjectStore 66 | 67 | /// Saves an object to store. 68 | /// - Parameter object: object to be saved. 69 | /// - Throws error: any error that might occur during the save operation. 70 | public func save(_ object: Object) throws { 71 | try sync { 72 | let data = try encoder.encode(object) 73 | let key = key(for: object) 74 | if store.object(forKey: key) == nil { 75 | increaseCounter() 76 | } 77 | store.set(data, forKey: key) 78 | } 79 | } 80 | 81 | /// Saves an array of objects to store. 82 | /// - Parameter objects: array of objects to be saved. 83 | /// - Throws error: any error that might occur during the save operation. 84 | public func save(_ objects: [Object]) throws { 85 | try sync { 86 | let pairs = try objects.map { object -> (key: String, data: Data) in 87 | let key = key(for: object) 88 | let data = try encoder.encode(object) 89 | return (key, data) 90 | } 91 | pairs.forEach { pair in 92 | if store.object(forKey: pair.key) == nil { 93 | increaseCounter() 94 | } 95 | store.set(pair.data, forKey: pair.key) 96 | } 97 | } 98 | } 99 | 100 | /// The number of all objects stored in store. 101 | public var objectsCount: Int { 102 | return store.integer(forKey: counterKey) 103 | } 104 | 105 | /// Whether the store contains a saved object with the given id. 106 | /// - Parameter id: object id. 107 | /// - Returns: true if store contains an object with the given id. 108 | public func containsObject(withId id: Object.ID) -> Bool { 109 | return object(withId: id) != nil 110 | } 111 | 112 | /// Returns an object for the given id, or `nil` if no object is found. 113 | /// - Parameter id: object id. 114 | /// - Returns: object with the given id, or `nil` if no object with the given id is found. 115 | public func object(withId id: Object.ID) -> Object? { 116 | guard let data = store.data(forKey: key(for: id)) else { return nil } 117 | return try? decoder.decode(Object.self, from: data) 118 | } 119 | 120 | /// Returns all objects in the store. 121 | /// - Returns: collection containing all objects stored in store. 122 | public func allObjects() -> [Object] { 123 | guard objectsCount > 0 else { return [] } 124 | return store.dictionaryRepresentation().keys.compactMap { key -> Object? in 125 | guard isObjectKey(key) else { return nil } 126 | guard let data = store.data(forKey: key) else { return nil } 127 | return try? decoder.decode(Object.self, from: data) 128 | } 129 | } 130 | 131 | /// Removes object with the given id —if found—. 132 | /// - Parameter id: id for the object to be deleted. 133 | public func remove(withId id: Object.ID) { 134 | sync { 135 | guard containsObject(withId: id) else { return } 136 | store.removeObject(forKey: key(for: id)) 137 | decreaseCounter() 138 | } 139 | } 140 | 141 | /// Removes objects with given ids —if found—. 142 | /// - Parameter ids: ids for the objects to be deleted. 143 | public func remove(withIds ids: [Object.ID]) { 144 | sync { 145 | ids.forEach(remove(withId:)) 146 | } 147 | } 148 | 149 | /// Removes all objects in store. 150 | /// 151 | /// > Note: This removes the entire persistent domain for the underlying UserDefaults store. 152 | /// 153 | public func removeAll() { 154 | sync { 155 | store.removePersistentDomain(forName: suiteName) 156 | store.removeSuite(named: suiteName) 157 | } 158 | } 159 | } 160 | 161 | // MARK: - Helpers 162 | 163 | extension MultiUserDefaultsStore { 164 | func sync(action: () throws -> Void) rethrows { 165 | lock.lock() 166 | defer { lock.unlock() } 167 | try action() 168 | } 169 | 170 | func increaseCounter() { 171 | let currentCount = store.integer(forKey: counterKey) 172 | store.set(currentCount + 1, forKey: counterKey) 173 | } 174 | 175 | func decreaseCounter() { 176 | let currentCount = store.integer(forKey: counterKey) 177 | if currentCount - 1 >= 0 { 178 | store.set(currentCount - 1, forKey: counterKey) 179 | } 180 | } 181 | 182 | var counterKey: String { 183 | return "\(suiteName)-count" 184 | } 185 | 186 | func key(for object: Object) -> String { 187 | return key(for: object.id) 188 | } 189 | 190 | func key(for id: Object.ID) -> String { 191 | return "\(suiteName)-\(id)" 192 | } 193 | 194 | func isObjectKey(_ key: String) -> Bool { 195 | return key.starts(with: "\(suiteName)-") 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /Sources/UserDefaults/SingleUserDefaultsStore.swift: -------------------------------------------------------------------------------- 1 | import Blueprints 2 | import Foundation 3 | 4 | /// The single user defaults object store is an implementation of `SingleObjectStore` that offers a 5 | /// convenient and type-safe way to store and retrieve a single `Codable` object to a user defaults suite. 6 | /// 7 | /// > Thread safety: This is a thread-safe class. 8 | public final class SingleUserDefaultsStore: SingleObjectStore { 9 | let store: UserDefaults 10 | let encoder = JSONEncoder() 11 | let decoder = JSONDecoder() 12 | let lock = NSRecursiveLock() 13 | let key = "object" 14 | 15 | /// Store's suite name. 16 | /// 17 | /// > Note: This is used as the suite name for the underlying UserDefaults store. 18 | /// 19 | /// > Important: Never use the same suite name for multiple stores with different object types, 20 | /// doing this might cause stores to have corrupted data. 21 | public let suiteName: String 22 | 23 | /// Initialize store with given suite name. 24 | /// 25 | /// > Note: This is used as the suite name for the underlying UserDefaults store. Using an invalid name like 26 | /// `default` will cause a precondition failure. 27 | /// 28 | /// > Important: Never use the same suite name for multiple stores with different object types, 29 | /// doing this might cause stores to have corrupted data. 30 | /// 31 | /// - Parameter suiteName: store's suite name. 32 | /// 33 | /// > Note: Creating a store is a fairly cheap operation, you can create multiple instances of the same store 34 | /// with a same suiteName. 35 | public required init(suiteName: String) { 36 | guard let store = UserDefaults(suiteName: suiteName) else { 37 | preconditionFailure( 38 | "Can not create store with suiteName: '\(suiteName)'." 39 | ) 40 | } 41 | self.suiteName = suiteName 42 | self.store = store 43 | } 44 | 45 | 46 | // MARK: - Deprecated 47 | 48 | /// Deprecated: Store's unique identifier. 49 | @available(*, deprecated, renamed: "suiteName") 50 | public var identifier: String { suiteName } 51 | 52 | /// Deprecated: Initialize store with given identifier. 53 | @available(*, deprecated, renamed: "init(suiteName:)") 54 | public required init(identifier: String) { 55 | guard let store = UserDefaults(suiteName: identifier) else { 56 | preconditionFailure( 57 | "Can not create store with identifier: '\(identifier)'." 58 | ) 59 | } 60 | self.suiteName = identifier 61 | self.store = store 62 | } 63 | 64 | // MARK: - SingleObjectStore 65 | 66 | /// Saves an object to store. 67 | /// - Parameter object: object to be saved. 68 | /// - Throws error: any error that might occur during the save operation. 69 | public func save(_ object: Object) throws { 70 | try sync { 71 | let data = try encoder.encode(object) 72 | store.set(data, forKey: key) 73 | } 74 | } 75 | 76 | /// Returns the object saved in the store 77 | /// - Returns: object saved in the store. `nil` if no object is saved in store. 78 | public func object() -> Object? { 79 | guard let data = store.data(forKey: key) else { return nil } 80 | return try? decoder.decode(Object.self, from: data) 81 | } 82 | 83 | /// Removes any saved object in the store. 84 | /// 85 | /// > Note: This removes the entire persistent domain for the underlying UserDefaults store. 86 | /// 87 | public func remove() { 88 | sync { 89 | store.removePersistentDomain(forName: suiteName) 90 | store.removeSuite(named: suiteName) 91 | } 92 | } 93 | } 94 | 95 | // MARK: - Helpers 96 | 97 | extension SingleUserDefaultsStore { 98 | func sync(action: () throws -> Void) rethrows { 99 | lock.lock() 100 | defer { lock.unlock() } 101 | try action() 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Tests/Blueprints/AnyMultiObjectStoreTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Blueprints 2 | @testable import TestUtils 3 | 4 | import Foundation 5 | import XCTest 6 | 7 | final class AnyMultiObjectStoreTests: XCTestCase { 8 | func testSave() throws { 9 | let stores = createFreshUsersStores() 10 | try stores.anyStore.save(.ahmad) 11 | XCTAssertEqual(stores.fakeStore.dictionary, [User.ahmad.id: .ahmad]) 12 | } 13 | 14 | func testSaveOptional() throws { 15 | let stores = createFreshUsersStores() 16 | try stores.anyStore.save(nil) 17 | XCTAssert(stores.fakeStore.dictionary.isEmpty) 18 | } 19 | 20 | func testSaveObjects() throws { 21 | let stores = createFreshUsersStores() 22 | try stores.anyStore.save([.ahmad, .dalia]) 23 | XCTAssertEqual( 24 | stores.fakeStore.dictionary, 25 | [User.ahmad.id: .ahmad, User.dalia.id: .dalia] 26 | ) 27 | } 28 | 29 | func testObjectsCount() throws { 30 | let stores = createFreshUsersStores() 31 | try stores.fakeStore.save(.dalia) 32 | XCTAssertEqual(stores.anyStore.objectsCount, 1) 33 | } 34 | 35 | func testContainsObject() throws { 36 | let stores = createFreshUsersStores() 37 | try stores.fakeStore.save(.dalia) 38 | XCTAssert(stores.anyStore.containsObject(withId: User.dalia.id)) 39 | XCTAssertFalse(stores.anyStore.containsObject(withId: User.ahmad.id)) 40 | } 41 | 42 | func testObject() throws { 43 | let stores = createFreshUsersStores() 44 | try stores.fakeStore.save(.dalia) 45 | XCTAssertEqual(stores.anyStore.object(withId: User.dalia.id), .dalia) 46 | XCTAssertNil(stores.anyStore.object(withId: User.ahmad.id)) 47 | } 48 | 49 | func testObjects() throws { 50 | let stores = createFreshUsersStores() 51 | try stores.fakeStore.save(.dalia) 52 | XCTAssertEqual( 53 | stores.anyStore.objects(withIds: [User.dalia.id]), 54 | [.dalia] 55 | ) 56 | XCTAssert(stores.anyStore.objects(withIds: [User.ahmad.id]).isEmpty) 57 | } 58 | 59 | func testAllObject() throws { 60 | let stores = createFreshUsersStores() 61 | try stores.fakeStore.save([.dalia, .kareem]) 62 | XCTAssertEqual(Set(stores.anyStore.allObjects()), [.dalia, .kareem]) 63 | } 64 | 65 | func testRemove() throws { 66 | let stores = createFreshUsersStores() 67 | try stores.fakeStore.save([.ahmad, .dalia, .kareem]) 68 | try stores.anyStore.remove(withId: User.kareem.id) 69 | XCTAssertEqual( 70 | stores.fakeStore.dictionary, 71 | [User.ahmad.id: .ahmad, User.dalia.id: .dalia] 72 | ) 73 | 74 | try stores.anyStore.remove(withIds: [User.ahmad.id, User.dalia.id]) 75 | XCTAssert(stores.fakeStore.dictionary.isEmpty) 76 | } 77 | 78 | func testRemoveAll() throws { 79 | let stores = createFreshUsersStores() 80 | try stores.fakeStore.save([.dalia, .kareem]) 81 | try stores.anyStore.removeAll() 82 | XCTAssert(stores.fakeStore.dictionary.isEmpty) 83 | } 84 | } 85 | 86 | // MARK: - Helpers 87 | 88 | private extension AnyMultiObjectStoreTests { 89 | typealias Stores = ( 90 | fakeStore: MultiObjectStoreFake, 91 | anyStore: AnyMultiObjectStore 92 | ) 93 | 94 | func createFreshUsersStores() -> Stores { 95 | let fakeStore = MultiObjectStoreFake() 96 | let anyStore = fakeStore.eraseToAnyStore() 97 | XCTAssert(anyStore.eraseToAnyStore() === anyStore) 98 | return (fakeStore, anyStore) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Tests/Blueprints/AnySingleObjectStoreTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Blueprints 2 | @testable import TestUtils 3 | 4 | import Foundation 5 | import XCTest 6 | 7 | final class AnySingleObjectStoreTests: XCTestCase { 8 | func testSave() throws { 9 | let stores = createFreshUserStores() 10 | try stores.anyStore.save(.ahmad) 11 | XCTAssertEqual(stores.fakeStore.underlyingObject, .ahmad) 12 | } 13 | 14 | func testSaveOptional() throws { 15 | let stores = createFreshUserStores() 16 | try stores.anyStore.save(nil) 17 | XCTAssertNil(stores.fakeStore.underlyingObject) 18 | } 19 | 20 | func testObject() throws { 21 | let stores = createFreshUserStores() 22 | try stores.fakeStore.save(.dalia) 23 | XCTAssertEqual(stores.anyStore.object(), .dalia) 24 | } 25 | 26 | func testRemove() throws { 27 | let stores = createFreshUserStores() 28 | try stores.anyStore.save(.dalia) 29 | try stores.anyStore.remove() 30 | XCTAssertNil(stores.fakeStore.underlyingObject) 31 | } 32 | } 33 | 34 | // MARK: - Helpers 35 | 36 | private extension AnySingleObjectStoreTests { 37 | typealias Stores = ( 38 | fakeStore: SingleObjectStoreFake, 39 | anyStore: AnySingleObjectStore 40 | ) 41 | 42 | func createFreshUserStores() -> Stores { 43 | let fakeStore = SingleObjectStoreFake() 44 | let anyStore = fakeStore.eraseToAnyStore() 45 | XCTAssert(anyStore.eraseToAnyStore() === anyStore) 46 | return (fakeStore, anyStore) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/Blueprints/MultiObjectStoreTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Blueprints 2 | @testable import TestUtils 3 | 4 | import Foundation 5 | import XCTest 6 | 7 | final class MultiObjectStoreTests: XCTestCase { 8 | func testSaveOptionalObject() throws { 9 | let store = createFreshUsersStore() 10 | let user: User? = .ahmad 11 | try store.save(user) 12 | try store.save(nil) 13 | XCTAssertEqual(store.allObjects(), [.ahmad]) 14 | XCTAssertEqual(store.dictionary, [User.ahmad.id: .ahmad]) 15 | } 16 | 17 | func testSaveObjects() throws { 18 | let store = createFreshUsersStore() 19 | try store.save([.ahmad, .dalia]) 20 | XCTAssertEqual(Set(store.allObjects()), [.ahmad, .dalia]) 21 | XCTAssertEqual(store.dictionary, [ 22 | User.ahmad.id: .ahmad, 23 | User.dalia.id: .dalia, 24 | ]) 25 | } 26 | 27 | func testSaveObjectsSet() throws { 28 | let store = createFreshUsersStore() 29 | let users: Set = [.ahmad, .dalia] 30 | try store.save(users) 31 | XCTAssertEqual(Set(store.allObjects()), users) 32 | XCTAssertEqual(store.dictionary, [ 33 | User.ahmad.id: .ahmad, 34 | User.dalia.id: .dalia, 35 | ]) 36 | } 37 | 38 | func testObjects() throws { 39 | let store = createFreshUsersStore() 40 | try store.save([.ahmad, .dalia]) 41 | let users = store.objects(withIds: [User.ahmad.id, User.invalid.id]) 42 | XCTAssertEqual(users, [.ahmad]) 43 | } 44 | 45 | func testRemoveObjects() throws { 46 | let store = createFreshUsersStore() 47 | try store.save(.ahmad) 48 | try store.remove(withIds: [User.ahmad.id, User.dalia.id]) 49 | XCTAssertEqual(store.objectsCount, 0) 50 | XCTAssert(store.dictionary.isEmpty) 51 | } 52 | } 53 | 54 | // MARK: - Helpers 55 | 56 | private extension MultiObjectStoreTests { 57 | func createFreshUsersStore() -> MultiObjectStoreFake { 58 | return .init() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Tests/Blueprints/SingleObjectStoreTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Blueprints 2 | @testable import TestUtils 3 | 4 | import Foundation 5 | import XCTest 6 | 7 | final class SingleObjectStoreTests: XCTestCase { 8 | func testSaveOptionalObject() throws { 9 | let store = createFreshUserStore() 10 | let user: User? = .ahmad 11 | 12 | try store.save(user) 13 | XCTAssertEqual(store.object(), .ahmad) 14 | XCTAssertEqual(store.underlyingObject, .ahmad) 15 | 16 | try store.save(nil) 17 | XCTAssertNil(store.object()) 18 | XCTAssertNil(store.underlyingObject) 19 | } 20 | } 21 | 22 | // MARK: - Helpers 23 | 24 | private extension SingleObjectStoreTests { 25 | func createFreshUserStore() -> SingleObjectStoreFake { 26 | return .init() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/CoreData/DatabaseTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreData) 2 | 3 | @testable import CoreDataStore 4 | 5 | import CoreData 6 | import Foundation 7 | import XCTest 8 | 9 | final class DatabaseTests: XCTestCase { 10 | func testModel() { 11 | let model = Database.entityModel 12 | XCTAssertEqual(model.entities.count, 1) 13 | 14 | let entity = model.entities[0] 15 | let properties = entity.properties.compactMap { 16 | $0 as? NSAttributeDescription 17 | } 18 | XCTAssertEqual(properties.count, 3) 19 | 20 | let sortedProperties = properties.sorted { $0.name < $1.name } 21 | 22 | XCTAssertEqual(sortedProperties[0].name, "data") 23 | XCTAssertEqual(sortedProperties[0].attributeType, .binaryDataAttributeType) 24 | XCTAssertFalse(sortedProperties[0].isOptional) 25 | 26 | XCTAssertEqual(sortedProperties[1].name, "id") 27 | XCTAssertEqual(sortedProperties[1].attributeType, .stringAttributeType) 28 | XCTAssertFalse(sortedProperties[1].isOptional) 29 | 30 | XCTAssertEqual(sortedProperties[2].name, "lastUpdated") 31 | XCTAssertEqual(sortedProperties[2].attributeType, .dateAttributeType) 32 | XCTAssertFalse(sortedProperties[2].isOptional) 33 | } 34 | 35 | func testEntitiesFetchRequest() { 36 | let database = Database(name: "test", container: Container(name: "test")) 37 | let request = database.entitiesFetchRequest() 38 | XCTAssertEqual(request.entityName, "Entity") 39 | XCTAssertEqual( 40 | request.sortDescriptors, 41 | [.init(key: "lastUpdated", ascending: true)] 42 | ) 43 | } 44 | 45 | func testEntityFetchRequest() { 46 | let database = Database(name: "test", container: Container(name: "test")) 47 | let id = "test-id" 48 | let request = database.entityFetchRequest(id) 49 | XCTAssertEqual(request.entityName, "Entity") 50 | XCTAssertEqual( 51 | request.predicate, 52 | NSPredicate(format: "id == %@", id) 53 | ) 54 | XCTAssertEqual(request.fetchLimit, 1) 55 | } 56 | } 57 | 58 | final class TestContainer: NSPersistentContainer { 59 | override class func defaultDirectoryURL() -> URL { 60 | super.defaultDirectoryURL().appendingPathComponent("Test") 61 | } 62 | } 63 | 64 | #endif 65 | -------------------------------------------------------------------------------- /Tests/CoreData/MultiCoreDataStoreTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreData) 2 | 3 | @testable import Blueprints 4 | @testable import CoreDataStore 5 | @testable import TestUtils 6 | 7 | import CoreData 8 | import Foundation 9 | import XCTest 10 | 11 | final class MultiCoreDataStoreTests: XCTestCase { 12 | private var store: MultiCoreDataStore? 13 | 14 | override func tearDownWithError() throws { 15 | try store?.removeAll() 16 | } 17 | 18 | func testCreateStore() { 19 | let databaseName = UUID().uuidString 20 | let store = createFreshUsersStore(databaseName: databaseName) 21 | XCTAssertEqual(store.databaseName, databaseName) 22 | } 23 | 24 | func testCreateStoreWithCustomContainer() { 25 | let databaseName = UUID().uuidString 26 | let store = MultiCoreDataStore(databaseName: databaseName) { model in 27 | TestContainer(name: databaseName, managedObjectModel: model) 28 | } 29 | self.store = store 30 | 31 | let path = store.databaseURL?.pathComponents.suffix(2).joined(separator: "/") 32 | let expectedPath = "Test/\(databaseName).sqlite" 33 | XCTAssertEqual(path, expectedPath) 34 | } 35 | 36 | func testDatabaseURL() { 37 | let store = createFreshUsersStore() 38 | let path = store.databaseURL?.pathComponents.suffix(2) 39 | XCTAssertEqual(path, ["CoreDataStore", "\(store.databaseName).sqlite"]) 40 | } 41 | 42 | func testSaveObject() throws { 43 | let store = createFreshUsersStore() 44 | try store.save(.ahmad) 45 | XCTAssertEqual(store.objectsCount, 1) 46 | XCTAssertEqual(store.allObjects(), [.ahmad]) 47 | } 48 | 49 | func testSaveOptional() throws { 50 | let store = createFreshUsersStore() 51 | 52 | try store.save(nil) 53 | XCTAssertEqual(store.objectsCount, 0) 54 | 55 | try store.save(.ahmad) 56 | XCTAssertEqual(store.objectsCount, 1) 57 | XCTAssertEqual(store.allObjects(), [.ahmad]) 58 | } 59 | 60 | func testSaveInvalidObject() { 61 | let store = createFreshUsersStore() 62 | let optionalUser: User? = .invalid 63 | 64 | XCTAssertThrowsError(try store.save(.invalid)) 65 | XCTAssertThrowsError(try store.save(optionalUser)) 66 | XCTAssert(store.allObjects().isEmpty) 67 | } 68 | 69 | func testSaveObjects() throws { 70 | let store = createFreshUsersStore() 71 | let users: [User] = [.ahmad, .dalia, .kareem] 72 | 73 | try store.save(users) 74 | XCTAssertEqual(store.objectsCount, 3) 75 | XCTAssertEqual(store.allObjects(), users) 76 | } 77 | 78 | func testSaveInvalidObjects() { 79 | let store = createFreshUsersStore() 80 | 81 | XCTAssertThrowsError(try store.save([.kareem, .ahmad, .invalid])) 82 | XCTAssertEqual(store.objectsCount, 0) 83 | XCTAssertEqual(store.allObjects(), []) 84 | } 85 | 86 | func testObjectsCountLogging() throws { 87 | let store = createFreshUsersStore() 88 | store.logger.error = StoresError.invalid 89 | let count = store.objectsCount 90 | XCTAssertEqual(count, 0) 91 | XCTAssertEqual( 92 | store.logger.lastOutput, 93 | "An error occurred in `MultiCoreDataStore.objectsCount`. Error: Invalid." 94 | ) 95 | } 96 | 97 | func testObject() { 98 | let store = createFreshUsersStore() 99 | 100 | XCTAssertNoThrow(try store.save(.dalia)) 101 | XCTAssertEqual(store.object(withId: User.dalia.id), .dalia) 102 | 103 | XCTAssertNil(store.object(withId: 123)) 104 | } 105 | 106 | func testObjectLogging() throws { 107 | let store = createFreshUsersStore() 108 | try store.save(.ahmad) 109 | 110 | let request = store.database.entityFetchRequest(store.key(for: .ahmad)) 111 | let entities = try store.database.context.fetch(request) 112 | entities[0].data = "{]".data(using: .utf8)! 113 | try store.database.context.save() 114 | 115 | let user = store.object(withId: User.ahmad.id) 116 | XCTAssertNil(user) 117 | XCTAssertEqual( 118 | store.logger.lastOutput, 119 | """ 120 | An error occurred in `MultiCoreDataStore.object(withId:)`. \ 121 | Error: The data couldn’t be read because it isn’t in the correct format. 122 | """ 123 | ) 124 | } 125 | 126 | func testObjects() { 127 | let store = createFreshUsersStore() 128 | XCTAssertNoThrow(try store.save([.ahmad, .kareem])) 129 | XCTAssertEqual( 130 | store.objects(withIds: [User.ahmad.id, User.kareem.id, 5]), 131 | [.ahmad, .kareem] 132 | ) 133 | } 134 | 135 | func testAllObjects() { 136 | let store = createFreshUsersStore() 137 | 138 | XCTAssertNoThrow(try store.save(.ahmad)) 139 | XCTAssertEqual(store.allObjects(), [.ahmad]) 140 | 141 | XCTAssertNoThrow(try store.save(.dalia)) 142 | XCTAssertEqual(store.allObjects(), [.ahmad, .dalia]) 143 | 144 | XCTAssertNoThrow(try store.save(.kareem)) 145 | XCTAssertEqual(store.allObjects(), [.ahmad, .dalia, .kareem]) 146 | } 147 | 148 | func testAllObjectsLogging() throws { 149 | let store = createFreshUsersStore() 150 | 151 | store.logger.error = StoresError.invalid 152 | XCTAssert(store.allObjects().isEmpty) 153 | XCTAssertEqual( 154 | store.logger.lastOutput, 155 | "An error occurred in `MultiCoreDataStore.allObjects()`. Error: Invalid." 156 | ) 157 | store.logger.error = nil 158 | 159 | try store.save([.ahmad, .dalia, .kareem]) 160 | 161 | let request = store.database.entityFetchRequest(store.key(for: .dalia)) 162 | let entities = try store.database.context.fetch(request) 163 | entities[0].data = "{]".data(using: .utf8)! 164 | try store.database.context.save() 165 | 166 | XCTAssertEqual(store.allObjects(), [.ahmad, .kareem]) 167 | XCTAssertEqual( 168 | store.logger.lastOutput, 169 | """ 170 | An error occurred in `MultiCoreDataStore.allObjects()`. \ 171 | Error: The data couldn’t be read because it isn’t in the correct format. 172 | """ 173 | ) 174 | } 175 | 176 | func testRemoveObject() throws { 177 | let store = createFreshUsersStore() 178 | 179 | XCTAssertNoThrow(try store.save(.kareem)) 180 | XCTAssertEqual(store.objectsCount, 1) 181 | XCTAssertEqual(store.allObjects(), [.kareem]) 182 | 183 | try store.remove(withId: 123) 184 | XCTAssertEqual(store.objectsCount, 1) 185 | XCTAssertEqual(store.allObjects(), [.kareem]) 186 | 187 | try store.remove(withId: User.kareem.id) 188 | XCTAssertEqual(store.objectsCount, 0) 189 | XCTAssert(store.allObjects().isEmpty) 190 | } 191 | 192 | func testRemoveObjects() throws { 193 | let store = createFreshUsersStore() 194 | try store.save(.kareem) 195 | try store.save(.dalia) 196 | XCTAssertEqual(store.objectsCount, 2) 197 | 198 | try store.remove(withIds: [User.kareem.id, 5, 6, 8]) 199 | XCTAssertEqual(store.objectsCount, 1) 200 | XCTAssertEqual(store.allObjects(), [.dalia]) 201 | } 202 | 203 | func testRemoveAll() throws { 204 | let store = createFreshUsersStore() 205 | try store.save(.ahmad) 206 | try store.save(.dalia) 207 | try store.save(.kareem) 208 | 209 | try store.removeAll() 210 | XCTAssertEqual(store.objectsCount, 0) 211 | XCTAssert(store.allObjects().isEmpty) 212 | } 213 | 214 | func testContainsObject() throws { 215 | let store = createFreshUsersStore() 216 | XCTAssertFalse(store.containsObject(withId: 10)) 217 | 218 | try store.save(.ahmad) 219 | XCTAssert(store.containsObject(withId: User.ahmad.id)) 220 | } 221 | 222 | func testContainsObjectLogging() throws { 223 | let store = createFreshUsersStore() 224 | store.logger.error = StoresError.invalid 225 | XCTAssertFalse(store.containsObject(withId: 10)) 226 | XCTAssertEqual( 227 | store.logger.lastOutput, 228 | """ 229 | An error occurred in `MultiCoreDataStore.containsObject(withId:)`. \ 230 | Error: Invalid. 231 | """ 232 | ) 233 | } 234 | 235 | func testUpdatingSameObjectDoesNotChangeCount() throws { 236 | let store = createFreshUsersStore() 237 | 238 | var users: [User] = [.ahmad, .dalia, .kareem] 239 | try store.save(users) 240 | 241 | for i in 0 ..< 10 { 242 | users[0].firstName = "\(i)" 243 | try store.save(users[0]) 244 | 245 | users[1].lastName = "\(i)" 246 | users[2].lastName = "\(i)" 247 | 248 | try store.save(Array(users[1...])) 249 | } 250 | 251 | XCTAssertEqual(store.objectsCount, users.count) 252 | } 253 | 254 | func testThreadSafety() { 255 | let store = createFreshUsersStore() 256 | let expectation1 = XCTestExpectation(description: "Store has 200 items.") 257 | for i in 0 ..< 200 { 258 | Thread.detachNewThread { 259 | let user = User( 260 | id: i, 261 | firstName: "", 262 | lastName: "", 263 | age: .random(in: 1 ..< 90) 264 | ) 265 | try? store.save(user) 266 | } 267 | } 268 | Thread.sleep(forTimeInterval: 2) 269 | if store.objectsCount == 200 { 270 | expectation1.fulfill() 271 | } 272 | 273 | let expectation2 = XCTestExpectation(description: "Store has 100 items.") 274 | for i in 0 ..< 100 { 275 | Thread.detachNewThread { 276 | try? store.remove(withId: i) 277 | } 278 | } 279 | Thread.sleep(forTimeInterval: 2) 280 | if store.objectsCount == 100 { 281 | expectation2.fulfill() 282 | } 283 | 284 | wait(for: [expectation1, expectation2], timeout: 5) 285 | } 286 | } 287 | 288 | // MARK: - Helpers 289 | 290 | private extension MultiCoreDataStoreTests { 291 | func createFreshUsersStore( 292 | databaseName: String = "users" 293 | ) -> MultiCoreDataStore { 294 | let store = MultiCoreDataStore(databaseName: databaseName) 295 | XCTAssertNoThrow(try store.removeAll()) 296 | self.store = store 297 | return store 298 | } 299 | } 300 | 301 | #endif 302 | -------------------------------------------------------------------------------- /Tests/CoreData/SingleCoreDataStoreTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreData) 2 | 3 | @testable import CoreDataStore 4 | @testable import TestUtils 5 | 6 | import CoreData 7 | import Foundation 8 | import XCTest 9 | 10 | final class SingleCoreDataStoreTests: XCTestCase { 11 | private var store: SingleCoreDataStore? 12 | 13 | override func tearDownWithError() throws { 14 | try store?.remove() 15 | } 16 | 17 | func testCreateStore() { 18 | let databaseName = UUID().uuidString 19 | let store = createFreshUserStore(databaseName: databaseName) 20 | XCTAssertEqual(store.databaseName, databaseName) 21 | } 22 | 23 | 24 | func testCreateStoreWithCustomContainer() { 25 | let databaseName = UUID().uuidString 26 | let store = SingleCoreDataStore(databaseName: databaseName) { model in 27 | TestContainer(name: databaseName, managedObjectModel: model) 28 | } 29 | self.store = store 30 | 31 | let path = store.databaseURL?.pathComponents.suffix(2).joined(separator: "/") 32 | let expectedPath = "Test/\(databaseName).sqlite" 33 | XCTAssertEqual(path, expectedPath) 34 | } 35 | 36 | func testDatabaseURL() { 37 | let store = createFreshUserStore() 38 | let path = store.databaseURL?.pathComponents.suffix(2) 39 | XCTAssertEqual(path, ["CoreDataStore", "\(store.databaseName).sqlite"]) 40 | } 41 | 42 | func testSaveObject() throws { 43 | let store = createFreshUserStore() 44 | try store.save(.ahmad) 45 | XCTAssertEqual(store.object(), .ahmad) 46 | 47 | let user: User? = .kareem 48 | try store.save(user) 49 | XCTAssertEqual(store.object(), .kareem) 50 | 51 | try store.save(nil) 52 | XCTAssertNil(store.object()) 53 | } 54 | 55 | func testSaveInvalidObject() { 56 | let store = createFreshUserStore() 57 | XCTAssertThrowsError(try store.save(User.invalid)) 58 | XCTAssertNil(store.object()) 59 | } 60 | 61 | func testObject() throws { 62 | let store = createFreshUserStore() 63 | try store.save(.dalia) 64 | XCTAssertEqual(store.object(), .dalia) 65 | } 66 | 67 | func testObjectLogging() throws { 68 | let store = createFreshUserStore() 69 | try store.save(.ahmad) 70 | 71 | let request = store.database.entityFetchRequest(store.key) 72 | let entities = try store.database.context.fetch(request) 73 | entities[0].data = "{]".data(using: .utf8)! 74 | try store.database.context.save() 75 | 76 | XCTAssertNil(store.object()) 77 | XCTAssertEqual( 78 | store.logger.lastOutput, 79 | """ 80 | An error occurred in `SingleCoreDataStore.object()`. \ 81 | Error: The data couldn’t be read because it isn’t in the correct format. 82 | """ 83 | ) 84 | } 85 | } 86 | 87 | // MARK: - Helpers 88 | 89 | private extension SingleCoreDataStoreTests { 90 | func createFreshUserStore( 91 | databaseName: String = "user" 92 | ) -> SingleCoreDataStore { 93 | let store = SingleCoreDataStore(databaseName: databaseName) 94 | XCTAssertNoThrow(try store.remove()) 95 | self.store = store 96 | return store 97 | } 98 | } 99 | 100 | #endif 101 | -------------------------------------------------------------------------------- /Tests/FileSystem/MultiFileSystemStoreTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Blueprints 2 | @testable import FileSystemStore 3 | @testable import TestUtils 4 | 5 | import Foundation 6 | import XCTest 7 | 8 | final class MultiFileSystemStoreTests: XCTestCase { 9 | private var store: MultiFileSystemStore? 10 | private let manager = FileManager.default 11 | private let decoder = JSONDecoder() 12 | 13 | override func tearDownWithError() throws { 14 | try store?.removeAll() 15 | } 16 | 17 | func testCreateStore() { 18 | let directory = FileManager.SearchPathDirectory.documentDirectory 19 | let path = UUID().uuidString 20 | let store = createFreshUsersStore(directory: directory, path: path) 21 | XCTAssertEqual(store.directory, directory) 22 | XCTAssertEqual(store.path, path) 23 | } 24 | 25 | func testDeprecatedCreateStore() { 26 | let identifier = UUID().uuidString 27 | let directory = FileManager.SearchPathDirectory.documentDirectory 28 | let store = MultiFileSystemStore( 29 | identifier: identifier, 30 | directory: directory 31 | ) 32 | XCTAssertEqual(identifier, store.identifier) 33 | XCTAssertEqual(directory, store.directory) 34 | self.store = store 35 | } 36 | 37 | func testSaveObject() throws { 38 | let store = createFreshUsersStore() 39 | 40 | try store.save(.ahmad) 41 | XCTAssertEqual(store.objectsCount, 1) 42 | XCTAssertEqual(store.object(withId: User.ahmad.id), .ahmad) 43 | XCTAssertEqual(store.allObjects(), [.ahmad]) 44 | 45 | XCTAssertEqual(try allUsers(), [.ahmad]) 46 | } 47 | 48 | func testSaveOptional() throws { 49 | let store = createFreshUsersStore() 50 | 51 | try store.save(nil) 52 | XCTAssertEqual(store.objectsCount, 0) 53 | XCTAssert(try allUsers().isEmpty) 54 | 55 | try store.save(.ahmad) 56 | XCTAssertEqual(store.objectsCount, 1) 57 | XCTAssertEqual(store.allObjects(), [.ahmad]) 58 | XCTAssertEqual(try allUsers(), [.ahmad]) 59 | } 60 | 61 | func testSaveInvalidObject() { 62 | let store = createFreshUsersStore() 63 | let optionalUser: User? = .invalid 64 | 65 | XCTAssertThrowsError(try store.save(.invalid)) 66 | XCTAssertThrowsError(try store.save(optionalUser)) 67 | XCTAssert(store.allObjects().isEmpty) 68 | XCTAssert(try allUsers().isEmpty) 69 | } 70 | 71 | func testSaveObjects() throws { 72 | let store = createFreshUsersStore() 73 | let users: Set = [.ahmad, .dalia, .kareem] 74 | 75 | try store.save(Array(users)) 76 | XCTAssertEqual(store.objectsCount, 3) 77 | XCTAssertEqual(Set(store.allObjects()), users) 78 | XCTAssertEqual(try allUsers(), users) 79 | 80 | try store.removeAll() 81 | 82 | try store.save(users) 83 | XCTAssertEqual(store.objectsCount, 3) 84 | XCTAssertEqual(Set(store.allObjects()), users) 85 | XCTAssertEqual(try allUsers(), users) 86 | } 87 | 88 | func testSaveInvalidObjects() { 89 | let store = createFreshUsersStore() 90 | 91 | XCTAssertThrowsError(try store.save([.kareem, .ahmad, .invalid])) 92 | XCTAssertEqual(store.objectsCount, 0) 93 | XCTAssertEqual(store.allObjects(), []) 94 | XCTAssert(try allUsers().isEmpty) 95 | } 96 | 97 | func testObjectsCountLogging() throws { 98 | let store = createFreshUsersStore() 99 | store.logger.error = StoresError.invalid 100 | XCTAssertEqual(store.objectsCount, 0) 101 | XCTAssertEqual( 102 | store.logger.lastOutput, 103 | """ 104 | An error occurred in `MultiFileSystemStore.objectsCount`. \ 105 | Error: Invalid. 106 | """ 107 | ) 108 | } 109 | 110 | func testObject() throws { 111 | let store = createFreshUsersStore() 112 | 113 | try store.save(.dalia) 114 | XCTAssertEqual(store.object(withId: User.dalia.id), .dalia) 115 | XCTAssertEqual(try allUsers(), [.dalia]) 116 | 117 | XCTAssertNil(store.object(withId: 123)) 118 | XCTAssertEqual(try allUsers(), [.dalia]) 119 | } 120 | 121 | func testObjectLogging() throws { 122 | let store = createFreshUsersStore() 123 | try store.save(.ahmad) 124 | let url = try url(forUser: .ahmad) 125 | let data = "{]".data(using: .utf8)! 126 | try data.write(to: url) 127 | 128 | let user = store.object(withId: User.ahmad.id) 129 | XCTAssertNil(user) 130 | XCTAssertEqual( 131 | store.logger.lastOutput, 132 | """ 133 | An error occurred in `MultiFileSystemStore.object(withId:)`. \ 134 | Error: The data couldn’t be read because it isn’t in the correct format. 135 | """ 136 | ) 137 | } 138 | 139 | func testObjects() throws { 140 | let store = createFreshUsersStore() 141 | try store.save([.ahmad, .kareem]) 142 | XCTAssertEqual( 143 | store.objects(withIds: [User.ahmad.id, User.kareem.id, 5]), 144 | [.ahmad, .kareem] 145 | ) 146 | XCTAssertEqual(try allUsers(), [.ahmad, .kareem]) 147 | } 148 | 149 | func testAllObjectsLogging() throws { 150 | let store = createFreshUsersStore() 151 | 152 | store.logger.error = StoresError.invalid 153 | XCTAssert(store.allObjects().isEmpty) 154 | XCTAssertEqual( 155 | store.logger.lastOutput, 156 | """ 157 | An error occurred in `MultiFileSystemStore.allObjects()`. \ 158 | Error: Invalid. 159 | """ 160 | ) 161 | store.logger.error = nil 162 | 163 | try store.save([.ahmad, .dalia, .kareem]) 164 | let url = try url(forUser: .dalia) 165 | let data = "{]".data(using: .utf8)! 166 | try data.write(to: url) 167 | 168 | XCTAssertEqual(store.allObjects(), [.ahmad, .kareem]) 169 | XCTAssertEqual( 170 | store.logger.lastOutput, 171 | """ 172 | An error occurred in `MultiFileSystemStore.allObjects()`. \ 173 | Error: The data couldn’t be read because it isn’t in the correct format. 174 | """ 175 | ) 176 | } 177 | 178 | func testAllObjects() throws { 179 | let store = createFreshUsersStore() 180 | 181 | try store.save(.ahmad) 182 | XCTAssertEqual(Set(store.allObjects()), [.ahmad]) 183 | XCTAssertEqual(try allUsers(), [.ahmad]) 184 | 185 | try store.save(.dalia) 186 | XCTAssertEqual(Set(store.allObjects()), [.ahmad, .dalia]) 187 | XCTAssertEqual(try allUsers(), [.ahmad, .dalia]) 188 | 189 | try store.save(.kareem) 190 | XCTAssertEqual(Set(store.allObjects()), [.ahmad, .dalia, .kareem]) 191 | XCTAssertEqual(try allUsers(), [.ahmad, .dalia, .kareem]) 192 | } 193 | 194 | func testRemoveObject() throws { 195 | let store = createFreshUsersStore() 196 | 197 | try store.save(.kareem) 198 | XCTAssertEqual(store.objectsCount, 1) 199 | XCTAssertEqual(store.allObjects(), [.kareem]) 200 | XCTAssertEqual(try allUsers(), [.kareem]) 201 | 202 | try store.remove(withId: 123) 203 | XCTAssertEqual(store.objectsCount, 1) 204 | XCTAssertEqual(store.allObjects(), [.kareem]) 205 | XCTAssertEqual(try allUsers(), [.kareem]) 206 | 207 | try store.remove(withId: User.kareem.id) 208 | XCTAssertEqual(store.objectsCount, 0) 209 | XCTAssert(store.allObjects().isEmpty) 210 | XCTAssert(try allUsers().isEmpty) 211 | } 212 | 213 | func testRemoveObjects() throws { 214 | let store = createFreshUsersStore() 215 | try store.save(.kareem) 216 | try store.save(.dalia) 217 | XCTAssertEqual(store.objectsCount, 2) 218 | XCTAssertEqual(Set(store.allObjects()), [.kareem, .dalia]) 219 | 220 | try store.remove(withIds: [User.kareem.id, 5, 6, 8]) 221 | XCTAssertEqual(store.objectsCount, 1) 222 | XCTAssertEqual(store.allObjects(), [.dalia]) 223 | XCTAssertEqual(try allUsers(), [.dalia]) 224 | } 225 | 226 | func testRemoveAll() throws { 227 | let store = createFreshUsersStore() 228 | try store.save(.ahmad) 229 | try store.save(.dalia) 230 | try store.save(.kareem) 231 | 232 | try store.removeAll() 233 | XCTAssertEqual(store.objectsCount, 0) 234 | XCTAssert(store.allObjects().isEmpty) 235 | XCTAssert(try allUsers().isEmpty) 236 | } 237 | 238 | func testContainsObject() throws { 239 | let store = createFreshUsersStore() 240 | XCTAssertFalse(store.containsObject(withId: 10)) 241 | 242 | try store.save(.ahmad) 243 | XCTAssert(store.containsObject(withId: User.ahmad.id)) 244 | } 245 | 246 | func testContainsObjectLogging() throws { 247 | let store = createFreshUsersStore() 248 | store.logger.error = StoresError.invalid 249 | XCTAssertFalse(store.containsObject(withId: 10)) 250 | XCTAssertEqual( 251 | store.logger.lastOutput, 252 | """ 253 | An error occurred in `MultiFileSystemStore.containsObject(withId:)`. \ 254 | Error: Invalid. 255 | """ 256 | ) 257 | } 258 | 259 | 260 | func testUpdatingSameObjectDoesNotChangeCount() throws { 261 | let store = createFreshUsersStore() 262 | 263 | let users: [User] = [.ahmad, .dalia, .kareem] 264 | try store.save(users) 265 | 266 | var user = User.ahmad 267 | for i in 0 ..< 10 { 268 | user.firstName = "\(i)" 269 | try store.save(user) 270 | } 271 | 272 | XCTAssertEqual(store.objectsCount, users.count) 273 | XCTAssertEqual(try allUsers().count, users.count) 274 | } 275 | 276 | func testThreadSafety() throws { 277 | let store = createFreshUsersStore() 278 | let expectation1 = XCTestExpectation(description: "Store has 200 items.") 279 | for i in 0 ..< 200 { 280 | Thread.detachNewThread { 281 | let user = User( 282 | id: i, 283 | firstName: "", 284 | lastName: "", 285 | age: .random(in: 1 ..< 90) 286 | ) 287 | try? store.save(user) 288 | } 289 | } 290 | Thread.sleep(forTimeInterval: 2) 291 | let allUsersCount1 = try allUsers().count 292 | if store.objectsCount == 200, allUsersCount1 == 200 { 293 | expectation1.fulfill() 294 | } 295 | 296 | let expectation2 = XCTestExpectation(description: "Store has 100 items.") 297 | for i in 0 ..< 100 { 298 | Thread.detachNewThread { 299 | try? store.remove(withId: i) 300 | } 301 | } 302 | Thread.sleep(forTimeInterval: 2) 303 | let allUsersCount2 = try allUsers().count 304 | if store.objectsCount == 100, allUsersCount2 == 100 { 305 | expectation2.fulfill() 306 | } 307 | 308 | wait(for: [expectation1, expectation2], timeout: 5) 309 | } 310 | } 311 | 312 | // MARK: - Helpers 313 | 314 | private extension MultiFileSystemStoreTests { 315 | func storeURL( 316 | directory: FileManager.SearchPathDirectory = .cachesDirectory, 317 | path: String = "users" 318 | ) throws -> URL { 319 | return try manager.url( 320 | for: directory, 321 | in: .userDomainMask, 322 | appropriateFor: nil, 323 | create: true 324 | ) 325 | .appendingPathComponent("Stores", isDirectory: true) 326 | .appendingPathComponent("MultiObjects", isDirectory: true) 327 | .appendingPathComponent(path, isDirectory: true) 328 | } 329 | 330 | func url(forUserWithId id: User.ID) throws -> URL { 331 | return try storeURL() 332 | .appendingPathComponent("\(id)") 333 | .appendingPathExtension("json") 334 | } 335 | 336 | func url(forUser user: User) throws -> URL { 337 | return try url(forUserWithId: user.id) 338 | } 339 | 340 | func url(forUserPath userPath: String) throws -> URL { 341 | return try storeURL() 342 | .appendingPathComponent(userPath, isDirectory: false) 343 | } 344 | 345 | func user(atPath path: String) throws -> User? { 346 | guard let data = manager.contents(atPath: path) else { return nil } 347 | return try decoder.decode(User.self, from: data) 348 | } 349 | 350 | func allUsers() throws -> Set { 351 | let storePath = try storeURL().path 352 | if manager.fileExists(atPath: storePath) == false { 353 | try manager.createDirectory( 354 | atPath: storePath, 355 | withIntermediateDirectories: true 356 | ) 357 | } 358 | let users = try manager.contentsOfDirectory(atPath: storePath) 359 | .compactMap(url(forUserPath:)) 360 | .map(\.path) 361 | .compactMap(user(atPath:)) 362 | return Set(users) 363 | } 364 | 365 | func createFreshUsersStore( 366 | directory: FileManager.SearchPathDirectory = .cachesDirectory, 367 | path: String = "users" 368 | ) -> MultiFileSystemStore { 369 | let store = MultiFileSystemStore(directory: directory, path: path) 370 | XCTAssertEqual(path, store.identifier) 371 | XCTAssertNoThrow(try store.removeAll()) 372 | self.store = store 373 | return store 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /Tests/FileSystem/SingleFileSystemStoreTests.swift: -------------------------------------------------------------------------------- 1 | @testable import FileSystemStore 2 | @testable import TestUtils 3 | 4 | import Foundation 5 | import XCTest 6 | 7 | final class SingleFileSystemStoreTests: XCTestCase { 8 | private var store: SingleFileSystemStore? 9 | private let manager = FileManager.default 10 | 11 | override func tearDownWithError() throws { 12 | try store?.remove() 13 | } 14 | 15 | func testCreateStore() { 16 | let directory = FileManager.SearchPathDirectory.documentDirectory 17 | let path = UUID().uuidString 18 | let store = createFreshUserStore(directory: directory, path: path) 19 | XCTAssertEqual(store.directory, directory) 20 | XCTAssertEqual(store.path, path) 21 | } 22 | 23 | func testDeprecatedCreateStore() { 24 | let identifier = UUID().uuidString 25 | let directory = FileManager.SearchPathDirectory.documentDirectory 26 | let store = SingleFileSystemStore( 27 | identifier: identifier, 28 | directory: directory 29 | ) 30 | XCTAssertEqual(identifier, store.identifier) 31 | XCTAssertEqual(directory, store.directory) 32 | self.store = store 33 | } 34 | 35 | func testSaveObject() throws { 36 | let path = UUID().uuidString 37 | let store = createFreshUserStore(path: path) 38 | 39 | try store.save(User.ahmad) 40 | XCTAssertNotNil(store.object) 41 | XCTAssertEqual(store.object(), User.ahmad) 42 | 43 | let url = try url(path: path) 44 | let data = try Data(contentsOf: url) 45 | let decodedUser = try JSONDecoder().decode(User.self, from: data) 46 | XCTAssertEqual(store.object(), decodedUser) 47 | 48 | try store.save(nil) 49 | XCTAssertNil(store.object()) 50 | XCTAssertFalse(manager.fileExists(atPath: url.path)) 51 | } 52 | 53 | func testSaveInvalidObject() { 54 | let store = createFreshUserStore() 55 | XCTAssertThrowsError(try store.save(User.invalid)) 56 | } 57 | 58 | func testObject() throws { 59 | let store = createFreshUserStore() 60 | 61 | try store.save(User.dalia) 62 | XCTAssertNotNil(store.object()) 63 | } 64 | 65 | func testObjectIsLoggingErrors() throws { 66 | let store = createFreshUserStore() 67 | 68 | let storePath = try storeURL().path 69 | if manager.fileExists(atPath: storePath) == false { 70 | try manager.createDirectory( 71 | atPath: storePath, 72 | withIntermediateDirectories: true 73 | ) 74 | } 75 | 76 | let path = try url().path 77 | let invalidData = "test".data(using: .utf8)! 78 | manager.createFile(atPath: path, contents: invalidData) 79 | XCTAssertNil(store.object()) 80 | XCTAssertEqual( 81 | store.logger.lastOutput, 82 | """ 83 | An error occurred in `SingleFileSystemStore.object()`. Error: The data \ 84 | couldn’t be read because it isn’t in the correct format. 85 | """ 86 | ) 87 | } 88 | 89 | func testRemove() throws { 90 | let path = UUID().uuidString 91 | let store = createFreshUserStore(path: path) 92 | 93 | try store.save(User.ahmad) 94 | XCTAssertNotNil(store.object) 95 | XCTAssertEqual(store.object(), User.ahmad) 96 | 97 | try store.remove() 98 | 99 | let url = try url(path: path) 100 | XCTAssertFalse(manager.fileExists(atPath: url.path)) 101 | } 102 | } 103 | 104 | // MARK: - Helpers 105 | 106 | private extension SingleFileSystemStoreTests { 107 | func storeURL( 108 | directory: FileManager.SearchPathDirectory = .cachesDirectory, 109 | path: String = "user" 110 | ) throws -> URL { 111 | return try manager.url( 112 | for: directory, 113 | in: .userDomainMask, 114 | appropriateFor: nil, 115 | create: true 116 | ) 117 | .appendingPathComponent("Stores", isDirectory: true) 118 | .appendingPathComponent("SingleObject", isDirectory: true) 119 | .appendingPathComponent(path, isDirectory: true) 120 | } 121 | 122 | func url( 123 | directory: FileManager.SearchPathDirectory = .cachesDirectory, 124 | path: String = "user" 125 | ) throws -> URL { 126 | return try storeURL(directory: directory, path: path) 127 | .appendingPathComponent("object") 128 | .appendingPathExtension("json") 129 | } 130 | 131 | func createFreshUserStore( 132 | directory: FileManager.SearchPathDirectory = .cachesDirectory, 133 | path: String = "user" 134 | ) -> SingleFileSystemStore { 135 | let store = SingleFileSystemStore(directory: directory, path: path) 136 | XCTAssertEqual(path, store.identifier) 137 | XCTAssertNoThrow(try store.remove()) 138 | self.store = store 139 | return store 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Tests/Keychain/KeychainErrorTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Security) 2 | 3 | @testable import KeychainStore 4 | 5 | import Foundation 6 | import XCTest 7 | 8 | final class KeychainErrorTests: XCTestCase { 9 | func testErrorDescription() { 10 | XCTAssertEqual( 11 | KeychainError.invalidResult.errorDescription, 12 | "Keychain Error: Invalid result." 13 | ) 14 | 15 | XCTAssertEqual( 16 | KeychainError.keychain(0).errorDescription, 17 | "Keychain Error: OSStatus=0." 18 | ) 19 | } 20 | } 21 | 22 | #endif 23 | -------------------------------------------------------------------------------- /Tests/Keychain/MultiKeychainStoreTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Security) 2 | 3 | @testable import TestUtils 4 | @testable import KeychainStore 5 | 6 | import Foundation 7 | import XCTest 8 | 9 | final class MultiKeychainStoreTests: XCTestCase { 10 | private var store: MultiKeychainStore? 11 | 12 | override func tearDownWithError() throws { 13 | try store?.removeAll() 14 | } 15 | 16 | func testCreateStore() throws { 17 | let identifier = UUID().uuidString 18 | let accessibility = KeychainAccessibility.afterFirstUnlock 19 | let store = createFreshUsersStore( 20 | identifier: identifier, 21 | accessibility: accessibility 22 | ) 23 | XCTAssertEqual(store.identifier, identifier) 24 | XCTAssertEqual(store.accessibility, accessibility) 25 | XCTAssertEqual( 26 | store.serviceName(), 27 | "com.omaralbeik.stores.multi.\(identifier)" 28 | ) 29 | } 30 | 31 | func testSaveObject() throws { 32 | let store = createFreshUsersStore() 33 | 34 | try store.save(.ahmad) 35 | XCTAssertEqual(store.objectsCount, 1) 36 | XCTAssertEqual(store.allObjects(), [.ahmad]) 37 | } 38 | 39 | func testSaveOptional() throws { 40 | let store = createFreshUsersStore() 41 | 42 | try store.save(nil) 43 | XCTAssertEqual(store.objectsCount, 0) 44 | 45 | try store.save(.ahmad) 46 | XCTAssertEqual(store.objectsCount, 1) 47 | XCTAssertEqual(store.allObjects(), [.ahmad]) 48 | } 49 | 50 | func testSaveInvalidObject() { 51 | let store = createFreshUsersStore() 52 | let optionalUser: User? = .invalid 53 | 54 | XCTAssertThrowsError(try store.save(.invalid)) 55 | XCTAssertThrowsError(try store.save(optionalUser)) 56 | XCTAssert(store.allObjects().isEmpty) 57 | } 58 | 59 | func testSaveObjects() throws { 60 | let store = createFreshUsersStore() 61 | let users: Set = [.ahmad, .dalia, .kareem] 62 | 63 | try store.save(Array(users)) 64 | XCTAssertEqual(store.objectsCount, 3) 65 | XCTAssertEqual(Set(store.allObjects()), users) 66 | 67 | try store.removeAll() 68 | } 69 | 70 | func testSaveInvalidObjects() { 71 | let store = createFreshUsersStore() 72 | 73 | XCTAssertThrowsError(try store.save([.kareem, .ahmad, .invalid])) 74 | XCTAssertEqual(store.objectsCount, 0) 75 | XCTAssertEqual(store.allObjects(), []) 76 | } 77 | 78 | func testObject() throws { 79 | let store = createFreshUsersStore() 80 | 81 | try store.save(.dalia) 82 | XCTAssertEqual(store.object(withId: User.dalia.id), .dalia) 83 | 84 | XCTAssertNil(store.object(withId: 123)) 85 | } 86 | 87 | func testObjects() throws { 88 | let store = createFreshUsersStore() 89 | try store.save([.ahmad, .kareem]) 90 | XCTAssertEqual( 91 | store.objects(withIds: [User.ahmad.id, User.kareem.id, 5]), 92 | [.ahmad, .kareem] 93 | ) 94 | } 95 | 96 | func testAllObjects() throws { 97 | let store = createFreshUsersStore() 98 | 99 | try store.save(.ahmad) 100 | XCTAssertEqual(Set(store.allObjects()), [.ahmad]) 101 | 102 | try store.save(.dalia) 103 | XCTAssertEqual(Set(store.allObjects()), [.ahmad, .dalia]) 104 | 105 | try store.save(.kareem) 106 | XCTAssertEqual(Set(store.allObjects()), [.ahmad, .dalia, .kareem]) 107 | } 108 | 109 | func testRemoveObject() throws { 110 | let store = createFreshUsersStore() 111 | 112 | try store.save(.kareem) 113 | XCTAssertEqual(store.objectsCount, 1) 114 | XCTAssertEqual(store.allObjects(), [.kareem]) 115 | 116 | try store.remove(withId: 123) 117 | XCTAssertEqual(store.objectsCount, 1) 118 | XCTAssertEqual(store.allObjects(), [.kareem]) 119 | 120 | try store.remove(withId: User.kareem.id) 121 | XCTAssertEqual(store.objectsCount, 0) 122 | XCTAssert(store.allObjects().isEmpty) 123 | } 124 | 125 | func testRemoveObjects() throws { 126 | let store = createFreshUsersStore() 127 | try store.save(.kareem) 128 | try store.save(.dalia) 129 | XCTAssertEqual(store.objectsCount, 2) 130 | XCTAssertEqual(Set(store.allObjects()), [.kareem, .dalia]) 131 | 132 | try store.remove(withIds: [User.kareem.id, 5, 6, 8]) 133 | XCTAssertEqual(store.objectsCount, 1) 134 | XCTAssertEqual(store.allObjects(), [.dalia]) 135 | } 136 | 137 | func testRemoveAll() throws { 138 | let store = createFreshUsersStore() 139 | try store.save(.ahmad) 140 | try store.save(.dalia) 141 | try store.save(.kareem) 142 | 143 | try store.removeAll() 144 | XCTAssertEqual(store.objectsCount, 0) 145 | XCTAssert(store.allObjects().isEmpty) 146 | } 147 | 148 | func testContainsObject() throws { 149 | let store = createFreshUsersStore() 150 | XCTAssertFalse(store.containsObject(withId: 10)) 151 | 152 | try store.save(.ahmad) 153 | XCTAssert(store.containsObject(withId: User.ahmad.id)) 154 | } 155 | 156 | func testUpdatingSameObjectDoesNotChangeCount() throws { 157 | let store = createFreshUsersStore() 158 | 159 | let users: [User] = [.ahmad, .dalia, .kareem] 160 | try store.save(users) 161 | 162 | var user = User.ahmad 163 | for i in 0 ..< 10 { 164 | user.firstName = "\(i)" 165 | try store.save(user) 166 | } 167 | 168 | XCTAssertEqual(store.objectsCount, users.count) 169 | } 170 | 171 | func testThreadSafety() { 172 | let store = createFreshUsersStore() 173 | let expectation1 = XCTestExpectation(description: "Store has 200 items.") 174 | for i in 0 ..< 200 { 175 | Thread.detachNewThread { 176 | let user = User( 177 | id: i, 178 | firstName: "", 179 | lastName: "", 180 | age: .random(in: 1 ..< 90) 181 | ) 182 | try! store.save(user) 183 | } 184 | } 185 | Thread.sleep(forTimeInterval: 4) 186 | if store.objectsCount == 200 { 187 | expectation1.fulfill() 188 | } 189 | 190 | let expectation2 = XCTestExpectation(description: "Store has 100 items.") 191 | for i in 0 ..< 100 { 192 | Thread.detachNewThread { 193 | try! store.remove(withId: i) 194 | } 195 | } 196 | Thread.sleep(forTimeInterval: 4) 197 | if store.objectsCount == 100 { 198 | expectation2.fulfill() 199 | } 200 | 201 | wait(for: [expectation1, expectation2], timeout: 10) 202 | } 203 | } 204 | 205 | // MARK: - Helpers 206 | 207 | private extension MultiKeychainStoreTests { 208 | func createFreshUsersStore( 209 | identifier: String = "users", 210 | accessibility: KeychainAccessibility = .whenUnlockedThisDeviceOnly 211 | ) -> MultiKeychainStore { 212 | let store = MultiKeychainStore.init( 213 | identifier: identifier, 214 | accessibility: accessibility 215 | ) 216 | XCTAssertNoThrow(try store.removeAll()) 217 | self.store = store 218 | return store 219 | } 220 | } 221 | 222 | #endif 223 | -------------------------------------------------------------------------------- /Tests/Keychain/SingleKeychainStoreTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Security) 2 | 3 | @testable import TestUtils 4 | @testable import KeychainStore 5 | 6 | import Foundation 7 | import XCTest 8 | 9 | final class SingleKeychainStoreTests: XCTestCase { 10 | private var store: SingleKeychainStore? 11 | 12 | override func tearDownWithError() throws { 13 | try store?.remove() 14 | } 15 | 16 | func testCreateStore() { 17 | let identifier = UUID().uuidString 18 | let accessibility = KeychainAccessibility.afterFirstUnlock 19 | let store = createFreshUserStore( 20 | identifier: identifier, 21 | accessibility: accessibility 22 | ) 23 | XCTAssertEqual(store.identifier, identifier) 24 | XCTAssertEqual(store.accessibility, accessibility) 25 | XCTAssertEqual( 26 | store.serviceName(), 27 | "com.omaralbeik.stores.single.\(identifier)" 28 | ) 29 | } 30 | 31 | func testSaveObject() throws { 32 | let store = createFreshUserStore() 33 | try store.save(.ahmad) 34 | XCTAssertEqual(store.object(), .ahmad) 35 | 36 | let user: User? = .kareem 37 | try store.save(user) 38 | XCTAssertEqual(store.object(), .kareem) 39 | 40 | try store.save(nil) 41 | XCTAssertNil(store.object()) 42 | } 43 | 44 | func testSaveInvalidObject() { 45 | let store = createFreshUserStore() 46 | XCTAssertThrowsError(try store.save(User.invalid)) 47 | } 48 | 49 | func testObject() throws { 50 | let store = createFreshUserStore() 51 | XCTAssertNoThrow(try store.save(.dalia)) 52 | XCTAssertEqual(store.object(), .dalia) 53 | } 54 | 55 | func testRemove() throws { 56 | let store = createFreshUserStore() 57 | XCTAssertNoThrow(try store.save(.dalia)) 58 | XCTAssertEqual(store.object(), .dalia) 59 | 60 | try store.remove() 61 | XCTAssertNil(store.object()) 62 | } 63 | } 64 | 65 | // MARK: - Helpers 66 | 67 | private extension SingleKeychainStoreTests { 68 | func createFreshUserStore( 69 | identifier: String = "user", 70 | accessibility: KeychainAccessibility = .whenUnlockedThisDeviceOnly 71 | ) -> SingleKeychainStore { 72 | let store = SingleKeychainStore.init( 73 | identifier: identifier, 74 | accessibility: accessibility 75 | ) 76 | XCTAssertNoThrow(try store.remove()) 77 | self.store = store 78 | return store 79 | } 80 | } 81 | 82 | #endif 83 | -------------------------------------------------------------------------------- /Tests/UserDefaults/MultiUserDefaultsStoreTests.swift: -------------------------------------------------------------------------------- 1 | @testable import TestUtils 2 | @testable import UserDefaultsStore 3 | 4 | import Foundation 5 | import XCTest 6 | 7 | final class MultiUserDefaultsStoreTests: XCTestCase { 8 | private var store: MultiUserDefaultsStore? 9 | 10 | override func tearDown() { 11 | store?.removeAll() 12 | } 13 | 14 | func testCreateStore() { 15 | let suiteName = UUID().uuidString 16 | let store = createFreshUsersStore(suiteName: suiteName) 17 | XCTAssertEqual(store.suiteName, suiteName) 18 | } 19 | 20 | func testDeprecatedCreateStore() { 21 | let identifier = UUID().uuidString 22 | let store = MultiUserDefaultsStore(identifier: identifier) 23 | XCTAssertEqual(identifier, store.identifier) 24 | self.store = store 25 | } 26 | 27 | func testSaveObject() throws { 28 | let store = createFreshUsersStore() 29 | 30 | try store.save(.ahmad) 31 | XCTAssertEqual(store.objectsCount, 1) 32 | XCTAssertEqual(store.allObjects(), [.ahmad]) 33 | XCTAssertEqual(allUsersInStore(), [.ahmad]) 34 | } 35 | 36 | func testSaveOptional() throws { 37 | let store = createFreshUsersStore() 38 | 39 | try store.save(nil) 40 | XCTAssertEqual(store.objectsCount, 0) 41 | XCTAssert(allUsersInStore().isEmpty) 42 | 43 | try store.save(.ahmad) 44 | XCTAssertEqual(store.objectsCount, 1) 45 | XCTAssertEqual(store.allObjects(), [.ahmad]) 46 | XCTAssertEqual(allUsersInStore(), [.ahmad]) 47 | } 48 | 49 | func testSaveInvalidObject() { 50 | let store = createFreshUsersStore() 51 | let optionalUser: User? = .invalid 52 | 53 | XCTAssertThrowsError(try store.save(.invalid)) 54 | XCTAssertThrowsError(try store.save(optionalUser)) 55 | XCTAssert(store.allObjects().isEmpty) 56 | XCTAssert(allUsersInStore().isEmpty) 57 | } 58 | 59 | func testSaveObjects() throws { 60 | let store = createFreshUsersStore() 61 | let users: Set = [.ahmad, .dalia, .kareem] 62 | 63 | try store.save(Array(users)) 64 | XCTAssertEqual(store.objectsCount, 3) 65 | XCTAssertEqual(Set(store.allObjects()), users) 66 | XCTAssertEqual(allUsersInStore(), users) 67 | } 68 | 69 | func testSaveInvalidObjects() { 70 | let store = createFreshUsersStore() 71 | 72 | XCTAssertThrowsError(try store.save([.kareem, .ahmad, .invalid])) 73 | XCTAssertEqual(store.objectsCount, 0) 74 | XCTAssertEqual(store.allObjects(), []) 75 | XCTAssert(allUsersInStore().isEmpty) 76 | } 77 | 78 | func testObject() throws { 79 | let store = createFreshUsersStore() 80 | 81 | try store.save(.dalia) 82 | XCTAssertEqual(store.object(withId: User.dalia.id), .dalia) 83 | XCTAssertEqual(allUsersInStore(), [.dalia]) 84 | 85 | XCTAssertNil(store.object(withId: 123)) 86 | XCTAssertEqual(allUsersInStore(), [.dalia]) 87 | } 88 | 89 | func testObjects() throws { 90 | let store = createFreshUsersStore() 91 | try store.save([.ahmad, .kareem]) 92 | XCTAssertEqual( 93 | store.objects(withIds: [User.ahmad.id, User.kareem.id, 5]), 94 | [.ahmad, .kareem] 95 | ) 96 | XCTAssertEqual(allUsersInStore(), [.ahmad, .kareem]) 97 | } 98 | 99 | func testAllObjects() throws { 100 | let store = createFreshUsersStore() 101 | 102 | try store.save(.ahmad) 103 | XCTAssertEqual(Set(store.allObjects()), [.ahmad]) 104 | XCTAssertEqual(allUsersInStore(), [.ahmad]) 105 | 106 | try store.save(.dalia) 107 | XCTAssertEqual(Set(store.allObjects()), [.ahmad, .dalia]) 108 | XCTAssertEqual(allUsersInStore(), [.ahmad, .dalia]) 109 | 110 | try store.save(.kareem) 111 | XCTAssertEqual(Set(store.allObjects()), [.ahmad, .dalia, .kareem]) 112 | XCTAssertEqual(allUsersInStore(), [.ahmad, .dalia, .kareem]) 113 | } 114 | 115 | func testRemoveObject() throws { 116 | let store = createFreshUsersStore() 117 | 118 | try store.save(.kareem) 119 | XCTAssertEqual(store.objectsCount, 1) 120 | XCTAssertEqual(store.allObjects(), [.kareem]) 121 | XCTAssertEqual(allUsersInStore(), [.kareem]) 122 | 123 | store.remove(withId: 123) 124 | XCTAssertEqual(store.objectsCount, 1) 125 | XCTAssertEqual(store.allObjects(), [.kareem]) 126 | XCTAssertEqual(allUsersInStore(), [.kareem]) 127 | 128 | store.remove(withId: User.kareem.id) 129 | XCTAssertEqual(store.objectsCount, 0) 130 | XCTAssert(store.allObjects().isEmpty) 131 | XCTAssert(allUsersInStore().isEmpty) 132 | } 133 | 134 | func testRemoveObjects() throws { 135 | let store = createFreshUsersStore() 136 | try store.save(.kareem) 137 | try store.save(.dalia) 138 | XCTAssertEqual(store.objectsCount, 2) 139 | XCTAssertEqual(Set(store.allObjects()), [.kareem, .dalia]) 140 | 141 | store.remove(withIds: [User.kareem.id, 5, 6, 8]) 142 | XCTAssertEqual(store.objectsCount, 1) 143 | XCTAssertEqual(store.allObjects(), [.dalia]) 144 | XCTAssertEqual(allUsersInStore(), [.dalia]) 145 | } 146 | 147 | func testRemoveAll() throws { 148 | let store = createFreshUsersStore() 149 | try store.save(.ahmad) 150 | try store.save(.dalia) 151 | try store.save(.kareem) 152 | 153 | store.removeAll() 154 | XCTAssertEqual(store.objectsCount, 0) 155 | XCTAssert(store.allObjects().isEmpty) 156 | XCTAssert(allUsersInStore().isEmpty) 157 | } 158 | 159 | func testContainsObject() throws { 160 | let store = createFreshUsersStore() 161 | XCTAssertFalse(store.containsObject(withId: 10)) 162 | 163 | try store.save(.ahmad) 164 | XCTAssert(store.containsObject(withId: User.ahmad.id)) 165 | } 166 | 167 | func testUpdatingSameObjectDoesNotChangeCount() throws { 168 | let store = createFreshUsersStore() 169 | 170 | let users: [User] = [.ahmad, .dalia, .kareem] 171 | try store.save(users) 172 | 173 | var user = User.ahmad 174 | for i in 0 ..< 10 { 175 | user.firstName = "\(i)" 176 | try store.save(user) 177 | } 178 | 179 | XCTAssertEqual(store.objectsCount, users.count) 180 | XCTAssertEqual(count(), users.count) 181 | } 182 | 183 | func testThreadSafety() { 184 | let store = createFreshUsersStore() 185 | let expectation1 = XCTestExpectation(description: "Store has 200 items.") 186 | for i in 0 ..< 200 { 187 | Thread.detachNewThread { 188 | let user = User( 189 | id: i, 190 | firstName: "", 191 | lastName: "", 192 | age: .random(in: 1 ..< 90) 193 | ) 194 | try? store.save(user) 195 | } 196 | } 197 | Thread.sleep(forTimeInterval: 2) 198 | if store.objectsCount == 200, allUsersInStore().count == 200 { 199 | expectation1.fulfill() 200 | } 201 | 202 | let expectation2 = XCTestExpectation(description: "Store has 100 items.") 203 | for i in 0 ..< 100 { 204 | Thread.detachNewThread { 205 | store.remove(withId: i) 206 | } 207 | } 208 | Thread.sleep(forTimeInterval: 2) 209 | if store.objectsCount == 100, allUsersInStore().count == 100 { 210 | expectation2.fulfill() 211 | } 212 | 213 | wait(for: [expectation1, expectation2], timeout: 5) 214 | } 215 | } 216 | 217 | // MARK: - Helpers 218 | 219 | private extension MultiUserDefaultsStoreTests { 220 | func createFreshUsersStore( 221 | suiteName: String = "users" 222 | ) -> MultiUserDefaultsStore { 223 | let store = MultiUserDefaultsStore(suiteName: suiteName) 224 | XCTAssertEqual(suiteName, store.identifier) 225 | store.removeAll() 226 | self.store = store 227 | return store 228 | } 229 | 230 | func allUsersInStore(with identifier: String = "users") -> Set { 231 | guard let store = UserDefaults(suiteName: identifier) else { return [] } 232 | let users = store.dictionaryRepresentation() 233 | .values 234 | .compactMap { $0 as? Data } 235 | .compactMap { try? JSONDecoder().decode(User.self, from: $0) } 236 | return Set(users) 237 | } 238 | 239 | func count(identifier: String = "users") -> Int { 240 | return UserDefaults(suiteName: identifier)? 241 | .integer(forKey: "\(identifier)-count") ?? 0 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /Tests/UserDefaults/SingleUserDefaultsStoreTests.swift: -------------------------------------------------------------------------------- 1 | @testable import TestUtils 2 | @testable import UserDefaultsStore 3 | 4 | import Foundation 5 | import XCTest 6 | 7 | final class SingleUserDefaultsStoreTests: XCTestCase { 8 | private var store: SingleUserDefaultsStore? 9 | 10 | override func tearDown() { 11 | store?.remove() 12 | } 13 | 14 | func testCreateStore() { 15 | let suiteName = UUID().uuidString 16 | let store = createFreshUserStore(suiteName: suiteName) 17 | XCTAssertEqual(store.suiteName, suiteName) 18 | } 19 | 20 | func testDeprecatedCreateStore() { 21 | let identifier = UUID().uuidString 22 | let store = SingleUserDefaultsStore(identifier: identifier) 23 | XCTAssertEqual(identifier, store.identifier) 24 | self.store = store 25 | } 26 | 27 | func testSaveObject() throws { 28 | let store = createFreshUserStore() 29 | try store.save(.ahmad) 30 | XCTAssertEqual(store.object(), .ahmad) 31 | XCTAssertEqual(userInStore(), .ahmad) 32 | 33 | let user: User? = .kareem 34 | try store.save(user) 35 | XCTAssertEqual(store.object(), .kareem) 36 | XCTAssertEqual(userInStore(), .kareem) 37 | 38 | try store.save(nil) 39 | XCTAssertNil(store.object()) 40 | XCTAssertNil(userInStore()) 41 | } 42 | 43 | func testSaveInvalidObject() { 44 | let store = createFreshUserStore() 45 | XCTAssertThrowsError(try store.save(User.invalid)) 46 | XCTAssertNil(userInStore()) 47 | } 48 | 49 | func testObject() throws { 50 | let store = createFreshUserStore() 51 | XCTAssertNoThrow(try store.save(.dalia)) 52 | XCTAssertEqual(store.object(), .dalia) 53 | XCTAssertEqual(userInStore(), .dalia) 54 | } 55 | 56 | func testRemove() { 57 | let store = createFreshUserStore() 58 | XCTAssertNoThrow(try store.save(.dalia)) 59 | XCTAssertEqual(store.object(), .dalia) 60 | 61 | store.remove() 62 | XCTAssertNil(store.object()) 63 | XCTAssertNil(userInStore()) 64 | } 65 | } 66 | 67 | // MARK: - Helpers 68 | 69 | private extension SingleUserDefaultsStoreTests { 70 | func createFreshUserStore( 71 | suiteName: String = "user" 72 | ) -> SingleUserDefaultsStore { 73 | let store = SingleUserDefaultsStore(suiteName: suiteName) 74 | XCTAssertEqual(suiteName, store.identifier) 75 | store.remove() 76 | self.store = store 77 | return store 78 | } 79 | 80 | func userInStore(identifier: String = "user") -> User? { 81 | guard let data = UserDefaults(suiteName: identifier)? 82 | .data(forKey: "object") else { return nil } 83 | return try? JSONDecoder().decode(User.self, from: data) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Tests/Utils/Documentation.docc/TestUtils.md: -------------------------------------------------------------------------------- 1 | # ``TestUtils`` 2 | 3 | A fake implementation of **Blueprints** that uses a dictionary to store and retrieve objects. 4 | -------------------------------------------------------------------------------- /Tests/Utils/MultiObjectStoreFake.swift: -------------------------------------------------------------------------------- 1 | import Blueprints 2 | import Foundation 3 | 4 | /// Multi object store fake that uses a dictionary to store and retrieve objects. 5 | public final class MultiObjectStoreFake< 6 | Object: Codable & Identifiable 7 | >: MultiObjectStore { 8 | /// Dictionary used to store and retrieve objects. 9 | public var dictionary: [Object.ID: Object] = [:] 10 | 11 | /// Optional error. Setting this will make any throwing method of the store throw the set error. 12 | public var error: Error? 13 | 14 | /// Create a new store fake with a given dictionary and an option error to be thrown. 15 | /// - Parameters: 16 | /// - dictionary: dictionary used to store and retrieve objects. Defaults to an empty dictionary. 17 | /// - error: optional error. Setting this will make any throwing method of the store throw the set error. 18 | /// Defaults to `nil`. 19 | public init(dictionary: [Object.ID: Object] = [:], error: Error? = nil) { 20 | self.dictionary = dictionary 21 | self.error = error 22 | } 23 | 24 | // MARK: - MultiObjectStore 25 | 26 | /// Saves an object to store. 27 | /// - Parameter object: object to be saved. 28 | /// - Throws error: any error that might occur during the save operation. 29 | public func save(_ object: Object) throws { 30 | if let error = error { 31 | throw error 32 | } 33 | dictionary[object.id] = object 34 | } 35 | 36 | /// The number of all objects stored in store. 37 | public var objectsCount: Int { dictionary.count } 38 | 39 | /// Whether the store contains a saved object with the given id. 40 | /// - Parameter id: object id. 41 | /// - Returns: true if store contains an object with the given id. 42 | public func containsObject(withId id: Object.ID) -> Bool { 43 | return dictionary[id] != nil 44 | } 45 | 46 | /// Returns an object for the given id, or `nil` if no object is found. 47 | /// - Parameter id: object id. 48 | /// - Returns: object with the given id, or `nil` if no object with the given id is found. 49 | public func object(withId id: Object.ID) -> Object? { 50 | return dictionary[id] 51 | } 52 | 53 | /// Returns all objects in the store. 54 | /// - Returns: collection containing all objects stored in store. 55 | public func allObjects() -> [Object] { 56 | return Array(dictionary.values) 57 | } 58 | 59 | /// Removes object with the given id —if found—. 60 | /// - Parameter id: id for the object to be deleted. 61 | /// - Throws error: any error that might occur during the removal operation. 62 | public func remove(withId id: Object.ID) throws { 63 | if let error = error { 64 | throw error 65 | } 66 | dictionary[id] = nil 67 | } 68 | 69 | /// Removes all objects in store. 70 | /// - Throws error: any error that might occur during the removal operation. 71 | public func removeAll() throws { 72 | if let error = error { 73 | throw error 74 | } 75 | dictionary = [:] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Tests/Utils/SingleObjectStoreFake.swift: -------------------------------------------------------------------------------- 1 | import Blueprints 2 | import Foundation 3 | 4 | /// Single object store fake that uses a property to store and retrieve objects. 5 | public final class SingleObjectStoreFake: SingleObjectStore { 6 | /// Optional object. 7 | public var underlyingObject: Object? 8 | 9 | /// Optional error. Setting this will make any throwing method of the store throw the set error. 10 | public var error: Error? 11 | 12 | /// Create a new store fake with a given dictionary and an option error to be thrown. 13 | /// - Parameters: 14 | /// - underlyingObject: optional object. Defaults to `nil`. 15 | /// - error: optional error. Setting this will make any throwing method of the store throw the set error. 16 | /// Defaults to `nil`. 17 | public init( 18 | underlyingObject: Object? = nil, 19 | error: Error? = nil 20 | ) { 21 | self.underlyingObject = underlyingObject 22 | self.error = error 23 | } 24 | 25 | // MARK: - SingleObjectStore 26 | 27 | /// Saves an object to store. 28 | /// - Parameter object: object to be saved. 29 | /// - Throws error: any error that might occur during the save operation. 30 | public func save(_ object: Object) throws { 31 | if let error = error { 32 | throw error 33 | } 34 | underlyingObject = object 35 | } 36 | 37 | /// Returns the object saved in the store 38 | /// - Returns: object saved in the store. `nil` if no object is saved in store. 39 | public func object() -> Object? { 40 | return underlyingObject 41 | } 42 | 43 | /// Removes any saved object in the store. 44 | /// - Throws error: any error that might occur during the removal operation. 45 | public func remove() throws { 46 | if let error = error { 47 | throw error 48 | } 49 | underlyingObject = nil 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/Utils/StoresError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum StoresError: LocalizedError { 4 | case invalid 5 | 6 | var errorDescription: String? { 7 | return "Invalid." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/Utils/User.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct User: Codable, Identifiable, Hashable { 4 | init( 5 | id: Int, 6 | firstName: String, 7 | lastName: String = "", 8 | age: Double = 30 9 | ) { 10 | self.id = id 11 | self.firstName = firstName 12 | self.lastName = lastName 13 | self.age = age 14 | } 15 | 16 | let id: Int 17 | var firstName: String 18 | var lastName: String 19 | var age: Double 20 | } 21 | 22 | extension User: Equatable { 23 | static func == (lhs: Self, rhs: Self) -> Bool { 24 | return lhs.id == rhs.id 25 | } 26 | } 27 | 28 | extension User: Comparable { 29 | static func < (lhs: Self, rhs: Self) -> Bool { 30 | lhs.id < rhs.id 31 | } 32 | } 33 | 34 | extension User: CustomStringConvertible { 35 | var description: String { 36 | return firstName 37 | } 38 | } 39 | 40 | extension User { 41 | static let ahmad = Self(id: 1, firstName: "Ahmad") 42 | static let dalia = Self(id: 2, firstName: "Dalia") 43 | static let kareem = Self(id: 3, firstName: "Kareem") 44 | 45 | static let invalid = Self(id: 4, firstName: "", age: .nan) 46 | } 47 | --------------------------------------------------------------------------------