From f19821ba4cad0c90e5ac4486a608e73878fd0392 Mon Sep 17 00:00:00 2001 From: Charles Maria Tor Date: Tue, 16 Feb 2021 20:37:18 +1100 Subject: [PATCH 1/2] Add binding functions and change state lensing to use functions rather than keypaths --- Sources/RecombinePackage/Action.swift | 4 +- Sources/RecombinePackage/Middleware.swift | 5 +- Sources/RecombinePackage/Store/AnyStore.swift | 11 +- .../RecombinePackage/Store/BaseStore.swift | 7 +- .../RecombinePackage/Store/LensedStore.swift | 23 ++-- .../Store/StoreProtocol.swift | 106 +++++++++++++++--- 6 files changed, 121 insertions(+), 35 deletions(-) diff --git a/Sources/RecombinePackage/Action.swift b/Sources/RecombinePackage/Action.swift index ae2a03d..b028104 100644 --- a/Sources/RecombinePackage/Action.swift +++ b/Sources/RecombinePackage/Action.swift @@ -1,4 +1,6 @@ -public enum ActionStrata { +public enum ActionStrata { + public typealias Raw = RawAction + public typealias Refined = RefinedAction case raw(Raw) case refined(Refined) } diff --git a/Sources/RecombinePackage/Middleware.swift b/Sources/RecombinePackage/Middleware.swift index 3efb079..450e8a7 100644 --- a/Sources/RecombinePackage/Middleware.swift +++ b/Sources/RecombinePackage/Middleware.swift @@ -1,7 +1,8 @@ import Combine -/// Middleware is a structure that allows you to modify, filter out and create more -/// actions, before the action being handled reaches the store. +/// Middleware is a dependency injection structure that allows you to transform raw actions into refined ones, +/// Refined actions produced by Middleware are then forwarded to the main reducer. +/// public struct Middleware { public typealias StatePublisher = Publishers.First.Publisher> public typealias Function = (StatePublisher, Input) -> AnyPublisher diff --git a/Sources/RecombinePackage/Store/AnyStore.swift b/Sources/RecombinePackage/Store/AnyStore.swift index 56395cb..3f10c24 100644 --- a/Sources/RecombinePackage/Store/AnyStore.swift +++ b/Sources/RecombinePackage/Store/AnyStore.swift @@ -2,7 +2,7 @@ import Combine public class AnyStore: StoreProtocol { public let underlying: BaseStore - public let keyPath: KeyPath + public let stateLens: (BaseState) -> SubState public let actionPromotion: (SubRefinedAction) -> BaseRefinedAction private var cancellables = Set() @Published @@ -17,7 +17,7 @@ public class AnyStore( - state keyPath: KeyPath, + state lens: @escaping (SubState) -> NewState, actions transform: @escaping (NewAction) -> SubRefinedAction ) -> LensedStore< BaseState, @@ -36,9 +36,10 @@ public class AnyStore { - .init( + let stateLens = self.stateLens + return .init( store: underlying, - lensing: self.keyPath.appending(path: keyPath), + lensing: { lens(stateLens($0)) }, actionPromotion: { self.actionPromotion(transform($0)) } ) } diff --git a/Sources/RecombinePackage/Store/BaseStore.swift b/Sources/RecombinePackage/Store/BaseStore.swift index 56a6892..104bb45 100644 --- a/Sources/RecombinePackage/Store/BaseStore.swift +++ b/Sources/RecombinePackage/Store/BaseStore.swift @@ -8,7 +8,7 @@ public class BaseStore: StoreProtocol { public private(set) var state: State public var statePublisher: Published.Publisher { $state } public var underlying: BaseStore { self } - public let keyPath: KeyPath = \.self + public let stateLens: (State) -> State = { $0 } public let rawActions = PassthroughSubject() public let refinedActions = PassthroughSubject() public let actionPromotion: (RefinedAction) -> RefinedAction = { $0 } @@ -44,6 +44,7 @@ public class BaseStore: StoreProtocol { action: action ) } + .removeDuplicates(by: stateEquality) .receive(on: scheduler) .sink { [unowned self] state in self.state = state @@ -67,7 +68,7 @@ public class BaseStore: StoreProtocol { } public func lensing( - state keyPath: KeyPath, + state lens: @escaping (SubState) -> NewState, actions transform: @escaping (NewAction) -> SubRefinedAction ) -> LensedStore< State, @@ -76,7 +77,7 @@ public class BaseStore: StoreProtocol { RefinedAction, NewAction > { - .init(store: self, lensing: keyPath, actionPromotion: transform) + .init(store: self, lensing: lens, actionPromotion: transform) } open func dispatch(refined actions: S) where S.Element == RefinedAction { diff --git a/Sources/RecombinePackage/Store/LensedStore.swift b/Sources/RecombinePackage/Store/LensedStore.swift index 2c3e7a2..60d9a7c 100644 --- a/Sources/RecombinePackage/Store/LensedStore.swift +++ b/Sources/RecombinePackage/Store/LensedStore.swift @@ -6,18 +6,18 @@ public class LensedStore.Publisher { $state } public let underlying: BaseStore - public let keyPath: KeyPath + public let stateLens: (BaseState) -> SubState public let actions = PassthroughSubject() public let actionPromotion: (SubRefinedAction) -> BaseRefinedAction private var cancellables = Set() - public required init(store: StoreType, lensing keyPath: KeyPath, actionPromotion: @escaping (SubRefinedAction) -> BaseRefinedAction) { + public required init(store: StoreType, lensing lens: @escaping (BaseState) -> SubState, actionPromotion: @escaping (SubRefinedAction) -> BaseRefinedAction) { self.underlying = store - self.keyPath = keyPath + self.stateLens = lens self.actionPromotion = actionPromotion - state = store.state[keyPath: keyPath] + state = lens(store.state) store.$state - .map { $0[keyPath: keyPath] } + .map(lens) .removeDuplicates() .sink { [unowned self] state in self.state = state @@ -31,7 +31,7 @@ public class LensedStore( - state keyPath: KeyPath, + state lens: @escaping (SubState) -> NewState, actions transform: @escaping (NewAction) -> SubRefinedAction ) -> LensedStore< BaseState, @@ -40,9 +40,10 @@ public class LensedStore { - .init( + let stateLens = self.stateLens + return .init( store: underlying, - lensing: self.keyPath.appending(path: keyPath), + lensing: { lens(stateLens($0)) }, actionPromotion: { self.actionPromotion(transform($0)) } ) } @@ -56,8 +57,8 @@ public class LensedStore) { - self.init(store: store, lensing: keyPath, actionPromotion: { $0 }) +public extension LensedStore where BaseRefinedAction == SubRefinedAction { + convenience init(store: StoreType, lensing lens: @escaping (BaseState) -> SubState) { + self.init(store: store, lensing: lens, actionPromotion: { $0 }) } } diff --git a/Sources/RecombinePackage/Store/StoreProtocol.swift b/Sources/RecombinePackage/Store/StoreProtocol.swift index d2a2132..cbf7da5 100644 --- a/Sources/RecombinePackage/Store/StoreProtocol.swift +++ b/Sources/RecombinePackage/Store/StoreProtocol.swift @@ -1,4 +1,5 @@ import Combine +import SwiftUI public protocol StoreProtocol: ObservableObject, Subscriber { associatedtype BaseState @@ -9,12 +10,12 @@ public protocol StoreProtocol: ObservableObject, Subscriber { var state: SubState { get } var statePublisher: Published.Publisher { get } var underlying: BaseStore { get } - var keyPath: KeyPath { get } + var stateLens: (BaseState) -> SubState { get } var actionPromotion: (SubRefinedAction) -> BaseRefinedAction { get } func dispatch(raw: S) where S.Element == RawAction func dispatch(refined: S) where S.Element == SubRefinedAction func lensing( - state keyPath: KeyPath, + state lens: @escaping (SubState) -> NewState, actions transform: @escaping (NewAction) -> SubRefinedAction ) -> LensedStore< BaseState, @@ -27,30 +28,109 @@ public protocol StoreProtocol: ObservableObject, Subscriber { } public extension StoreProtocol { - func lensing( - actions transform: @escaping (NewAction) -> SubRefinedAction + func lensing( + state lens: @escaping (SubState) -> NewState ) -> LensedStore< BaseState, NewState, RawAction, BaseRefinedAction, + SubRefinedAction + > { + lensing(state: lens, actions: { $0 }) + } + + func lensing( + state keyPath: KeyPath + ) -> LensedStore< + BaseState, + NewState, + RawAction, + BaseRefinedAction, + SubRefinedAction + > { + lensing(state: { $0[keyPath: keyPath] }) + } + + func lensing( + actions transform: @escaping (NewAction) -> SubRefinedAction + ) -> LensedStore< + BaseState, + SubState, + RawAction, + BaseRefinedAction, NewAction - > where NewState == SubState { - lensing(state: \.self, actions: transform) + > { + lensing(state: { $0 }, actions: transform) } func lensing( - state keyPath: KeyPath + state keyPath: KeyPath, + actions transform: @escaping (NewAction) -> SubRefinedAction ) -> LensedStore< BaseState, NewState, RawAction, BaseRefinedAction, NewAction - > where NewAction == SubRefinedAction { - lensing(state: keyPath, actions: { $0 }) + > { + lensing(state: { $0[keyPath: keyPath] }, actions: transform) } +} +public extension StoreProtocol { + /// Create a SwiftUI Binding from a `KeyPath` and a `SubRefinedAction`. + /// - Parameters: + /// - keyPath: A keypath to the state property. + /// - action: The refined action which will be called when the value is changed. + /// - Returns: A `Binding` whose getter is the property and whose setter dispatches the refined action. + func binding( + state keyPath: KeyPath, + actions transform: @escaping (Value) -> SubRefinedAction + ) -> Binding { + .init( + get: { self.state[keyPath: keyPath] }, + set: { self.dispatch(refined: transform($0)) } + ) + } + + /// Create a SwiftUI Binding from the `SubState` of the store and a `SubRefinedAction`. + /// - Parameters: + /// - actions: The refined action which will be called when the value is changed. + /// - Returns: A `Binding` whose getter is the state and whose setter dispatches the refined action. + func binding( + actions transform: @escaping (SubState) -> SubRefinedAction + ) -> Binding { + .init( + get: { self.state }, + set: { self.dispatch(refined: transform($0)) } + ) + } + + /// Create a SwiftUI Binding from a `KeyPath` when the value of that path is equivalent to `SubRefinedAction`. + /// - Parameters: + /// - keyPath: A keypath to the state property. + /// - Returns: A `Binding` whose getter is the property and whose setter dispatches the store's refined action. + func binding( + state keyPath: KeyPath + ) -> Binding where SubRefinedAction == Value { + .init( + get: { self.state[keyPath: keyPath] }, + set: { self.dispatch(refined: $0) } + ) + } + + /// Create a SwiftUI Binding from the `SubState` when its value is equivalent to `SubRefinedAction`. + /// - Returns: A `Binding` whose getter is the state and whose setter dispatches the store's refined action. + func binding() -> Binding where SubRefinedAction == SubState { + .init( + get: { self.state }, + set: { self.dispatch(refined: $0) } + ) + } +} + +public extension StoreProtocol { func dispatch(refined actions: SubRefinedAction...) { dispatch(refined: actions) } @@ -64,12 +144,12 @@ public extension StoreProtocol { } } -extension StoreProtocol { - public func receive(subscription: Subscription) { +public extension StoreProtocol { + func receive(subscription: Subscription) { subscription.request(.unlimited) } - public func receive(_ input: ActionStrata) -> Subscribers.Demand { + func receive(_ input: ActionStrata) -> Subscribers.Demand { switch input { case let .raw(action): dispatch(raw: action) @@ -79,5 +159,5 @@ extension StoreProtocol { return .unlimited } - public func receive(completion: Subscribers.Completion) {} + func receive(completion: Subscribers.Completion) {} } From 7f8ea980c9e2028f3b337b31fb973a7aee4b9c07 Mon Sep 17 00:00:00 2001 From: Charles Maria Tor Date: Tue, 16 Feb 2021 21:33:39 +1100 Subject: [PATCH 2/2] Add binding tests --- Sources/RecombinePackage/Middleware.swift | 2 +- .../Store/StoreProtocol.swift | 16 +++---- Tests/RecombineTests/StoreTests.swift | 42 ++++++++++++++++++- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/Sources/RecombinePackage/Middleware.swift b/Sources/RecombinePackage/Middleware.swift index 450e8a7..81b1f56 100644 --- a/Sources/RecombinePackage/Middleware.swift +++ b/Sources/RecombinePackage/Middleware.swift @@ -9,7 +9,7 @@ public struct Middleware { public typealias Transform = (StatePublisher, Output) -> Result internal let transform: Function - /// Create a blank slate Middleware. + /// Create a passthrough Middleware. public init() where Input == Output { self.transform = { Just($1).eraseToAnyPublisher() } } diff --git a/Sources/RecombinePackage/Store/StoreProtocol.swift b/Sources/RecombinePackage/Store/StoreProtocol.swift index cbf7da5..522424e 100644 --- a/Sources/RecombinePackage/Store/StoreProtocol.swift +++ b/Sources/RecombinePackage/Store/StoreProtocol.swift @@ -79,17 +79,17 @@ public extension StoreProtocol { } public extension StoreProtocol { - /// Create a SwiftUI Binding from a `KeyPath` and a `SubRefinedAction`. + /// Create a SwiftUI Binding from a lensing function and a `SubRefinedAction`. /// - Parameters: - /// - keyPath: A keypath to the state property. + /// - lens: A lens to the state property. /// - action: The refined action which will be called when the value is changed. /// - Returns: A `Binding` whose getter is the property and whose setter dispatches the refined action. func binding( - state keyPath: KeyPath, + state lens: @escaping (SubState) -> Value, actions transform: @escaping (Value) -> SubRefinedAction ) -> Binding { .init( - get: { self.state[keyPath: keyPath] }, + get: { lens(self.state) }, set: { self.dispatch(refined: transform($0)) } ) } @@ -107,15 +107,15 @@ public extension StoreProtocol { ) } - /// Create a SwiftUI Binding from a `KeyPath` when the value of that path is equivalent to `SubRefinedAction`. + /// Create a SwiftUI Binding from a lensing function when the value of that function is equivalent to `SubRefinedAction`. /// - Parameters: - /// - keyPath: A keypath to the state property. + /// - lens: A lens to the state property. /// - Returns: A `Binding` whose getter is the property and whose setter dispatches the store's refined action. func binding( - state keyPath: KeyPath + state lens: @escaping (SubState) -> Value ) -> Binding where SubRefinedAction == Value { .init( - get: { self.state[keyPath: keyPath] }, + get: { lens(self.state) }, set: { self.dispatch(refined: $0) } ) } diff --git a/Tests/RecombineTests/StoreTests.swift b/Tests/RecombineTests/StoreTests.swift index 3889251..d0da8d3 100644 --- a/Tests/RecombineTests/StoreTests.swift +++ b/Tests/RecombineTests/StoreTests.swift @@ -30,7 +30,10 @@ class StoreTests: XCTestCase { middleware: .init(), publishOn: ImmediateScheduler.shared ) - let subStore = store.lensing(state: \.subState.value, actions: TestFakes.NestedTest.Action.sub) + let subStore = store.lensing( + state: \.subState.value, + actions: TestFakes.NestedTest.Action.sub + ) let stateRecorder = subStore.$state.dropFirst().record() let actionsRecorder = subStore.actions.record() @@ -43,6 +46,43 @@ class StoreTests: XCTestCase { let actions = try wait(for: actionsRecorder.prefix(1), timeout: 1) XCTAssertEqual(actions, [.set(string)]) } + + func testBinding() throws { + let store = BaseStore( + state: TestFakes.NestedTest.State(), + reducer: TestFakes.NestedTest.reducer, + middleware: .init(), + publishOn: ImmediateScheduler.shared + ) + let binding1 = store.binding( + state: \.subState.value, + actions: { .sub(.set("\($0)1")) } + ) + let binding2 = store.lensing( + state: \.subState.value + ).binding( + actions: { .sub(.set("\($0)2")) } + ) + let binding3 = store.lensing( + state: \.subState, + actions: { .sub(.set("\($0)3")) } + ).binding( + state: \.value + ) + let stateRecorder = store.$state.dropFirst().record() + + let string = "Oh Yeah!" + + binding1.wrappedValue = string + binding2.wrappedValue = string + binding3.wrappedValue = string + + let state = try wait(for: stateRecorder.prefix(3), timeout: 1) + XCTAssertEqual( + state.map(\.subState.value), + zip(repeatElement(string, count: 3), 1...).map { "\($0)\($1)" } + ) + } } // Used for deinitialization test