diff --git a/Sources/QLoop/QLAnchor+ConvenienceInit.swift b/Sources/QLoop/QLAnchor+ConvenienceInit.swift index bc47c3e..942f091 100644 --- a/Sources/QLoop/QLAnchor+ConvenienceInit.swift +++ b/Sources/QLoop/QLAnchor+ConvenienceInit.swift @@ -11,4 +11,23 @@ public extension QLAnchor { self.init(onChange: onChange, onError: QLAnchor.emptyErr) } + + convenience init(repeaters: QLAnchor...) { + self.init(echoFilter: QLAnchor.DefaultEchoFilter, + repeaters: repeaters) + } + + convenience init(echoFilter: @escaping EchoFilter, + repeaters: QLAnchor...) { + self.init(echoFilter: echoFilter, + repeaters: repeaters) + } + + convenience init(echoFilter: @escaping EchoFilter, + repeaters: [QLAnchor]) { + self.init(onChange: QLAnchor.emptyIn, + onError: QLAnchor.emptyErr) + self.repeaters = repeaters + self.echoFilter = echoFilter + } } diff --git a/Sources/QLoop/QLAnchor.swift b/Sources/QLoop/QLAnchor.swift index 4564d53..9a81fe8 100644 --- a/Sources/QLoop/QLAnchor.swift +++ b/Sources/QLoop/QLAnchor.swift @@ -8,6 +8,25 @@ public final class QLAnchor: AnyAnchor { public typealias OnChange = (Input?)->() public typealias OnError = (Error)->() + public typealias EchoFilter = (Input?, QLAnchor) -> (Bool) + internal static var DefaultEchoFilter: EchoFilter { return { _, _ in return true } } + + internal final class Repeater { + weak var anchor: QLAnchor? + init(_ anchor: QLAnchor) { + self.anchor = anchor + } + func echo(value: Input?, filter: EchoFilter) { + if let repeater = self.anchor, + filter(value, repeater) { + repeater.value = value + } + } + func echo(error: Error) { + anchor?.error = error + } + } + lazy var inputQueue = DispatchQueue(label: "\(self).inputQueue", qos: .userInitiated) @@ -17,6 +36,22 @@ public final class QLAnchor: AnyAnchor { self.onError = onError } + public var onChange: OnChange + + public var onError: OnError + + public var inputSegment: AnySegment? + + public var repeaters: [QLAnchor] { + get { return _repeaters.compactMap { $0.anchor } } + set { self._repeaters = newValue.map { Repeater($0) } } + } + + internal var _repeaters: [Repeater] = [] + + public var echoFilter: EchoFilter = DefaultEchoFilter + + private var _value: Input? public var value: Input? { get { var safeInput: Input? = nil @@ -25,14 +60,12 @@ public final class QLAnchor: AnyAnchor { } set { inputQueue.sync { self._value = newValue } - if QLCommon.Config.Anchor.autoThrowResultFailures, - let errGettable = newValue as? ErrorGettable, - let err = errGettable.getError() { + + if let err = getReroutableError(newValue) { self.error = err } else { - DispatchQueue.main.async { - self.onChange(newValue) - } + dispatch(value: newValue) + echo(value: newValue) } if (QLCommon.Config.Anchor.releaseValues) { @@ -40,9 +73,8 @@ public final class QLAnchor: AnyAnchor { } } } - private var _value: Input? - + private var _error: Error? public var error: Error? { get { var safeError: Error? = nil @@ -52,19 +84,44 @@ public final class QLAnchor: AnyAnchor { set { let err: Error = newValue ?? QLCommon.Error.ThrownButNotSet inputQueue.sync { self._error = err } - DispatchQueue.main.async { - self.onError(err) - } + dispatch(error: err) + echo(error: err) if (QLCommon.Config.Anchor.releaseValues) { inputQueue.sync { self._error = nil } } } } - private var _error: Error? - public var onChange: OnChange - public var onError: OnError + private func getReroutableError(_ newValue: Input?) -> Error? { + guard QLCommon.Config.Anchor.autoThrowResultFailures, + let errGettable = newValue as? ErrorGettable, + let err = errGettable.getError() + else { return nil } + return err + } - public var inputSegment: AnySegment? + private func dispatch(value: Input?) { + DispatchQueue.main.async { + self.onChange(value) + } + } + + private func dispatch(error: Error) { + DispatchQueue.main.async { + self.onError(error) + } + } + + private func echo(value: Input?) { + for repeater in _repeaters { + repeater.echo(value: value, filter: echoFilter) + } + } + + private func echo(error: Error) { + for repeater in _repeaters { + repeater.echo(error: error) + } + } } diff --git a/Tests/QLoopTests/QLAnchorTests.swift b/Tests/QLoopTests/QLAnchorTests.swift index 22b1b0e..57297ba 100644 --- a/Tests/QLoopTests/QLAnchorTests.swift +++ b/Tests/QLoopTests/QLAnchorTests.swift @@ -80,4 +80,75 @@ final class QLAnchorTests: XCTestCase { wait(for: [expect], timeout: 8.0) XCTAssert((receivedError as? QLCommon.Error) == QLCommon.Error.ThrownButNotSet) } + + func test_given_it_has_repeaters_with_default_filter_when_input_set_then_it_echoes_to_them_as_well() { + var receivedVal0: Int = -1 + var receivedVal1: Int = -1 + var receivedVal2: Int = -1 + let expectOriginal0 = expectation(description: "should dispatch value") + let expectRepeater1 = expectation(description: "should echo value to repeater1") + let expectRepeater2 = expectation(description: "should echo value to repeater2") + let repeater1 = QLAnchor(onChange: { receivedVal1 = $0!; expectRepeater1.fulfill() }) + let repeater2 = QLAnchor(onChange: { receivedVal2 = $0!; expectRepeater2.fulfill() }) + let subject = QLAnchor(repeaters: repeater1, repeater2) + + subject.onChange = { receivedVal0 = $0!; expectOriginal0.fulfill() } + + subject.value = 99 + + wait(for: [expectOriginal0, expectRepeater1, expectRepeater2], timeout: 8.0) + XCTAssertEqual(receivedVal0, 99) + XCTAssertEqual(receivedVal1, 99) + XCTAssertEqual(receivedVal2, 99) + } + + func test_given_it_has_repeaters_with_default_filter_when_error_set_then_it_echoes_to_them_as_well() { + var receivedErr0: Error? = nil + var receivedErr1: Error? = nil + var receivedErr2: Error? = nil + let expectOriginal0 = expectation(description: "should dispatch error") + let expectRepeater1 = expectation(description: "should echo error to repeater1") + let expectRepeater2 = expectation(description: "should echo error to repeater2") + let repeater1 = QLAnchor(onChange: { _ in }, + onError: { receivedErr1 = $0; expectRepeater1.fulfill() }) + let repeater2 = QLAnchor(onChange: { _ in }, + onError: { receivedErr2 = $0; expectRepeater2.fulfill() }) + let subject = QLAnchor(repeaters: repeater1, repeater2) + + subject.onError = { receivedErr0 = $0; expectOriginal0.fulfill() } + + subject.error = QLCommon.Error.Unknown + + wait(for: [expectOriginal0, expectRepeater1, expectRepeater2], timeout: 8.0) + XCTAssertNotNil(receivedErr0) + XCTAssertNotNil(receivedErr1) + XCTAssertNotNil(receivedErr2) + } + + func test_given_it_has_repeaters_with_custom_filter_when_input_set_then_it_dispatches_then_echoes_to_them_conditionally() { + var receivedVal0: Int = -1 + var receivedVal1: Int = -1 + var receivedVal2: Int = -1 + let expectOriginal0 = expectation(description: "should dispatch value") + let expectRepeater2 = expectation(description: "should echo value to repeater2") + let repeater1 = QLAnchor(onChange: { receivedVal1 = $0! }) + let repeater2 = QLAnchor(onChange: { receivedVal2 = $0!; expectRepeater2.fulfill() }) + + let subject = QLAnchor( + echoFilter: ({ val, repeater in + return (val == 11 && repeater === repeater1) + || (val == 22 && repeater === repeater2) + }), + repeaters: repeater1, repeater2 + ) + + subject.onChange = { receivedVal0 = $0!; expectOriginal0.fulfill() } + + subject.value = 22 + + wait(for: [expectOriginal0, expectRepeater2], timeout: 8.0) + XCTAssertEqual(receivedVal0, 22) + XCTAssertEqual(receivedVal1, -1) + XCTAssertEqual(receivedVal2, 22) + } } diff --git a/docs/reference/QLAnchor.md b/docs/reference/QLAnchor.md index a064a20..9cf1d9e 100644 --- a/docs/reference/QLAnchor.md +++ b/docs/reference/QLAnchor.md @@ -20,6 +20,8 @@ - init(onChange: `(Input?)->()`, onError: `(Error)->()` ) +- init(repeaters: `QLAnchor.Repeater`, `...` ) +
@@ -64,3 +66,40 @@ An `anchor` : `QLAnchor` implements a type of **semaphore** that makes use of synchronous dispatch queues around its `value` and `error` nodes. Inputs can safely arrive on any thread, and the events are guaranteed to arrive in serial fashion, although their order is not. + + +##### Repeaters + +Repeaters offer a way to fork multiple streams off of the main path. + +When an Anchor has repeaters applied, then it will `echo` any `value` and `error` changes +to each of them. + +By default, it forwards all changes to all repeaters. In order to make it conditional, we can +set an `EchoFilter`, which gets called prior to forwarding to each repeater. Return `false` +from the EchoFilter to block that repeater from receiving the change. + + +##### EchoFilter + +- `(Input?, QLAnchor) -> (Bool)` + +Default filter returns `true`. You can evaluate the input value and decide whether or not the +particular `anchor` (repeater) should receive the new value. + +To identify the anchor, you will need to do so using the object reference. + +example: + +``` +let progressRepeater = viewController.progressAnchor +let finalRepeater = viewController.downloadCompleteAnchor + +let baseAnchor = QLAnchor( + echoFilter: ({ obj, repeater in + return (obj.isProgress && repeater === progressRepeater) + || (obj.isFinal && repeater === finalRepeater) + }), + repeaters: progressRepeater, finalRepeater +) +```