Skip to content

Commit 4ac808a

Browse files
committed
Add withUnretained operator
1 parent d7b896f commit 4ac808a

File tree

4 files changed

+273
-4
lines changed

4 files changed

+273
-4
lines changed

CombineExt.xcodeproj/project.pbxproj

+8
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
EA0D86D5287D19DC0085356E /* MapToResultTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0D86D2287D19DC0085356E /* MapToResultTests.swift */; };
5050
EA0D86D6287D19DC0085356E /* MapToValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0D86D3287D19DC0085356E /* MapToValueTests.swift */; };
5151
EAEAAC72287FB3C900288379 /* CombineSchedulers in Frameworks */ = {isa = PBXBuildFile; productRef = EAEAAC71287FB3C900288379 /* CombineSchedulers */; };
52+
E17B23B526DFBFBD008E595F /* WithUnretained.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17B23B426DFBFBD008E595F /* WithUnretained.swift */; };
53+
E17B23B726DFFA56008E595F /* WithUnretainedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17B23B626DFFA56008E595F /* WithUnretainedTests.swift */; };
5254
OBJ_100 /* ZipMany.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_33 /* ZipMany.swift */; };
5355
OBJ_101 /* CurrentValueRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_35 /* CurrentValueRelay.swift */; };
5456
OBJ_102 /* PassthroughRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_36 /* PassthroughRelay.swift */; };
@@ -146,6 +148,8 @@
146148
EA0D86D1287D19DC0085356E /* EnumeratedTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnumeratedTests.swift; sourceTree = "<group>"; };
147149
EA0D86D2287D19DC0085356E /* MapToResultTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapToResultTests.swift; sourceTree = "<group>"; };
148150
EA0D86D3287D19DC0085356E /* MapToValueTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapToValueTests.swift; sourceTree = "<group>"; };
151+
E17B23B426DFBFBD008E595F /* WithUnretained.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithUnretained.swift; sourceTree = "<group>"; };
152+
E17B23B626DFFA56008E595F /* WithUnretainedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithUnretainedTests.swift; sourceTree = "<group>"; };
149153
OBJ_10 /* Sink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sink.swift; sourceTree = "<group>"; };
150154
OBJ_12 /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = "<group>"; };
151155
OBJ_14 /* Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Event.swift; sourceTree = "<group>"; };
@@ -289,6 +293,7 @@
289293
1970A8A925246FBD00799AB6 /* FilterMany.swift */,
290294
BFADDC8025BCE4C200465E9B /* FlatMapBatches.swift */,
291295
63FEBC9227E9FCDB00E934AD /* FlatMapFirst.swift */,
296+
E17B23B426DFBFBD008E595F /* WithUnretained.swift */,
292297
);
293298
path = Operators;
294299
sourceTree = "<group>";
@@ -347,6 +352,7 @@
347352
OBJ_61 /* ZipManyTests.swift */,
348353
BFADDC8A25BCE91E00465E9B /* FlatMapBatchesTests.swift */,
349354
63FEBC9427E9FE9000E934AD /* FlatMapFirstTests.swift */,
355+
E17B23B626DFFA56008E595F /* WithUnretainedTests.swift */,
350356
);
351357
path = Tests;
352358
sourceTree = SOURCE_ROOT;
@@ -590,6 +596,7 @@
590596
OBJ_126 /* CreateTests.swift in Sources */,
591597
OBJ_127 /* CurrentValueRelayTests.swift in Sources */,
592598
C387777F24E6BF8F00FAD2D8 /* NwiseTests.swift in Sources */,
599+
E17B23B726DFFA56008E595F /* WithUnretainedTests.swift in Sources */,
593600
OBJ_128 /* DematerializeTests.swift in Sources */,
594601
OBJ_129 /* FlatMapLatestTests.swift in Sources */,
595602
OBJ_130 /* MapManyTests.swift in Sources */,
@@ -626,6 +633,7 @@
626633
EA0D86CE287D19CC0085356E /* MapToValue.swift in Sources */,
627634
OBJ_83 /* ObjectOwnership.swift in Sources */,
628635
OBJ_84 /* Amb.swift in Sources */,
636+
E17B23B526DFBFBD008E595F /* WithUnretained.swift in Sources */,
629637
OBJ_85 /* AssignOwnership.swift in Sources */,
630638
OBJ_86 /* AssignToMany.swift in Sources */,
631639
BF3D3B5D253B83F300D830ED /* IgnoreFailure.swift in Sources */,

Package.resolved

+13-4
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,26 @@
66
"repositoryURL": "https://github.com/pointfreeco/combine-schedulers",
77
"state": {
88
"branch": null,
9-
"revision": "4cf088c29a20f52be0f2ca54992b492c54e0076b",
10-
"version": "0.5.3"
9+
"revision": "ec62f32d21584214a4b27c8cee2b2ad70ab2c38a",
10+
"version": "0.11.0"
11+
}
12+
},
13+
{
14+
"package": "swift-concurrency-extras",
15+
"repositoryURL": "https://github.com/pointfreeco/swift-concurrency-extras",
16+
"state": {
17+
"branch": null,
18+
"revision": "479750bd98fac2e813fffcf2af0728b5b0085795",
19+
"version": "0.1.1"
1120
}
1221
},
1322
{
1423
"package": "xctest-dynamic-overlay",
1524
"repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay",
1625
"state": {
1726
"branch": null,
18-
"revision": "50a70a9d3583fe228ce672e8923010c8df2deddd",
19-
"version": "0.2.1"
27+
"revision": "50843cbb8551db836adec2290bb4bc6bac5c1865",
28+
"version": "0.9.0"
2029
}
2130
}
2231
]
+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
//
2+
// WithUnretained.swift
3+
// CombineExt
4+
//
5+
// Created by Robert on 01/09/2021.
6+
// Copyright © 2020 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+
Provides an unretained, safe to use (i.e. not implicitly unwrapped), reference to an object along with the events published by the publisher.
16+
17+
In the case the provided object cannot be retained successfully, the publisher will complete.
18+
19+
- parameter obj: The object to provide an unretained reference on.
20+
- parameter resultSelector: A function to combine the unretained referenced on `obj` and the value of the observable sequence.
21+
- returns: A publisher that contains the result of `resultSelector` being called with an unretained reference on `obj` and the values of the upstream.
22+
*/
23+
func withUnretained<UnretainedObject: AnyObject, Output>(_ obj: UnretainedObject, resultSelector: @escaping (UnretainedObject, Self.Output) -> Output) -> Publishers.WithUnretained<UnretainedObject, Self, Output> {
24+
Publishers.WithUnretained(unretainedObject: obj, upstream: self, resultSelector: resultSelector)
25+
}
26+
27+
/**
28+
Provides an unretained, safe to use (i.e. not implicitly unwrapped), reference to an object along with the events published by the publisher.
29+
30+
In the case the provided object cannot be retained successfully, the publisher will complete.
31+
32+
- parameter obj: The object to provide an unretained reference on.
33+
- returns: A publisher that publishes a sequence of tuples that contains both an unretained reference on `obj` and the values of the upstream.
34+
*/
35+
func withUnretained<UnretainedObject: AnyObject>(_ obj: UnretainedObject) -> Publishers.WithUnretained<UnretainedObject, Self, (UnretainedObject, Output)> {
36+
Publishers.WithUnretained(unretainedObject: obj, upstream: self) { ($0, $1) }
37+
}
38+
39+
/// Attaches a subscriber with closure-based behavior.
40+
///
41+
/// Use ``Publisher/sink(unretainedObject:receiveCompletion:receiveValue:)`` to observe values received by the publisher and process them using a closure you specify.
42+
/// This method creates the subscriber and immediately requests an unlimited number of values, prior to returning the subscriber.
43+
/// The return value should be held, otherwise the stream will be canceled.
44+
///
45+
/// - parameter obj: The object to provide an unretained reference on.
46+
/// - parameter receiveComplete: The closure to execute on completion.
47+
/// - parameter receiveValue: The closure to execute on receipt of a value.
48+
/// - Returns: A cancellable instance, which you use when you end assignment of the received value. Deallocation of the result will tear down the subscription stream.
49+
func sink<UnretainedObject: AnyObject>(unretainedObject obj: UnretainedObject, receiveCompletion: @escaping ((Subscribers.Completion<Self.Failure>) -> Void), receiveValue: @escaping ((UnretainedObject, Self.Output) -> Void)) -> AnyCancellable {
50+
withUnretained(obj)
51+
.sink(receiveCompletion: receiveCompletion, receiveValue: receiveValue)
52+
}
53+
}
54+
55+
// MARK: - Publisher
56+
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
57+
public extension Publishers {
58+
struct WithUnretained<UnretainedObject: AnyObject, Upstream: Publisher, Output>: Publisher {
59+
public typealias Failure = Upstream.Failure
60+
61+
private weak var unretainedObject: UnretainedObject?
62+
private let upstream: Upstream
63+
private let resultSelector: (UnretainedObject, Upstream.Output) -> Output
64+
65+
public init(unretainedObject: UnretainedObject, upstream: Upstream, resultSelector: @escaping (UnretainedObject, Upstream.Output) -> Output) {
66+
self.unretainedObject = unretainedObject
67+
self.upstream = upstream
68+
self.resultSelector = resultSelector
69+
}
70+
71+
public func receive<S: Combine.Subscriber>(subscriber: S) where Failure == S.Failure, Output == S.Input {
72+
upstream.subscribe(Subscriber(unretainedObject: unretainedObject, downstream: subscriber, resultSelector: resultSelector))
73+
}
74+
}
75+
}
76+
77+
// MARK: - Subscriber
78+
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
79+
private extension Publishers.WithUnretained {
80+
class Subscriber<Downstream: Combine.Subscriber>: Combine.Subscriber where Downstream.Input == Output, Downstream.Failure == Failure {
81+
typealias Input = Upstream.Output
82+
typealias Failure = Downstream.Failure
83+
84+
private weak var unretainedObject: UnretainedObject?
85+
private let downstream: Downstream
86+
private let resultSelector: (UnretainedObject, Input) -> Output
87+
88+
init(unretainedObject: UnretainedObject?, downstream: Downstream, resultSelector: @escaping (UnretainedObject, Input) -> Output) {
89+
self.unretainedObject = unretainedObject
90+
self.downstream = downstream
91+
self.resultSelector = resultSelector
92+
}
93+
94+
func receive(subscription: Subscription) {
95+
if unretainedObject == nil { return }
96+
downstream.receive(subscription: subscription)
97+
}
98+
99+
func receive(_ input: Input) -> Subscribers.Demand {
100+
guard let unretainedObject = unretainedObject else { return .none }
101+
return downstream.receive(resultSelector(unretainedObject, input))
102+
}
103+
104+
func receive(completion: Subscribers.Completion<Failure>) {
105+
if unretainedObject == nil {
106+
return downstream.receive(completion: .finished)
107+
}
108+
downstream.receive(completion: completion)
109+
}
110+
}
111+
}
112+
#endif

Tests/WithUnretainedTests.swift

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//
2+
// WithUnretainedTests.swift
3+
// CombineExtTests
4+
//
5+
// Created by Robert on 02/09/2021.
6+
//
7+
8+
#if !os(watchOS)
9+
import XCTest
10+
import Foundation
11+
import Combine
12+
import CombineExt
13+
14+
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
15+
final class WithUnretainedTests: XCTestCase {
16+
fileprivate var testClass: TestClass!
17+
var subscription: AnyCancellable?
18+
var values: [String] = []
19+
20+
enum WithUnretainedTestsError: Swift.Error {
21+
case someError
22+
}
23+
24+
override func setUp() {
25+
super.setUp()
26+
27+
testClass = TestClass()
28+
values = []
29+
}
30+
31+
override func tearDown() {
32+
subscription?.cancel()
33+
subscription = nil
34+
}
35+
36+
func testObjectAttached() {
37+
let testClassId = testClass.id
38+
var completed = false
39+
40+
let correctValues = [
41+
"\(testClassId), 1",
42+
"\(testClassId), 2",
43+
"\(testClassId), 3",
44+
"\(testClassId), 5",
45+
"\(testClassId), 8"
46+
]
47+
48+
let inputArr = [1, 2, 3, 5, 8]
49+
50+
subscription = Publishers.Sequence<[Int], WithUnretainedTestsError>(sequence: inputArr)
51+
.withUnretained(self.testClass)
52+
.map { "\($0.id), \($1)" }
53+
.sink(receiveCompletion: { _ in completed = true },
54+
receiveValue: { self.values.append($0) })
55+
56+
XCTAssertEqual(values, correctValues)
57+
XCTAssertTrue(completed)
58+
}
59+
60+
func testObjectDeallocatesWithEmptyPublisher() {
61+
subscription = Empty<Int, WithUnretainedTestsError>()
62+
.withUnretained(self.testClass)
63+
.sink(receiveCompletion: { _ in }, receiveValue: { _ in })
64+
65+
// Confirm the object can be deallocated
66+
XCTAssertTrue(testClass != nil)
67+
testClass = nil
68+
XCTAssertTrue(testClass == nil)
69+
}
70+
71+
func testObjectDeallocates() {
72+
let inputArr = [1, 2, 3, 5, 8]
73+
74+
subscription = Publishers.Sequence<[Int], WithUnretainedTestsError>(sequence: inputArr)
75+
.withUnretained(self.testClass)
76+
.sink(receiveCompletion: { _ in }, receiveValue: { _ in })
77+
78+
// Confirm the object can be deallocated
79+
XCTAssertTrue(testClass != nil)
80+
testClass = nil
81+
XCTAssertTrue(testClass == nil)
82+
}
83+
84+
func testObjectDeallocatesSequenceCompletes() {
85+
let testClassId = testClass.id
86+
var completed = false
87+
88+
let correctValues = [
89+
"\(testClassId), 1",
90+
"\(testClassId), 2",
91+
"\(testClassId), 3"
92+
]
93+
94+
let inputArr = [1, 2, 3]
95+
subscription = Publishers.Sequence<[Int], WithUnretainedTestsError>(sequence: inputArr)
96+
.withUnretained(self.testClass)
97+
.handleEvents(receiveOutput: { _, value in
98+
// Release the object in the middle of the sequence
99+
// to confirm it properly terminates the sequence
100+
if value == 3 {
101+
self.testClass = nil
102+
}
103+
})
104+
.map { "\($0.id), \($1)" }
105+
.sink(receiveCompletion: { _ in completed = true },
106+
receiveValue: { self.values.append($0) })
107+
108+
XCTAssertEqual(values, correctValues)
109+
XCTAssertTrue(completed)
110+
}
111+
112+
func testResultsSelector() {
113+
let testClassId = testClass.id
114+
var completed = false
115+
116+
let inputArr = [(1, "a"), (2, "b"), (3, "c"), (5, "d"), (8, "e")]
117+
118+
let correctValues = [
119+
"\(testClassId), 1, a",
120+
"\(testClassId), 2, b",
121+
"\(testClassId), 3, c",
122+
"\(testClassId), 5, d",
123+
"\(testClassId), 8, e"
124+
]
125+
126+
subscription = Publishers.Sequence<[(Int, String)], WithUnretainedTestsError>(sequence: inputArr)
127+
.withUnretained(self.testClass) { ($0, $1.0, $1.1) }
128+
.map { "\($0.id), \($1), \($2)" }
129+
.sink(receiveCompletion: { _ in completed = true },
130+
receiveValue: { self.values.append($0) })
131+
132+
XCTAssertEqual(values, correctValues)
133+
XCTAssertTrue(completed)
134+
}
135+
}
136+
137+
private class TestClass {
138+
let id: String = UUID().uuidString
139+
}
140+
#endif

0 commit comments

Comments
 (0)