Skip to content

Commit d4c2bf1

Browse files
author
Thibault Wittemberg
committed
operators: add compactScan() and tryCompactScan()
1 parent 665fc63 commit d4c2bf1

File tree

3 files changed

+216
-0
lines changed

3 files changed

+216
-0
lines changed

README.md

+25
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ All operators, utilities and helpers respect Combine's publisher contract, inclu
4444
* [ignoreFailure](#ignoreFailure)
4545
* [mapToResult](#mapToResult)
4646
* [flatMapBatches(of:)](#flatMapBatchesof)
47+
* [compactScan()](#compactScan)
4748

4849
### Publishers
4950
* [AnyPublisher.create](#AnypublisherCreate)
@@ -755,6 +756,30 @@ subscription = ints
755756
.finished
756757
```
757758

759+
------
760+
761+
### compactScan()
762+
763+
Transforms elements from the upstream publisher by providing the current element to a closure along with the last value returned by the closure. If the closure returns a nil value, then the accumulator won't change until the next non-nil upstream publisher value.
764+
765+
```swift
766+
let cancellable = (0...5)
767+
.publisher
768+
.compactScan(0) {
769+
guard $1.isMultiple(of: 2) else { return nil }
770+
return $0 + $1
771+
}
772+
.sink { print ("\($0)") }
773+
```
774+
775+
#### Output
776+
777+
```none
778+
0 2 6
779+
```
780+
781+
The `tryCompactScan()` version behaves the same but with a throwing closure.
782+
758783
## Publishers
759784

760785
This section outlines some of the custom Combine publishers CombineExt provides

Sources/Operators/CompactScan.swift

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//
2+
// CompactScan.swift
3+
// CombineExt
4+
//
5+
// Created by Thibault Wittemberg on 04/09/2021.
6+
// Copyright © 2021 Combine Community. All rights reserved.
7+
//
8+
9+
#if canImport(Combine)
10+
import Combine
11+
12+
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
13+
public extension Publisher {
14+
15+
/// Transforms elements from the upstream publisher by providing the current
16+
/// element to a closure along with the last value returned by the closure.
17+
///
18+
/// The ``nextPartialResult`` closure might return nil values. In that case the accumulator won't change until the next non-nil upstream publisher value.
19+
///
20+
/// Use ``Publisher/compactScan(_:_:)`` to accumulate all previously-published values into a single
21+
/// value, which you then combine with each newly-published value.
22+
///
23+
/// The following example logs a running total of all values received
24+
/// from the sequence publisher.
25+
///
26+
/// let range = (0...5)
27+
/// let cancellable = range.publisher
28+
/// .compactScan(0) {
29+
/// guard $1.isMultiple(of: 2) else { return nil }
30+
/// return $0 + $1
31+
/// }
32+
/// .sink { print ("\($0)", terminator: " ") }
33+
/// // Prints: "0 2 6 ".
34+
///
35+
/// - Parameters:
36+
/// - initialResult: The previous result returned by the `nextPartialResult` closure.
37+
/// - nextPartialResult: A closure that takes as its arguments the previous value returned by the closure and the next element emitted from the upstream publisher.
38+
/// - Returns: A publisher that transforms elements by applying a closure that receives its previous return value and the next element from the upstream publisher.
39+
func compactScan<T>(_ initialResult: T, _ nextPartialResult: @escaping (T, Output) -> T?) -> AnyPublisher<T, Failure> {
40+
self.scan((initialResult, initialResult)) { accumulator, value -> (T, T?) in
41+
let lastNonNilAccumulator = accumulator.0
42+
let newAccumulator = nextPartialResult(lastNonNilAccumulator, value)
43+
return (newAccumulator ?? lastNonNilAccumulator, newAccumulator)
44+
}
45+
.compactMap { $0.1 }
46+
.eraseToAnyPublisher()
47+
}
48+
49+
/// Transforms elements from the upstream publisher by providing the current element to an error-throwing closure along with the last value returned by the closure.
50+
///
51+
/// The ``nextPartialResult`` closure might return nil values. In that case the accumulator won't change until the next non-nil upstream publisher value.
52+
///
53+
/// Use ``Publisher/tryCompactScan(_:_:)`` to accumulate all previously-published values into a single value, which you then combine with each newly-published value.
54+
/// If your accumulator closure throws an error, the publisher terminates with the error.
55+
///
56+
/// In the example below, ``Publisher/tryCompactScan(_:_:)`` calls a division function on elements of a collection publisher. The resulting publisher publishes each result until the function encounters a `DivisionByZeroError`, which terminates the publisher.
57+
///m
58+
/// struct DivisionByZeroError: Error {}
59+
///
60+
/// /// A function that throws a DivisionByZeroError if `current` provided by the TryScan publisher is zero.
61+
/// func myThrowingFunction(_ lastValue: Int, _ currentValue: Int) throws -> Int? {
62+
/// guard currentValue.isMultiple(of: 2) else { return nil }
63+
/// guard currentValue != 0 else { throw DivisionByZeroError() }
64+
/// return lastValue / currentValue
65+
/// }
66+
///
67+
/// let numbers = [1, 2, 3, 4, 5, 0, 6, 7, 8, 9]
68+
/// let cancellable = numbers.publisher
69+
/// .tryCompactScan(10) { try myThrowingFunction($0, $1) }
70+
/// .sink(
71+
/// receiveCompletion: { print ("\($0)") },
72+
/// receiveValue: { print ("\($0)", terminator: " ") }
73+
/// )
74+
///
75+
/// // Prints: "6 2 failure(DivisionByZeroError())".
76+
///
77+
/// If the closure throws an error, the publisher fails with the error.
78+
///
79+
/// - Parameters:
80+
/// - initialResult: The previous result returned by the `nextPartialResult` closure.
81+
/// - nextPartialResult: An error-throwing closure that takes as its arguments the previous value returned by the closure and the next element emitted from the upstream publisher.
82+
/// - Returns: A publisher that transforms elements by applying a closure that receives its previous return value and the next element from the upstream publisher.
83+
func tryCompactScan<T>(_ initialResult: T, _ nextPartialResult: @escaping (T, Output) throws -> T?) -> AnyPublisher<T, Error> {
84+
self.tryScan((initialResult, initialResult)) { accumulator, value -> (T, T?) in
85+
let lastNonNilAccumulator = accumulator.0
86+
let newAccumulator = try nextPartialResult(lastNonNilAccumulator, value)
87+
return (newAccumulator ?? lastNonNilAccumulator, newAccumulator)
88+
}
89+
.compactMap { $0.1 }
90+
.eraseToAnyPublisher()
91+
}
92+
}
93+
#endif

Tests/CompactScanTests.swift

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//
2+
// CompactScanTests.swift
3+
// CombineExtTests
4+
//
5+
// Created by Thibault Wittemberg on 04/09/2021.
6+
// Copyright © 2021 Combine Community. All rights reserved.
7+
//
8+
9+
#if !os(watchOS)
10+
import Combine
11+
import CombineExt
12+
import XCTest
13+
14+
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
15+
final class CompactScanTests: XCTestCase {
16+
func testCompactScan_drops_nil_values() {
17+
let expectedValues = [0, 2, 6]
18+
var receivedValues = [Int]()
19+
20+
// Given: a stream of integers from 0 to 5
21+
let sut = (0...5).publisher
22+
23+
// When: using a compactScan operator using a closure that returns nil when the value from the upstream publisher is odd
24+
let cancellable = sut
25+
.compactScan(0) {
26+
guard $1.isMultiple(of: 2) else { return nil }
27+
return $0 + $1
28+
}
29+
.assertNoFailure()
30+
.sink { receivedValues.append($0) }
31+
32+
// Then: the nil results have been discarded
33+
XCTAssertEqual(receivedValues, expectedValues)
34+
35+
cancellable.cancel()
36+
}
37+
38+
func testTryCompactScan_drops_nil_values() {
39+
let expectedValues = [0, 2, 6]
40+
var receivedValues = [Int]()
41+
42+
// Given: a stream of integers from 0 to 5
43+
let sut = (0...5).publisher
44+
45+
// When: using a tryCompactScan operator using a closure that returns nil when the value from the upstream publisher is odd
46+
let cancellable = sut
47+
.tryCompactScan(0) {
48+
guard $1.isMultiple(of: 2) else { return nil }
49+
return $0 + $1
50+
}
51+
.assertNoFailure()
52+
.sink { receivedValues.append($0) }
53+
54+
// Then: the nil results have been discarded
55+
XCTAssertEqual(receivedValues, expectedValues)
56+
57+
cancellable.cancel()
58+
}
59+
60+
func testTryCompactScan_drops_nil_values_and_throws_error() {
61+
struct DivisionByZeroError: Error, Equatable {}
62+
63+
let expectedValues = [6, 2]
64+
var receivedValues = [Int]()
65+
66+
let expectedError = DivisionByZeroError()
67+
var receivedCompletion: Subscribers.Completion<Error>?
68+
69+
// Given: a sequence a integers containing a 0
70+
let sut = [1, 2, 3, 4, 5, 0, 6, 7, 8, 9].publisher
71+
72+
// When: using a tryCompactScan operator using a closure that returns nil when the value from the upstream publisher is odd
73+
// and throws when the value is 0
74+
let cancellable = sut
75+
.tryCompactScan(10) {
76+
guard $1.isMultiple(of: 2) else { return nil }
77+
guard $1 != 0 else { throw expectedError }
78+
return ($0 + $1) / $1
79+
}
80+
.sink {
81+
receivedCompletion = $0
82+
} receiveValue: {
83+
receivedValues.append($0)
84+
}
85+
86+
cancellable.cancel()
87+
88+
// Then: the nil results have been discarded
89+
XCTAssertEqual(receivedValues, expectedValues)
90+
91+
// Then: the thrown error provoqued a failure
92+
switch receivedCompletion {
93+
case let .failure(receivedError): XCTAssertEqual(receivedError as? DivisionByZeroError, expectedError)
94+
default: XCTFail()
95+
}
96+
}
97+
}
98+
#endif

0 commit comments

Comments
 (0)