Skip to content

Commit 68f60e3

Browse files
committed
Add AsyncMaterializedSequence
Creates an asynchronous sequence that iterates over the events of the original sequence.
1 parent d9bd410 commit 68f60e3

File tree

3 files changed

+254
-2
lines changed

3 files changed

+254
-2
lines changed

Package.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ let package = Package(
1818
.target(
1919
name: "AsyncSwiftly"
2020
),
21+
.target(
22+
name: "AsyncMaterializedSequence"
23+
),
2124
.target(
2225
name: "TestingSupport",
2326
dependencies: [
@@ -26,11 +29,22 @@ let package = Package(
2629
),
2730
.testTarget(
2831
name: "AsyncSwiftlyTests",
29-
dependencies: ["AsyncSwiftly"]
32+
dependencies: [
33+
"AsyncSwiftly",
34+
]
35+
),
36+
.testTarget(
37+
name: "AsyncMaterializedSequenceTests",
38+
dependencies: [
39+
"AsyncMaterializedSequence",
40+
"TestingSupport",
41+
]
3042
),
3143
.testTarget(
3244
name: "TestingSupportTests",
33-
dependencies: ["TestingSupport"]
45+
dependencies: [
46+
"TestingSupport",
47+
]
3448
),
3549
]
3650
)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import Foundation
2+
3+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
4+
extension AsyncSequence {
5+
6+
/// Creates an asynchronous sequence that iterates over the events of the original sequence.
7+
///
8+
/// ```
9+
/// for await event in (1...3).async.materialize() {
10+
/// print("\(event)")
11+
/// }
12+
///
13+
/// // .value(1)
14+
/// // .value(2)
15+
/// // .value(3)
16+
/// // .completed(.finished)
17+
/// ```
18+
///
19+
/// - Returns: An `AsyncSequence` where the element is an event of the original `AsyncSequence`.
20+
@inlinable
21+
public func materialize() -> AsyncMaterializedSequence<Self> {
22+
return AsyncMaterializedSequence(self)
23+
}
24+
}
25+
26+
/// An `AsyncSequence` that iterates over the events of the original `AsyncSequence`.
27+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
28+
public struct AsyncMaterializedSequence<Base: AsyncSequence> {
29+
30+
@usableFromInline
31+
let base: Base
32+
33+
@inlinable
34+
init(_ base: Base) {
35+
self.base = base
36+
}
37+
}
38+
39+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
40+
extension AsyncMaterializedSequence: AsyncSequence {
41+
42+
public enum Completion {
43+
case failure(Base.Failure)
44+
case finished
45+
}
46+
47+
public enum Event {
48+
case value(Base.Element)
49+
case completed(Completion)
50+
}
51+
52+
public struct Iterator: AsyncIteratorProtocol {
53+
54+
@usableFromInline
55+
var base: Base.AsyncIterator
56+
57+
@usableFromInline
58+
private(set) var baseIsCompleted: Bool = false
59+
60+
@inlinable
61+
init(_ base: Base.AsyncIterator) {
62+
self.base = base
63+
}
64+
65+
@inlinable
66+
public mutating func next() async -> Event? {
67+
guard !baseIsCompleted else {
68+
return nil
69+
}
70+
71+
do {
72+
if let element = try await base.next() {
73+
return .value(element)
74+
} else {
75+
baseIsCompleted = true
76+
return .completed(.finished)
77+
}
78+
} catch let error as Base.Failure {
79+
baseIsCompleted = true
80+
return .completed(.failure(error))
81+
} catch {
82+
preconditionFailure("Unexpected error: \(error)")
83+
}
84+
}
85+
}
86+
87+
@inlinable
88+
public func makeAsyncIterator() -> Iterator {
89+
Iterator(base.makeAsyncIterator())
90+
}
91+
}
92+
93+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
94+
extension AsyncMaterializedSequence: Sendable where Base: Sendable, Base.Element: Sendable {}
95+
96+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
97+
extension AsyncMaterializedSequence.Completion: Sendable {}
98+
99+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
100+
extension AsyncMaterializedSequence.Event: Sendable where Base.Element: Sendable {}
101+
102+
@available(*, unavailable)
103+
extension AsyncMaterializedSequence.Iterator: Sendable {}
104+
105+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
106+
extension AsyncMaterializedSequence.Completion: Equatable where Base.Failure: Equatable {}
107+
108+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
109+
extension AsyncMaterializedSequence.Event: Equatable where Base.Element: Equatable, Base.Failure: Equatable {}
110+
111+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
112+
extension AsyncMaterializedSequence.Completion: CustomDebugStringConvertible {
113+
114+
public var debugDescription: String {
115+
switch self {
116+
case .failure(let error):
117+
".failure(\(error.localizedDescription))"
118+
case .finished:
119+
".finished"
120+
}
121+
}
122+
}
123+
124+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
125+
extension AsyncMaterializedSequence.Event: CustomDebugStringConvertible {
126+
127+
public var debugDescription: String {
128+
switch self {
129+
case .value(let element):
130+
".value(\(element))"
131+
case .completed(let completion):
132+
".completed(\(completion))"
133+
}
134+
}
135+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import Testing
2+
import TestingSupport
3+
import AsyncMaterializedSequence
4+
5+
struct AsyncMaterializedSequenceTests {
6+
7+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
8+
@Test func materialize_produces_next_events_of_values_of_original_element() async throws {
9+
let source = 1...3
10+
let sequence = source.async.materialize().prefix(source.count)
11+
12+
await #expect(sequence.collect() == [
13+
.value(1),
14+
.value(2),
15+
.value(3),
16+
])
17+
}
18+
19+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
20+
@Test func materialize_produces_completed_event_when_source_sequence_completes() async throws {
21+
let source = 0..<1
22+
let sequence = source.async.materialize()
23+
24+
await #expect(sequence.collect() == [
25+
.value(0),
26+
.completed(.finished),
27+
])
28+
}
29+
30+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
31+
@Test func materialize_produces_completed_event_when_source_sequence_is_empty() async throws {
32+
let source: [Int] = []
33+
let sequence = source.async.materialize()
34+
35+
await #expect(sequence.collect() == [
36+
.completed(.finished),
37+
])
38+
}
39+
40+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
41+
@Test func materialize_forwards_termination_from_source_when_iteration_is_finished() async throws {
42+
let source = 1...3
43+
44+
var iterator = source.async.materialize().makeAsyncIterator()
45+
while let _ = await iterator.next() {}
46+
47+
let pastEnd = await iterator.next()
48+
#expect(pastEnd == nil)
49+
}
50+
51+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
52+
@Test func materialize_produces_completed_event_when_source_sequence_throws() async throws {
53+
let source = AsyncThrowingStream<Int, Error> { continuation in
54+
continuation.finish(throwing: TestError())
55+
}
56+
57+
let sequence = source.materialize()
58+
let events = await sequence.collect()
59+
60+
#expect(events.count == 1)
61+
62+
let event = try #require(events.last)
63+
64+
switch event {
65+
case .completed(.failure(let error)) where error is TestError:
66+
break
67+
default:
68+
Issue.record("Unexpected event: \(event)")
69+
}
70+
}
71+
72+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
73+
@Test func materialize_produces_completed_event_when_source_sequence_is_cancelled() async throws {
74+
let trigger = AsyncStream.makeStream(of: Void.self, bufferingPolicy: .bufferingNewest(1))
75+
let source = AsyncStream<Int> { continuation in
76+
continuation.yield(0)
77+
}
78+
let sequence = source.materialize()
79+
80+
let task = Task {
81+
var firstIteration = false
82+
return await sequence.reduce(into: [AsyncMaterializedSequence<AsyncStream<Int>>.Event]()) {
83+
if !firstIteration {
84+
firstIteration = true
85+
trigger.continuation.finish()
86+
}
87+
$0.append($1)
88+
}
89+
}
90+
91+
// ensure the other task actually starts
92+
await trigger.stream.first { _ in true }
93+
94+
// cancellation should ensure the loop finishes
95+
// without regards to the remaining underlying sequence
96+
task.cancel()
97+
98+
await #expect(task.value == [
99+
.value(0),
100+
.completed(.finished),
101+
])
102+
}
103+
}

0 commit comments

Comments
 (0)