Skip to content

Commit 9e20a3f

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

File tree

4 files changed

+223
-0
lines changed

4 files changed

+223
-0
lines changed

CombineExt.xcodeproj/project.pbxproj

+8
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
/* Begin PBXBuildFile section */
2525
1970A8AA25246FBD00799AB6 /* FilterMany.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1970A8A925246FBD00799AB6 /* FilterMany.swift */; };
2626
1970A8B42524730500799AB6 /* FilterManyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1970A8B32524730400799AB6 /* FilterManyTests.swift */; };
27+
1A2FF9A826E3D9770098C2D1 /* CompactScan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2FF9A726E3D9770098C2D1 /* CompactScan.swift */; };
28+
1A2FF9AB26E3D9FC0098C2D1 /* CompactScanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2FF9A926E3D9800098C2D1 /* CompactScanTests.swift */; };
2729
BF330EF624F1FFFE001281FC /* CombineSchedulers in Frameworks */ = {isa = PBXBuildFile; productRef = BF330EF524F1FFFE001281FC /* CombineSchedulers */; };
2830
BF330EF924F20032001281FC /* Timer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF330EF824F20032001281FC /* Timer.swift */; };
2931
BF330EFB24F20080001281FC /* Lock.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF330EFA24F20080001281FC /* Lock.swift */; };
@@ -108,6 +110,8 @@
108110
/* Begin PBXFileReference section */
109111
1970A8A925246FBD00799AB6 /* FilterMany.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMany.swift; sourceTree = "<group>"; };
110112
1970A8B32524730400799AB6 /* FilterManyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterManyTests.swift; sourceTree = "<group>"; };
113+
1A2FF9A726E3D9770098C2D1 /* CompactScan.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompactScan.swift; sourceTree = "<group>"; };
114+
1A2FF9A926E3D9800098C2D1 /* CompactScanTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompactScanTests.swift; sourceTree = "<group>"; };
111115
BF330EF824F20032001281FC /* Timer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timer.swift; sourceTree = "<group>"; };
112116
BF330EFA24F20080001281FC /* Lock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lock.swift; sourceTree = "<group>"; };
113117
BF3D3B5C253B83F300D830ED /* IgnoreFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IgnoreFailure.swift; sourceTree = "<group>"; };
@@ -242,6 +246,7 @@
242246
OBJ_18 /* AssignOwnership.swift */,
243247
OBJ_19 /* AssignToMany.swift */,
244248
OBJ_20 /* CombineLatestMany.swift */,
249+
1A2FF9A726E3D9770098C2D1 /* CompactScan.swift */,
245250
OBJ_21 /* Create.swift */,
246251
OBJ_22 /* Dematerialize.swift */,
247252
OBJ_23 /* FlatMapLatest.swift */,
@@ -290,6 +295,7 @@
290295
OBJ_42 /* AssignOwnershipTests.swift */,
291296
OBJ_43 /* AssignToManyTests.swift */,
292297
OBJ_44 /* CombineLatestManyTests.swift */,
298+
1A2FF9A926E3D9800098C2D1 /* CompactScanTests.swift */,
293299
OBJ_45 /* CreateTests.swift */,
294300
OBJ_46 /* CurrentValueRelayTests.swift */,
295301
OBJ_47 /* DematerializeTests.swift */,
@@ -550,6 +556,7 @@
550556
OBJ_123 /* AssignOwnershipTests.swift in Sources */,
551557
OBJ_124 /* AssignToManyTests.swift in Sources */,
552558
1970A8B42524730500799AB6 /* FilterManyTests.swift in Sources */,
559+
1A2FF9AB26E3D9FC0098C2D1 /* CompactScanTests.swift in Sources */,
553560
D836234A24EA9888002353AC /* MergeManyTests.swift in Sources */,
554561
OBJ_125 /* CombineLatestManyTests.swift in Sources */,
555562
BF3D3B67253B88E500D830ED /* IgnoreFailureTests.swift in Sources */,
@@ -581,6 +588,7 @@
581588
buildActionMask = 0;
582589
files = (
583590
OBJ_79 /* DemandBuffer.swift in Sources */,
591+
1A2FF9A826E3D9770098C2D1 /* CompactScan.swift in Sources */,
584592
OBJ_80 /* Sink.swift in Sources */,
585593
OBJ_81 /* Optional.swift in Sources */,
586594
C387777C24E6BBE900FAD2D8 /* Nwise.swift in Sources */,

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

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