From ad3abbb34566cdfaaf285c3b98441e40dc905ebb Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Mon, 2 Jun 2025 17:13:53 -0400 Subject: [PATCH 1/3] Support asyncStream in realtime --- .../Source/AsyncAwait/Query+AsyncAwait.swift | 57 ++++++++++++++ .../AsyncAwaitIntegrationTests.swift | 78 +++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 Firestore/Swift/Source/AsyncAwait/Query+AsyncAwait.swift diff --git a/Firestore/Swift/Source/AsyncAwait/Query+AsyncAwait.swift b/Firestore/Swift/Source/AsyncAwait/Query+AsyncAwait.swift new file mode 100644 index 00000000000..520a6d5ac53 --- /dev/null +++ b/Firestore/Swift/Source/AsyncAwait/Query+AsyncAwait.swift @@ -0,0 +1,57 @@ +/* + * 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 { + func snapshots() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let listener = self.addSnapshotListener { snapshot, error in + if let snapshot = snapshot { + continuation.yield(snapshot) + } else if let error = error { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + listener.remove() + } + } + } + + func snapshots(options: SnapshotListenOptions) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let listener = self.addSnapshotListener(options: options) { snapshot, error in + if let snapshot = snapshot { + continuation.yield(snapshot) + } else if let error = error { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + listener.remove() + } + } + } +} diff --git a/Firestore/Swift/Tests/Integration/AsyncAwaitIntegrationTests.swift b/Firestore/Swift/Tests/Integration/AsyncAwaitIntegrationTests.swift index 1014135f1c8..b552a38a173 100644 --- a/Firestore/Swift/Tests/Integration/AsyncAwaitIntegrationTests.swift +++ b/Firestore/Swift/Tests/Integration/AsyncAwaitIntegrationTests.swift @@ -35,6 +35,41 @@ let emptyBundle = """ #if swift(>=5.5.2) @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) class AsyncAwaitIntegrationTests: FSTIntegrationTestCase { + func assertQuerySnapshotDataEquals(_ snapshot: Any, + _ expectedData: [[String: Any]]) throws { + let extractedData = FIRQuerySnapshotGetData(snapshot as! QuerySnapshot) + guard extractedData.count == expectedData.count else { + XCTFail( + "Result count mismatch: Expected \(expectedData.count), got \(extractedData.count)" + ) + return + } + for index in 0 ..< extractedData.count { + XCTAssertTrue(areDictionariesEqual(extractedData[index], expectedData[index])) + } + } + + // TODO(swift testing): update the function to be able to check other value types as well. + func areDictionariesEqual(_ dict1: [String: Any], _ dict2: [String: Any]) -> Bool { + guard dict1.count == dict2.count + else { return false } // Check if the number of elements matches + + for (key, value1) in dict1 { + guard let value2 = dict2[key] else { return false } + + // Value Checks (Assuming consistent types after the type check) + if let str1 = value1 as? String, let str2 = value2 as? String { + if str1 != str2 { return false } + } else if let int1 = value1 as? Int, let int2 = value2 as? Int { + if int1 != int2 { return false } + } else { + // Handle other potential types or return false for mismatch + return false + } + } + return true + } + func testAddData() async throws { let collection = collectionRef() let document = try await collection.addDocument(data: [:]) @@ -120,5 +155,48 @@ let emptyBundle = """ } XCTAssertThrowsError(try deleteAllIndexes(), "The client has already been terminated.") } + + func testCanListenToDefaultSourceFirstAndThenCacheAsyncStream() async throws { + let collRef = collectionRef(withDocuments: [ + "a": ["k": "a", "sort": 0], + "b": ["k": "b", "sort": 1], + ]) + + let query = collRef.whereField("sort", isGreaterThanOrEqualTo: 1).order(by: "sort") + + // 1. Create a signal stream. The test will wait on this stream. + // The Task will write to it after receiving the first snapshot. + let (signalStream, signalContinuation) = AsyncStream.makeStream(of: Void.self) + + let stream = query.snapshots() + var iterator = stream.makeAsyncIterator() + + let task = Task { + // This task will now run and eventually signal its progress. + let firstSnapshot = try await iterator.next() + + // Assertions for the first snapshot + XCTAssertNotNil(firstSnapshot, "Expected an initial snapshot.") + try assertQuerySnapshotDataEquals(firstSnapshot!, [["k": "b", "sort": 1]]) + XCTAssertEqual(firstSnapshot!.metadata.isFromCache, false) + + // 2. Send the signal to the test function now that we have the first snapshot. + signalContinuation.yield(()) + signalContinuation.finish() // We only need to signal once. + + // This next await will be suspended until it's cancelled. + let second = try await iterator.next() + + // After cancellation, the iterator should terminate and return nil. + XCTAssertNil(second, "iterator.next() should have returned nil after cancellation.") + } + + // 3. Instead of sleeping, await the signal from the Task. + // This line will pause execution until `signalContinuation.yield()` is called. + await signalStream.first { _ in true } + + // 4. As soon as we receive the signal, we know it's safe to cancel. + task.cancel() + } } #endif From f7a77dec8d356ac7b2e5d6cf6597e13bc1b11b23 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Mon, 2 Jun 2025 17:34:09 -0400 Subject: [PATCH 2/3] rename --- Firestore/Swift/Source/AsyncAwait/Query+AsyncAwait.swift | 4 ++-- .../Swift/Tests/Integration/AsyncAwaitIntegrationTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Firestore/Swift/Source/AsyncAwait/Query+AsyncAwait.swift b/Firestore/Swift/Source/AsyncAwait/Query+AsyncAwait.swift index 520a6d5ac53..d8f25226cf1 100644 --- a/Firestore/Swift/Source/AsyncAwait/Query+AsyncAwait.swift +++ b/Firestore/Swift/Source/AsyncAwait/Query+AsyncAwait.swift @@ -23,7 +23,7 @@ import Foundation @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) public extension Query { - func snapshots() -> AsyncThrowingStream { + func asyncThrowingStream() -> AsyncThrowingStream { AsyncThrowingStream { continuation in let listener = self.addSnapshotListener { snapshot, error in if let snapshot = snapshot { @@ -39,7 +39,7 @@ public extension Query { } } - func snapshots(options: SnapshotListenOptions) -> AsyncThrowingStream { + func asyncThrowingStream(options: SnapshotListenOptions) -> AsyncThrowingStream { AsyncThrowingStream { continuation in let listener = self.addSnapshotListener(options: options) { snapshot, error in if let snapshot = snapshot { diff --git a/Firestore/Swift/Tests/Integration/AsyncAwaitIntegrationTests.swift b/Firestore/Swift/Tests/Integration/AsyncAwaitIntegrationTests.swift index b552a38a173..e02ae6581a5 100644 --- a/Firestore/Swift/Tests/Integration/AsyncAwaitIntegrationTests.swift +++ b/Firestore/Swift/Tests/Integration/AsyncAwaitIntegrationTests.swift @@ -168,7 +168,7 @@ let emptyBundle = """ // The Task will write to it after receiving the first snapshot. let (signalStream, signalContinuation) = AsyncStream.makeStream(of: Void.self) - let stream = query.snapshots() + let stream = query.asyncThrowingStream() var iterator = stream.makeAsyncIterator() let task = Task { From 8c65663ccf23f5ef71daaaec954bbfd9efa8771d Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 5 Jun 2025 13:59:12 -0400 Subject: [PATCH 3/3] add tests --- .../Firestore.xcodeproj/project.pbxproj | 18 +- ....swift => Query+AsyncThrowingStream.swift} | 19 +- .../AsyncAwaitIntegrationTests.swift | 43 ---- .../SnapshotStreamListenerSourceTests.swift | 211 ++++++++++++++++++ 4 files changed, 226 insertions(+), 65 deletions(-) rename Firestore/Swift/Source/AsyncAwait/{Query+AsyncAwait.swift => Query+AsyncThrowingStream.swift} (69%) create mode 100644 Firestore/Swift/Tests/Integration/SnapshotStreamListenerSourceTests.swift diff --git a/Firestore/Example/Firestore.xcodeproj/project.pbxproj b/Firestore/Example/Firestore.xcodeproj/project.pbxproj index 8deefcabab8..8754248d50a 100644 --- a/Firestore/Example/Firestore.xcodeproj/project.pbxproj +++ b/Firestore/Example/Firestore.xcodeproj/project.pbxproj @@ -748,6 +748,9 @@ 61D35E0DE04E70D3BC243A65 /* FIRGeoPointTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 5492E048202154AA00B64F25 /* FIRGeoPointTests.mm */; }; 61ECC7CE18700CBD73D0D810 /* leveldb_migrations_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = EF83ACD5E1E9F25845A9ACED /* leveldb_migrations_test.cc */; }; 61F72C5620BC48FD001A68CB /* serializer_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = 61F72C5520BC48FD001A68CB /* serializer_test.cc */; }; + 62180C272DF20F6500B370CD /* SnapshotStreamListenerSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62180C262DF20F4B00B370CD /* SnapshotStreamListenerSourceTests.swift */; }; + 62180C282DF20F6500B370CD /* SnapshotStreamListenerSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62180C262DF20F4B00B370CD /* SnapshotStreamListenerSourceTests.swift */; }; + 62180C292DF20F6500B370CD /* SnapshotStreamListenerSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62180C262DF20F4B00B370CD /* SnapshotStreamListenerSourceTests.swift */; }; 621D620A28F9CE7400D2FA26 /* QueryIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621D620928F9CE7400D2FA26 /* QueryIntegrationTests.swift */; }; 621D620B28F9CE7400D2FA26 /* QueryIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621D620928F9CE7400D2FA26 /* QueryIntegrationTests.swift */; }; 621D620C28F9CE7400D2FA26 /* QueryIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621D620928F9CE7400D2FA26 /* QueryIntegrationTests.swift */; }; @@ -1777,7 +1780,7 @@ 4334F87873015E3763954578 /* status_testing.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = status_testing.h; sourceTree = ""; }; 4375BDCDBCA9938C7F086730 /* Validation_BloomFilterTest_MD5_5000_1_bloom_filter_proto.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; name = Validation_BloomFilterTest_MD5_5000_1_bloom_filter_proto.json; path = bloom_filter_golden_test_data/Validation_BloomFilterTest_MD5_5000_1_bloom_filter_proto.json; sourceTree = ""; }; 444B7AB3F5A2929070CB1363 /* hard_assert_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.cpp; path = hard_assert_test.cc; sourceTree = ""; }; - 4564AD9C55EC39C080EB9476 /* globals_cache_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; path = globals_cache_test.cc; sourceTree = ""; }; + 4564AD9C55EC39C080EB9476 /* globals_cache_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.cpp; path = globals_cache_test.cc; sourceTree = ""; }; 478DC75A0DCA6249A616DD30 /* Validation_BloomFilterTest_MD5_500_0001_membership_test_result.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; name = Validation_BloomFilterTest_MD5_500_0001_membership_test_result.json; path = bloom_filter_golden_test_data/Validation_BloomFilterTest_MD5_500_0001_membership_test_result.json; sourceTree = ""; }; 48D0915834C3D234E5A875A9 /* grpc_stream_tester.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = grpc_stream_tester.h; sourceTree = ""; }; 4B3E4A77493524333133C5DC /* Validation_BloomFilterTest_MD5_50000_1_bloom_filter_proto.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; name = Validation_BloomFilterTest_MD5_50000_1_bloom_filter_proto.json; path = bloom_filter_golden_test_data/Validation_BloomFilterTest_MD5_50000_1_bloom_filter_proto.json; sourceTree = ""; }; @@ -1895,7 +1898,7 @@ 5B5414D28802BC76FDADABD6 /* stream_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.cpp; path = stream_test.cc; sourceTree = ""; }; 5B96CC29E9946508F022859C /* Validation_BloomFilterTest_MD5_50000_0001_membership_test_result.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; name = Validation_BloomFilterTest_MD5_50000_0001_membership_test_result.json; path = bloom_filter_golden_test_data/Validation_BloomFilterTest_MD5_50000_0001_membership_test_result.json; sourceTree = ""; }; 5C68EE4CB94C0DD6E333F546 /* Validation_BloomFilterTest_MD5_1_01_membership_test_result.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; name = Validation_BloomFilterTest_MD5_1_01_membership_test_result.json; path = bloom_filter_golden_test_data/Validation_BloomFilterTest_MD5_1_01_membership_test_result.json; sourceTree = ""; }; - 5C6DEA63FBDE19D841291723 /* memory_globals_cache_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; path = memory_globals_cache_test.cc; sourceTree = ""; }; + 5C6DEA63FBDE19D841291723 /* memory_globals_cache_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.cpp; path = memory_globals_cache_test.cc; sourceTree = ""; }; 5C7942B6244F4C416B11B86C /* leveldb_mutation_queue_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.cpp; path = leveldb_mutation_queue_test.cc; sourceTree = ""; }; 5CAE131920FFFED600BE9A4A /* Firestore_Benchmarks_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Firestore_Benchmarks_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5CAE131D20FFFED600BE9A4A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -1935,6 +1938,7 @@ 618BBE9A20B89AAC00B5BCE7 /* status.pb.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = status.pb.h; sourceTree = ""; }; 61F72C5520BC48FD001A68CB /* serializer_test.cc */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = serializer_test.cc; sourceTree = ""; }; 620C1427763BA5D3CCFB5A1F /* BridgingHeader.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = BridgingHeader.h; sourceTree = ""; }; + 62180C262DF20F4B00B370CD /* SnapshotStreamListenerSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotStreamListenerSourceTests.swift; sourceTree = ""; }; 621D620928F9CE7400D2FA26 /* QueryIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryIntegrationTests.swift; sourceTree = ""; }; 62E103B28B48A81D682A0DE9 /* Pods_Firestore_Example_tvOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Firestore_Example_tvOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 62E54B832A9E910A003347C8 /* IndexingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexingTests.swift; sourceTree = ""; }; @@ -1945,7 +1949,7 @@ 69E6C311558EC77729A16CF1 /* Pods-Firestore_Example_iOS-Firestore_SwiftTests_iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Firestore_Example_iOS-Firestore_SwiftTests_iOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Firestore_Example_iOS-Firestore_SwiftTests_iOS/Pods-Firestore_Example_iOS-Firestore_SwiftTests_iOS.debug.xcconfig"; sourceTree = ""; }; 6A7A30A2DB3367E08939E789 /* bloom_filter.pb.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = bloom_filter.pb.h; sourceTree = ""; }; 6AE927CDFC7A72BF825BE4CB /* Pods-Firestore_Tests_tvOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Firestore_Tests_tvOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-Firestore_Tests_tvOS/Pods-Firestore_Tests_tvOS.release.xcconfig"; sourceTree = ""; }; - 6E42FA109D363EA7F3387AAE /* thread_safe_memoizer_testing.cc */ = {isa = PBXFileReference; includeInIndex = 1; path = thread_safe_memoizer_testing.cc; sourceTree = ""; }; + 6E42FA109D363EA7F3387AAE /* thread_safe_memoizer_testing.cc */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.cpp; path = thread_safe_memoizer_testing.cc; sourceTree = ""; }; 6E8302DE210222ED003E1EA3 /* FSTFuzzTestFieldPath.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FSTFuzzTestFieldPath.h; sourceTree = ""; }; 6E8302DF21022309003E1EA3 /* FSTFuzzTestFieldPath.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = FSTFuzzTestFieldPath.mm; sourceTree = ""; }; 6EA39FDD20FE820E008D461F /* FSTFuzzTestSerializer.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FSTFuzzTestSerializer.mm; sourceTree = ""; }; @@ -2123,7 +2127,7 @@ E42355285B9EF55ABD785792 /* Pods_Firestore_Example_macOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Firestore_Example_macOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E592181BFD7C53C305123739 /* Pods-Firestore_Tests_iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Firestore_Tests_iOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Firestore_Tests_iOS/Pods-Firestore_Tests_iOS.debug.xcconfig"; sourceTree = ""; }; E76F0CDF28E5FA62D21DE648 /* leveldb_target_cache_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.cpp; path = leveldb_target_cache_test.cc; sourceTree = ""; }; - EA10515F99A42D71DA2D2841 /* thread_safe_memoizer_testing_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; path = thread_safe_memoizer_testing_test.cc; sourceTree = ""; }; + EA10515F99A42D71DA2D2841 /* thread_safe_memoizer_testing_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.cpp; path = thread_safe_memoizer_testing_test.cc; sourceTree = ""; }; ECEBABC7E7B693BE808A1052 /* Pods_Firestore_IntegrationTests_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Firestore_IntegrationTests_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EF3A65472C66B9560041EE69 /* FIRVectorValueTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FIRVectorValueTests.mm; sourceTree = ""; }; EF6C285029E462A200A7D4F1 /* FIRAggregateTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = FIRAggregateTests.mm; sourceTree = ""; }; @@ -2141,7 +2145,7 @@ F848C41C03A25C42AD5A4BC2 /* target_cache_test.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = target_cache_test.h; sourceTree = ""; }; F869D85E900E5AF6CD02E2FC /* firebase_auth_credentials_provider_test.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; name = firebase_auth_credentials_provider_test.mm; path = credentials/firebase_auth_credentials_provider_test.mm; sourceTree = ""; }; FA2E9952BA2B299C1156C43C /* Pods-Firestore_Benchmarks_iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Firestore_Benchmarks_iOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Firestore_Benchmarks_iOS/Pods-Firestore_Benchmarks_iOS.debug.xcconfig"; sourceTree = ""; }; - FC44D934D4A52C790659C8D6 /* leveldb_globals_cache_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; path = leveldb_globals_cache_test.cc; sourceTree = ""; }; + FC44D934D4A52C790659C8D6 /* leveldb_globals_cache_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.cpp; path = leveldb_globals_cache_test.cc; sourceTree = ""; }; FC738525340E594EBFAB121E /* Pods-Firestore_Example_tvOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Firestore_Example_tvOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-Firestore_Example_tvOS/Pods-Firestore_Example_tvOS.release.xcconfig"; sourceTree = ""; }; FF73B39D04D1760190E6B84A /* FIRQueryUnitTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; path = FIRQueryUnitTests.mm; sourceTree = ""; }; FFCA39825D9678A03D1845D0 /* document_overlay_cache_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.cpp; path = document_overlay_cache_test.cc; sourceTree = ""; }; @@ -2267,6 +2271,7 @@ 124C932A22C1635300CA8C2D /* Integration */ = { isa = PBXGroup; children = ( + 62180C262DF20F4B00B370CD /* SnapshotStreamListenerSourceTests.swift */, EF6C286C29E6D22200A7D4F1 /* AggregationIntegrationTests.swift */, 062072B62773A055001655D7 /* AsyncAwaitIntegrationTests.swift */, 124C932B22C1642C00CA8C2D /* CodableIntegrationTests.swift */, @@ -4655,6 +4660,7 @@ 4D42E5C756229C08560DD731 /* XCTestCase+Await.mm in Sources */, 09BE8C01EC33D1FD82262D5D /* aggregate_query_test.cc in Sources */, 0EC3921AE220410F7394729B /* aggregation_result.pb.cc in Sources */, + 62180C272DF20F6500B370CD /* SnapshotStreamListenerSourceTests.swift in Sources */, 276A563D546698B6AAC20164 /* annotations.pb.cc in Sources */, 7B8D7BAC1A075DB773230505 /* app_testing.mm in Sources */, DC1C711290E12F8EF3601151 /* array_sorted_map_test.cc in Sources */, @@ -4902,6 +4908,7 @@ 736C4E82689F1CA1859C4A3F /* XCTestCase+Await.mm in Sources */, 412BE974741729A6683C386F /* aggregate_query_test.cc in Sources */, DF983A9C1FBF758AF3AF110D /* aggregation_result.pb.cc in Sources */, + 62180C292DF20F6500B370CD /* SnapshotStreamListenerSourceTests.swift in Sources */, EA46611779C3EEF12822508C /* annotations.pb.cc in Sources */, 8F4F40E9BC7ED588F67734D5 /* app_testing.mm in Sources */, A6E236CE8B3A47BE32254436 /* array_sorted_map_test.cc in Sources */, @@ -5401,6 +5408,7 @@ 5492E0442021457E00B64F25 /* XCTestCase+Await.mm in Sources */, B04E4FE20930384DF3A402F9 /* aggregate_query_test.cc in Sources */, 1A3D8028303B45FCBB21CAD3 /* aggregation_result.pb.cc in Sources */, + 62180C282DF20F6500B370CD /* SnapshotStreamListenerSourceTests.swift in Sources */, 02EB33CC2590E1484D462912 /* annotations.pb.cc in Sources */, EBFC611B1BF195D0EC710AF4 /* app_testing.mm in Sources */, FCA48FB54FC50BFDFDA672CD /* array_sorted_map_test.cc in Sources */, diff --git a/Firestore/Swift/Source/AsyncAwait/Query+AsyncAwait.swift b/Firestore/Swift/Source/AsyncAwait/Query+AsyncThrowingStream.swift similarity index 69% rename from Firestore/Swift/Source/AsyncAwait/Query+AsyncAwait.swift rename to Firestore/Swift/Source/AsyncAwait/Query+AsyncThrowingStream.swift index d8f25226cf1..c3e028d0372 100644 --- a/Firestore/Swift/Source/AsyncAwait/Query+AsyncAwait.swift +++ b/Firestore/Swift/Source/AsyncAwait/Query+AsyncThrowingStream.swift @@ -23,23 +23,8 @@ import Foundation @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) public extension Query { - func asyncThrowingStream() -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - let listener = self.addSnapshotListener { snapshot, error in - if let snapshot = snapshot { - continuation.yield(snapshot) - } else if let error = error { - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { _ in - listener.remove() - } - } - } - - func asyncThrowingStream(options: SnapshotListenOptions) -> AsyncThrowingStream { + func snapshotStream(options: SnapshotListenOptions = SnapshotListenOptions()) + -> AsyncThrowingStream { AsyncThrowingStream { continuation in let listener = self.addSnapshotListener(options: options) { snapshot, error in if let snapshot = snapshot { diff --git a/Firestore/Swift/Tests/Integration/AsyncAwaitIntegrationTests.swift b/Firestore/Swift/Tests/Integration/AsyncAwaitIntegrationTests.swift index e02ae6581a5..15f4c3a2627 100644 --- a/Firestore/Swift/Tests/Integration/AsyncAwaitIntegrationTests.swift +++ b/Firestore/Swift/Tests/Integration/AsyncAwaitIntegrationTests.swift @@ -155,48 +155,5 @@ let emptyBundle = """ } XCTAssertThrowsError(try deleteAllIndexes(), "The client has already been terminated.") } - - func testCanListenToDefaultSourceFirstAndThenCacheAsyncStream() async throws { - let collRef = collectionRef(withDocuments: [ - "a": ["k": "a", "sort": 0], - "b": ["k": "b", "sort": 1], - ]) - - let query = collRef.whereField("sort", isGreaterThanOrEqualTo: 1).order(by: "sort") - - // 1. Create a signal stream. The test will wait on this stream. - // The Task will write to it after receiving the first snapshot. - let (signalStream, signalContinuation) = AsyncStream.makeStream(of: Void.self) - - let stream = query.asyncThrowingStream() - var iterator = stream.makeAsyncIterator() - - let task = Task { - // This task will now run and eventually signal its progress. - let firstSnapshot = try await iterator.next() - - // Assertions for the first snapshot - XCTAssertNotNil(firstSnapshot, "Expected an initial snapshot.") - try assertQuerySnapshotDataEquals(firstSnapshot!, [["k": "b", "sort": 1]]) - XCTAssertEqual(firstSnapshot!.metadata.isFromCache, false) - - // 2. Send the signal to the test function now that we have the first snapshot. - signalContinuation.yield(()) - signalContinuation.finish() // We only need to signal once. - - // This next await will be suspended until it's cancelled. - let second = try await iterator.next() - - // After cancellation, the iterator should terminate and return nil. - XCTAssertNil(second, "iterator.next() should have returned nil after cancellation.") - } - - // 3. Instead of sleeping, await the signal from the Task. - // This line will pause execution until `signalContinuation.yield()` is called. - await signalStream.first { _ in true } - - // 4. As soon as we receive the signal, we know it's safe to cancel. - task.cancel() - } } #endif diff --git a/Firestore/Swift/Tests/Integration/SnapshotStreamListenerSourceTests.swift b/Firestore/Swift/Tests/Integration/SnapshotStreamListenerSourceTests.swift new file mode 100644 index 00000000000..cd0a1819ed6 --- /dev/null +++ b/Firestore/Swift/Tests/Integration/SnapshotStreamListenerSourceTests.swift @@ -0,0 +1,211 @@ +/* + * 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. + */ + +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +class SnapshotStreamListenerSourceTests: FSTIntegrationTestCase { + func assertQuerySnapshotDataEquals(_ snapshot: Any, + _ expectedData: [[String: Any]]) throws { + let extractedData = FIRQuerySnapshotGetData(snapshot as! QuerySnapshot) + guard extractedData.count == expectedData.count else { + XCTFail( + "Result count mismatch: Expected \(expectedData.count), got \(extractedData.count)" + ) + return + } + for index in 0 ..< extractedData.count { + XCTAssertTrue(areDictionariesEqual(extractedData[index], expectedData[index])) + } + } + + // TODO(swift testing): update the function to be able to check other value types as well. + func areDictionariesEqual(_ dict1: [String: Any], _ dict2: [String: Any]) -> Bool { + guard dict1.count == dict2.count + else { return false } // Check if the number of elements matches + + for (key, value1) in dict1 { + guard let value2 = dict2[key] else { return false } + + // Value Checks (Assuming consistent types after the type check) + if let str1 = value1 as? String, let str2 = value2 as? String { + if str1 != str2 { return false } + } else if let int1 = value1 as? Int, let int2 = value2 as? Int { + if int1 != int2 { return false } + } else { + // Handle other potential types or return false for mismatch + return false + } + } + return true + } + + func testSnapshotStreamReturnsNilAfterCancellation() async throws { + // 1. Set up the collection. + let collRef = collectionRef(withDocuments: ["a": ["k": "a"]]) + readDocumentSet(forRef: collRef) // populate the cache. + + // 2. Create the signal stream to coordinate cancellation timing. + let (signalStream, signalContinuation) = AsyncStream.makeStream(of: Void.self) + + // 3. Wrap the asynchronous work in a Task. + let task = Task { + // Use a standard stream that stays open for new events. + var iterator = collRef.snapshotStream().makeAsyncIterator() + + // Await the first snapshot to confirm the listener is active. + let firstDefault = try await iterator.next() + + XCTAssertNotNil(firstDefault, "Expected an initial snapshot.") + try assertQuerySnapshotDataEquals(firstDefault!, [["k": "a"]]) + XCTAssertEqual(firstDefault!.metadata.isFromCache, true) + + // 4. Send the signal that the first snapshot has been received. + signalContinuation.yield(()) + signalContinuation.finish() + + // This await will be suspended until the task is cancelled. + let secondDefault = try await iterator.next() + + // 5. Assert that the iterator returned nil as requested, because the + // task was cancelled while it was awaiting this event. + XCTAssertNil(secondDefault, "iterator.next() should have returned nil after cancellation.") + } + + // 6. Wait for the signal, ensuring we don't cancel prematurely. + await signalStream.first { _ in true } + + // 7. Now that we know the first snapshot has been processed, cancel the task. + task.cancel() + } + + func testCanListenToDefaultSourceFirstAndThenCacheAsyncStream() async throws { + let collRef = collectionRef(withDocuments: [ + "a": ["k": "a", "sort": 0], + "b": ["k": "b", "sort": 1], + ]) + + let query = collRef.whereField("sort", isGreaterThanOrEqualTo: 1).order(by: "sort") + + // 1. Create a signal stream. The test will wait on this stream. + // The Task will write to it after receiving the first snapshot. + let (signalStreamDefault, signalContinuationDefault) = AsyncStream.makeStream(of: Void.self) + + let streamDefault = query.snapshotStream() + var iteratorDefault = streamDefault.makeAsyncIterator() + + let task = Task { + // This task will now run and eventually signal its progress. + let firstSnapshotDefault = try await iteratorDefault.next() + + // Assertions for the first snapshot + XCTAssertNotNil(firstSnapshotDefault, "Expected an initial snapshot.") + try assertQuerySnapshotDataEquals(firstSnapshotDefault!, [["k": "b", "sort": 1]]) + XCTAssertEqual(firstSnapshotDefault!.metadata.isFromCache, false) + + let streamCache = query.snapshotStream( + options: SnapshotListenOptions().withSource(ListenSource.cache) + ) + var iteratorCache = streamCache.makeAsyncIterator() + // This task will now run and eventually signal its progress. + let firstSnapshotCache = try await iteratorCache.next() + // Assertions for the first snapshot + XCTAssertNotNil(firstSnapshotCache, "Expected an initial snapshot.") + try assertQuerySnapshotDataEquals(firstSnapshotCache!, [["k": "b", "sort": 1]]) + XCTAssertEqual(firstSnapshotCache!.metadata.isFromCache, false) + + // 2. Send the signal to the test function now that we have the first snapshot. + signalContinuationDefault.yield(()) + signalContinuationDefault.finish() // We only need to signal once. + + // This next await will be suspended until it's cancelled. + let secondDefault = try await iteratorDefault.next() + + // This next await will be suspended until it's cancelled. + let secondCache = try await iteratorCache.next() + + // After cancellation, the iterator should terminate and return nil. + XCTAssertNil(secondDefault, "iterator.next() should have returned nil after cancellation.") + // After cancellation, the iterator should terminate and return nil. + XCTAssertNil(secondCache, "iterator.next() should have returned nil after cancellation.") + } + + // 3. Instead of sleeping, await the signal from the Task. + // This line will pause execution until `signalContinuation.yield()` is called. + await signalStreamDefault.first { _ in true } + + // 4. As soon as we receive the signal, we know it's safe to cancel. + task.cancel() + } + + func testCanListenToDefaultSourceFirstAndThenCacheAsync2() async throws { + let collRef = collectionRef(withDocuments: [ + "a": ["k": "a", "sort": 0], + "b": ["k": "b", "sort": 1], + ]) + let query = collRef.whereField("sort", isGreaterThanOrEqualTo: 1).order(by: "sort") + + // 1. Create iterators for both the default (server) and cache sources. + var serverIterator = query.snapshotStream().makeAsyncIterator() + + // 2. Await the server snapshot first. This populates the cache. + let serverSnapshot = try await serverIterator.next() + XCTAssertNotNil(serverSnapshot) + try assertQuerySnapshotDataEquals(serverSnapshot!, [["k": "b", "sort": 1]]) + XCTAssertFalse( + serverSnapshot!.metadata.isFromCache, + "The first snapshot should come from the server." + ) + + // 3. Now, await the snapshot from the cache iterator. It will immediately + // return the data that the server listener just populated. + var cacheIterator = query + .snapshotStream(options: SnapshotListenOptions().withSource(ListenSource.cache)) + .makeAsyncIterator() + let cacheSnapshot = try await cacheIterator.next() + + XCTAssertNotNil(cacheSnapshot) + try assertQuerySnapshotDataEquals(cacheSnapshot!, [["k": "b", "sort": 1]]) + + // Because the server listener is active, the cache data is fresh, + // so isFromCache will be false. + XCTAssertFalse( + cacheSnapshot!.metadata.isFromCache, + "Cache snapshot metadata should be synced by the active server listener." + ) + + // Cleanup is handled automatically when the iterators go out of scope. + } + + func testCanRaiseSnapshotFromCacheForQueryAsync() async throws { + // 1. Set up the collection and populate the cache, same as the original. + let collRef = collectionRef(withDocuments: ["a": ["k": "a"]]) + readDocumentSet(forRef: collRef) // populate the cache. + + // Create an async stream iterator that only listens to the cache. + var iterator = collRef + .snapshotStream(options: SnapshotListenOptions().withSource(ListenSource.cache)) + .makeAsyncIterator() + + // Await the snapshot from the iterator. + guard let querySnap = try await iterator.next() else { + XCTFail("Expected a snapshot from the cache but received nil.") + return + } + + // 4. Perform the same assertions as the original test. + try assertQuerySnapshotDataEquals(querySnap, [["k": "a"]]) + XCTAssertTrue(querySnap.metadata.isFromCache, "Snapshot should have come from the cache.") + } +}