Skip to content

Commit fa32f3d

Browse files
authored
Merge pull request #12 from 0xOpenBytes/feature/AsyncEffects
Feature/async effects
2 parents 05d2457 + f8e30e1 commit fa32f3d

File tree

7 files changed

+698
-241
lines changed

7 files changed

+698
-241
lines changed

Package.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ let package = Package(
2424
from: "1.1.1"
2525
),
2626
.package(
27-
url: "https://github.com/0xOpenBytes/t",
28-
from: "0.2.0"
29-
)
27+
url: "https://github.com/0xLeif/swift-custom-dump",
28+
from: "0.4.1"
29+
)
3030
],
3131
targets: [
3232
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
@@ -35,7 +35,7 @@ let package = Package(
3535
name: "CacheStore",
3636
dependencies: [
3737
"c",
38-
"t"
38+
.product(name: "CustomDump", package: "swift-custom-dump")
3939
]
4040
),
4141
.testTarget(

README.md

Lines changed: 160 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,118 +1,205 @@
11
# CacheStore
22

3-
*SwiftUI Observable Cache*
3+
*SwiftUI State Management*
44

55
## What is `CacheStore`?
66

7-
`CacheStore` is a SwiftUI framework to help with state. Define keyed values that you can share locally or globally in your projects. `CacheStore` uses [`c`](https://github.com/0xOpenBytes/c), which a simple composition framework. [`c`](https://github.com/0xOpenBytes/c) has the ability to create transformations that are either unidirectional or bidirectional. There is also a cache that values can be set and resolved, which is used in `CacheStore`.
7+
`CacheStore` is a SwiftUI tate management framework that uses a dictionary as the state. Scoping creates a single source of truth for the parent state. `CacheStore` uses [`c`](https://github.com/0xOpenBytes/c), which a simple composition framework. [`c`](https://github.com/0xOpenBytes/c) has the ability to create transformations that are either unidirectional or bidirectional.
8+
9+
### CacheStore Basic Idea
10+
11+
A `[AnyHashable: Any]` can be used as the single source of truth for an app. Scoping can be done by limiting the known keys. Modification to the scoped value or parent value should be reflected throughout the app.
812

913
## Objects
1014
- `CacheStore`: An object that needs defined Keys to get and set values.
1115
- `Store`: An object that needs defined Keys, Actions, and Dependencies. (Preferred)
16+
- `TestStore`: A testable wrapper around `Store` to make it easy to write XCTestCases
1217

13-
## Store
18+
### Store
1419

15-
A `Store` is an object that you send actions to and read state from. Stores use a private `CacheStore` to manage state behind the scenes. All state changes must be defined in a `StoreActionHandler` where the state gets modified depending on an action.
20+
A `Store` is an object that you send actions to and read state from. Stores use a `CacheStore` to manage state behind the scenes. All state changes must be defined in a `StoreActionHandler` where the state gets modified depending on an action.
1621

17-
## Basic Store Example
22+
### TestStore
1823

19-
Here is a basic `Store` example where this is a Boolean variable called `isOn`. The only way you can modify that variable is be using defined actions for the given store. In this example there is only one action, toggle.
24+
When creating tests you should use `TestStore` to send and receive actions while making expectations. If any expectation is false it will be reported in a `XCTestCase`. If there are any effects left at the end of the test, there will be a failure as all effects must be completed and all resulting actions handled. `TestStore` uses a FIFO (first in first out) queue to manage the effects.
2025

21-
```swift
22-
enum StoreKey {
23-
case isOn
24-
}
26+
## Basic Usage
2527

26-
enum Action {
27-
case toggle
28-
}
29-
30-
let actionHandler = StoreActionHandler<StoreKey, Action, Void> { (store: inout CacheStore<StoreKey>, action: Action, _: Void) in
31-
switch action {
32-
case .toggle:
33-
store.update(.isOn, as: Bool.self, updater: { $0?.toggle() })
34-
}
35-
}
28+
<details>
29+
<summary>Store Example</summary>
3630

37-
let store = Store<StoreKey, Action, Void>(
38-
initialValues: [.isOn: false],
39-
actionHandler: actionHandler,
40-
dependency: ()
41-
)
31+
```swift
32+
import CacheStore
33+
import SwiftUI
4234

43-
try t.assert(store.get(.isOn), isEqualTo: false)
35+
struct Post: Codable, Hashable {
36+
var id: Int
37+
var userId: Int
38+
var title: String
39+
var body: String
40+
}
4441

45-
store.handle(action: .toggle)
42+
enum StoreKey {
43+
case url
44+
case posts
45+
case isLoading
46+
}
4647

47-
try t.assert(store.get(.isOn), isEqualTo: true)
48-
```
48+
enum Action {
49+
case fetchPosts
50+
case postsResponse(Result<[Post], Error>)
51+
}
4952

50-
## Basic CacheStore Example
53+
extension String: Error { }
5154

52-
Here is a simple application that has two files, an `App` file and `ContentView` file. The `App` contains the `StateObject` `CacheStore`. It then adds the `CacheStore` to the global cache using [`c`](https://github.com/0xOpenBytes/c). `ContentView` can then resolve the cache as an `ObservableObject` which can read or write to the cache. The cache can be injected into the `ContentView` directly, see `ContentView_Previews`, or indirectly, see `ContentView`.
55+
struct Dependency {
56+
var fetchPosts: (URL) async -> Result<[Post], Error>
57+
}
5358

54-
```swift
55-
import c
56-
import CacheStore
57-
import SwiftUI
59+
extension Dependency {
60+
static var mock: Dependency {
61+
Dependency(
62+
fetchPosts: { _ in
63+
sleep(1)
64+
return .success([Post(id: 1, userId: 1, title: "Mock", body: "Post")])
65+
}
66+
)
67+
}
68+
69+
static var live: Dependency {
70+
Dependency { url in
71+
do {
72+
let (data, _) = try await URLSession.shared.data(from: url)
73+
return .success(try JSONDecoder().decode([Post].self, from: data))
74+
} catch {
75+
return .failure(error)
76+
}
77+
}
78+
}
79+
}
5880

59-
enum CacheKey: Hashable {
60-
case someValue
81+
let actionHandler = StoreActionHandler<StoreKey, Action, Dependency> { cacheStore, action, dependency in
82+
switch action {
83+
case .fetchPosts:
84+
struct FetchPostsID: Hashable { }
85+
86+
guard let url = cacheStore.get(.url, as: URL.self) else {
87+
return ActionEffect(.postsResponse(.failure("Key `.url` was not a URL")))
88+
}
89+
90+
cacheStore.set(value: true, forKey: .isLoading)
91+
92+
return ActionEffect(id: FetchPostsID()) {
93+
.postsResponse(await dependency.fetchPosts(url))
94+
}
95+
96+
case let .postsResponse(.success(posts)):
97+
cacheStore.set(value: false, forKey: .isLoading)
98+
cacheStore.set(value: posts, forKey: .posts)
99+
100+
case let .postsResponse(.failure(error)):
101+
cacheStore.set(value: false, forKey: .isLoading)
102+
}
103+
104+
return .none
61105
}
62106

63-
@main
64-
struct DemoApp: App {
65-
@StateObject var cacheStore = CacheStore<CacheKey>(
66-
initialValues: [.someValue: "🥳"]
107+
struct ContentView: View {
108+
@ObservedObject var store: Store<StoreKey, Action, Dependency> = .init(
109+
initialValues: [
110+
.url: URL(string: "https://jsonplaceholder.typicode.com/posts") as Any
111+
],
112+
actionHandler: actionHandler,
113+
dependency: .live
67114
)
115+
.debug
68116

69-
var body: some Scene {
70-
c.set(value: cacheStore, forKey: "CacheStore")
71-
72-
return WindowGroup {
73-
VStack {
74-
Text("@StateObject value: \(cacheStore.resolve(.someValue) as String)")
75-
ContentView()
117+
private var isLoading: Bool {
118+
store.get(.isLoading, as: Bool.self) ?? true
119+
}
120+
121+
var body: some View {
122+
if
123+
!isLoading,
124+
let posts = store.get(.posts, as: [Post].self)
125+
{
126+
List(posts, id: \.self) { post in
127+
Text(post.title)
76128
}
129+
} else {
130+
ProgressView()
131+
.onAppear {
132+
store.handle(action: .fetchPosts)
133+
}
77134
}
78135
}
79136
}
80-
81137
```
82138

83-
### ContentView
139+
</details>
140+
141+
<details>
142+
<summary>Testing</summary>
84143

85144
```swift
86-
import c
87145
import CacheStore
88-
import SwiftUI
146+
import XCTest
147+
@testable import CacheStoreDemo
89148

90-
struct ContentView: View {
91-
@ObservedObject var cacheStore: CacheStore<CacheKey> = c.resolve("CacheStore")
92-
93-
var stringValue: String {
94-
cacheStore.resolve(.someValue)
149+
class CacheStoreDemoTests: XCTestCase {
150+
override func setUp() {
151+
TestStoreFailure.handler = XCTFail
95152
}
96153

97-
var body: some View {
98-
VStack {
99-
Text("Current Value: \(stringValue)")
100-
Button("Update Value") {
101-
cacheStore.set(value: ":D", forKey: .someValue)
102-
}
154+
func testExample_success() throws {
155+
let store = TestStore(
156+
initialValues: [
157+
.url: URL(string: "https://jsonplaceholder.typicode.com/posts") as Any
158+
],
159+
actionHandler: actionHandler,
160+
dependency: .mock
161+
)
162+
163+
store.send(.fetchPosts) { cacheStore in
164+
cacheStore.set(value: true, forKey: .isLoading)
165+
}
166+
store.send(.fetchPosts) { cacheStore in
167+
cacheStore.set(value: true, forKey: .isLoading)
168+
}
169+
170+
let expectedPosts: [Post] = [Post(id: 1, userId: 1, title: "Mock", body: "Post")]
171+
172+
store.receive(.postsResponse(.success(expectedPosts))) { cacheStore in
173+
cacheStore.set(value: false, forKey: .isLoading)
174+
cacheStore.set(value: expectedPosts, forKey: .posts)
103175
}
104-
.padding()
105176
}
106-
}
107-
108-
struct ContentView_Previews: PreviewProvider {
109-
static var previews: some View {
110-
ContentView(
111-
cacheStore: CacheStore(
112-
initialValues: [.someValue: "Preview Cache Value"]
113-
)
177+
178+
func testExample_failure() throws {
179+
let store = TestStore(
180+
initialValues: [
181+
:
182+
],
183+
actionHandler: actionHandler,
184+
dependency: .mock
114185
)
186+
187+
store.send(.fetchPosts, expecting: { _ in })
188+
189+
store.receive(.postsResponse(.failure("Key `.url` was not a URL"))) { cacheStore in
190+
cacheStore.set(value: false, forKey: .isLoading)
191+
}
115192
}
116193
}
117-
118194
```
195+
196+
</details>
197+
198+
***
199+
200+
## Acknowledgement of Dependencies
201+
- [pointfreeco/swift-custom-dump](https://github.com/pointfreeco/swift-custom-dump)
202+
203+
204+
## Inspiration
205+
- [pointfreeco/swift-composable-architecture](https://github.com/pointfreeco/swift-composable-architecture)
Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,64 @@
1+
import Foundation
2+
3+
/// ActionHandlers can handle any value of `Action`. Normally `Action` is an enum.
14
public protocol ActionHandling {
5+
/// `Action` Type that is passed into the handle function
26
associatedtype Action
37

48
/// Handle the given `Action`
59
func handle(action: Action)
610
}
711

12+
/// Async effect produced from an `Action` that can optionally produce another `Action`
13+
public struct ActionEffect<Action> {
14+
/// ID used to identify and cancel the effect
15+
public let id: AnyHashable
16+
/// Async closure that optionally produces an `Action`
17+
public let effect: () async -> Action?
18+
19+
/// No effect
20+
public static var none: Self {
21+
ActionEffect { nil }
22+
}
23+
24+
/// init for `ActionEffect<Action>` taking an async effect
25+
public init(
26+
id: AnyHashable = UUID(),
27+
effect: @escaping () async -> Action?
28+
) {
29+
self.id = id
30+
self.effect = effect
31+
}
32+
33+
/// init for `ActionEffect<Action>` taking an immediate action
34+
public init(_ action: Action) {
35+
self.id = UUID()
36+
self.effect = { action }
37+
}
38+
}
39+
40+
/// Handles an `Action` that modifies a `CacheStore` using a `Dependency`
841
public struct StoreActionHandler<Key: Hashable, Action, Dependency> {
9-
private let handler: (inout CacheStore<Key>, Action, Dependency) -> Void
42+
private let handler: (inout CacheStore<Key>, Action, Dependency) -> ActionEffect<Action>?
1043

44+
/// init for `StoreActionHandler<Key: Hashable, Action, Dependency>`
1145
public init(
12-
_ handler: @escaping (inout CacheStore<Key>, Action, Dependency) -> Void
46+
_ handler: @escaping (inout CacheStore<Key>, Action, Dependency) -> ActionEffect<Action>?
1347
) {
1448
self.handler = handler
1549
}
1650

51+
/// `StoreActionHandler` that doesn't handle any `Action`
1752
public static var none: StoreActionHandler<Key, Action, Dependency> {
18-
StoreActionHandler { _, _, _ in }
53+
StoreActionHandler { _, _, _ in nil }
1954
}
2055

21-
public func handle(store: inout CacheStore<Key>, action: Action, dependency: Dependency) {
56+
/// Mutate `CacheStore<Key>` for `Action` with `Dependency`
57+
public func handle(
58+
store: inout CacheStore<Key>,
59+
action: Action,
60+
dependency: Dependency
61+
) -> ActionEffect<Action>? {
2262
handler(&store, action, dependency)
2363
}
2464
}

0 commit comments

Comments
 (0)