Skip to content

Commit 4f7dd49

Browse files
committed
Add support for AsyncStream / AsyncSequence for Firestore
1 parent 6bcf978 commit 4f7dd49

File tree

4 files changed

+290
-0
lines changed

4 files changed

+290
-0
lines changed

Firestore/Swift/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# Unreleased
2+
- [added] Added `AsyncSequence` support for `Query.snapshots` and
3+
`DocumentReference.snapshots`, providing a modern, structured-concurrency
4+
alternative to `addSnapshotListener`.
5+
16
# 10.17.0
27
- [deprecated] All of the public API from `FirebaseFirestoreSwift` can now
38
be accessed through the `FirebaseFirestore` module. Therefore,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright 2024 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#if SWIFT_PACKAGE
18+
@_exported import FirebaseFirestoreInternalWrapper
19+
#else
20+
@_exported import FirebaseFirestoreInternal
21+
#endif // SWIFT_PACKAGE
22+
import Foundation
23+
24+
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
25+
public extension DocumentReference {
26+
/// An asynchronous sequence of document snapshots.
27+
///
28+
/// This stream emits a new `DocumentSnapshot` every time the underlying data changes.
29+
var snapshots: AsyncThrowingStream<DocumentSnapshot, Error> {
30+
return snapshots(includeMetadataChanges: false)
31+
}
32+
33+
/// An asynchronous sequence of document snapshots.
34+
///
35+
/// - Parameter includeMetadataChanges: Whether to receive events for metadata-only changes.
36+
/// - Returns: An `AsyncThrowingStream` of `DocumentSnapshot` events.
37+
func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream<DocumentSnapshot, Error> {
38+
return AsyncThrowingStream { continuation in
39+
let listener = self.addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in
40+
if let error = error {
41+
continuation.finish(throwing: error)
42+
} else if let snapshot = snapshot {
43+
continuation.yield(snapshot)
44+
}
45+
}
46+
continuation.onTermination = { @Sendable _ in
47+
listener.remove()
48+
}
49+
}
50+
}
51+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright 2024 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#if SWIFT_PACKAGE
18+
@_exported import FirebaseFirestoreInternalWrapper
19+
#else
20+
@_exported import FirebaseFirestoreInternal
21+
#endif // SWIFT_PACKAGE
22+
import Foundation
23+
24+
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
25+
public extension Query {
26+
/// An asynchronous sequence of query snapshots.
27+
///
28+
/// This stream emits a new `QuerySnapshot` every time the underlying data changes.
29+
var snapshots: AsyncThrowingStream<QuerySnapshot, Error> {
30+
return snapshots(includeMetadataChanges: false)
31+
}
32+
33+
/// An asynchronous sequence of query snapshots.
34+
///
35+
/// - Parameter includeMetadataChanges: Whether to receive events for metadata-only changes.
36+
/// - Returns: An `AsyncThrowingStream` of `QuerySnapshot` events.
37+
func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream<QuerySnapshot, Error> {
38+
return AsyncThrowingStream { continuation in
39+
let listener = self.addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in
40+
if let error = error {
41+
continuation.finish(throwing: error)
42+
} else if let snapshot = snapshot {
43+
continuation.yield(snapshot)
44+
}
45+
}
46+
continuation.onTermination = { @Sendable _ in
47+
listener.remove()
48+
}
49+
}
50+
}
51+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/*
2+
* Copyright 2024 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import XCTest
18+
@testable import FirebaseFirestore
19+
20+
// MARK: - Mock Objects for Testing
21+
22+
private class MockListenerRegistration: ListenerRegistration {
23+
var isRemoved = false
24+
func remove() {
25+
isRemoved = true
26+
}
27+
}
28+
29+
private typealias SnapshotListener = (QuerySnapshot?, Error?) -> Void
30+
private typealias DocumentSnapshotListener = (DocumentSnapshot?, Error?) -> Void
31+
32+
private class MockQuery: Query {
33+
var capturedListener: SnapshotListener?
34+
let mockListenerRegistration = MockListenerRegistration()
35+
36+
override func addSnapshotListener(
37+
_ listener: @escaping SnapshotListener
38+
) -> ListenerRegistration {
39+
capturedListener = listener
40+
return mockListenerRegistration
41+
}
42+
43+
override func addSnapshotListener(
44+
includeMetadataChanges: Bool,
45+
listener: @escaping SnapshotListener
46+
) -> ListenerRegistration {
47+
capturedListener = listener
48+
return mockListenerRegistration
49+
}
50+
}
51+
52+
private class MockDocumentReference: DocumentReference {
53+
var capturedListener: DocumentSnapshotListener?
54+
let mockListenerRegistration = MockListenerRegistration()
55+
56+
override func addSnapshotListener(
57+
_ listener: @escaping DocumentSnapshotListener
58+
) -> ListenerRegistration {
59+
capturedListener = listener
60+
return mockListenerRegistration
61+
}
62+
63+
override func addSnapshotListener(
64+
includeMetadataChanges: Bool,
65+
listener: @escaping DocumentSnapshotListener
66+
) -> ListenerRegistration {
67+
capturedListener = listener
68+
return mockListenerRegistration
69+
}
70+
}
71+
72+
// MARK: - AsyncSequenceTests
73+
74+
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
75+
class AsyncSequenceTests: XCTestCase {
76+
func testQuerySnapshotsYieldsValues() async throws {
77+
let mockQuery = MockQuery()
78+
let expectation = XCTestExpectation(description: "Received snapshot")
79+
80+
let task = Task {
81+
for try await _ in mockQuery.snapshots {
82+
expectation.fulfill()
83+
break // Exit after first result
84+
}
85+
}
86+
87+
// Ensure the listener has been set up
88+
XCTAssertNotNil(mockQuery.capturedListener)
89+
90+
// Simulate a snapshot event
91+
mockQuery.capturedListener?(QuerySnapshot(), nil)
92+
93+
await fulfillment(of: [expectation], timeout: 1.0)
94+
task.cancel()
95+
}
96+
97+
func testQuerySnapshotsThrowsErrors() async throws {
98+
let mockQuery = MockQuery()
99+
let expectedError = NSError(domain: "TestError", code: 123, userInfo: nil)
100+
var receivedError: Error?
101+
102+
let task = Task {
103+
do {
104+
for try await _ in mockQuery.snapshots {
105+
XCTFail("Should not have received a value.")
106+
}
107+
} catch {
108+
receivedError = error
109+
}
110+
}
111+
112+
// Ensure the listener has been set up
113+
XCTAssertNotNil(mockQuery.capturedListener)
114+
115+
// Simulate an error event
116+
mockQuery.capturedListener?(nil, expectedError)
117+
118+
// Allow the task to process the error
119+
try await Task.sleep(nanoseconds: 100_000_000)
120+
121+
XCTAssertNotNil(receivedError)
122+
XCTAssertEqual((receivedError as NSError?)?.domain, expectedError.domain)
123+
XCTAssertEqual((receivedError as NSError?)?.code, expectedError.code)
124+
task.cancel()
125+
}
126+
127+
func testQuerySnapshotsCancellationRemovesListener() async throws {
128+
let mockQuery = MockQuery()
129+
130+
let task = Task {
131+
for try await _ in mockQuery.snapshots {
132+
XCTFail("Should not receive any values as the task is cancelled immediately.")
133+
}
134+
}
135+
136+
// Ensure the listener was attached before we cancel
137+
XCTAssertNotNil(mockQuery.capturedListener)
138+
XCTAssertFalse(mockQuery.mockListenerRegistration.isRemoved)
139+
140+
task.cancel()
141+
142+
// Allow time for the cancellation handler to execute
143+
try await Task.sleep(nanoseconds: 100_000_000)
144+
145+
XCTAssertTrue(mockQuery.mockListenerRegistration.isRemoved)
146+
}
147+
148+
func testDocumentReferenceSnapshotsYieldsValues() async throws {
149+
let mockDocRef = MockDocumentReference()
150+
let expectation = XCTestExpectation(description: "Received document snapshot")
151+
152+
let task = Task {
153+
for try await _ in mockDocRef.snapshots {
154+
expectation.fulfill()
155+
break
156+
}
157+
}
158+
159+
XCTAssertNotNil(mockDocRef.capturedListener)
160+
mockDocRef.capturedListener?(DocumentSnapshot(), nil)
161+
162+
await fulfillment(of: [expectation], timeout: 1.0)
163+
task.cancel()
164+
}
165+
166+
func testDocumentReferenceSnapshotsCancellationRemovesListener() async throws {
167+
let mockDocRef = MockDocumentReference()
168+
169+
let task = Task {
170+
for try await _ in mockDocRef.snapshots {
171+
XCTFail("Should not receive values.")
172+
}
173+
}
174+
175+
XCTAssertNotNil(mockDocRef.capturedListener)
176+
XCTAssertFalse(mockDocRef.mockListenerRegistration.isRemoved)
177+
178+
task.cancel()
179+
try await Task.sleep(nanoseconds: 100_000_000)
180+
181+
XCTAssertTrue(mockDocRef.mockListenerRegistration.isRemoved)
182+
}
183+
}

0 commit comments

Comments
 (0)