Skip to content

Commit 689f88a

Browse files
committed
Add withUnretained operator
1 parent 665fc63 commit 689f88a

File tree

3 files changed

+260
-0
lines changed

3 files changed

+260
-0
lines changed

CombineExt.xcodeproj/project.pbxproj

+8
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
C387777F24E6BF8F00FAD2D8 /* NwiseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C387777D24E6BF6C00FAD2D8 /* NwiseTests.swift */; };
3838
D836234824EA9446002353AC /* MergeMany.swift in Sources */ = {isa = PBXBuildFile; fileRef = D836234724EA9446002353AC /* MergeMany.swift */; };
3939
D836234A24EA9888002353AC /* MergeManyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D836234924EA9888002353AC /* MergeManyTests.swift */; };
40+
E17B23B526DFBFBD008E595F /* WithUnretained.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17B23B426DFBFBD008E595F /* WithUnretained.swift */; };
41+
E17B23B726DFFA56008E595F /* WithUnretainedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17B23B626DFFA56008E595F /* WithUnretainedTests.swift */; };
4042
OBJ_100 /* ZipMany.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_33 /* ZipMany.swift */; };
4143
OBJ_101 /* CurrentValueRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_35 /* CurrentValueRelay.swift */; };
4244
OBJ_102 /* PassthroughRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_36 /* PassthroughRelay.swift */; };
@@ -122,6 +124,8 @@
122124
"CombineExt::CombineExtTests::Product" /* CombineExtTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = CombineExtTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
123125
D836234724EA9446002353AC /* MergeMany.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MergeMany.swift; sourceTree = "<group>"; };
124126
D836234924EA9888002353AC /* MergeManyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MergeManyTests.swift; sourceTree = "<group>"; };
127+
E17B23B426DFBFBD008E595F /* WithUnretained.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithUnretained.swift; sourceTree = "<group>"; };
128+
E17B23B626DFFA56008E595F /* WithUnretainedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithUnretainedTests.swift; sourceTree = "<group>"; };
125129
OBJ_10 /* Sink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sink.swift; sourceTree = "<group>"; };
126130
OBJ_12 /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = "<group>"; };
127131
OBJ_14 /* Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Event.swift; sourceTree = "<group>"; };
@@ -261,6 +265,7 @@
261265
OBJ_33 /* ZipMany.swift */,
262266
1970A8A925246FBD00799AB6 /* FilterMany.swift */,
263267
BFADDC8025BCE4C200465E9B /* FlatMapBatches.swift */,
268+
E17B23B426DFBFBD008E595F /* WithUnretained.swift */,
264269
);
265270
path = Operators;
266271
sourceTree = "<group>";
@@ -313,6 +318,7 @@
313318
OBJ_60 /* WithLatestFromTests.swift */,
314319
OBJ_61 /* ZipManyTests.swift */,
315320
BFADDC8A25BCE91E00465E9B /* FlatMapBatchesTests.swift */,
321+
E17B23B626DFFA56008E595F /* WithUnretainedTests.swift */,
316322
);
317323
path = Tests;
318324
sourceTree = SOURCE_ROOT;
@@ -556,6 +562,7 @@
556562
OBJ_126 /* CreateTests.swift in Sources */,
557563
OBJ_127 /* CurrentValueRelayTests.swift in Sources */,
558564
C387777F24E6BF8F00FAD2D8 /* NwiseTests.swift in Sources */,
565+
E17B23B726DFFA56008E595F /* WithUnretainedTests.swift in Sources */,
559566
OBJ_128 /* DematerializeTests.swift in Sources */,
560567
OBJ_129 /* FlatMapLatestTests.swift in Sources */,
561568
OBJ_130 /* MapManyTests.swift in Sources */,
@@ -587,6 +594,7 @@
587594
OBJ_82 /* Event.swift in Sources */,
588595
OBJ_83 /* ObjectOwnership.swift in Sources */,
589596
OBJ_84 /* Amb.swift in Sources */,
597+
E17B23B526DFBFBD008E595F /* WithUnretained.swift in Sources */,
590598
OBJ_85 /* AssignOwnership.swift in Sources */,
591599
OBJ_86 /* AssignToMany.swift in Sources */,
592600
BF3D3B5D253B83F300D830ED /* IgnoreFailure.swift in Sources */,
+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)