Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Firestore/Swift/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# Unreleased
- [added] Added `AsyncSequence` support for `Query.snapshots` and
`DocumentReference.snapshots`, providing a modern, structured-concurrency
alternative to `addSnapshotListener`.

# 10.17.0
- [deprecated] All of the public API from `FirebaseFirestoreSwift` can now
be accessed through the `FirebaseFirestore` module. Therefore,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#if SWIFT_PACKAGE
@_exported import FirebaseFirestoreInternalWrapper
#else
@_exported import FirebaseFirestoreInternal
#endif // SWIFT_PACKAGE
import Foundation

@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
public extension DocumentReference {
/// An asynchronous sequence of document snapshots.
///
/// This stream emits a new `DocumentSnapshot` every time the underlying data changes.
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
var snapshots: some AsyncSequence<DocumentSnapshot, Error> {
return snapshots(includeMetadataChanges: false)
}

/// An asynchronous sequence of document snapshots.
///
/// - Parameter includeMetadataChanges: Whether to receive events for metadata-only changes.
/// - Returns: An `AsyncThrowingStream` of `DocumentSnapshot` events.
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
func snapshots(includeMetadataChanges: Bool) -> some AsyncSequence<DocumentSnapshot, Error> {
return AsyncThrowingStream { continuation in
let listener = self
.addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in
if let error = error {
continuation.finish(throwing: error)
} else if let snapshot = snapshot {
continuation.yield(snapshot)
}
}
continuation.onTermination = { @Sendable _ in
listener.remove()
}
}
}
}
54 changes: 54 additions & 0 deletions Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#if SWIFT_PACKAGE
@_exported import FirebaseFirestoreInternalWrapper
#else
@_exported import FirebaseFirestoreInternal
#endif // SWIFT_PACKAGE
import Foundation

@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
public extension Query {
/// An asynchronous sequence of query snapshots.
///
/// This stream emits a new `QuerySnapshot` every time the underlying data changes.
@available(iOS 18.0, *)
var snapshots: some AsyncSequence<QuerySnapshot, Error> {

Check failure on line 30 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / spm-source (macos-15, Xcode_16.4, tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 30 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / spm-source (macos-15, Xcode_16.4, tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 30 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / spm-source (macos-15, Xcode_16.4, tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 30 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 30 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 30 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 30 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 30 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS)

'Failure' is only available in macOS 15.0 or newer

Check failure on line 30 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS)

'Failure' is only available in macOS 15.0 or newer

Check failure on line 30 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS)

'Failure' is only available in macOS 15.0 or newer
return snapshots(includeMetadataChanges: false)
}

/// An asynchronous sequence of query snapshots.
///
/// - Parameter includeMetadataChanges: Whether to receive events for metadata-only changes.
/// - Returns: An `AsyncThrowingStream` of `QuerySnapshot` events.
@available(iOS 18.0, *)
func snapshots(includeMetadataChanges: Bool) -> some AsyncSequence<QuerySnapshot, Error> {

Check failure on line 39 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / spm-source (macos-15, Xcode_16.4, tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 39 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / spm-source (macos-15, Xcode_16.4, tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 39 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / spm-source (macos-15, Xcode_16.4, tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 39 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 39 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 39 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 39 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (tvOS)

'Failure' is only available in tvOS 18.0 or newer

Check failure on line 39 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS)

'Failure' is only available in macOS 15.0 or newer

Check failure on line 39 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS)

'Failure' is only available in macOS 15.0 or newer

Check failure on line 39 in Firestore/Swift/Source/AsyncAwait/Query+AsyncSequence.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS)

'Failure' is only available in macOS 15.0 or newer
return AsyncThrowingStream { continuation in
let listener = self
.addSnapshotListener(includeMetadataChanges: includeMetadataChanges) { snapshot, error in
if let error = error {
continuation.finish(throwing: error)
} else if let snapshot = snapshot {
continuation.yield(snapshot)
}
}
continuation.onTermination = { @Sendable _ in
listener.remove()
}
}
}
}
178 changes: 178 additions & 0 deletions Firestore/Swift/Tests/Unit/AsyncAwait/AsyncSequenceTests.swift
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests don't appear to be running when I reproduced the xcodebuild job locally. I'm still debugging with the goal of adding a small SPM based unit test target.

Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

@testable import FirebaseFirestore
import XCTest

// MARK: - Mock Objects for Testing

private class MockListenerRegistration: ListenerRegistration {
var isRemoved = false
func remove() {
isRemoved = true
}
}

private typealias SnapshotListener = (QuerySnapshot?, Error?) -> Void
private typealias DocumentSnapshotListener = (DocumentSnapshot?, Error?) -> Void

private class MockQuery: Query {
var capturedListener: SnapshotListener?
let mockListenerRegistration = MockListenerRegistration()

override func addSnapshotListener(_ listener: @escaping SnapshotListener)
-> ListenerRegistration {
capturedListener = listener
return mockListenerRegistration
}

override func addSnapshotListener(includeMetadataChanges: Bool,
listener: @escaping SnapshotListener) -> ListenerRegistration {
capturedListener = listener
return mockListenerRegistration
}
}

private class MockDocumentReference: DocumentReference {
var capturedListener: DocumentSnapshotListener?
let mockListenerRegistration = MockListenerRegistration()

override func addSnapshotListener(_ listener: @escaping DocumentSnapshotListener)
-> ListenerRegistration {
capturedListener = listener
return mockListenerRegistration
}

override func addSnapshotListener(includeMetadataChanges: Bool,
listener: @escaping DocumentSnapshotListener)
-> ListenerRegistration {
capturedListener = listener
return mockListenerRegistration
}
}

// MARK: - AsyncSequenceTests

@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
class AsyncSequenceTests: XCTestCase {
func testQuerySnapshotsYieldsValues() async throws {
let mockQuery = MockQuery()
let expectation = XCTestExpectation(description: "Received snapshot")

let task = Task {
for try await _ in mockQuery.snapshots {
expectation.fulfill()
break // Exit after first result
}
}

// Ensure the listener has been set up
XCTAssertNotNil(mockQuery.capturedListener)

// Simulate a snapshot event
mockQuery.capturedListener?(QuerySnapshot(), nil)

await fulfillment(of: [expectation], timeout: 1.0)
task.cancel()
}

func testQuerySnapshotsThrowsErrors() async throws {
let mockQuery = MockQuery()
let expectedError = NSError(domain: "TestError", code: 123, userInfo: nil)
var receivedError: Error?

let task = Task {
do {
for try await _ in mockQuery.snapshots {
XCTFail("Should not have received a value.")
}
} catch {
receivedError = error
}
}

// Ensure the listener has been set up
XCTAssertNotNil(mockQuery.capturedListener)

// Simulate an error event
mockQuery.capturedListener?(nil, expectedError)

// Allow the task to process the error
try await Task.sleep(nanoseconds: 100_000_000)

XCTAssertNotNil(receivedError)
XCTAssertEqual((receivedError as NSError?)?.domain, expectedError.domain)
XCTAssertEqual((receivedError as NSError?)?.code, expectedError.code)
task.cancel()
}

func testQuerySnapshotsCancellationRemovesListener() async throws {
let mockQuery = MockQuery()

let task = Task {
for try await _ in mockQuery.snapshots {
XCTFail("Should not receive any values as the task is cancelled immediately.")
}
}

// Ensure the listener was attached before we cancel
XCTAssertNotNil(mockQuery.capturedListener)
XCTAssertFalse(mockQuery.mockListenerRegistration.isRemoved)

task.cancel()

// Allow time for the cancellation handler to execute
try await Task.sleep(nanoseconds: 100_000_000)

XCTAssertTrue(mockQuery.mockListenerRegistration.isRemoved)
}

func testDocumentReferenceSnapshotsYieldsValues() async throws {
let mockDocRef = MockDocumentReference()
let expectation = XCTestExpectation(description: "Received document snapshot")

let task = Task {
for try await _ in mockDocRef.snapshots {
expectation.fulfill()
break
}
}

XCTAssertNotNil(mockDocRef.capturedListener)
mockDocRef.capturedListener?(DocumentSnapshot(), nil)

await fulfillment(of: [expectation], timeout: 1.0)
task.cancel()
}

func testDocumentReferenceSnapshotsCancellationRemovesListener() async throws {
let mockDocRef = MockDocumentReference()

let task = Task {
for try await _ in mockDocRef.snapshots {
XCTFail("Should not receive values.")
}
}

XCTAssertNotNil(mockDocRef.capturedListener)
XCTAssertFalse(mockDocRef.mockListenerRegistration.isRemoved)

task.cancel()
try await Task.sleep(nanoseconds: 100_000_000)

XCTAssertTrue(mockDocRef.mockListenerRegistration.isRemoved)
}
}
Loading