Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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(iOS 18.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(iOS 18.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> {
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> {
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(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
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