Skip to content

Commit ca170dd

Browse files
authored
Adds Publisher.prefix(while:behavior:). (#70)
* Adds Publisher.prefix(while:behavior:). Figured we should add this to Ext after stumbling upon @jessegrosjean’s [Swift Forums thread](https://forums.swift.org/t/how-to-make-combines-prefix-operator-inclusive/39216) and @mayoff’s [approach with composed operators](https://forums.swift.org/t/how-to-make-combines-prefix-operator-inclusive/39216/8). * Avoid the [PrefixInclusiveEvent] dance.
1 parent 5818492 commit ca170dd

File tree

4 files changed

+285
-2
lines changed

4 files changed

+285
-2
lines changed

CombineExt.xcodeproj/project.pbxproj

+8
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
BF43CC1725008C45005AFA28 /* IgnoreOutputSetOutputTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF43CC1625008C45005AFA28 /* IgnoreOutputSetOutputTypeTests.swift */; };
3838
BFADDC8125BCE4C200465E9B /* FlatMapBatches.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFADDC8025BCE4C200465E9B /* FlatMapBatches.swift */; };
3939
BFADDC8B25BCE91E00465E9B /* FlatMapBatchesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFADDC8A25BCE91E00465E9B /* FlatMapBatchesTests.swift */; };
40+
BF45214E259C0C610065E60E /* PrefixWhileBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF45214D259C0C610065E60E /* PrefixWhileBehavior.swift */; };
41+
BF452158259C110C0065E60E /* PrefixWhileBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF452157259C110C0065E60E /* PrefixWhileBehaviorTests.swift */; };
4042
C387777C24E6BBE900FAD2D8 /* Nwise.swift in Sources */ = {isa = PBXBuildFile; fileRef = C387777B24E6BBE900FAD2D8 /* Nwise.swift */; };
4143
C387777F24E6BF8F00FAD2D8 /* NwiseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C387777D24E6BF6C00FAD2D8 /* NwiseTests.swift */; };
4244
D836234824EA9446002353AC /* MergeMany.swift in Sources */ = {isa = PBXBuildFile; fileRef = D836234724EA9446002353AC /* MergeMany.swift */; };
@@ -124,6 +126,8 @@
124126
BF43CC1625008C45005AFA28 /* IgnoreOutputSetOutputTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IgnoreOutputSetOutputTypeTests.swift; sourceTree = "<group>"; };
125127
BFADDC8025BCE4C200465E9B /* FlatMapBatches.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlatMapBatches.swift; sourceTree = "<group>"; };
126128
BFADDC8A25BCE91E00465E9B /* FlatMapBatchesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlatMapBatchesTests.swift; sourceTree = "<group>"; };
129+
BF45214D259C0C610065E60E /* PrefixWhileBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefixWhileBehavior.swift; sourceTree = "<group>"; };
130+
BF452157259C110C0065E60E /* PrefixWhileBehaviorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefixWhileBehaviorTests.swift; sourceTree = "<group>"; };
127131
C387777B24E6BBE900FAD2D8 /* Nwise.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Nwise.swift; sourceTree = "<group>"; };
128132
C387777D24E6BF6C00FAD2D8 /* NwiseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NwiseTests.swift; sourceTree = "<group>"; };
129133
"CombineExt::CombineExt::Product" /* CombineExt.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CombineExt.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -260,6 +264,7 @@
260264
C387777B24E6BBE900FAD2D8 /* Nwise.swift */,
261265
OBJ_26 /* Partition.swift */,
262266
OBJ_27 /* PrefixDuration.swift */,
267+
BF45214D259C0C610065E60E /* PrefixWhileBehavior.swift */,
263268
OBJ_28 /* RemoveAllDuplicates.swift */,
264269
712E36C72711B79000A2AAFE /* RetryWhen.swift */,
265270
OBJ_29 /* SetOutputType.swift */,
@@ -314,6 +319,7 @@
314319
OBJ_52 /* PartitionTests.swift */,
315320
OBJ_53 /* PassthroughRelayTests.swift */,
316321
OBJ_54 /* PrefixDurationTests.swift */,
322+
BF452157259C110C0065E60E /* PrefixWhileBehaviorTests.swift */,
317323
OBJ_55 /* RemoveAllDuplicatesTests.swift */,
318324
OBJ_56 /* ReplaySubjectTests.swift */,
319325
7182326E26DAAF230026BAD3 /* RetryWhenTests.swift */,
@@ -565,6 +571,7 @@
565571
D836234A24EA9888002353AC /* MergeManyTests.swift in Sources */,
566572
OBJ_125 /* CombineLatestManyTests.swift in Sources */,
567573
BF3D3B67253B88E500D830ED /* IgnoreFailureTests.swift in Sources */,
574+
BF452158259C110C0065E60E /* PrefixWhileBehaviorTests.swift in Sources */,
568575
OBJ_126 /* CreateTests.swift in Sources */,
569576
OBJ_127 /* CurrentValueRelayTests.swift in Sources */,
570577
C387777F24E6BF8F00FAD2D8 /* NwiseTests.swift in Sources */,
@@ -622,6 +629,7 @@
622629
OBJ_95 /* RemoveAllDuplicates.swift in Sources */,
623630
OBJ_96 /* SetOutputType.swift in Sources */,
624631
OBJ_97 /* ShareReplay.swift in Sources */,
632+
BF45214E259C0C610065E60E /* PrefixWhileBehavior.swift in Sources */,
625633
OBJ_98 /* Toggle.swift in Sources */,
626634
OBJ_99 /* WithLatestFrom.swift in Sources */,
627635
OBJ_100 /* ZipMany.swift in Sources */,

README.md

+34-2
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ All operators, utilities and helpers respect Combine's publisher contract, inclu
3838
* [removeAllDuplicates and removeAllDuplicates(by:) ](#removeAllDuplicates)
3939
* [share(replay:)](#sharereplay)
4040
* [prefix(duration:tolerance:​on:options:)](#prefixduration)
41-
* [toggle()](#toggle)
41+
* [prefix(while:behavior:​)](#prefixwhilebehavior)
42+
* [toggle()](#toggle)
4243
* [nwise(_:) and pairwise()](#nwise)
4344
* [ignoreOutput(setOutputType:)](#ignoreOutputsetOutputType)
4445
* [ignoreFailure](#ignoreFailure)
@@ -553,7 +554,7 @@ second subscriber: 4
553554

554555
### prefix(duration:)
555556

556-
An overload on `Publisher.prefix` that that republishes values for a provided `duration` (in seconds), and then completes.
557+
An overload on `Publisher.prefix` that republishes values for a provided `duration` (in seconds), and then completes.
557558

558559
```swift
559560
let subject = PassthroughSubject<Int, Never>()
@@ -579,6 +580,37 @@ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
579580
3
580581
```
581582

583+
### prefix(while:behavior:)
584+
585+
An overload on `Publisher.prefix(while:)` that allows for inclusion of the first element that doesn’t pass the `while` predicate.
586+
587+
```swift
588+
let subject = PassthroughSubject<Int, Never>()
589+
590+
subscription = subject
591+
.prefix(
592+
while: { $0 % 2 == 0 },
593+
behavior: .inclusive
594+
)
595+
.sink(
596+
receivecompletion: { print($0) },
597+
receiveValue: { print($0) }
598+
)
599+
600+
subject.send(0)
601+
subject.send(2)
602+
subject.send(4)
603+
subject.send(5)
604+
```
605+
606+
```none
607+
0
608+
2
609+
4
610+
5
611+
finished
612+
```
613+
582614
### toggle()
583615

584616
Toggle each boolean element of a publisher collection.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//
2+
// PrefixWhileBehavior.swift
3+
// CombineExt
4+
//
5+
// Created by Jasdev Singh on 29/12/2020.
6+
// Copyright © 2020 Combine Community. All rights reserved.
7+
//
8+
9+
#if canImport(Combine)
10+
import Combine
11+
import Foundation
12+
13+
/// Whether to include the first element that doesn’t pass
14+
/// the `while` predicate passed to `Combine.Publisher.prefix(while:behavior:)`.
15+
public enum PrefixWhileBehavior {
16+
/// Include the first element that doesn’t pass the `while` predicate.
17+
case inclusive
18+
19+
/// Exclude the first element that doesn’t pass the `while` predicate.
20+
case exclusive
21+
}
22+
23+
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
24+
public extension Publisher {
25+
/// An overload on `Publisher.prefix(while:)` that allows for inclusion of the first element that doesn’t pass the `while` predicate.
26+
///
27+
/// - parameters:
28+
/// - predicate: A closure that takes an element as its parameter and returns a Boolean value that indicates whether publishing should continue.
29+
/// - behavior: Whether or not to include the first element that doesn’t pass `predicate`.
30+
///
31+
/// - returns: A publisher that passes through elements until the predicate indicates publishing should finish — and optionally that first `predicate`-failing element.
32+
func prefix(
33+
while predicate: @escaping (Output) -> Bool,
34+
behavior: PrefixWhileBehavior = .exclusive
35+
) -> AnyPublisher<Output, Failure> {
36+
switch behavior {
37+
case .exclusive:
38+
return prefix(while: predicate)
39+
.eraseToAnyPublisher()
40+
case .inclusive:
41+
return flatMap { next in
42+
Just(PrefixInclusiveEvent.whileValueOrIncluded(next))
43+
.append(!predicate(next) ? [.end] : [])
44+
.setFailureType(to: Failure.self)
45+
}
46+
.prefix(while: \.isWhileValueOrIncluded)
47+
.compactMap(\.value)
48+
.eraseToAnyPublisher()
49+
}
50+
}
51+
}
52+
#endif
53+
54+
// MARK: - Helpers
55+
56+
private enum PrefixInclusiveEvent<Output> {
57+
case end
58+
case whileValueOrIncluded(Output)
59+
60+
var isWhileValueOrIncluded: Bool {
61+
switch self {
62+
case .end:
63+
return false
64+
case .whileValueOrIncluded:
65+
return true
66+
}
67+
}
68+
69+
var value: Output? {
70+
switch self {
71+
case .end:
72+
return nil
73+
case let .whileValueOrIncluded(inner):
74+
return inner
75+
}
76+
}
77+
}

Tests/PrefixWhileBehaviorTests.swift

+166
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
//
2+
// PrefixWhileBehaviorTests.swift
3+
// CombineExt
4+
//
5+
// Created by Jasdev Singh on 29/12/2020.
6+
// Copyright © 2020 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 PrefixWhileBehaviorTests: XCTestCase {
16+
private struct SomeError: Error, Equatable {}
17+
18+
private var cancellable: AnyCancellable!
19+
20+
func testExclusiveValueEventsWithFinished() {
21+
let intSubject = PassthroughSubject<Int, Never>()
22+
23+
var values = [Int]()
24+
var completions = [Subscribers.Completion<Never>]()
25+
26+
cancellable = intSubject
27+
.prefix(
28+
while: { $0 % 2 == 0 },
29+
behavior: .exclusive
30+
)
31+
.sink(
32+
receiveCompletion: { completions.append($0) },
33+
receiveValue: { values.append($0) }
34+
)
35+
36+
[0, 2, 4, 5]
37+
.forEach(intSubject.send)
38+
39+
XCTAssertEqual(values, [0, 2, 4])
40+
XCTAssertEqual(completions, [.finished])
41+
}
42+
43+
func testExclusiveValueEventsWithError() {
44+
let intSubject = PassthroughSubject<Int, SomeError>()
45+
46+
var values = [Int]()
47+
var completions = [Subscribers.Completion<SomeError>]()
48+
49+
cancellable = intSubject
50+
.prefix(
51+
while: { $0 % 2 == 0 },
52+
behavior: .exclusive
53+
)
54+
.sink(
55+
receiveCompletion: { completions.append($0) },
56+
receiveValue: { values.append($0) }
57+
)
58+
59+
[0, 2, 4]
60+
.forEach(intSubject.send)
61+
62+
intSubject.send(completion: .failure(.init()))
63+
64+
XCTAssertEqual(values, [0, 2, 4])
65+
XCTAssertEqual(completions, [.failure(.init())])
66+
}
67+
68+
func testInclusiveValueEventsWithStopElement() {
69+
let intSubject = PassthroughSubject<Int, Never>()
70+
71+
var values = [Int]()
72+
var completions = [Subscribers.Completion<Never>]()
73+
74+
cancellable = intSubject
75+
.prefix(
76+
while: { $0 % 2 == 0 },
77+
behavior: .inclusive
78+
)
79+
.sink(
80+
receiveCompletion: { completions.append($0) },
81+
receiveValue: { values.append($0) }
82+
)
83+
84+
[0, 2, 4, 5]
85+
.forEach(intSubject.send)
86+
87+
XCTAssertEqual(values, [0, 2, 4, 5])
88+
XCTAssertEqual(completions, [.finished])
89+
}
90+
91+
func testInclusiveValueEventsWithErrorAfterStopElement() {
92+
let intSubject = PassthroughSubject<Int, SomeError>()
93+
94+
var values = [Int]()
95+
var completions = [Subscribers.Completion<SomeError>]()
96+
97+
cancellable = intSubject
98+
.prefix(
99+
while: { $0 % 2 == 0 },
100+
behavior: .inclusive
101+
)
102+
.sink(
103+
receiveCompletion: { completions.append($0) },
104+
receiveValue: { values.append($0) }
105+
)
106+
107+
[0, 2, 4, 5]
108+
.forEach(intSubject.send)
109+
110+
intSubject.send(completion: .failure(.init()))
111+
112+
XCTAssertEqual(values, [0, 2, 4, 5])
113+
XCTAssertEqual(completions, [.finished])
114+
}
115+
116+
func testInclusiveValueEventsWithErrorBeforeStop() {
117+
let intSubject = PassthroughSubject<Int, SomeError>()
118+
119+
var values = [Int]()
120+
var completions = [Subscribers.Completion<SomeError>]()
121+
122+
cancellable = intSubject
123+
.prefix(
124+
while: { $0 % 2 == 0 },
125+
behavior: .inclusive
126+
)
127+
.sink(
128+
receiveCompletion: { completions.append($0) },
129+
receiveValue: { values.append($0) }
130+
)
131+
132+
[0, 2, 4]
133+
.forEach(intSubject.send)
134+
135+
intSubject.send(completion: .failure(.init()))
136+
137+
XCTAssertEqual(values, [0, 2, 4])
138+
XCTAssertEqual(completions, [.failure(.init())])
139+
}
140+
141+
func testInclusiveEarlyCompletion() {
142+
let intSubject = PassthroughSubject<Int, SomeError>()
143+
144+
var values = [Int]()
145+
var completions = [Subscribers.Completion<SomeError>]()
146+
147+
cancellable = intSubject
148+
.prefix(
149+
while: { $0 % 2 == 0 },
150+
behavior: .inclusive
151+
)
152+
.sink(
153+
receiveCompletion: { completions.append($0) },
154+
receiveValue: { values.append($0) }
155+
)
156+
157+
[0, 2, 4]
158+
.forEach(intSubject.send)
159+
160+
intSubject.send(completion: .finished)
161+
162+
XCTAssertEqual(values, [0, 2, 4])
163+
XCTAssertEqual(completions, [.finished])
164+
}
165+
}
166+
#endif

0 commit comments

Comments
 (0)