diff --git a/Firestore/Example/Firestore.xcodeproj/project.pbxproj b/Firestore/Example/Firestore.xcodeproj/project.pbxproj index abba41a6f55..d106d804805 100644 --- a/Firestore/Example/Firestore.xcodeproj/project.pbxproj +++ b/Firestore/Example/Firestore.xcodeproj/project.pbxproj @@ -1133,6 +1133,7 @@ A7309DAD4A3B5334536ECA46 /* remote_event_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = 584AE2C37A55B408541A6FF3 /* remote_event_test.cc */; }; A7399FB3BEC50BBFF08EC9BA /* mutation_queue_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = 3068AA9DFBBA86C1FE2A946E /* mutation_queue_test.cc */; }; A7669E72BCED7FBADA4B1314 /* thread_safe_memoizer_testing_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = EA10515F99A42D71DA2D2841 /* thread_safe_memoizer_testing_test.cc */; }; + A78366DBE0BFDE42474A728A /* TestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E73D03B9C02CAC7BEBAFA86 /* TestHelper.swift */; }; A80D38096052F928B17E1504 /* user_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = CCC9BD953F121B9E29F9AA42 /* user_test.cc */; }; A833A216988ADFD4876763CD /* Validation_BloomFilterTest_MD5_50000_01_membership_test_result.json in Resources */ = {isa = PBXBuildFile; fileRef = C8FB22BCB9F454DA44BA80C8 /* Validation_BloomFilterTest_MD5_50000_01_membership_test_result.json */; }; A841EEB5A94A271523EAE459 /* Validation_BloomFilterTest_MD5_50000_0001_bloom_filter_proto.json in Resources */ = {isa = PBXBuildFile; fileRef = A5D9044B72061CAF284BC9E4 /* Validation_BloomFilterTest_MD5_50000_0001_bloom_filter_proto.json */; }; @@ -1172,6 +1173,7 @@ ACC9369843F5ED3BD2284078 /* timestamp_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = ABF6506B201131F8005F2C74 /* timestamp_test.cc */; }; AD00D000A63837FB47291BFE /* Validation_BloomFilterTest_MD5_1_0001_bloom_filter_proto.json in Resources */ = {isa = PBXBuildFile; fileRef = 4B59C0A7B2A4548496ED4E7D /* Validation_BloomFilterTest_MD5_1_0001_bloom_filter_proto.json */; }; AD12205540893CEB48647937 /* filesystem_testing.cc in Sources */ = {isa = PBXBuildFile; fileRef = BA02DA2FCD0001CFC6EB08DA /* filesystem_testing.cc */; }; + AD34726BFD3461FF64BBD56D /* TestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E73D03B9C02CAC7BEBAFA86 /* TestHelper.swift */; }; AD35AA07F973934BA30C9000 /* remote_event_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = 584AE2C37A55B408541A6FF3 /* remote_event_test.cc */; }; AD3C26630E33BE59C49BEB0D /* grpc_unary_call_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = B6D964942163E63900EB9CFB /* grpc_unary_call_test.cc */; }; AD74843082C6465A676F16A7 /* async_queue_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = B6FB467B208E9A8200554BA2 /* async_queue_test.cc */; }; @@ -1572,6 +1574,7 @@ ED9DF1EB20025227B38736EC /* message_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = CE37875365497FFA8687B745 /* message_test.cc */; }; EDF35B147B116F659D0D2CA8 /* Validation_BloomFilterTest_MD5_1_0001_membership_test_result.json in Resources */ = {isa = PBXBuildFile; fileRef = C939D1789E38C09F9A0C1157 /* Validation_BloomFilterTest_MD5_1_0001_membership_test_result.json */; }; EE470CC3C8FBCDA5F70A8466 /* local_store_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = 307FF03D0297024D59348EBD /* local_store_test.cc */; }; + EE4C4BE7F93366AE6368EE02 /* TestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E73D03B9C02CAC7BEBAFA86 /* TestHelper.swift */; }; EE6DBFB0874A50578CE97A7F /* leveldb_remote_document_cache_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = 0840319686A223CC4AD3FAB1 /* leveldb_remote_document_cache_test.cc */; }; EECC1EC64CA963A8376FA55C /* persistence_testing.cc in Sources */ = {isa = PBXBuildFile; fileRef = 9113B6F513D0473AEABBAF1F /* persistence_testing.cc */; }; EF3518F84255BAF3EBD317F6 /* exponential_backoff_test.cc in Sources */ = {isa = PBXBuildFile; fileRef = B6D1B68420E2AB1A00B35856 /* exponential_backoff_test.cc */; }; @@ -1739,6 +1742,7 @@ 062072B62773A055001655D7 /* AsyncAwaitIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAwaitIntegrationTests.swift; sourceTree = ""; }; 0840319686A223CC4AD3FAB1 /* leveldb_remote_document_cache_test.cc */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.cpp; path = leveldb_remote_document_cache_test.cc; sourceTree = ""; }; 0D964D4936953635AC7E0834 /* Validation_BloomFilterTest_MD5_1_01_bloom_filter_proto.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; name = Validation_BloomFilterTest_MD5_1_01_bloom_filter_proto.json; path = bloom_filter_golden_test_data/Validation_BloomFilterTest_MD5_1_01_bloom_filter_proto.json; sourceTree = ""; }; + 0E73D03B9C02CAC7BEBAFA86 /* TestHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TestHelper.swift; path = TestHelper/TestHelper.swift; sourceTree = ""; }; 0EE5300F8233D14025EF0456 /* string_apple_test.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; path = string_apple_test.mm; sourceTree = ""; }; 11984BA0A99D7A7ABA5B0D90 /* Pods-Firestore_Example_iOS-Firestore_SwiftTests_iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Firestore_Example_iOS-Firestore_SwiftTests_iOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-Firestore_Example_iOS-Firestore_SwiftTests_iOS/Pods-Firestore_Example_iOS-Firestore_SwiftTests_iOS.release.xcconfig"; sourceTree = ""; }; 1235769122B7E915007DDFA9 /* EncodableFieldValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncodableFieldValueTests.swift; sourceTree = ""; }; @@ -2599,6 +2603,7 @@ 544A20ED20F6C046004E52CD /* API */, 5495EB012040E90200EBA509 /* Codable */, 124C932A22C1635300CA8C2D /* Integration */, + C7D3D622BB13EB3C3301DA4F /* TestHelper */, 620C1427763BA5D3CCFB5A1F /* BridgingHeader.h */, 54C9EDF52040E16300A969CD /* Info.plist */, ); @@ -2978,6 +2983,14 @@ path = core; sourceTree = ""; }; + C7D3D622BB13EB3C3301DA4F /* TestHelper */ = { + isa = PBXGroup; + children = ( + 0E73D03B9C02CAC7BEBAFA86 /* TestHelper.swift */, + ); + name = TestHelper; + sourceTree = ""; + }; DAFF0CF621E64AC30062958F /* macOS */ = { isa = PBXGroup; children = ( @@ -4699,6 +4712,7 @@ 655F8647F57E5F2155DFF7B5 /* PipelineTests.swift in Sources */, 621D620C28F9CE7400D2FA26 /* QueryIntegrationTests.swift in Sources */, 1CFBD4563960D8A20C4679A3 /* SnapshotListenerSourceTests.swift in Sources */, + EE4C4BE7F93366AE6368EE02 /* TestHelper.swift in Sources */, EFF22EAC2C5060A4009A369B /* VectorIntegrationTests.swift in Sources */, 4D42E5C756229C08560DD731 /* XCTestCase+Await.mm in Sources */, 09BE8C01EC33D1FD82262D5D /* aggregate_query_test.cc in Sources */, @@ -4951,6 +4965,7 @@ C8C2B945D84DD98391145F3F /* PipelineTests.swift in Sources */, 621D620B28F9CE7400D2FA26 /* QueryIntegrationTests.swift in Sources */, A0BC30D482B0ABD1A3A24CDC /* SnapshotListenerSourceTests.swift in Sources */, + A78366DBE0BFDE42474A728A /* TestHelper.swift in Sources */, EFF22EAB2C5060A4009A369B /* VectorIntegrationTests.swift in Sources */, 736C4E82689F1CA1859C4A3F /* XCTestCase+Await.mm in Sources */, 412BE974741729A6683C386F /* aggregate_query_test.cc in Sources */, @@ -5458,6 +5473,7 @@ E04CB0D580980748D5DC453F /* PipelineTests.swift in Sources */, 621D620A28F9CE7400D2FA26 /* QueryIntegrationTests.swift in Sources */, B00F8D1819EE20C45B660940 /* SnapshotListenerSourceTests.swift in Sources */, + AD34726BFD3461FF64BBD56D /* TestHelper.swift in Sources */, EFF22EAA2C5060A4009A369B /* VectorIntegrationTests.swift in Sources */, 5492E0442021457E00B64F25 /* XCTestCase+Await.mm in Sources */, B04E4FE20930384DF3A402F9 /* aggregate_query_test.cc in Sources */, diff --git a/Firestore/Source/API/FIRPipelineBridge.mm b/Firestore/Source/API/FIRPipelineBridge.mm index ac3091249e0..11f3f4c56d5 100644 --- a/Firestore/Source/API/FIRPipelineBridge.mm +++ b/Firestore/Source/API/FIRPipelineBridge.mm @@ -20,6 +20,7 @@ #include +#import "Firestore/Source/API/FIRCollectionReference+Internal.h" #import "Firestore/Source/API/FIRDocumentReference+Internal.h" #import "Firestore/Source/API/FIRFieldPath+Internal.h" #import "Firestore/Source/API/FIRFirestore+Internal.h" @@ -39,6 +40,7 @@ #include "Firestore/core/src/api/pipeline_result.h" #include "Firestore/core/src/api/pipeline_snapshot.h" #include "Firestore/core/src/api/stages.h" +#include "Firestore/core/src/util/comparison.h" #include "Firestore/core/src/util/error_apple.h" #include "Firestore/core/src/util/status.h" #include "Firestore/core/src/util/string_apple.h" @@ -57,12 +59,12 @@ using firebase::firestore::api::Field; using firebase::firestore::api::FindNearestStage; using firebase::firestore::api::FunctionExpr; -using firebase::firestore::api::GenericStage; using firebase::firestore::api::LimitStage; using firebase::firestore::api::MakeFIRTimestamp; using firebase::firestore::api::OffsetStage; using firebase::firestore::api::Ordering; using firebase::firestore::api::Pipeline; +using firebase::firestore::api::RawStage; using firebase::firestore::api::RemoveFieldsStage; using firebase::firestore::api::ReplaceWith; using firebase::firestore::api::Sample; @@ -73,6 +75,7 @@ using firebase::firestore::api::Where; using firebase::firestore::model::FieldPath; using firebase::firestore::nanopb::SharedMessage; +using firebase::firestore::util::ComparisonResult; using firebase::firestore::util::MakeCallback; using firebase::firestore::util::MakeNSString; using firebase::firestore::util::MakeString; @@ -80,6 +83,13 @@ NS_ASSUME_NONNULL_BEGIN +inline std::string EnsureLeadingSlash(const std::string &path) { + if (!path.empty() && path[0] == '/') { + return path; + } + return "/" + path; +} + @implementation FIRExprBridge @end @@ -216,10 +226,19 @@ @implementation FIRCollectionSourceStageBridge { std::shared_ptr collection_source; } -- (id)initWithPath:(NSString *)path { +- (id)initWithRef:(FIRCollectionReference *)ref firestore:(FIRFirestore *)db { self = [super init]; if (self) { - collection_source = std::make_shared(MakeString(path)); + if (ref.firestore.databaseID.CompareTo(db.databaseID) != ComparisonResult::Same) { + ThrowInvalidArgument( + "Invalid CollectionReference. The project ID (\"%s\") or the database (\"%s\") does not " + "match " + "the project ID (\"%s\") and database (\"%s\") of the target database of this Pipeline.", + ref.firestore.databaseID.project_id(), ref.firestore.databaseID.database_id(), + db.databaseID.project_id(), db.databaseID.project_id()); + } + collection_source = + std::make_shared(EnsureLeadingSlash(MakeString(ref.path))); } return self; } @@ -270,12 +289,21 @@ @implementation FIRDocumentsSourceStageBridge { std::shared_ptr cpp_document_source; } -- (id)initWithDocuments:(NSArray *)documents { +- (id)initWithDocuments:(NSArray *)documents firestore:(FIRFirestore *)db { self = [super init]; if (self) { std::vector cpp_documents; - for (NSString *doc in documents) { - cpp_documents.push_back(MakeString(doc)); + for (FIRDocumentReference *doc in documents) { + if (doc.firestore.databaseID.CompareTo(db.databaseID) != ComparisonResult::Same) { + ThrowInvalidArgument("Invalid DocumentReference. The project ID (\"%s\") or the database " + "(\"%s\") does not match " + "the project ID (\"%s\") and database (\"%s\") of the target database " + "of this Pipeline.", + doc.firestore.databaseID.project_id(), + doc.firestore.databaseID.database_id(), db.databaseID.project_id(), + db.databaseID.project_id()); + } + cpp_documents.push_back(EnsureLeadingSlash(MakeString(doc.path))); } cpp_document_source = std::make_shared(std::move(cpp_documents)); } @@ -682,9 +710,11 @@ - (id)initWithPercentage:(double)percentage { - (std::shared_ptr)cppStageWithReader:(FSTUserDataReader *)reader { if (!isUserDataRead) { if ([type isEqualToString:@"count"]) { - cpp_sample = std::make_shared("count", _count, 0); + cpp_sample = + std::make_shared(Sample::SampleMode(Sample::SampleMode::DOCUMENTS), _count, 0); } else { - cpp_sample = std::make_shared("percentage", 0, _percentage); + cpp_sample = + std::make_shared(Sample::SampleMode(Sample::SampleMode::PERCENT), 0, _percentage); } } @@ -722,16 +752,20 @@ - (id)initWithOther:(FIRPipelineBridge *)other { @implementation FIRUnnestStageBridge { FIRExprBridge *_field; - NSString *_Nullable _indexField; + FIRExprBridge *_Nullable _index_field; + FIRExprBridge *_alias; Boolean isUserDataRead; std::shared_ptr cpp_unnest; } -- (id)initWithField:(FIRExprBridge *)field indexField:(NSString *_Nullable)indexField { +- (id)initWithField:(FIRExprBridge *)field + alias:(FIRExprBridge *)alias + indexField:(FIRExprBridge *_Nullable)index_field { self = [super init]; if (self) { _field = field; - _indexField = indexField; + _alias = alias; + _index_field = index_field; isUserDataRead = NO; } return self; @@ -739,13 +773,14 @@ - (id)initWithField:(FIRExprBridge *)field indexField:(NSString *_Nullable)index - (std::shared_ptr)cppStageWithReader:(FSTUserDataReader *)reader { if (!isUserDataRead) { - absl::optional cpp_index_field; - if (_indexField != nil) { - cpp_index_field = MakeString(_indexField); + absl::optional> cpp_index_field; + if (_index_field != nil) { + cpp_index_field = [_index_field cppExprWithReader:reader]; } else { cpp_index_field = absl::nullopt; } - cpp_unnest = std::make_shared([_field cppExprWithReader:reader], cpp_index_field); + cpp_unnest = std::make_shared([_field cppExprWithReader:reader], + [_alias cppExprWithReader:reader], cpp_index_field); } isUserDataRead = YES; @@ -754,16 +789,16 @@ - (id)initWithField:(FIRExprBridge *)field indexField:(NSString *_Nullable)index @end -@implementation FIRGenericStageBridge { +@implementation FIRRawStageBridge { NSString *_name; - NSArray *_params; + NSArray *_params; NSDictionary *_Nullable _options; Boolean isUserDataRead; - std::shared_ptr cpp_generic_stage; + std::shared_ptr cpp_generic_stage; } - (id)initWithName:(NSString *)name - params:(NSArray *)params + params:(NSArray *)params options:(NSDictionary *_Nullable)options { self = [super init]; if (self) { @@ -775,20 +810,59 @@ - (id)initWithName:(NSString *)name return self; } +- (firebase::firestore::google_firestore_v1_Value)convertIdToV1Value:(id)value + reader:(FSTUserDataReader *)reader { + if ([value isKindOfClass:[FIRExprBridge class]]) { + return [((FIRExprBridge *)value) cppExprWithReader:reader]->to_proto(); + } else if ([value isKindOfClass:[FIRAggregateFunctionBridge class]]) { + return [((FIRAggregateFunctionBridge *)value) cppExprWithReader:reader]->to_proto(); + } else if ([value isKindOfClass:[NSDictionary class]]) { + NSDictionary *dictionary = (NSDictionary *)value; + + std::unordered_map cpp_dictionary; + for (NSString *key in dictionary) { + if ([dictionary[key] isKindOfClass:[FIRExprBridge class]]) { + cpp_dictionary[MakeString(key)] = + [((FIRExprBridge *)dictionary[key]) cppExprWithReader:reader]->to_proto(); + } else if ([dictionary[key] isKindOfClass:[FIRAggregateFunctionBridge class]]) { + cpp_dictionary[MakeString(key)] = + [((FIRAggregateFunctionBridge *)dictionary[key]) cppExprWithReader:reader]->to_proto(); + } else { + ThrowInvalidArgument( + "Dictionary value must be an FIRExprBridge or FIRAggregateFunctionBridge."); + } + } + + firebase::firestore::google_firestore_v1_Value result; + result.which_value_type = google_firestore_v1_Value_map_value_tag; + + nanopb::SetRepeatedField( + &result.map_value.fields, &result.map_value.fields_count, cpp_dictionary, + [](const std::pair &entry) { + return firebase::firestore::_google_firestore_v1_MapValue_FieldsEntry{ + nanopb::MakeBytesArray(entry.first), entry.second}; + }); + return result; + } else { + ThrowInvalidArgument("Invalid value to convert to google_firestore_v1_Value."); + } +} + - (std::shared_ptr)cppStageWithReader:(FSTUserDataReader *)reader { if (!isUserDataRead) { - std::vector> cpp_params; - for (FIRExprBridge *param in _params) { - cpp_params.push_back([param cppExprWithReader:reader]); + std::vector cpp_params; + for (id param in _params) { + cpp_params.push_back([self convertIdToV1Value:param reader:reader]); } + std::unordered_map> cpp_options; if (_options) { for (NSString *key in _options) { cpp_options[MakeString(key)] = [_options[key] cppExprWithReader:reader]; } } - cpp_generic_stage = std::make_shared(MakeString(_name), std::move(cpp_params), - std::move(cpp_options)); + cpp_generic_stage = std::make_shared(MakeString(_name), std::move(cpp_params), + std::move(cpp_options)); } isUserDataRead = YES; @@ -900,7 +974,9 @@ - (id)initWithCppResult:(api::PipelineResult)result db:(std::shared_ptr *dictionary = [dataWriter convertedValue:*data]; + NSLog(@"Dictionary contents: %@", dictionary); + return dictionary; } - (nullable id)get:(id)field { @@ -931,35 +1007,41 @@ - (nullable id)get:(id)field @implementation FIRPipelineBridge { NSArray *_stages; FIRFirestore *firestore; + Boolean isUserDataRead; std::shared_ptr cpp_pipeline; } - (id)initWithStages:(NSArray *)stages db:(FIRFirestore *)db { _stages = stages; firestore = db; + isUserDataRead = NO; return [super init]; } - (void)executeWithCompletion:(void (^)(__FIRPipelineSnapshotBridge *_Nullable result, NSError *_Nullable error))completion { - std::vector> cpp_stages; - for (FIRStageBridge *stage in _stages) { - cpp_stages.push_back([stage cppStageWithReader:firestore.dataReader]); - } - cpp_pipeline = std::make_shared(cpp_stages, firestore.wrapped); - - cpp_pipeline->execute([completion](StatusOr maybe_value) { - if (maybe_value.ok()) { - __FIRPipelineSnapshotBridge *bridge = [[__FIRPipelineSnapshotBridge alloc] - initWithCppSnapshot:std::move(maybe_value).ValueOrDie()]; - completion(bridge, nil); - } else { - completion(nil, MakeNSError(std::move(maybe_value).status())); - } - }); + [self cppPipelineWithReader:firestore.dataReader]->execute( + [completion](StatusOr maybe_value) { + if (maybe_value.ok()) { + __FIRPipelineSnapshotBridge *bridge = [[__FIRPipelineSnapshotBridge alloc] + initWithCppSnapshot:std::move(maybe_value).ValueOrDie()]; + completion(bridge, nil); + } else { + completion(nil, MakeNSError(std::move(maybe_value).status())); + } + }); } - (std::shared_ptr)cppPipelineWithReader:(FSTUserDataReader *)reader { + if (!isUserDataRead) { + std::vector> cpp_stages; + for (FIRStageBridge *stage in _stages) { + cpp_stages.push_back([stage cppStageWithReader:firestore.dataReader]); + } + cpp_pipeline = std::make_shared(cpp_stages, firestore.wrapped); + } + + isUserDataRead = YES; return cpp_pipeline; } diff --git a/Firestore/Source/Public/FirebaseFirestore/FIRPipelineBridge.h b/Firestore/Source/Public/FirebaseFirestore/FIRPipelineBridge.h index 7b8ebf80e9b..cf72c897f3b 100644 --- a/Firestore/Source/Public/FirebaseFirestore/FIRPipelineBridge.h +++ b/Firestore/Source/Public/FirebaseFirestore/FIRPipelineBridge.h @@ -70,7 +70,7 @@ NS_SWIFT_SENDABLE NS_SWIFT_NAME(CollectionSourceStageBridge) @interface FIRCollectionSourceStageBridge : FIRStageBridge -- (id)initWithPath:(NSString *)path; +- (id)initWithRef:(FIRCollectionReference *)ref firestore:(FIRFirestore *)db; @end @@ -94,7 +94,7 @@ NS_SWIFT_SENDABLE NS_SWIFT_NAME(DocumentsSourceStageBridge) @interface FIRDocumentsSourceStageBridge : FIRStageBridge -- (id)initWithDocuments:(NSArray *)documents; +- (id)initWithDocuments:(NSArray *)documents firestore:(FIRFirestore *)db; @end @@ -191,14 +191,16 @@ NS_SWIFT_NAME(UnionStageBridge) NS_SWIFT_SENDABLE NS_SWIFT_NAME(UnnestStageBridge) @interface FIRUnnestStageBridge : FIRStageBridge -- (id)initWithField:(FIRExprBridge *)field indexField:(NSString *_Nullable)indexField; +- (id)initWithField:(FIRExprBridge *)field + alias:(FIRExprBridge *)alias + indexField:(FIRExprBridge *_Nullable)index_field; @end NS_SWIFT_SENDABLE -NS_SWIFT_NAME(GenericStageBridge) -@interface FIRGenericStageBridge : FIRStageBridge +NS_SWIFT_NAME(RawStageBridge) +@interface FIRRawStageBridge : FIRStageBridge - (id)initWithName:(NSString *)name - params:(NSArray *)params + params:(NSArray *)params options:(NSDictionary *_Nullable)options; @end diff --git a/Firestore/Swift/Source/Helper/PipelineHelper.swift b/Firestore/Swift/Source/Helper/PipelineHelper.swift index 582e90021b1..cde334b7ae8 100644 --- a/Firestore/Swift/Source/Helper/PipelineHelper.swift +++ b/Firestore/Swift/Source/Helper/PipelineHelper.swift @@ -13,13 +13,17 @@ // limitations under the License. enum Helper { - static func sendableToExpr(_ value: Sendable) -> Expr { + static func sendableToExpr(_ value: Sendable?) -> Expr { + guard let value = value else { + return Constant.nil + } + if value is Expr { return value as! Expr - } else if value is [String: Sendable] { - return map(value as! [String: Sendable]) - } else if value is [Sendable] { - return array(value as! [Sendable]) + } else if value is [String: Sendable?] { + return map(value as! [String: Sendable?]) + } else if value is [Sendable?] { + return array(value as! [Sendable?]) } else { return Constant(value) } @@ -33,7 +37,7 @@ enum Helper { return exprMap } - static func map(_ elements: [String: Sendable]) -> FunctionExpr { + static func map(_ elements: [String: Sendable?]) -> FunctionExpr { var result: [Expr] = [] for (key, value) in elements { result.append(Constant(key)) @@ -42,10 +46,37 @@ enum Helper { return FunctionExpr("map", result) } - static func array(_ elements: [Sendable]) -> FunctionExpr { + static func array(_ elements: [Sendable?]) -> FunctionExpr { let transformedElements = elements.map { element in sendableToExpr(element) } return FunctionExpr("array", transformedElements) } + + // This function is used to convert Swift type into Objective-C type. + static func sendableToAnyObjectForRawStage(_ value: Sendable?) -> AnyObject { + guard let value = value else { + return Constant.nil.bridge + } + + guard !(value is NSNull) else { + return Constant.nil.bridge + } + + if value is Expr { + return (value as! Expr).toBridge() + } else if value is AggregateFunction { + return (value as! AggregateFunction).toBridge() + } else if value is [String: Sendable?] { + let mappedValue: [String: Sendable?] = (value as! [String: Sendable?]).mapValues { + if $0 is AggregateFunction { + return ($0 as! AggregateFunction).toBridge() + } + return sendableToExpr($0).toBridge() + } + return mappedValue as NSDictionary + } else { + return Constant(value).bridge + } + } } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Aggregation/AggregateFunction.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Aggregation/AggregateFunction.swift index ed7c25bd129..6d7e05098a9 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Aggregation/AggregateFunction.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Aggregation/AggregateFunction.swift @@ -12,6 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +extension AggregateFunction { + func toBridge() -> AggregateFunctionBridge { + return (self as AggregateBridgeWrapper).bridge + } +} + public class AggregateFunction: AggregateBridgeWrapper, @unchecked Sendable { let bridge: AggregateFunctionBridge diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/ArrayExpression.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/ArrayExpression.swift new file mode 100644 index 00000000000..e1f5d749c5f --- /dev/null +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/ArrayExpression.swift @@ -0,0 +1,24 @@ +// 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. + +public class ArrayExpression: FunctionExpr, @unchecked Sendable { + var result: [Expr] = [] + public init(_ elements: [Sendable]) { + for element in elements { + result.append(Helper.sendableToExpr(element)) + } + + super.init("array", result) + } +} diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/Constant.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/Constant.swift index 0d4f30fe463..bfb958b468c 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/Constant.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/Constant.swift @@ -48,6 +48,11 @@ public struct Constant: Expr, BridgeWrapper, @unchecked Sendable { self.init(value as Any) } + // Initializer for Bytes + public init(_ value: [UInt8]) { + self.init(value as Any) + } + // Initializer for GeoPoint values public init(_ value: GeoPoint) { self.init(value as Any) diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/FunctionExpr/BooleanExpr.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/FunctionExpr/BooleanExpr.swift index 9826d1698c0..8b4bfe23b80 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/FunctionExpr/BooleanExpr.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/FunctionExpr/BooleanExpr.swift @@ -17,6 +17,10 @@ public class BooleanExpr: FunctionExpr, @unchecked Sendable { super.init(functionName, agrs) } + public func countIf() -> AggregateFunction { + return AggregateFunction("count_if", [self]) + } + public static func && (lhs: BooleanExpr, rhs: @autoclosure () throws -> BooleanExpr) rethrows -> BooleanExpr { try BooleanExpr("and", [lhs, rhs()]) @@ -27,6 +31,11 @@ public class BooleanExpr: FunctionExpr, @unchecked Sendable { try BooleanExpr("or", [lhs, rhs()]) } + public static func ^ (lhs: BooleanExpr, + rhs: @autoclosure () throws -> BooleanExpr) rethrows -> BooleanExpr { + try BooleanExpr("xor", [lhs, rhs()]) + } + public static prefix func ! (lhs: BooleanExpr) -> BooleanExpr { return BooleanExpr("not", [lhs]) } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/MapExpression.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/MapExpression.swift new file mode 100644 index 00000000000..93d9bb4859b --- /dev/null +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Expr/MapExpression.swift @@ -0,0 +1,25 @@ +// 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. + +public class MapExpression: FunctionExpr, @unchecked Sendable { + var result: [Expr] = [] + public init(_ elements: [String: Sendable]) { + for element in elements { + result.append(Constant(element.key)) + result.append(Helper.sendableToExpr(element.value)) + } + + super.init("map", result) + } +} diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/Pipeline.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/Pipeline.swift index 4e49c97301b..6c2a6e34053 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/Pipeline.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/Pipeline.swift @@ -246,6 +246,13 @@ public struct Pipeline: @unchecked Sendable { ) } + public func select(_ selections: [Selectable]) -> Pipeline { + return Pipeline( + stages: stages + [Select(selections: selections)], + db: db + ) + } + /// Filters documents from previous stages, including only those matching the specified /// `BooleanExpr`. /// @@ -699,7 +706,7 @@ public struct Pipeline: @unchecked Sendable { /// ```swift /// // let pipeline: Pipeline = ... /// // Example: Assuming a hypothetical backend stage "customFilterV2". - /// let genericPipeline = pipeline.genericStage( + /// let genericPipeline = pipeline.rawStage( /// name: "customFilterV2", /// params: [Field("userScore"), 80], // Ordered parameters. /// options: ["mode": "strict", "logLevel": 2] // Optional named parameters. @@ -712,10 +719,10 @@ public struct Pipeline: @unchecked Sendable { /// - params: An array of ordered, `Sendable` parameters for the stage. /// - options: Optional dictionary of named, `Sendable` parameters. /// - Returns: A new `Pipeline` object with this stage appended. - public func genericStage(name: String, params: [Sendable], - options: [String: Sendable]? = nil) -> Pipeline { + public func rawStage(name: String, params: [Sendable?], + options: [String: Sendable]? = nil) -> Pipeline { return Pipeline( - stages: stages + [GenericStage(name: name, params: params, options: options)], + stages: stages + [RawStage(name: name, params: params, options: options)], db: db ) } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineResult.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineResult.swift index 6e1d892f3cb..e5728d44409 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineResult.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineResult.swift @@ -45,7 +45,7 @@ public struct PipelineResult: @unchecked Sendable { public let updateTime: Timestamp? /// Retrieves all fields in the result as a dictionary. - public let data: [String: Sendable] + public let data: [String: Sendable?] /// Retrieves the field specified by `fieldPath`. /// - Parameter fieldPath: The field path (e.g., "foo" or "foo.bar"). diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineSnapshot.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineSnapshot.swift index e25191b8ad2..a260cc55cee 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineSnapshot.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineSnapshot.swift @@ -25,7 +25,7 @@ public struct PipelineSnapshot: Sendable { public let pipeline: Pipeline /// An array of all the results in the `PipelineSnapshot`. - let results_cache: [PipelineResult] + public let results: [PipelineResult] /// The time at which the pipeline producing this result was executed. public let executionTime: Timestamp @@ -36,10 +36,6 @@ public struct PipelineSnapshot: Sendable { self.bridge = bridge self.pipeline = pipeline executionTime = self.bridge.execution_time - results_cache = self.bridge.results.map { PipelineResult($0) } - } - - public func results() -> [PipelineResult] { - return results_cache + results = self.bridge.results.map { PipelineResult($0) } } } diff --git a/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineSource.swift b/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineSource.swift index da0b5b5b1b4..6a0026340a2 100644 --- a/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineSource.swift +++ b/Firestore/Swift/Source/SwiftAPI/Pipeline/PipelineSource.swift @@ -21,8 +21,12 @@ public struct PipelineSource: @unchecked Sendable { } public func collection(_ path: String) -> Pipeline { - let normalizedPath = path.hasPrefix("/") ? path : "/" + path - return Pipeline(stages: [CollectionSource(collection: normalizedPath)], db: db) + return Pipeline(stages: [CollectionSource(collection: db.collection(path), db: db)], db: db) + } + + public func collection(_ ref: CollectionReference) -> Pipeline { + let collectionStage = CollectionSource(collection: ref, db: db) + return Pipeline(stages: [collectionStage], db: db) } public func collectionGroup(_ collectionId: String) -> Pipeline { @@ -37,13 +41,13 @@ public struct PipelineSource: @unchecked Sendable { } public func documents(_ docs: [DocumentReference]) -> Pipeline { - let paths = docs.map { $0.path.hasPrefix("/") ? $0.path : "/" + $0.path } - return Pipeline(stages: [DocumentsSource(paths: paths)], db: db) + return Pipeline(stages: [DocumentsSource(docs: docs, db: db)], db: db) } public func documents(_ paths: [String]) -> Pipeline { - let normalizedPaths = paths.map { $0.hasPrefix("/") ? $0 : "/" + $0 } - return Pipeline(stages: [DocumentsSource(paths: normalizedPaths)], db: db) + let docs = paths.map { db.document($0) } + let documentsStage = DocumentsSource(docs: docs, db: db) + return Pipeline(stages: [documentsStage], db: db) } public func create(from query: Query) -> Pipeline { diff --git a/Firestore/Swift/Source/SwiftAPI/Stages.swift b/Firestore/Swift/Source/SwiftAPI/Stages.swift index 65796af8471..9ecab6945f4 100644 --- a/Firestore/Swift/Source/SwiftAPI/Stages.swift +++ b/Firestore/Swift/Source/SwiftAPI/Stages.swift @@ -33,11 +33,13 @@ class CollectionSource: Stage { let name: String = "collection" let bridge: StageBridge - private var collection: String + private var collection: CollectionReference + private let db: Firestore - init(collection: String) { + init(collection: CollectionReference, db: Firestore) { self.collection = collection - bridge = CollectionSourceStageBridge(path: collection) + self.db = db + bridge = CollectionSourceStageBridge(ref: collection, firestore: db) } } @@ -70,12 +72,14 @@ class DatabaseSource: Stage { class DocumentsSource: Stage { let name: String = "documents" let bridge: StageBridge - private var references: [String] + private var docs: [DocumentReference] + private let db: Firestore // Initialize with an array of String paths - init(paths: [String]) { - references = paths - bridge = DocumentsSourceStageBridge(documents: paths) + init(docs: [DocumentReference], db: Firestore) { + self.docs = docs + self.db = db + bridge = DocumentsSourceStageBridge(documents: docs, firestore: db) } } @@ -323,32 +327,37 @@ class Union: Stage { class Unnest: Stage { let name: String = "unnest" let bridge: StageBridge - private var field: Selectable + private var alias: Expr + private var field: Expr private var indexField: String? init(field: Selectable, indexField: String? = nil) { - self.field = field + let seletable = field as! SelectableWrapper + self.field = seletable.expr + alias = Field(seletable.alias) self.indexField = indexField + bridge = UnnestStageBridge( - field: Helper.sendableToExpr(field).toBridge(), - indexField: indexField + field: self.field.toBridge(), + alias: alias.toBridge(), + indexField: indexField.map { Field($0).toBridge() } ?? nil ) } } @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) -class GenericStage: Stage { +class RawStage: Stage { let name: String let bridge: StageBridge - private var params: [Sendable] + private var params: [Sendable?] private var options: [String: Sendable]? - init(name: String, params: [Sendable], options: [String: Sendable]? = nil) { + init(name: String, params: [Sendable?], options: [String: Sendable]? = nil) { self.name = name self.params = params self.options = options - let bridgeParams = params.map { Helper.sendableToExpr($0).toBridge() } + let bridgeParams = params.map { Helper.sendableToAnyObjectForRawStage($0) } let bridgeOptions = options?.mapValues { Helper.sendableToExpr($0).toBridge() } - bridge = GenericStageBridge(name: name, params: bridgeParams, options: bridgeOptions) + bridge = RawStageBridge(name: name, params: bridgeParams, options: bridgeOptions) } } diff --git a/Firestore/Swift/Tests/Integration/PipelineApiTests.swift b/Firestore/Swift/Tests/Integration/PipelineApiTests.swift index 25ca1f09a6d..f712cceca1f 100644 --- a/Firestore/Swift/Tests/Integration/PipelineApiTests.swift +++ b/Firestore/Swift/Tests/Integration/PipelineApiTests.swift @@ -263,22 +263,22 @@ final class PipelineTests: FSTIntegrationTestCase { // ... } } - func testGenericStage() async throws { + func testRawStage() async throws { // Assume we don't have a built-in "where" stage, the customer could still - // add this stage by calling genericStage, passing the name of the stage "where", + // add this stage by calling rawStage, passing the name of the stage "where", // and providing positional argument values. _ = db.pipeline().collection("books") - .genericStage(name: "where", - params: [Field("published").lt(1900)]) + .rawStage(name: "where", + params: [Field("published").lt(1900)]) .select("title", "author") // In cases where the stage also supports named argument values, then these can be // provided with a third argument that maps the argument name to value. // Note that these named arguments are always optional in the stage definition. _ = db.pipeline().collection("books") - .genericStage(name: "where", - params: [Field("published").lt(1900)], - options: ["someOptionalParamName": "the argument value for this param"]) + .rawStage(name: "where", + params: [Field("published").lt(1900)], + options: ["someOptionalParamName": "the argument value for this param"]) .select("title", "author") } diff --git a/Firestore/Swift/Tests/Integration/PipelineTests.swift b/Firestore/Swift/Tests/Integration/PipelineTests.swift index 7bfdc8525a1..cf522b9e1f1 100644 --- a/Firestore/Swift/Tests/Integration/PipelineTests.swift +++ b/Firestore/Swift/Tests/Integration/PipelineTests.swift @@ -14,19 +14,22 @@ * limitations under the License. */ +import FirebaseCore import FirebaseFirestore import Foundation +import XCTest -private let bookDocs: [String: [String: Any]] = [ +private let bookDocs: [String: [String: Sendable]] = [ "book1": [ "title": "The Hitchhiker's Guide to the Galaxy", "author": "Douglas Adams", "genre": "Science Fiction", "published": 1979, "rating": 4.2, - "tags": ["comedy", "space", "adventure"], // Array literal - "awards": ["hugo": true, "nebula": false], // Dictionary literal - "nestedField": ["level.1": ["level.2": true]], // Nested dictionary literal + "tags": ["comedy", "space", "adventure"], + "awards": ["hugo": true, "nebula": false, "others": ["unknown": ["year": 1980]]], // Corrected + "nestedField": ["level.1": ["level.2": true]], + "embedding": VectorValue([10, 1, 1, 1, 1, 1, 1, 1, 1, 1]), ], "book2": [ "title": "Pride and Prejudice", @@ -36,6 +39,7 @@ private let bookDocs: [String: [String: Any]] = [ "rating": 4.5, "tags": ["classic", "social commentary", "love"], "awards": ["none": true], + "embedding": VectorValue([1, 10, 1, 1, 1, 1, 1, 1, 1, 1]), // Added ], "book3": [ "title": "One Hundred Years of Solitude", @@ -45,6 +49,7 @@ private let bookDocs: [String: [String: Any]] = [ "rating": 4.3, "tags": ["family", "history", "fantasy"], "awards": ["nobel": true, "nebula": false], + "embedding": VectorValue([1, 1, 10, 1, 1, 1, 1, 1, 1, 1]), ], "book4": [ "title": "The Lord of the Rings", @@ -54,6 +59,9 @@ private let bookDocs: [String: [String: Any]] = [ "rating": 4.7, "tags": ["adventure", "magic", "epic"], "awards": ["hugo": false, "nebula": false], + "remarks": NSNull(), // Added + "cost": Double.nan, // Added + "embedding": VectorValue([1, 1, 1, 10, 1, 1, 1, 1, 1, 1]), // Added ], "book5": [ "title": "The Handmaid's Tale", @@ -63,6 +71,7 @@ private let bookDocs: [String: [String: Any]] = [ "rating": 4.1, "tags": ["feminism", "totalitarianism", "resistance"], "awards": ["arthur c. clarke": true, "booker prize": false], + "embedding": VectorValue([1, 1, 1, 1, 10, 1, 1, 1, 1, 1]), // Added ], "book6": [ "title": "Crime and Punishment", @@ -72,6 +81,7 @@ private let bookDocs: [String: [String: Any]] = [ "rating": 4.3, "tags": ["philosophy", "crime", "redemption"], "awards": ["none": true], + "embedding": VectorValue([1, 1, 1, 1, 1, 10, 1, 1, 1, 1]), // Added ], "book7": [ "title": "To Kill a Mockingbird", @@ -81,6 +91,7 @@ private let bookDocs: [String: [String: Any]] = [ "rating": 4.2, "tags": ["racism", "injustice", "coming-of-age"], "awards": ["pulitzer": true], + "embedding": VectorValue([1, 1, 1, 1, 1, 1, 10, 1, 1, 1]), // Added ], "book8": [ "title": "1984", @@ -90,6 +101,7 @@ private let bookDocs: [String: [String: Any]] = [ "rating": 4.2, "tags": ["surveillance", "totalitarianism", "propaganda"], "awards": ["prometheus": true], + "embedding": VectorValue([1, 1, 1, 1, 1, 1, 1, 10, 1, 1]), // Added ], "book9": [ "title": "The Great Gatsby", @@ -99,6 +111,7 @@ private let bookDocs: [String: [String: Any]] = [ "rating": 4.0, "tags": ["wealth", "american dream", "love"], "awards": ["none": true], + "embedding": VectorValue([1, 1, 1, 1, 1, 1, 1, 1, 10, 1]), // Added ], "book10": [ "title": "Dune", @@ -108,6 +121,7 @@ private let bookDocs: [String: [String: Any]] = [ "rating": 4.6, "tags": ["politics", "desert", "ecology"], "awards": ["hugo": true, "nebula": true], + "embedding": VectorValue([1, 1, 1, 1, 1, 1, 1, 1, 1, 10]), // Added ], ] @@ -118,17 +132,6 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { super.setUp() } - func testCount() async throws { - try await firestore().collection("foo").document("bar").setData(["foo": "bar", "x": 42]) - let snapshot = try await firestore() - .pipeline() - .collection("/foo") - .where(Field("foo").eq(Constant("bar"))) - .execute() - - print(snapshot) - } - func testEmptyResults() async throws { let collRef = collectionRef( withDocuments: bookDocs @@ -141,7 +144,7 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { .limit(0) .execute() - XCTAssertTrue(snapshot.results().isEmpty) + TestHelper.compare(pipelineSnapshot: snapshot, expectedCount: 0) } func testFullResults() async throws { @@ -155,14 +158,1354 @@ class PipelineIntegrationTests: FSTIntegrationTestCase { .collection(collRef.path) .execute() - let results = snapshot.results() - XCTAssertEqual(results.count, 10) + TestHelper.compare(pipelineSnapshot: snapshot, expectedIDs: [ + "book1", "book10", "book2", "book3", "book4", + "book5", "book6", "book7", "book8", "book9", + ], enforceOrder: false) + } + + func testReturnsExecutionTime() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline().collection(collRef.path) + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, bookDocs.count, "Should fetch all documents") + + let executionTimeValue = snapshot.executionTime.dateValue().timeIntervalSince1970 + + XCTAssertGreaterThan(executionTimeValue, 0, "Execution time should be positive and not zero") + } + + func testReturnsExecutionTimeForEmptyQuery() async throws { + let collRef = + collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline().collection(collRef.path).limit(0) + let snapshot = try await pipeline.execute() + + TestHelper.compare(pipelineSnapshot: snapshot, expectedCount: 0) + + let executionTimeValue = snapshot.executionTime.dateValue().timeIntervalSince1970 + XCTAssertGreaterThan(executionTimeValue, 0, "Execution time should be positive and not zero") + } + + func testReturnsCreateAndUpdateTimeForEachDocument() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + let pipeline = db.pipeline().collection(collRef.path) + var snapshot = try await pipeline.execute() + + XCTAssertEqual( + snapshot.results.count, + bookDocs.count, + "Initial fetch should return all documents" + ) + for doc in snapshot.results { + XCTAssertNotNil( + doc.createTime, + "Document \(String(describing: doc.id)) should have createTime" + ) + XCTAssertNotNil( + doc.updateTime, + "Document \(String(describing: doc.id)) should have updateTime" + ) + if let createTime = doc.createTime, let updateTime = doc.updateTime { + let createTimestamp = createTime.dateValue().timeIntervalSince1970 + let updateTimestamp = updateTime.dateValue().timeIntervalSince1970 + + XCTAssertEqual(createTimestamp, + updateTimestamp, + "Initial createTime and updateTime should be equal for \(String(describing: doc.id))") + } + } + + // Update documents + let batch = db.batch() + for doc in snapshot.results { + batch + .updateData( + ["newField": "value"], + forDocument: doc.ref! + ) + } + + try await batch.commit() + + snapshot = try await pipeline.execute() + XCTAssertEqual( + snapshot.results.count, + bookDocs.count, + "Fetch after update should return all documents" + ) + + for doc in snapshot.results { + XCTAssertNotNil( + doc.createTime, + "Document \(String(describing: doc.id)) should still have createTime after update" + ) + XCTAssertNotNil( + doc.updateTime, + "Document \(String(describing: doc.id)) should still have updateTime after update" + ) + if let createTime = doc.createTime, let updateTime = doc.updateTime { + let createTimestamp = createTime.dateValue().timeIntervalSince1970 + let updateTimestamp = updateTime.dateValue().timeIntervalSince1970 + + XCTAssertLessThan(createTimestamp, + updateTimestamp, + "updateTime (\(updateTimestamp)) should be after createTime (\(createTimestamp)) for \(String(describing: doc.id))") + } + } + } + + func testReturnsExecutionTimeForAggregateQuery() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .aggregate(Field("rating").avg().as("avgRating")) + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Aggregate query should return a single result") + + let executionTimeValue = snapshot.executionTime.dateValue().timeIntervalSince1970 + XCTAssertGreaterThan(executionTimeValue, 0, "Execution time should be positive") + } + + func testTimestampsAreNilForAggregateQueryResults() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .aggregate( + [Field("rating").avg().as("avgRating")], + groups: ["genre"] + ) // Make sure 'groupBy' and 'average' are correct + let snapshot = try await pipeline.execute() + + // There are 8 unique genres in bookDocs + XCTAssertEqual(snapshot.results.count, 8, "Should return one result per genre") + + for doc in snapshot.results { + XCTAssertNil( + doc.createTime, + "createTime should be nil for aggregate result (docID: \(String(describing: doc.id)), data: \(doc.data))" + ) + XCTAssertNil( + doc.updateTime, + "updateTime should be nil for aggregate result (docID: \(String(describing: doc.id)), data: \(doc.data))" + ) + } + } + + func testSupportsCollectionReferenceAsSource() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline().collection(collRef) + let snapshot = try await pipeline.execute() + + TestHelper.compare(pipelineSnapshot: snapshot, expectedCount: bookDocs.count) + } + + func testSupportsListOfDocumentReferencesAsSource() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let docRefs: [DocumentReference] = [ + collRef.document("book1"), + collRef.document("book2"), + collRef.document("book3"), + ] + let pipeline = db.pipeline().documents(docRefs) + let snapshot = try await pipeline.execute() + + TestHelper + .compare( + pipelineSnapshot: snapshot, + expectedIDs: ["book1", "book2", "book3"], + enforceOrder: false + ) + } + + func testSupportsListOfDocumentPathsAsSource() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let docPaths: [String] = [ + collRef.document("book1").path, + collRef.document("book2").path, + collRef.document("book3").path, + ] + let pipeline = db.pipeline().documents(docPaths) + let snapshot = try await pipeline.execute() + + TestHelper + .compare( + pipelineSnapshot: snapshot, + expectedIDs: ["book1", "book2", "book3"], + enforceOrder: false + ) + } + + func testRejectsCollectionReferenceFromAnotherDB() async throws { + let db1 = firestore() + + let db2 = Firestore.firestore(app: db1.app, database: "db2") + + let collRefDb2 = db2.collection("foo") - let actualIDs = Set(results.map { $0.id }) - let expectedIDs = Set([ - "book1", "book2", "book3", "book4", "book5", - "book6", "book7", "book8", "book9", "book10", + XCTAssertTrue(FSTNSExceptionUtil.testForException({ + _ = db1.pipeline().collection(collRefDb2) + }, reasonContains: "Invalid CollectionReference")) + } + + func testRejectsDocumentReferenceFromAnotherDB() async throws { + let db1 = firestore() + + let db2 = Firestore.firestore(app: db1.app, database: "db2") + + let docRefDb2 = db2.collection("foo").document("bar") + + XCTAssertTrue(FSTNSExceptionUtil.testForException({ + _ = db1.pipeline().documents([docRefDb2]) + }, reasonContains: "Invalid DocumentReference")) + } + + func testSupportsCollectionGroupAsSource() async throws { + let db = firestore() + + let rootCollForTest = collectionRef() + + let randomSubCollectionId = String(UUID().uuidString.prefix(8)) + + // Create parent documents first to ensure they exist before creating subcollections. + let doc1Ref = rootCollForTest.document("book1").collection(randomSubCollectionId) + .document("translation") + try await doc1Ref.setData(["order": 1]) + + let doc2Ref = rootCollForTest.document("book2").collection(randomSubCollectionId) + .document("translation") + try await doc2Ref.setData(["order": 2]) + + let pipeline = db.pipeline() + .collectionGroup(randomSubCollectionId) + .sort(Field("order").ascending()) + + let snapshot = try await pipeline.execute() + + // Assert that only the two documents from the targeted subCollectionId are fetched, in the + // correct order. + TestHelper + .compare( + pipelineSnapshot: snapshot, + expectedIDs: [doc1Ref.documentID, doc2Ref.documentID], + enforceOrder: true + ) + } + + func testSupportsDatabaseAsSource() async throws { + let db = firestore() + let testRootCol = collectionRef() // Provides a unique root path for this test + + let randomIDValue = UUID().uuidString.prefix(8) + + // Document 1 + let collADocRef = testRootCol.document("docA") // Using specific IDs for clarity in debugging + try await collADocRef.setData(["order": 1, "randomId": randomIDValue, "name": "DocInCollA"]) + + // Document 2 + let collBDocRef = testRootCol.document("docB") // Using specific IDs for clarity in debugging + try await collBDocRef.setData(["order": 2, "randomId": randomIDValue, "name": "DocInCollB"]) + + // Document 3 (control, should not be fetched by the main query due to different randomId) + let collCDocRef = testRootCol.document("docC") + try await collCDocRef.setData([ + "order": 3, + "randomId": "\(UUID().uuidString)", + "name": "DocInCollC", ]) - XCTAssertEqual(actualIDs, expectedIDs) + + // Document 4 (control, no randomId, should not be fetched) + let collDDocRef = testRootCol.document("docD") + try await collDDocRef.setData(["order": 4, "name": "DocInCollDNoRandomId"]) + + // Document 5 (control, correct randomId but in a sub-sub-collection to test depth) + // This also helps ensure the database() query scans deeply. + let subSubCollDocRef = testRootCol.document("parentForSubSub").collection("subSubColl") + .document("docE") + try await subSubCollDocRef.setData([ + "order": 0, + "randomId": randomIDValue, + "name": "DocInSubSubColl", + ]) + + let pipeline = db.pipeline() + .database() // Source is the entire database + .where(Field("randomId").eq(randomIDValue)) + .sort(Ascending("order")) + let snapshot = try await pipeline.execute() + + // We expect 3 documents: docA, docB, and docE (from sub-sub-collection) + XCTAssertEqual( + snapshot.results.count, + 3, + "Should fetch the three documents with the correct randomId" + ) + // Order should be docE (order 0), docA (order 1), docB (order 2) + TestHelper + .compare( + pipelineSnapshot: snapshot, + expectedIDs: [subSubCollDocRef.documentID, collADocRef.documentID, collBDocRef.documentID], + enforceOrder: true + ) + } + + func testAcceptsAndReturnsAllSupportedDataTypes() async throws { + let db = firestore() + let randomCol = collectionRef() // Ensure a unique collection for the test + + // Add a dummy document to the collection. + // A pipeline query with .select against an empty collection might not behave as expected. + try await randomCol.document("dummyDoc").setData(["field": "value"]) + + let refDate = Date(timeIntervalSince1970: 1_678_886_400) + let refTimestamp = Timestamp(date: refDate) + + let constantsFirst: [Selectable] = [ + Constant(1).as("number"), + Constant("a string").as("string"), + Constant(true).as("boolean"), + Constant.nil.as("nil"), + Constant(GeoPoint(latitude: 0.1, longitude: 0.2)).as("geoPoint"), + Constant(refTimestamp).as("timestamp"), + Constant(refDate).as("date"), // Firestore will convert this to a Timestamp + Constant([1, 2, 3, 4, 5, 6, 7, 0] as [UInt8]).as("bytes"), + Constant(db.document("foo/bar")).as("documentReference"), + Constant(VectorValue([1, 2, 3])).as("vectorValue"), + Constant([1, 2, 3]).as("arrayValue"), // Treated as an array of numbers + ] + + let constantsSecond: [Selectable] = [ + MapExpression([ + "number": 1, + "string": "a string", + "boolean": true, + "nil": Constant.nil, + "geoPoint": GeoPoint(latitude: 0.1, longitude: 0.2), + "timestamp": refTimestamp, + "date": refDate, + "uint8Array": Data([1, 2, 3, 4, 5, 6, 7, 0]), + "documentReference": Constant(db.document("foo/bar")), + "vectorValue": VectorValue([1, 2, 3]), + "map": [ + "number": 2, + "string": "b string", + ], + "array": [1, "c string"], + ]).as("map"), + ArrayExpression([ + 1000, + "another string", + false, + Constant.nil, + GeoPoint(latitude: 10.1, longitude: 20.2), + Timestamp(date: Date(timeIntervalSince1970: 1_700_000_000)), // Different timestamp + Date(timeIntervalSince1970: 1_700_000_000), // Different date + [11, 22, 33] as [UInt8], + db.document("another/doc"), + VectorValue([7, 8, 9]), + [ + "nestedInArrayMapKey": "value", + "anotherNestedKey": refTimestamp, + ], + [2000, "deep nested array string"], + ]).as("array"), + ] + + let expectedResultsMap: [String: Sendable?] = [ + "number": 1, + "string": "a string", + "boolean": true, + "nil": nil, + "geoPoint": GeoPoint(latitude: 0.1, longitude: 0.2), + "timestamp": refTimestamp, + "date": refTimestamp, // Dates are converted to Timestamps + "bytes": [1, 2, 3, 4, 5, 6, 7, 0] as [UInt8], + "documentReference": db.document("foo/bar"), + "vectorValue": VectorValue([1, 2, 3]), + "arrayValue": [1, 2, 3], + "map": [ + "number": 1, + "string": "a string", + "boolean": true, + "nil": nil, + "geoPoint": GeoPoint(latitude: 0.1, longitude: 0.2), + "timestamp": refTimestamp, + "date": refTimestamp, + "uint8Array": Data([1, 2, 3, 4, 5, 6, 7, 0]), + "documentReference": db.document("foo/bar"), + "vectorValue": VectorValue([1, 2, 3]), + "map": [ + "number": 2, + "string": "b string", + ], + "array": [1, "c string"], + ], + "array": [ + 1000, + "another string", + false, + nil, + GeoPoint(latitude: 10.1, longitude: 20.2), + Timestamp(date: Date(timeIntervalSince1970: 1_700_000_000)), + Timestamp(date: Date(timeIntervalSince1970: 1_700_000_000)), // Dates are converted + [11, 22, 33] as [UInt8], + db.document("another/doc"), + VectorValue([7, 8, 9]), + [ + "nestedInArrayMapKey": "value", + "anotherNestedKey": refTimestamp, + ], + [2000, "deep nested array string"], + ], + ] + + let pipeline = db.pipeline() + .collection(randomCol.path) + .limit(1) + .select( + constantsFirst + constantsSecond + ) + let snapshot = try await pipeline.execute() + + TestHelper.compare(pipelineResult: snapshot.results.first!, expected: expectedResultsMap) + } + + func testAcceptsAndReturnsNil() async throws { + let db = firestore() + let randomCol = collectionRef() // Ensure a unique collection for the test + + // Add a dummy document to the collection. + // A pipeline query with .select against an empty collection might not behave as expected. + try await randomCol.document("dummyDoc").setData(["field": "value"]) + + let refDate = Date(timeIntervalSince1970: 1_678_886_400) + let refTimestamp = Timestamp(date: refDate) + + let constantsFirst: [Selectable] = [ + Constant.nil.as("nil"), + ] + + let expectedResultsMap: [String: Sendable?] = [ + "nil": nil, + ] + + let pipeline = db.pipeline() + .collection(randomCol.path) + .limit(1) + .select( + constantsFirst + ) + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1) + TestHelper.compare(pipelineResult: snapshot.results.first!, expected: expectedResultsMap) + } + + func testConvertsArraysAndPlainObjectsToFunctionValues() async throws { + let collRef = collectionRef(withDocuments: bookDocs) // Uses existing bookDocs + let db = collRef.firestore + + // Expected data for "The Lord of the Rings" + let expectedTitle = "The Lord of the Rings" + let expectedAuthor = "J.R.R. Tolkien" + let expectedGenre = "Fantasy" + let expectedPublished = 1954 + let expectedRating = 4.7 + let expectedTags = ["adventure", "magic", "epic"] + let expectedAwards: [String: Sendable] = ["hugo": false, "nebula": false] + + let metadataArrayElements: [Sendable] = [ + 1, + 2, + expectedGenre, + expectedRating * 10, + [expectedTitle], + ["published": expectedPublished], + ] + + let metadataMapElements: [String: Sendable] = [ + "genre": expectedGenre, + "rating": expectedRating * 10, + "nestedArray": [expectedTitle], + "nestedMap": ["published": expectedPublished], + ] + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("rating").descending()) + .limit(1) // This should pick "The Lord of the Rings" (rating 4.7) + .select( + Field("title"), + Field("author"), + Field("genre"), + Field("rating"), + Field("published"), + Field("tags"), + Field("awards") + ) + .addFields( + ArrayExpression([ + 1, + 2, + Field("genre"), + Field("rating").multiply(10), + ArrayExpression([Field("title")]), + MapExpression(["published": Field("published")]), + ]).as("metadataArray"), + MapExpression([ + "genre": Field("genre"), + "rating": Field("rating").multiply(10), + "nestedArray": ArrayExpression([Field("title")]), + "nestedMap": MapExpression(["published": Field("published")]), + ]).as("metadata") + ) + .where( + Field("metadataArray").eq(metadataArrayElements) && + Field("metadata").eq(metadataMapElements) + ) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + + if let resultDoc = snapshot.results.first { + let expectedFullDoc: [String: Sendable?] = [ + "title": expectedTitle, + "author": expectedAuthor, + "genre": expectedGenre, + "published": expectedPublished, + "rating": expectedRating, + "tags": expectedTags, + "awards": expectedAwards, + "metadataArray": metadataArrayElements, + "metadata": metadataMapElements, + ] + + TestHelper.compare(pipelineResult: resultDoc, expected: expectedFullDoc) + } else { + XCTFail("No document retrieved") + } + } + + func testSupportsAggregate() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + var pipeline = db.pipeline() + .collection(collRef.path) + .aggregate(CountAll().as("count")) + var snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Count all should return a single aggregate document") + if let result = snapshot.results.first { + TestHelper.compare(pipelineResult: result, expected: ["count": bookDocs.count]) + } else { + XCTFail("No result for count all aggregation") + } + + pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("genre").eq("Science Fiction")) + .aggregate( + CountAll().as("count"), + Field("rating").avg().as("avgRating"), + Field("rating").maximum().as("maxRating") + ) + snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Filtered aggregate should return a single document") + if let result = snapshot.results.first { + let expectedAggValues: [String: Sendable] = [ + "count": 2, + "avgRating": 4.4, + "maxRating": 4.6, + ] + TestHelper.compare(pipelineResult: result, expected: expectedAggValues) + } else { + XCTFail("No result for filtered aggregation") + } + } + + func testRejectsGroupsWithoutAccumulators() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let dummyDocRef = collRef.document("dummyDocForRejectTest") + try await dummyDocRef.setData(["field": "value"]) + + do { + _ = try await db.pipeline() + .collection(collRef.path) + .where(Field("published").lt(1900)) + .aggregate([], groups: ["genre"]) + .execute() + + XCTFail( + "The pipeline should have thrown an error for groups without accumulators, but it did not." + ) + + } catch { + XCTAssert(true, "Successfully caught expected error for groups without accumulators.") + } + } + + func testReturnsGroupAndAccumulateResults() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("published").lt(1984)) + .aggregate( + [Field("rating").avg().as("avgRating")], + groups: ["genre"] + ) + .where(Field("avgRating").gt(4.3)) + .sort(Field("avgRating").descending()) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual( + snapshot.results.count, + 3, + "Should return 3 documents after grouping and filtering." + ) + + let expectedResultsArray: [[String: Sendable]] = [ + ["avgRating": 4.7, "genre": "Fantasy"], + ["avgRating": 4.5, "genre": "Romance"], + ["avgRating": 4.4, "genre": "Science Fiction"], + ] + + TestHelper + .compare(pipelineSnapshot: snapshot, expected: expectedResultsArray, enforceOrder: true) + } + + func testReturnsMinMaxCountAndCountAllAccumulations() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .aggregate( + Field("cost").count().as("booksWithCost"), + CountAll().as("count"), + Field("rating").maximum().as("maxRating"), + Field("published").minimum().as("minPublished") + ) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Aggregate should return a single document") + + let expectedValues: [String: Sendable] = [ + "booksWithCost": 1, + "count": bookDocs.count, + "maxRating": 4.7, + "minPublished": 1813, + ] + + if let result = snapshot.results.first { + TestHelper.compare(pipelineResult: result, expected: expectedValues) + } else { + XCTFail("No result for min/max/count/countAll aggregation") + } + } + + func testReturnsCountIfAccumulation() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let expectedCount = 3 + let expectedResults: [String: Sendable] = ["count": expectedCount] + let condition = Field("rating").gt(4.3) + + var pipeline = db.pipeline() + .collection(collRef.path) + .aggregate(condition.countIf().as("count")) + var snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "countIf aggregate should return a single document") + if let result = snapshot.results.first { + TestHelper.compare(pipelineResult: result, expected: expectedResults) + } else { + XCTFail("No result for countIf aggregation") + } + } + + func testDistinctStage() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .distinct(Field("genre"), Field("author")) + .sort(Field("genre").ascending(), Field("author").ascending()) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["genre": "Dystopian", "author": "George Orwell"], + ["genre": "Dystopian", "author": "Margaret Atwood"], + ["genre": "Fantasy", "author": "J.R.R. Tolkien"], + ["genre": "Magical Realism", "author": "Gabriel García Márquez"], + ["genre": "Modernist", "author": "F. Scott Fitzgerald"], + ["genre": "Psychological Thriller", "author": "Fyodor Dostoevsky"], + ["genre": "Romance", "author": "Jane Austen"], + ["genre": "Science Fiction", "author": "Douglas Adams"], + ["genre": "Science Fiction", "author": "Frank Herbert"], + ["genre": "Southern Gothic", "author": "Harper Lee"], + ] + + XCTAssertEqual(snapshot.results.count, expectedResults.count, "Snapshot results count mismatch") + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testSelectStage() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .select(Field("title"), Field("author")) + .sort(Field("author").ascending()) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "The Hitchhiker's Guide to the Galaxy", "author": "Douglas Adams"], + ["title": "The Great Gatsby", "author": "F. Scott Fitzgerald"], + ["title": "Dune", "author": "Frank Herbert"], + ["title": "Crime and Punishment", "author": "Fyodor Dostoevsky"], + ["title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez"], + ["title": "1984", "author": "George Orwell"], + ["title": "To Kill a Mockingbird", "author": "Harper Lee"], + ["title": "The Lord of the Rings", "author": "J.R.R. Tolkien"], + ["title": "Pride and Prejudice", "author": "Jane Austen"], + ["title": "The Handmaid's Tale", "author": "Margaret Atwood"], + ] + + XCTAssertEqual( + snapshot.results.count, + expectedResults.count, + "Snapshot results count mismatch for select stage." + ) + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testAddFieldStage() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .select(Field("title"), Field("author")) + .addFields(Constant("bar").as("foo")) + .sort(Field("author").ascending()) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "The Hitchhiker's Guide to the Galaxy", "author": "Douglas Adams", "foo": "bar"], + ["title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "foo": "bar"], + ["title": "Dune", "author": "Frank Herbert", "foo": "bar"], + ["title": "Crime and Punishment", "author": "Fyodor Dostoevsky", "foo": "bar"], + ["title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez", "foo": "bar"], + ["title": "1984", "author": "George Orwell", "foo": "bar"], + ["title": "To Kill a Mockingbird", "author": "Harper Lee", "foo": "bar"], + ["title": "The Lord of the Rings", "author": "J.R.R. Tolkien", "foo": "bar"], + ["title": "Pride and Prejudice", "author": "Jane Austen", "foo": "bar"], + ["title": "The Handmaid's Tale", "author": "Margaret Atwood", "foo": "bar"], + ] + + XCTAssertEqual( + snapshot.results.count, + expectedResults.count, + "Snapshot results count mismatch for addField stage." + ) + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testRemoveFieldsStage() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .select(Field("title"), Field("author")) + .sort(Field("author").ascending()) // Sort before removing the 'author' field + .removeFields(Field("author")) + + let snapshot = try await pipeline.execute() + + // Expected results are sorted by author, but only contain the title + let expectedResults: [[String: Sendable]] = [ + ["title": "The Hitchhiker's Guide to the Galaxy"], // Douglas Adams + ["title": "The Great Gatsby"], // F. Scott Fitzgerald + ["title": "Dune"], // Frank Herbert + ["title": "Crime and Punishment"], // Fyodor Dostoevsky + ["title": "One Hundred Years of Solitude"], // Gabriel García Márquez + ["title": "1984"], // George Orwell + ["title": "To Kill a Mockingbird"], // Harper Lee + ["title": "The Lord of the Rings"], // J.R.R. Tolkien + ["title": "Pride and Prejudice"], // Jane Austen + ["title": "The Handmaid's Tale"], // Margaret Atwood + ] + + XCTAssertEqual( + snapshot.results.count, + expectedResults.count, + "Snapshot results count mismatch for removeFields stage." + ) + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testWhereStageWithAndConditions() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + // Test Case 1: Two AND conditions + var pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("rating").gt(4.5) + && Field("genre").eqAny(["Science Fiction", "Romance", "Fantasy"])) + var snapshot = try await pipeline.execute() + var expectedIDs = ["book10", "book4"] // Dune (SF, 4.6), LOTR (Fantasy, 4.7) + TestHelper.compare(pipelineSnapshot: snapshot, expectedIDs: expectedIDs, enforceOrder: false) + + // Test Case 2: Three AND conditions + pipeline = db.pipeline() + .collection(collRef.path) + .where( + Field("rating").gt(4.5) + && Field("genre").eqAny(["Science Fiction", "Romance", "Fantasy"]) + && Field("published").lt(1965) + ) + snapshot = try await pipeline.execute() + expectedIDs = ["book4"] // LOTR (Fantasy, 4.7, published 1954) + TestHelper.compare(pipelineSnapshot: snapshot, expectedIDs: expectedIDs, enforceOrder: false) + } + + func testWhereStageWithOrAndXorConditions() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + // Test Case 1: OR conditions + var pipeline = db.pipeline() + .collection(collRef.path) + .where( + Field("genre").eq("Romance") + || Field("genre").eq("Dystopian") + || Field("genre").eq("Fantasy") + ) + .select(Field("title")) + .sort(Field("title").ascending()) + + var snapshot = try await pipeline.execute() + var expectedResults: [[String: Sendable]] = [ + ["title": "1984"], // Dystopian + ["title": "Pride and Prejudice"], // Romance + ["title": "The Handmaid's Tale"], // Dystopian + ["title": "The Lord of the Rings"], // Fantasy + ] + + XCTAssertEqual( + snapshot.results.count, + expectedResults.count, + "Snapshot results count mismatch for OR conditions." + ) + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + + // Test Case 2: XOR conditions + // XOR is true if an odd number of its arguments are true. + pipeline = db.pipeline() + .collection(collRef.path) + .where( + Field("genre").eq("Romance") // Book2 (T), Book5 (F), Book4 (F), Book8 (F) + ^ Field("genre").eq("Dystopian") // Book2 (F), Book5 (T), Book4 (F), Book8 (T) + ^ Field("genre").eq("Fantasy") // Book2 (F), Book5 (F), Book4 (T), Book8 (F) + ^ Field("published").eq(1949) // Book2 (F), Book5 (F), Book4 (F), Book8 (T) + ) + .select(Field("title")) + .sort(Field("title").ascending()) + + snapshot = try await pipeline.execute() + + expectedResults = [ + ["title": "Pride and Prejudice"], + ["title": "The Handmaid's Tale"], + ["title": "The Lord of the Rings"], + ] + + XCTAssertEqual( + snapshot.results.count, + expectedResults.count, + "Snapshot results count mismatch for XOR conditions." + ) + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + func testSortOffsetAndLimitStages() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("author").ascending()) + .offset(5) + .limit(3) + .select("title", "author") + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable]] = [ + ["title": "1984", "author": "George Orwell"], + ["title": "To Kill a Mockingbird", "author": "Harper Lee"], + ["title": "The Lord of the Rings", "author": "J.R.R. Tolkien"], + ] + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: true) + } + + // MARK: - Generic Stage Tests + + func testRawStageSelectFields() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let expectedSelectedData: [String: Sendable] = [ + "title": "1984", + "metadata": ["author": "George Orwell"], + ] + + let selectParameters: [Sendable] = + [ + [ + "title": Field("title"), + "metadata": ["author": Field("author")], + ], + ] + + let pipeline = db.pipeline() + .collection(collRef.path) + .rawStage(name: "select", params: selectParameters) + .sort(Field("title").ascending()) + .limit(1) + + let snapshot = try await pipeline.execute() + + XCTAssertEqual(snapshot.results.count, 1, "Should retrieve one document") + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [expectedSelectedData], + enforceOrder: true + ) + } + + func testCanAddFields() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .sort(Field("author").ascending()) + .limit(1) + .select("title", "author") + .rawStage( + name: "add_fields", + params: [ + [ + "display": Field("title").strConcat( + Constant(" - "), + Field("author") + ), + ], + ] + ) + + let snapshot = try await pipeline.execute() + + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [ + [ + "title": "The Hitchhiker's Guide to the Galaxy", + "author": "Douglas Adams", + "display": "The Hitchhiker's Guide to the Galaxy - Douglas Adams", + ], + ], + enforceOrder: false + ) + } + + func testCanPerformDistinctQuery() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .select("title", "author", "rating") + .rawStage( + name: "distinct", + params: [ + ["rating": Field("rating")], + ] + ) + .sort(Field("rating").descending()) + + let snapshot = try await pipeline.execute() + + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [ + ["rating": 4.7], + ["rating": 4.6], + ["rating": 4.5], + ["rating": 4.3], + ["rating": 4.2], + ["rating": 4.1], + ["rating": 4.0], + ], + enforceOrder: true + ) + } + + func testCanPerformAggregateQuery() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let emptySendableDictionary: [String: Sendable?] = [:] + + let pipeline = db.pipeline() + .collection(collRef.path) + .select("title", "author", "rating") + .rawStage( + name: "aggregate", + params: [ + [ + "averageRating": Field("rating").avg(), + ], + emptySendableDictionary, + ] + ) + + let snapshot = try await pipeline.execute() + + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [ + [ + "averageRating": 4.3100000000000005, + ], + ], + enforceOrder: true + ) + } + + func testCanFilterWithWhere() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .select("title", "author") + .rawStage( + name: "where", + params: [Field("author").eq("Douglas Adams")] + ) + + let snapshot = try await pipeline.execute() + + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [ + [ + "title": "The Hitchhiker's Guide to the Galaxy", + "author": "Douglas Adams", + ], + ], + enforceOrder: false + ) + } + + func testCanLimitOffsetAndSort() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .select("title", "author") + .rawStage( + name: "sort", + params: [ + [ + "direction": "ascending", + "expression": Field("author"), + ], + ] + ) + .rawStage(name: "offset", params: [3]) + .rawStage(name: "limit", params: [1]) + + let snapshot = try await pipeline.execute() + + TestHelper.compare( + pipelineSnapshot: snapshot, + expected: [ + [ + "author": "Fyodor Dostoevsky", + "title": "Crime and Punishment", + ], + ], + enforceOrder: false + ) + } + + // MARK: - Replace Stage Test + + func testReplaceStagePromoteAwardsAndAddFlag() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").eq("The Hitchhiker's Guide to the Galaxy")) + .replace(with: "awards") + + let snapshot = try await pipeline.execute() + + TestHelper.compare(pipelineSnapshot: snapshot, expectedCount: 1) + + let expectedBook1Transformed: [String: Sendable?] = [ + "hugo": true, + "nebula": false, + "others": ["unknown": ["year": 1980]], + ] + + TestHelper + .compare( + pipelineSnapshot: snapshot, + expected: [expectedBook1Transformed], + enforceOrder: false + ) + } + + func testReplaceWithExprResult() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").eq("The Hitchhiker's Guide to the Galaxy")) + .replace(with: + MapExpression([ + "foo": "bar", + "baz": MapExpression([ + "title": Field("title"), + ]), + ])) + + let snapshot = try await pipeline.execute() + + let expectedResults: [String: Sendable?] = [ + "foo": "bar", + "baz": ["title": "The Hitchhiker's Guide to the Galaxy"], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: [expectedResults], enforceOrder: false) + } + + // MARK: - Sample Stage Tests + + func testSampleStageLimit3() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .sample(count: 3) + + let snapshot = try await pipeline.execute() + + TestHelper + .compare(pipelineSnapshot: snapshot, expectedCount: 3) + } + + func testSampleStageLimitPercentage60Average() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + var avgSize = 0.0 + let numIterations = 20 + for _ in 0 ..< numIterations { + let snapshot = try await db + .pipeline() + .collection(collRef.path) + .sample(percentage: 0.6) + .execute() + avgSize += Double(snapshot.results.count) + } + avgSize /= Double(numIterations) + XCTAssertEqual(avgSize, 6.0, accuracy: 1.0, "Average size should be close to 6") + } + + // MARK: - Union Stage Test + + func testUnionStageCombineAuthors() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .union(db.pipeline() + .collection(collRef.path)) + + let snapshot = try await pipeline.execute() + + let bookSequence = (1 ... 10).map { "book\($0)" } + let repeatedIDs = bookSequence + bookSequence + TestHelper.compare(pipelineSnapshot: snapshot, expectedIDs: repeatedIDs, enforceOrder: false) + } + + func testUnnestStage() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").eq("The Hitchhiker's Guide to the Galaxy")) + .unnest(Field("tags").as("tag"), indexField: "tagsIndex") + .select( + "title", + "author", + "genre", + "published", + "rating", + "tags", + "tag", + "awards", + "nestedField" + ) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable?]] = [ + [ + "title": "The Hitchhiker's Guide to the Galaxy", + "author": "Douglas Adams", + "genre": "Science Fiction", + "published": 1979, + "rating": 4.2, + "tags": ["comedy", "space", "adventure"], + "tag": "comedy", + "awards": ["hugo": true, "nebula": false, "others": ["unknown": ["year": 1980]]], + "nestedField": ["level.1": ["level.2": true]], + ], + [ + "title": "The Hitchhiker's Guide to the Galaxy", + "author": "Douglas Adams", + "genre": "Science Fiction", + "published": 1979, + "rating": 4.2, + "tags": ["comedy", "space", "adventure"], + "tag": "space", + "awards": ["hugo": true, "nebula": false, "others": ["unknown": ["year": 1980]]], + "nestedField": ["level.1": ["level.2": true]], + ], + [ + "title": "The Hitchhiker's Guide to the Galaxy", + "author": "Douglas Adams", + "genre": "Science Fiction", + "published": 1979, + "rating": 4.2, + "tags": ["comedy", "space", "adventure"], + "tag": "adventure", + "awards": ["hugo": true, "nebula": false, "others": ["unknown": ["year": 1980]]], + "nestedField": ["level.1": ["level.2": true]], + ], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: false) + } + + func testUnnestExpr() async throws { + let collRef = collectionRef(withDocuments: bookDocs) + let db = collRef.firestore + + let pipeline = db.pipeline() + .collection(collRef.path) + .where(Field("title").eq("The Hitchhiker's Guide to the Galaxy")) + .unnest(ArrayExpression([1, 2, 3]).as("copy")) + .select( + "title", + "author", + "genre", + "published", + "rating", + "tags", + "copy", + "awards", + "nestedField" + ) + + let snapshot = try await pipeline.execute() + + let expectedResults: [[String: Sendable?]] = [ + [ + "title": "The Hitchhiker's Guide to the Galaxy", + "author": "Douglas Adams", + "genre": "Science Fiction", + "published": 1979, + "rating": 4.2, + "tags": ["comedy", "space", "adventure"], + "copy": 1, + "awards": ["hugo": true, "nebula": false, "others": ["unknown": ["year": 1980]]], + "nestedField": ["level.1": ["level.2": true]], + ], + [ + "title": "The Hitchhiker's Guide to the Galaxy", + "author": "Douglas Adams", + "genre": "Science Fiction", + "published": 1979, + "rating": 4.2, + "tags": ["comedy", "space", "adventure"], + "copy": 2, + "awards": ["hugo": true, "nebula": false, "others": ["unknown": ["year": 1980]]], + "nestedField": ["level.1": ["level.2": true]], + ], + [ + "title": "The Hitchhiker's Guide to the Galaxy", + "author": "Douglas Adams", + "genre": "Science Fiction", + "published": 1979, + "rating": 4.2, + "tags": ["comedy", "space", "adventure"], + "copy": 3, + "awards": ["hugo": true, "nebula": false, "others": ["unknown": ["year": 1980]]], + "nestedField": ["level.1": ["level.2": true]], + ], + ] + + TestHelper.compare(pipelineSnapshot: snapshot, expected: expectedResults, enforceOrder: false) } } diff --git a/Firestore/Swift/Tests/TestHelper/TestHelper.swift b/Firestore/Swift/Tests/TestHelper/TestHelper.swift new file mode 100644 index 00000000000..e65d3960176 --- /dev/null +++ b/Firestore/Swift/Tests/TestHelper/TestHelper.swift @@ -0,0 +1,227 @@ +/* + * 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. + */ + +import FirebaseCore +import FirebaseFirestore +import Foundation +import XCTest + +public enum TestHelper { + public static func compare(pipelineSnapshot snapshot: PipelineSnapshot, + expectedCount: Int, + file: StaticString = #file, + line: UInt = #line) { + XCTAssertEqual( + snapshot.results.count, + expectedCount, + "Snapshot results count mismatch", + file: file, + line: line + ) + } + + static func compare(pipelineSnapshot snapshot: PipelineSnapshot, + expectedIDs: [String], + enforceOrder: Bool, + file: StaticString = #file, + line: UInt = #line) { + let results = snapshot.results + XCTAssertEqual( + results.count, + expectedIDs.count, + "Snapshot document IDs count mismatch. Expected \(expectedIDs.count), got \(results.count). Actual IDs: \(results.map { $0.id })", + file: file, + line: line + ) + + if enforceOrder { + let actualIDs = results.map { $0.id! } + XCTAssertEqual( + actualIDs, + expectedIDs, + "Snapshot document IDs mismatch. Expected: \(expectedIDs.sorted()), got: \(actualIDs)", + file: file, + line: line + ) + } else { + let actualIDs = results.map { $0.id! }.sorted() + XCTAssertEqual( + actualIDs, + expectedIDs.sorted(), + "Snapshot document IDs mismatch. Expected (sorted): \(expectedIDs.sorted()), got (sorted): \(actualIDs)", + file: file, + line: line + ) + } + } + + static func compare(pipelineSnapshot snapshot: PipelineSnapshot, + expected: [[String: Sendable?]], + enforceOrder: Bool, + file: StaticString = #file, + line: UInt = #line) { + guard snapshot.results.count == expected.count else { + XCTFail("Mismatch in expected results count and actual results count.") + return + } + + if enforceOrder { + for i in 0 ..< expected.count { + compare(pipelineResult: snapshot.results[i], expected: expected[i]) + } + } else { + let result = snapshot.results.map { $0.data } + XCTAssertTrue(areArraysOfDictionariesEqualRegardlessOfOrder(result, expected), + "PipelineSnapshot mismatch. Expected \(expected), got \(result)") + } + } + + static func compare(pipelineResult result: PipelineResult, + expected: [String: Sendable?], + file: StaticString = #file, + line: UInt = #line) { + XCTAssertTrue(areDictionariesEqual(result.data, expected), + "Document data mismatch. Expected \(expected), got \(result.data)") + } + + // MARK: - Internal helper + + private static func isNilOrNSNull(_ value: Sendable?) -> Bool { + // First, use a `guard` to safely unwrap the optional. + // If it's nil, we can immediately return true. + guard let unwrappedValue = value else { + return true + } + + // If it wasn't nil, we now check if the unwrapped value is the NSNull object. + return unwrappedValue is NSNull + } + + // A custom function to compare two values of type 'Sendable' + private static func areEqual(_ value1: Sendable?, _ value2: Sendable?) -> Bool { + if isNilOrNSNull(value1) || isNilOrNSNull(value2) { + return isNilOrNSNull(value1) && isNilOrNSNull(value2) + } + + switch (value1!, value2!) { + case let (v1 as [String: Sendable?], v2 as [String: Sendable?]): + return areDictionariesEqual(v1, v2) + case let (v1 as [Sendable?], v2 as [Sendable?]): + return areArraysEqual(v1, v2) + case let (v1 as Timestamp, v2 as Timestamp): + return v1 == v2 + case let (v1 as Date, v2 as Timestamp): + // Firestore converts Dates to Timestamps + return Timestamp(date: v1) == v2 + case let (v1 as GeoPoint, v2 as GeoPoint): + return v1.latitude == v2.latitude && v1.longitude == v2.longitude + case let (v1 as DocumentReference, v2 as DocumentReference): + return v1.path == v2.path + case let (v1 as VectorValue, v2 as VectorValue): + return v1.array == v2.array + case let (v1 as Data, v2 as Data): + return v1 == v2 + case let (v1 as Int, v2 as Int): + return v1 == v2 + case let (v1 as Double, v2 as Double): + let doubleEpsilon = 0.000001 + return abs(v1 - v2) <= doubleEpsilon + case let (v1 as Float, v2 as Float): + let floatEpsilon: Float = 0.00001 + return abs(v1 - v2) <= floatEpsilon + case let (v1 as String, v2 as String): + return v1 == v2 + case let (v1 as Bool, v2 as Bool): + return v1 == v2 + case let (v1 as UInt8, v2 as UInt8): + return v1 == v2 + default: + // Fallback for any other types, might need more specific checks + return false + } + } + + // A function to compare two dictionaries + private static func areDictionariesEqual(_ dict1: [String: Sendable?], + _ dict2: [String: Sendable?]) -> Bool { + guard dict1.count == dict2.count else { return false } + + for (key, value1) in dict1 { + guard let value2 = dict2[key], areEqual(value1, value2) else { + XCTFail(""" + Dictionary value mismatch for key: '\(key)' + Expected value: '\(String(describing: value1))' (from dict1) + Actual value: '\(String(describing: dict2[key]))' (from dict2) + Full dict1: \(String(describing: dict1)) + Full dict2: \(String(describing: dict2)) + """) + return false + } + } + return true + } + + private static func areArraysEqual(_ array1: [Sendable?], _ array2: [Sendable?]) -> Bool { + guard array1.count == array2.count else { return false } + + for (index, value1) in array1.enumerated() { + let value2 = array2[index] + if !areEqual(value1, value2) { + XCTFail(""" + Array value mismatch. + Expected array value: '\(String(describing: value1))' + Actual array value: '\(String(describing: value2))' + """) + return false + } + } + return true + } + + private static func areArraysOfDictionariesEqualRegardlessOfOrder(_ array1: [[String: Sendable?]], + _ array2: [[String: Sendable?]]) + -> Bool { + // 1. Check if the arrays have the same number of dictionaries. + guard array1.count == array2.count else { + return false + } + + // Create a mutable copy of array2 to remove matched dictionaries + var mutableArray2 = array2 + + // Iterate through each dictionary in array1 + for dict1 in array1 { + var foundMatch = false + // Try to find an equivalent dictionary in mutableArray2 + if let index = mutableArray2.firstIndex(where: { dict2 in + areDictionariesEqual(dict1, dict2) // Use our deep comparison function + }) { + // If a match is found, remove it from mutableArray2 to handle duplicates + mutableArray2.remove(at: index) + foundMatch = true + } + + // If no match was found for the current dictionary from array1, arrays are not equal + if !foundMatch { + return false + } + } + + // If we've iterated through all of array1 and mutableArray2 is empty, + // it means all dictionaries found a unique match. + return mutableArray2.isEmpty + } +} diff --git a/Firestore/core/src/api/aggregate_expressions.cc b/Firestore/core/src/api/aggregate_expressions.cc index fb58918833c..8509dfda59a 100644 --- a/Firestore/core/src/api/aggregate_expressions.cc +++ b/Firestore/core/src/api/aggregate_expressions.cc @@ -25,7 +25,7 @@ namespace api { google_firestore_v1_Value AggregateFunction::to_proto() const { google_firestore_v1_Value result; result.which_value_type = google_firestore_v1_Value_function_value_tag; - + result.function_value = google_firestore_v1_Function{}; result.function_value.name = nanopb::MakeBytesArray(name_); result.function_value.args_count = static_cast(params_.size()); result.function_value.args = nanopb::MakeArray( diff --git a/Firestore/core/src/api/ordering.cc b/Firestore/core/src/api/ordering.cc index 388280b532a..47d5ad6013b 100644 --- a/Firestore/core/src/api/ordering.cc +++ b/Firestore/core/src/api/ordering.cc @@ -30,14 +30,16 @@ google_firestore_v1_Value Ordering::to_proto() const { result.map_value.fields_count = 2; result.map_value.fields = nanopb::MakeArray(2); - result.map_value.fields[0].key = nanopb::MakeBytesArray("expression"); - result.map_value.fields[0].value = expr_->to_proto(); - result.map_value.fields[1].key = nanopb::MakeBytesArray("direction"); + + result.map_value.fields[0].key = nanopb::MakeBytesArray("direction"); google_firestore_v1_Value direction; direction.which_value_type = google_firestore_v1_Value_string_value_tag; direction.string_value = nanopb::MakeBytesArray( this->direction_ == ASCENDING ? "ascending" : "descending"); - result.map_value.fields[1].value = direction; + result.map_value.fields[0].value = direction; + + result.map_value.fields[1].key = nanopb::MakeBytesArray("expression"); + result.map_value.fields[1].value = expr_->to_proto(); return result; } diff --git a/Firestore/core/src/api/stages.cc b/Firestore/core/src/api/stages.cc index afa943c8cb0..0c514fe0ee6 100644 --- a/Firestore/core/src/api/stages.cc +++ b/Firestore/core/src/api/stages.cc @@ -84,7 +84,7 @@ google_firestore_v1_Pipeline_Stage DocumentsSource::to_proto() const { result.name = nanopb::MakeBytesArray("documents"); - result.args_count = documents_.size(); + result.args_count = static_cast(documents_.size()); result.args = nanopb::MakeArray(result.args_count); for (size_t i = 0; i < documents_.size(); ++i) { @@ -306,42 +306,79 @@ google_firestore_v1_Pipeline_Stage RemoveFieldsStage::to_proto() const { return result; } +google_firestore_v1_Value ReplaceWith::ReplaceMode::to_proto() const { + google_firestore_v1_Value result; + result.which_value_type = google_firestore_v1_Value_string_value_tag; + switch (mode_) { + case FULL_REPLACE: + result.string_value = nanopb::MakeBytesArray("full_replace"); + break; + case MERGE_PREFER_NEST: + result.string_value = nanopb::MakeBytesArray("merge_prefer_nest"); + break; + } + return result; +} + google_firestore_v1_Pipeline_Stage ReplaceWith::to_proto() const { google_firestore_v1_Pipeline_Stage result; result.name = nanopb::MakeBytesArray("replace_with"); - result.args_count = 1; - result.args = nanopb::MakeArray(1); + result.args_count = 2; + result.args = nanopb::MakeArray(2); result.args[0] = expr_->to_proto(); + result.args[1] = mode_.to_proto(); + result.options_count = 0; result.options = nullptr; return result; } -ReplaceWith::ReplaceWith(std::shared_ptr expr) : expr_(std::move(expr)) { +ReplaceWith::ReplaceWith(std::shared_ptr expr, ReplaceMode mode) + : expr_(std::move(expr)), mode_(mode) { +} + +google_firestore_v1_Value Sample::SampleMode::to_proto() const { + google_firestore_v1_Value result; + result.which_value_type = google_firestore_v1_Value_string_value_tag; + switch (mode_) { + case DOCUMENTS: + result.string_value = nanopb::MakeBytesArray("documents"); + break; + case PERCENT: + result.string_value = nanopb::MakeBytesArray("percent"); + break; + } + return result; } -Sample::Sample(std::string type, int64_t count, double percentage) - : type_(type), count_(count), percentage_(percentage) { +Sample::Sample(SampleMode mode, int64_t count, double percentage) + : mode_(mode), count_(count), percentage_(percentage) { } google_firestore_v1_Pipeline_Stage Sample::to_proto() const { google_firestore_v1_Pipeline_Stage result; result.name = nanopb::MakeBytesArray("sample"); - result.args_count = 1; - result.args = nanopb::MakeArray(1); - if (type_ == "count") { - result.args[0].which_value_type = - google_firestore_v1_Value_integer_value_tag; - result.args[0].integer_value = count_; - } else { - result.args[0].which_value_type = - google_firestore_v1_Value_double_value_tag; - result.args[0].double_value = percentage_; + result.args_count = 2; + result.args = nanopb::MakeArray(2); + + switch (mode_.mode()) { + case SampleMode::Mode::DOCUMENTS: + result.args[0].which_value_type = + google_firestore_v1_Value_integer_value_tag; + result.args[0].integer_value = count_; + break; + case SampleMode::Mode::PERCENT: + result.args[0].which_value_type = + google_firestore_v1_Value_double_value_tag; + result.args[0].double_value = percentage_; + break; } + result.args[1] = mode_.to_proto(); + result.options_count = 0; result.options = nullptr; return result; @@ -364,27 +401,28 @@ google_firestore_v1_Pipeline_Stage Union::to_proto() const { } Unnest::Unnest(std::shared_ptr field, - absl::optional index_field) - : field_(std::move(field)), index_field_(std::move(index_field)) { + std::shared_ptr alias, + absl::optional> index_field) + : field_(std::move(field)), + alias_(alias), + index_field_(std::move(index_field)) { } google_firestore_v1_Pipeline_Stage Unnest::to_proto() const { google_firestore_v1_Pipeline_Stage result; result.name = nanopb::MakeBytesArray("unnest"); - result.args_count = 1; - result.args = nanopb::MakeArray(1); + result.args_count = 2; + result.args = nanopb::MakeArray(2); result.args[0] = field_->to_proto(); + result.args[1] = alias_->to_proto(); if (index_field_.has_value()) { result.options_count = 1; result.options = nanopb::MakeArray(1); result.options[0].key = nanopb::MakeBytesArray("index_field"); - result.options[0].value.which_value_type = - google_firestore_v1_Value_string_value_tag; - result.options[0].value.string_value = - nanopb::MakeBytesArray(index_field_.value()); + result.options[0].value = index_field_.value()->to_proto(); } else { result.options_count = 0; result.options = nullptr; @@ -393,16 +431,16 @@ google_firestore_v1_Pipeline_Stage Unnest::to_proto() const { return result; } -GenericStage::GenericStage( +RawStage::RawStage( std::string name, - std::vector> params, + std::vector params, std::unordered_map> options) : name_(std::move(name)), params_(std::move(params)), options_(std::move(options)) { } -google_firestore_v1_Pipeline_Stage GenericStage::to_proto() const { +google_firestore_v1_Pipeline_Stage RawStage::to_proto() const { google_firestore_v1_Pipeline_Stage result; result.name = nanopb::MakeBytesArray(name_); @@ -410,7 +448,7 @@ google_firestore_v1_Pipeline_Stage GenericStage::to_proto() const { result.args = nanopb::MakeArray(result.args_count); for (size_t i = 0; i < result.args_count; i++) { - result.args[i] = params_[i]->to_proto(); + result.args[i] = params_[i]; } nanopb::SetRepeatedField( diff --git a/Firestore/core/src/api/stages.h b/Firestore/core/src/api/stages.h index a3c7303ae92..e8bf34ac70a 100644 --- a/Firestore/core/src/api/stages.h +++ b/Firestore/core/src/api/stages.h @@ -252,22 +252,58 @@ class RemoveFieldsStage : public Stage { class ReplaceWith : public Stage { public: - explicit ReplaceWith(std::shared_ptr expr); + class ReplaceMode { + public: + enum Mode { + FULL_REPLACE, + MERGE_PREFER_NEST, + MERGE_PREFER_PARENT = FULL_REPLACE + }; + + explicit ReplaceMode(Mode mode) : mode_(mode) { + } + google_firestore_v1_Value to_proto() const; + + private: + Mode mode_; + }; + + explicit ReplaceWith( + std::shared_ptr expr, + ReplaceMode mode = ReplaceMode(ReplaceMode::Mode::FULL_REPLACE)); ~ReplaceWith() override = default; google_firestore_v1_Pipeline_Stage to_proto() const override; private: std::shared_ptr expr_; + ReplaceMode mode_; }; class Sample : public Stage { public: - Sample(std::string type, int64_t count, double percentage); + class SampleMode { + public: + enum Mode { DOCUMENTS = 0, PERCENT }; + + explicit SampleMode(Mode mode) : mode_(mode) { + } + + Mode mode() const { + return mode_; + } + + google_firestore_v1_Value to_proto() const; + + private: + Mode mode_; + }; + + Sample(SampleMode mode, int64_t count, double percentage); ~Sample() override = default; google_firestore_v1_Pipeline_Stage to_proto() const override; private: - std::string type_; + SampleMode mode_; int64_t count_; double percentage_; }; @@ -284,26 +320,29 @@ class Union : public Stage { class Unnest : public Stage { public: - Unnest(std::shared_ptr field, absl::optional index_field); + Unnest(std::shared_ptr field, + std::shared_ptr alias, + absl::optional> index_field); ~Unnest() override = default; google_firestore_v1_Pipeline_Stage to_proto() const override; private: std::shared_ptr field_; - absl::optional index_field_; + std::shared_ptr alias_; + absl::optional> index_field_; }; -class GenericStage : public Stage { +class RawStage : public Stage { public: - GenericStage(std::string name, - std::vector> params, - std::unordered_map> options); - ~GenericStage() override = default; + RawStage(std::string name, + std::vector params, + std::unordered_map> options); + ~RawStage() override = default; google_firestore_v1_Pipeline_Stage to_proto() const override; private: std::string name_; - std::vector> params_; + std::vector params_; std::unordered_map> options_; };