├── .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 | [](https://github.com/omaralbeik/Stores/actions/workflows/CI.yml)
4 | [](https://codecov.io/gh/omaralbeik/Stores)
5 | [](https://swiftpackageindex.com/omaralbeik/Stores)
6 | [](https://swiftpackageindex.com/omaralbeik/Stores)
7 | [](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 | 
33 | 
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 |
69 |
70 |
71 |
72 | UserDefaults
73 |
74 | ```swift
75 | // Store for multiple objects
76 | let store = MultiUserDefaultsStore(suiteName: "users")
77 |
78 | // Store for a single object
79 | let store = SingleUserDefaultsStore(suiteName: "users")
80 | ```
81 |
82 |
83 |
84 |
85 |
86 | FileSystem
87 |
88 | ```swift
89 | // Store for multiple objects
90 | let store = MultiFileSystemStore(path: "users")
91 |
92 | // Store for a single object
93 | let store = SingleFileSystemStore(path: "users")
94 | ```
95 |
96 |
97 |
98 |
99 |
100 | CoreData
101 |
102 | ```swift
103 | // Store for multiple objects
104 | let store = MultiCoreDataStore(databaseName: "users")
105 |
106 | // Store for a single object
107 | let store = SingleCoreDataStore(databaseName: "users")
108 | ```
109 |
110 |
111 |
112 |
113 |
114 | Keychain
115 |
116 | ```swift
117 | // Store for multiple objects
118 | let store = MultiKeychainStore(identifier: "users")
119 |
120 | // Store for a single object
121 | let store = SingleKeychainStore(identifier: "users")
122 | ```
123 |
124 |
125 |
126 |
127 |
128 | Fakes (for testing)
129 |
130 | ```swift
131 | // Store for multiple objects
132 | let store = MultiObjectStoreFake()
133 |
134 | // Store for a single object
135 | let store = SingleObjectStoreFake()
136 | ```
137 |
138 |
139 |
140 |
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 |
145 |
146 |
147 | Realm
148 |
149 | ```swift
150 | // Store for multiple objects
151 | final class MultiRealmStore: MultiObjectStore {
152 | // ...
153 | }
154 |
155 | // Store for a single object
156 | final class SingleRealmStore: SingleObjectStore {
157 | // ...
158 | }
159 | ```
160 |
161 |
162 |
163 |
164 |
165 | SQLite
166 |
167 | ```swift
168 | // Store for multiple objects
169 | final class MultiSQLiteStore: MultiObjectStore {
170 | // ...
171 | }
172 |
173 | // Store for a single object
174 | final class SingleSQLiteStore: SingleObjectStore {
175 | // ...
176 | }
177 | ```
178 |
179 |
180 |
181 |
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 | 
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 |
--------------------------------------------------------------------------------