diff --git a/CombineExt.xcodeproj/project.pbxproj b/CombineExt.xcodeproj/project.pbxproj index fe9cb70..8a76e64 100644 --- a/CombineExt.xcodeproj/project.pbxproj +++ b/CombineExt.xcodeproj/project.pbxproj @@ -37,6 +37,8 @@ C387777F24E6BF8F00FAD2D8 /* NwiseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C387777D24E6BF6C00FAD2D8 /* NwiseTests.swift */; }; D836234824EA9446002353AC /* MergeMany.swift in Sources */ = {isa = PBXBuildFile; fileRef = D836234724EA9446002353AC /* MergeMany.swift */; }; D836234A24EA9888002353AC /* MergeManyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D836234924EA9888002353AC /* MergeManyTests.swift */; }; + E17B23B526DFBFBD008E595F /* WithUnretained.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17B23B426DFBFBD008E595F /* WithUnretained.swift */; }; + E17B23B726DFFA56008E595F /* WithUnretainedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17B23B626DFFA56008E595F /* WithUnretainedTests.swift */; }; OBJ_100 /* ZipMany.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_33 /* ZipMany.swift */; }; OBJ_101 /* CurrentValueRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_35 /* CurrentValueRelay.swift */; }; OBJ_102 /* PassthroughRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_36 /* PassthroughRelay.swift */; }; @@ -122,6 +124,8 @@ "CombineExt::CombineExtTests::Product" /* CombineExtTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = CombineExtTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D836234724EA9446002353AC /* MergeMany.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MergeMany.swift; sourceTree = ""; }; D836234924EA9888002353AC /* MergeManyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MergeManyTests.swift; sourceTree = ""; }; + E17B23B426DFBFBD008E595F /* WithUnretained.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithUnretained.swift; sourceTree = ""; }; + E17B23B626DFFA56008E595F /* WithUnretainedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithUnretainedTests.swift; sourceTree = ""; }; OBJ_10 /* Sink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sink.swift; sourceTree = ""; }; OBJ_12 /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = ""; }; OBJ_14 /* Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Event.swift; sourceTree = ""; }; @@ -261,6 +265,7 @@ OBJ_33 /* ZipMany.swift */, 1970A8A925246FBD00799AB6 /* FilterMany.swift */, BFADDC8025BCE4C200465E9B /* FlatMapBatches.swift */, + E17B23B426DFBFBD008E595F /* WithUnretained.swift */, ); path = Operators; sourceTree = ""; @@ -313,6 +318,7 @@ OBJ_60 /* WithLatestFromTests.swift */, OBJ_61 /* ZipManyTests.swift */, BFADDC8A25BCE91E00465E9B /* FlatMapBatchesTests.swift */, + E17B23B626DFFA56008E595F /* WithUnretainedTests.swift */, ); path = Tests; sourceTree = SOURCE_ROOT; @@ -556,6 +562,7 @@ OBJ_126 /* CreateTests.swift in Sources */, OBJ_127 /* CurrentValueRelayTests.swift in Sources */, C387777F24E6BF8F00FAD2D8 /* NwiseTests.swift in Sources */, + E17B23B726DFFA56008E595F /* WithUnretainedTests.swift in Sources */, OBJ_128 /* DematerializeTests.swift in Sources */, OBJ_129 /* FlatMapLatestTests.swift in Sources */, OBJ_130 /* MapManyTests.swift in Sources */, @@ -587,6 +594,7 @@ OBJ_82 /* Event.swift in Sources */, OBJ_83 /* ObjectOwnership.swift in Sources */, OBJ_84 /* Amb.swift in Sources */, + E17B23B526DFBFBD008E595F /* WithUnretained.swift in Sources */, OBJ_85 /* AssignOwnership.swift in Sources */, OBJ_86 /* AssignToMany.swift in Sources */, BF3D3B5D253B83F300D830ED /* IgnoreFailure.swift in Sources */, diff --git a/Sources/Operators/WithUnretained.swift b/Sources/Operators/WithUnretained.swift new file mode 100644 index 0000000..fec8d3d --- /dev/null +++ b/Sources/Operators/WithUnretained.swift @@ -0,0 +1,112 @@ +// +// WithUnretained.swift +// CombineExt +// +// Created by Robert on 01/09/2021. +// Copyright © 2020 Combine Community. All rights reserved. +// + +#if canImport(Combine) +import Combine + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension Publisher { + /** + Provides an unretained, safe to use (i.e. not implicitly unwrapped), reference to an object along with the events published by the publisher. + + In the case the provided object cannot be retained successfully, the publisher will complete. + + - parameter obj: The object to provide an unretained reference on. + - parameter resultSelector: A function to combine the unretained referenced on `obj` and the value of the observable sequence. + - returns: A publisher that contains the result of `resultSelector` being called with an unretained reference on `obj` and the values of the upstream. + */ + func withUnretained(_ obj: UnretainedObject, resultSelector: @escaping (UnretainedObject, Self.Output) -> Output) -> Publishers.WithUnretained { + Publishers.WithUnretained(unretainedObject: obj, upstream: self, resultSelector: resultSelector) + } + + /** + Provides an unretained, safe to use (i.e. not implicitly unwrapped), reference to an object along with the events published by the publisher. + + In the case the provided object cannot be retained successfully, the publisher will complete. + + - parameter obj: The object to provide an unretained reference on. + - returns: A publisher that publishes a sequence of tuples that contains both an unretained reference on `obj` and the values of the upstream. + */ + func withUnretained(_ obj: UnretainedObject) -> Publishers.WithUnretained { + Publishers.WithUnretained(unretainedObject: obj, upstream: self) { ($0, $1) } + } + + /// Attaches a subscriber with closure-based behavior. + /// + /// Use ``Publisher/sink(unretainedObject:receiveCompletion:receiveValue:)`` to observe values received by the publisher and process them using a closure you specify. + /// This method creates the subscriber and immediately requests an unlimited number of values, prior to returning the subscriber. + /// The return value should be held, otherwise the stream will be canceled. + /// + /// - parameter obj: The object to provide an unretained reference on. + /// - parameter receiveComplete: The closure to execute on completion. + /// - parameter receiveValue: The closure to execute on receipt of a value. + /// - 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. + func sink(unretainedObject obj: UnretainedObject, receiveCompletion: @escaping ((Subscribers.Completion) -> Void), receiveValue: @escaping ((UnretainedObject, Self.Output) -> Void)) -> AnyCancellable { + withUnretained(obj) + .sink(receiveCompletion: receiveCompletion, receiveValue: receiveValue) + } +} + +// MARK: - Publisher +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension Publishers { + struct WithUnretained: Publisher { + public typealias Failure = Upstream.Failure + + private weak var unretainedObject: UnretainedObject? + private let upstream: Upstream + private let resultSelector: (UnretainedObject, Upstream.Output) -> Output + + public init(unretainedObject: UnretainedObject, upstream: Upstream, resultSelector: @escaping (UnretainedObject, Upstream.Output) -> Output) { + self.unretainedObject = unretainedObject + self.upstream = upstream + self.resultSelector = resultSelector + } + + public func receive(subscriber: S) where Failure == S.Failure, Output == S.Input { + upstream.subscribe(Subscriber(unretainedObject: unretainedObject, downstream: subscriber, resultSelector: resultSelector)) + } + } +} + +// MARK: - Subscriber +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +private extension Publishers.WithUnretained { + class Subscriber: Combine.Subscriber where Downstream.Input == Output, Downstream.Failure == Failure { + typealias Input = Upstream.Output + typealias Failure = Downstream.Failure + + private weak var unretainedObject: UnretainedObject? + private let downstream: Downstream + private let resultSelector: (UnretainedObject, Input) -> Output + + init(unretainedObject: UnretainedObject?, downstream: Downstream, resultSelector: @escaping (UnretainedObject, Input) -> Output) { + self.unretainedObject = unretainedObject + self.downstream = downstream + self.resultSelector = resultSelector + } + + func receive(subscription: Subscription) { + if unretainedObject == nil { return } + downstream.receive(subscription: subscription) + } + + func receive(_ input: Input) -> Subscribers.Demand { + guard let unretainedObject = unretainedObject else { return .none } + return downstream.receive(resultSelector(unretainedObject, input)) + } + + func receive(completion: Subscribers.Completion) { + if unretainedObject == nil { + return downstream.receive(completion: .finished) + } + downstream.receive(completion: completion) + } + } +} +#endif diff --git a/Tests/WithUnretainedTests.swift b/Tests/WithUnretainedTests.swift new file mode 100644 index 0000000..363d896 --- /dev/null +++ b/Tests/WithUnretainedTests.swift @@ -0,0 +1,140 @@ +// +// WithUnretainedTests.swift +// CombineExtTests +// +// Created by Robert on 02/09/2021. +// + +#if !os(watchOS) +import XCTest +import Foundation +import Combine +import CombineExt + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +final class WithUnretainedTests: XCTestCase { + fileprivate var testClass: TestClass! + var subscription: AnyCancellable? + var values: [String] = [] + + enum WithUnretainedTestsError: Swift.Error { + case someError + } + + override func setUp() { + super.setUp() + + testClass = TestClass() + values = [] + } + + override func tearDown() { + subscription?.cancel() + subscription = nil + } + + func testObjectAttached() { + let testClassId = testClass.id + var completed = false + + let correctValues = [ + "\(testClassId), 1", + "\(testClassId), 2", + "\(testClassId), 3", + "\(testClassId), 5", + "\(testClassId), 8" + ] + + let inputArr = [1, 2, 3, 5, 8] + + subscription = Publishers.Sequence<[Int], WithUnretainedTestsError>(sequence: inputArr) + .withUnretained(self.testClass) + .map { "\($0.id), \($1)" } + .sink(receiveCompletion: { _ in completed = true }, + receiveValue: { self.values.append($0) }) + + XCTAssertEqual(values, correctValues) + XCTAssertTrue(completed) + } + + func testObjectDeallocatesWithEmptyPublisher() { + subscription = Empty() + .withUnretained(self.testClass) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) + + // Confirm the object can be deallocated + XCTAssertTrue(testClass != nil) + testClass = nil + XCTAssertTrue(testClass == nil) + } + + func testObjectDeallocates() { + let inputArr = [1, 2, 3, 5, 8] + + subscription = Publishers.Sequence<[Int], WithUnretainedTestsError>(sequence: inputArr) + .withUnretained(self.testClass) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) + + // Confirm the object can be deallocated + XCTAssertTrue(testClass != nil) + testClass = nil + XCTAssertTrue(testClass == nil) + } + + func testObjectDeallocatesSequenceCompletes() { + let testClassId = testClass.id + var completed = false + + let correctValues = [ + "\(testClassId), 1", + "\(testClassId), 2", + "\(testClassId), 3" + ] + + let inputArr = [1, 2, 3] + subscription = Publishers.Sequence<[Int], WithUnretainedTestsError>(sequence: inputArr) + .withUnretained(self.testClass) + .handleEvents(receiveOutput: { _, value in + // Release the object in the middle of the sequence + // to confirm it properly terminates the sequence + if value == 3 { + self.testClass = nil + } + }) + .map { "\($0.id), \($1)" } + .sink(receiveCompletion: { _ in completed = true }, + receiveValue: { self.values.append($0) }) + + XCTAssertEqual(values, correctValues) + XCTAssertTrue(completed) + } + + func testResultsSelector() { + let testClassId = testClass.id + var completed = false + + let inputArr = [(1, "a"), (2, "b"), (3, "c"), (5, "d"), (8, "e")] + + let correctValues = [ + "\(testClassId), 1, a", + "\(testClassId), 2, b", + "\(testClassId), 3, c", + "\(testClassId), 5, d", + "\(testClassId), 8, e" + ] + + subscription = Publishers.Sequence<[(Int, String)], WithUnretainedTestsError>(sequence: inputArr) + .withUnretained(self.testClass) { ($0, $1.0, $1.1) } + .map { "\($0.id), \($1), \($2)" } + .sink(receiveCompletion: { _ in completed = true }, + receiveValue: { self.values.append($0) }) + + XCTAssertEqual(values, correctValues) + XCTAssertTrue(completed) + } +} + +private class TestClass { + let id: String = UUID().uuidString +} +#endif