From c58035c2726310b64e5e9c5ba0aab38354c8614f Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Sat, 11 Apr 2026 15:52:52 -0400 Subject: [PATCH] Add Firestore query filter bindings --- .../Firebase/CloudFirestore/ApiDefinition.cs | 227 ++++++++++++ .../FirebaseRuntimeDriftCases.cs | 326 +++++++++++++++++- .../runtime-drift-cases.json | 11 + 3 files changed, 560 insertions(+), 4 deletions(-) diff --git a/source/Firebase/CloudFirestore/ApiDefinition.cs b/source/Firebase/CloudFirestore/ApiDefinition.cs index 891c031b..0ef81ebc 100644 --- a/source/Firebase/CloudFirestore/ApiDefinition.cs +++ b/source/Firebase/CloudFirestore/ApiDefinition.cs @@ -424,6 +424,201 @@ interface AggregateQuerySnapshot NSObject GetValue (AggregateField aggregateField); } + // @interface FIRFilter : NSObject + [BaseType (typeof (NSObject), Name = "FIRFilter")] + interface Filter + { + // +(FIRFilter * _Nonnull)filterWhereField:(NSString * _Nonnull)field isEqualTo:(id _Nonnull)value; + [Static] + [Export ("filterWhereField:isEqualTo:")] + Filter WhereEqualsTo (string field, NSObject nsValue); + + [Static] + [Wrap ("WhereEqualsTo (field, FromObject (value))")] + Filter WhereEqualsTo (string field, object value); + + // +(FIRFilter * _Nonnull)filterWhereFieldPath:(FIRFieldPath * _Nonnull)path isEqualTo:(id _Nonnull)value; + [Static] + [Export ("filterWhereFieldPath:isEqualTo:")] + Filter WhereEqualsTo (FieldPath path, NSObject nsValue); + + [Static] + [Wrap ("WhereEqualsTo (path, FromObject (value))")] + Filter WhereEqualsTo (FieldPath path, object value); + + // +(FIRFilter * _Nonnull)filterWhereField:(NSString * _Nonnull)field isNotEqualTo:(id _Nonnull)value; + [Static] + [Export ("filterWhereField:isNotEqualTo:")] + Filter WhereNotEqualTo (string field, NSObject nsValue); + + [Static] + [Wrap ("WhereNotEqualTo (field, FromObject (value))")] + Filter WhereNotEqualTo (string field, object value); + + // +(FIRFilter * _Nonnull)filterWhereFieldPath:(FIRFieldPath * _Nonnull)path isNotEqualTo:(id _Nonnull)value; + [Static] + [Export ("filterWhereFieldPath:isNotEqualTo:")] + Filter WhereNotEqualTo (FieldPath path, NSObject nsValue); + + [Static] + [Wrap ("WhereNotEqualTo (path, FromObject (value))")] + Filter WhereNotEqualTo (FieldPath path, object value); + + // +(FIRFilter * _Nonnull)filterWhereField:(NSString * _Nonnull)field isGreaterThan:(id _Nonnull)value; + [Static] + [Export ("filterWhereField:isGreaterThan:")] + Filter WhereGreaterThan (string field, NSObject nsValue); + + [Static] + [Wrap ("WhereGreaterThan (field, FromObject (value))")] + Filter WhereGreaterThan (string field, object value); + + // +(FIRFilter * _Nonnull)filterWhereFieldPath:(FIRFieldPath * _Nonnull)path isGreaterThan:(id _Nonnull)value; + [Static] + [Export ("filterWhereFieldPath:isGreaterThan:")] + Filter WhereGreaterThan (FieldPath path, NSObject nsValue); + + [Static] + [Wrap ("WhereGreaterThan (path, FromObject (value))")] + Filter WhereGreaterThan (FieldPath path, object value); + + // +(FIRFilter * _Nonnull)filterWhereField:(NSString * _Nonnull)field isGreaterThanOrEqualTo:(id _Nonnull)value; + [Static] + [Export ("filterWhereField:isGreaterThanOrEqualTo:")] + Filter WhereGreaterThanOrEqualsTo (string field, NSObject nsValue); + + [Static] + [Wrap ("WhereGreaterThanOrEqualsTo (field, FromObject (value))")] + Filter WhereGreaterThanOrEqualsTo (string field, object value); + + // +(FIRFilter * _Nonnull)filterWhereFieldPath:(FIRFieldPath * _Nonnull)path isGreaterThanOrEqualTo:(id _Nonnull)value; + [Static] + [Export ("filterWhereFieldPath:isGreaterThanOrEqualTo:")] + Filter WhereGreaterThanOrEqualsTo (FieldPath path, NSObject nsValue); + + [Static] + [Wrap ("WhereGreaterThanOrEqualsTo (path, FromObject (value))")] + Filter WhereGreaterThanOrEqualsTo (FieldPath path, object value); + + // +(FIRFilter * _Nonnull)filterWhereField:(NSString * _Nonnull)field isLessThan:(id _Nonnull)value; + [Static] + [Export ("filterWhereField:isLessThan:")] + Filter WhereLessThan (string field, NSObject nsValue); + + [Static] + [Wrap ("WhereLessThan (field, FromObject (value))")] + Filter WhereLessThan (string field, object value); + + // +(FIRFilter * _Nonnull)filterWhereFieldPath:(FIRFieldPath * _Nonnull)path isLessThan:(id _Nonnull)value; + [Static] + [Export ("filterWhereFieldPath:isLessThan:")] + Filter WhereLessThan (FieldPath path, NSObject nsValue); + + [Static] + [Wrap ("WhereLessThan (path, FromObject (value))")] + Filter WhereLessThan (FieldPath path, object value); + + // +(FIRFilter * _Nonnull)filterWhereField:(NSString * _Nonnull)field isLessThanOrEqualTo:(id _Nonnull)value; + [Static] + [Export ("filterWhereField:isLessThanOrEqualTo:")] + Filter WhereLessThanOrEqualsTo (string field, NSObject nsValue); + + [Static] + [Wrap ("WhereLessThanOrEqualsTo (field, FromObject (value))")] + Filter WhereLessThanOrEqualsTo (string field, object value); + + // +(FIRFilter * _Nonnull)filterWhereFieldPath:(FIRFieldPath * _Nonnull)path isLessThanOrEqualTo:(id _Nonnull)value; + [Static] + [Export ("filterWhereFieldPath:isLessThanOrEqualTo:")] + Filter WhereLessThanOrEqualsTo (FieldPath path, NSObject nsValue); + + [Static] + [Wrap ("WhereLessThanOrEqualsTo (path, FromObject (value))")] + Filter WhereLessThanOrEqualsTo (FieldPath path, object value); + + // +(FIRFilter * _Nonnull)filterWhereField:(NSString * _Nonnull)field arrayContains:(id _Nonnull)value; + [Static] + [Export ("filterWhereField:arrayContains:")] + Filter WhereArrayContains (string field, NSObject nsValue); + + [Static] + [Wrap ("WhereArrayContains (field, FromObject (value))")] + Filter WhereArrayContains (string field, object value); + + // +(FIRFilter * _Nonnull)filterWhereFieldPath:(FIRFieldPath * _Nonnull)path arrayContains:(id _Nonnull)value; + [Static] + [Export ("filterWhereFieldPath:arrayContains:")] + Filter WhereArrayContains (FieldPath path, NSObject nsValue); + + [Static] + [Wrap ("WhereArrayContains (path, FromObject (value))")] + Filter WhereArrayContains (FieldPath path, object value); + + // +(FIRFilter * _Nonnull)filterWhereField:(NSString * _Nonnull)field arrayContainsAny:(NSArray * _Nonnull)values; + [Static] + [Export ("filterWhereField:arrayContainsAny:")] + Filter WhereArrayContainsAny (string field, NSObject [] values); + + [Static] + [Wrap ("WhereArrayContainsAny (field, CloudFirestoreHelper.GetNSObjects (values))")] + Filter WhereArrayContainsAny (string field, object [] values); + + // +(FIRFilter * _Nonnull)filterWhereFieldPath:(FIRFieldPath * _Nonnull)path arrayContainsAny:(NSArray * _Nonnull)values; + [Static] + [Export ("filterWhereFieldPath:arrayContainsAny:")] + Filter WhereArrayContainsAny (FieldPath path, NSObject [] values); + + [Static] + [Wrap ("WhereArrayContainsAny (path, CloudFirestoreHelper.GetNSObjects (values))")] + Filter WhereArrayContainsAny (FieldPath path, object [] values); + + // +(FIRFilter * _Nonnull)filterWhereField:(NSString * _Nonnull)field in:(NSArray * _Nonnull)values; + [Static] + [Export ("filterWhereField:in:")] + Filter WhereFieldIn (string field, NSObject [] values); + + [Static] + [Wrap ("WhereFieldIn (field, CloudFirestoreHelper.GetNSObjects (values))")] + Filter WhereFieldIn (string field, object [] values); + + // +(FIRFilter * _Nonnull)filterWhereFieldPath:(FIRFieldPath * _Nonnull)path in:(NSArray * _Nonnull)values; + [Static] + [Export ("filterWhereFieldPath:in:")] + Filter WhereFieldIn (FieldPath path, NSObject [] values); + + [Static] + [Wrap ("WhereFieldIn (path, CloudFirestoreHelper.GetNSObjects (values))")] + Filter WhereFieldIn (FieldPath path, object [] values); + + // +(FIRFilter * _Nonnull)filterWhereField:(NSString * _Nonnull)field notIn:(NSArray * _Nonnull)values; + [Static] + [Export ("filterWhereField:notIn:")] + Filter WhereFieldNotIn (string field, NSObject [] values); + + [Static] + [Wrap ("WhereFieldNotIn (field, CloudFirestoreHelper.GetNSObjects (values))")] + Filter WhereFieldNotIn (string field, object [] values); + + // +(FIRFilter * _Nonnull)filterWhereFieldPath:(FIRFieldPath * _Nonnull)path notIn:(NSArray * _Nonnull)values; + [Static] + [Export ("filterWhereFieldPath:notIn:")] + Filter WhereFieldNotIn (FieldPath path, NSObject [] values); + + [Static] + [Wrap ("WhereFieldNotIn (path, CloudFirestoreHelper.GetNSObjects (values))")] + Filter WhereFieldNotIn (FieldPath path, object [] values); + + // +(FIRFilter * _Nonnull)orFilterWithFilters:(NSArray * _Nonnull)filters; + [Static] + [Export ("orFilterWithFilters:")] + Filter Or (Filter [] filters); + + // +(FIRFilter * _Nonnull)andFilterWithFilters:(NSArray * _Nonnull)filters; + [Static] + [Export ("andFilterWithFilters:")] + Filter And (Filter [] filters); + } + // void (^)(id _Nullable result, NSError *_Nullable error) delegate void TransactionCompletionHandler ([NullAllowed] NSObject result, [NullAllowed] NSError error); // (nullable void (^)(FIRLoadBundleTaskProgress *_Nullable progress, NSError *_Nullable error) @@ -667,6 +862,10 @@ interface Query [Export ("addSnapshotListenerWithIncludeMetadataChanges:listener:")] IListenerRegistration AddSnapshotListener (bool includeMetadataChanges, QuerySnapshotHandler listener); + // -(FIRQuery * _Nonnull)queryWhereFilter:(FIRFilter * _Nonnull)filter; + [Export ("queryWhereFilter:")] + Query FilteredBy (Filter filter); + // -(FIRQuery * _Nonnull)queryWhereField:(NSString * _Nonnull)field isEqualTo:(id _Nonnull)value; [Export ("queryWhereField:isEqualTo:")] Query WhereEqualsTo (string field, NSObject nsValue); @@ -681,6 +880,20 @@ interface Query [Wrap ("WhereEqualsTo (path, FromObject (value))")] Query WhereEqualsTo (FieldPath path, object value); + // -(FIRQuery * _Nonnull)queryWhereField:(NSString * _Nonnull)field isNotEqualTo:(id _Nonnull)value; + [Export ("queryWhereField:isNotEqualTo:")] + Query WhereNotEqualTo (string field, NSObject nsValue); + + [Wrap ("WhereNotEqualTo (field, FromObject (value))")] + Query WhereNotEqualTo (string field, object value); + + // -(FIRQuery * _Nonnull)queryWhereFieldPath:(FIRFieldPath * _Nonnull)path isNotEqualTo:(id _Nonnull)value; + [Export ("queryWhereFieldPath:isNotEqualTo:")] + Query WhereNotEqualTo (FieldPath path, NSObject nsValue); + + [Wrap ("WhereNotEqualTo (path, FromObject (value))")] + Query WhereNotEqualTo (FieldPath path, object value); + // -(FIRQuery * _Nonnull)queryWhereField:(NSString * _Nonnull)field isLessThan:(id _Nonnull)value; [Export ("queryWhereField:isLessThan:")] Query WhereLessThan (string field, NSObject nsValue); @@ -779,6 +992,20 @@ interface Query [Wrap ("WhereFieldIn (path, CloudFirestoreHelper.GetNSObjects (values))")] Query WhereFieldIn (FieldPath path, object [] values); + // -(FIRQuery * _Nonnull)queryWhereField:(NSString * _Nonnull)field notIn:(NSArray * _Nonnull)values; + [Export ("queryWhereField:notIn:")] + Query WhereFieldNotIn (string field, NSObject [] values); + + [Wrap ("WhereFieldNotIn (field, CloudFirestoreHelper.GetNSObjects (values))")] + Query WhereFieldNotIn (string field, object [] values); + + // -(FIRQuery * _Nonnull)queryWhereFieldPath:(FIRFieldPath * _Nonnull)path notIn:(NSArray * _Nonnull)values; + [Export ("queryWhereFieldPath:notIn:")] + Query WhereFieldNotIn (FieldPath path, NSObject [] values); + + [Wrap ("WhereFieldNotIn (path, CloudFirestoreHelper.GetNSObjects (values))")] + Query WhereFieldNotIn (FieldPath path, object [] values); + // -(FIRQuery * _Nonnull)queryFilteredUsingPredicate:(NSPredicate * _Nonnull)predicate; [Export ("queryFilteredUsingPredicate:")] Query FilteredBy (NSPredicate predicate); diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs index 5b7ace22..e9d09afa 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs @@ -60,6 +60,12 @@ using ObjCRuntime; #endif +#if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFIRESTORE_QUERY_FILTERS +using Firebase.CloudFirestore; +using Foundation; +using ObjCRuntime; +#endif + #if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFUNCTIONS_USEFUNCTIONSEMULATORORIGIN using Firebase.CloudFunctions; using Foundation; @@ -982,6 +988,317 @@ void OnMarshalObjectiveCException(object? sender, MarshalObjectiveCExceptionEven } #endif +#if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFIRESTORE_QUERY_FILTERS + static async Task VerifyCloudFirestoreQueryFiltersAsync() + { + const string queryWhereFilterSelector = "queryWhereFilter:"; + const string queryWhereFieldNotEqualSelector = "queryWhereField:isNotEqualTo:"; + const string queryWhereFieldPathNotEqualSelector = "queryWhereFieldPath:isNotEqualTo:"; + const string queryWhereFieldNotInSelector = "queryWhereField:notIn:"; + const string queryWhereFieldPathNotInSelector = "queryWhereFieldPath:notIn:"; + const string filterWhereFieldNotInSelector = "filterWhereField:notIn:"; + + var expectedQuerySignatures = new (string MethodName, Type[] ParameterTypes, string Selector)[] + { + ( + nameof(Query.FilteredBy), + new[] { typeof(Filter) }, + queryWhereFilterSelector), + ( + nameof(Query.WhereNotEqualTo), + new[] { typeof(string), typeof(NSObject) }, + queryWhereFieldNotEqualSelector), + ( + nameof(Query.WhereNotEqualTo), + new[] { typeof(FieldPath), typeof(NSObject) }, + queryWhereFieldPathNotEqualSelector), + ( + nameof(Query.WhereFieldNotIn), + new[] { typeof(string), typeof(NSObject[]) }, + queryWhereFieldNotInSelector), + ( + nameof(Query.WhereFieldNotIn), + new[] { typeof(FieldPath), typeof(NSObject[]) }, + queryWhereFieldPathNotInSelector), + }; + + foreach (var (methodName, parameterTypes, selector) in expectedQuerySignatures) + { + var signature = typeof(Query).GetMethod( + methodName, + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: parameterTypes, + modifiers: null); + if (signature?.ReturnType != typeof(Query)) + { + throw new InvalidOperationException( + $"Expected managed API '{typeof(Query).FullName}.{methodName}({string.Join(", ", parameterTypes.Select(type => type.FullName))})' " + + $"to return '{typeof(Query).FullName}' for selector '{selector}', observed '{signature?.ReturnType.FullName ?? ""}'."); + } + } + + var expectedFilterSignatures = new (string MethodName, Type[] ParameterTypes, string Selector)[] + { + ( + nameof(Filter.WhereNotEqualTo), + new[] { typeof(string), typeof(NSObject) }, + "filterWhereField:isNotEqualTo:"), + ( + nameof(Filter.WhereFieldNotIn), + new[] { typeof(string), typeof(NSObject[]) }, + filterWhereFieldNotInSelector), + }; + + foreach (var (methodName, parameterTypes, selector) in expectedFilterSignatures) + { + var signature = typeof(Filter).GetMethod( + methodName, + BindingFlags.Static | BindingFlags.Public, + binder: null, + types: parameterTypes, + modifiers: null); + if (signature?.ReturnType != typeof(Filter)) + { + throw new InvalidOperationException( + $"Expected managed API '{typeof(Filter).FullName}.{methodName}({string.Join(", ", parameterTypes.Select(type => type.FullName))})' " + + $"to return '{typeof(Filter).FullName}' for selector '{selector}', observed '{signature?.ReturnType.FullName ?? ""}'."); + } + } + + var firestore = Firestore.SharedInstance; + if (firestore is null) + { + throw new InvalidOperationException("Firebase.CloudFirestore.Firestore.SharedInstance returned null after App.Configure()."); + } + + var collectionName = $"codex-query-filter-e2e-{Guid.NewGuid():N}"; + var collection = firestore.GetCollection(collectionName); + if (collection is null) + { + throw new InvalidOperationException("Firebase.CloudFirestore.Firestore.GetCollection returned null."); + } + + if (!collection.RespondsToSelector(new Selector(queryWhereFilterSelector))) + { + throw new InvalidOperationException($"Native FIRQuery does not respond to expected selector '{queryWhereFilterSelector}'."); + } + + if (!collection.RespondsToSelector(new Selector(queryWhereFieldNotEqualSelector))) + { + throw new InvalidOperationException($"Native FIRQuery does not respond to expected selector '{queryWhereFieldNotEqualSelector}'."); + } + + if (!collection.RespondsToSelector(new Selector(queryWhereFieldPathNotEqualSelector))) + { + throw new InvalidOperationException($"Native FIRQuery does not respond to expected selector '{queryWhereFieldPathNotEqualSelector}'."); + } + + if (!collection.RespondsToSelector(new Selector(queryWhereFieldNotInSelector))) + { + throw new InvalidOperationException($"Native FIRQuery does not respond to expected selector '{queryWhereFieldNotInSelector}'."); + } + + if (!collection.RespondsToSelector(new Selector(queryWhereFieldPathNotInSelector))) + { + throw new InvalidOperationException($"Native FIRQuery does not respond to expected selector '{queryWhereFieldPathNotInSelector}'."); + } + + NSException? marshaledException = null; + MarshalObjectiveCExceptionMode? marshaledExceptionMode = null; + + void OnMarshalObjectiveCException(object? sender, MarshalObjectiveCExceptionEventArgs args) + { + marshaledException ??= args.Exception; + marshaledExceptionMode ??= args.ExceptionMode; + } + + Runtime.MarshalObjectiveCException += OnMarshalObjectiveCException; + try + { + Query stringNotEqualQuery; + Query pathNotEqualQuery; + Query stringNotInQuery; + Query pathNotInQuery; + Query filterNotInQuery; + + try + { + await SetSeedDocumentAsync("alpha", "include", "red", 1); + await SetSeedDocumentAsync("beta", "exclude", "blue", 2); + await SetSeedDocumentAsync("gamma", "include", "green", 3); + + using var groupPath = new FieldPath(new[] { "group" }); + using var colorPath = new FieldPath(new[] { "color" }); + + stringNotEqualQuery = collection.WhereNotEqualTo("group", "exclude"); + pathNotEqualQuery = collection.WhereNotEqualTo(groupPath, "exclude"); + stringNotInQuery = collection.WhereFieldNotIn("color", new object[] { "red", "blue" }); + pathNotInQuery = collection.WhereFieldNotIn(colorPath, new object[] { "red", "blue" }); + + var notInFilter = Filter.WhereFieldNotIn("color", new object[] { "red", "blue" }); + filterNotInQuery = collection.FilteredBy(notInFilter); + } + catch (ObjCException ex) + { + throw new InvalidOperationException( + $"Firestore query filter selectors should not throw after the missing bindings are added, but observed {ex.GetType().FullName}. " + + $"Selectors exercised: setData:completion:, '{queryWhereFilterSelector}', '{queryWhereFieldNotEqualSelector}', " + + $"'{queryWhereFieldPathNotEqualSelector}', '{queryWhereFieldNotInSelector}', '{queryWhereFieldPathNotInSelector}', '{filterWhereFieldNotInSelector}'. " + + $"NSException.Name: {FormatDetail(marshaledException?.Name?.ToString())}. " + + $"NSException.Reason: {FormatDetail(marshaledException?.Reason)}. " + + $"Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}.", + ex); + } + + if (marshaledException is not null) + { + throw new InvalidOperationException( + $"Firestore query filter selectors completed, but Runtime.MarshalObjectiveCException captured unexpected NSException.Name '{marshaledException.Name}'. " + + $"Reason: {FormatDetail(marshaledException.Reason)}. Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}."); + } + + var stringNotEqualCount = await GetServerCountAsync(stringNotEqualQuery, "string notEqualTo query"); + var pathNotEqualCount = await GetServerCountAsync(pathNotEqualQuery, "FieldPath notEqualTo query"); + var stringNotInCount = await GetServerCountAsync(stringNotInQuery, "string notIn query"); + var pathNotInCount = await GetServerCountAsync(pathNotInQuery, "FieldPath notIn query"); + var filterNotInCount = await GetServerCountAsync(filterNotInQuery, "Filter notIn query"); + + RequireCount(stringNotEqualCount, 2, "string notEqualTo query"); + RequireCount(pathNotEqualCount, 2, "FieldPath notEqualTo query"); + RequireCount(stringNotInCount, 1, "string notIn query"); + RequireCount(pathNotInCount, 1, "FieldPath notIn query"); + RequireCount(filterNotInCount, 1, "Filter notIn query"); + + return + $"Firestore query filter APIs crossed the native selector boundary and reached the backend. " + + $"Collection: {collectionName}. " + + $"Counts: string notEqualTo={stringNotEqualCount}, FieldPath notEqualTo={pathNotEqualCount}, " + + $"string notIn={stringNotInCount}, FieldPath notIn={pathNotInCount}, Filter notIn={filterNotInCount}."; + } + finally + { + Runtime.MarshalObjectiveCException -= OnMarshalObjectiveCException; + } + + async Task SetSeedDocumentAsync(string documentId, string group, string color, int score) + { + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completionInvoked = false; + NSError? callbackError = null; + + var document = collection.GetDocument(documentId); + using var groupKey = new NSString("group"); + using var colorKey = new NSString("color"); + using var scoreKey = new NSString("score"); + using var groupValue = new NSString(group); + using var colorValue = new NSString(color); + using var scoreValue = NSNumber.FromInt32(score); + using var data = NSDictionary.FromObjectsAndKeys( + new NSObject[] { groupValue, colorValue, scoreValue }, + new[] { groupKey, colorKey, scoreKey }, + 3); + + document.SetData(data, error => + { + completionInvoked = true; + callbackError = error; + completionSource.TrySetResult(error); + }); + + var completedTask = await Task.WhenAny(completionSource.Task, Task.Delay(AsyncTimeout)); + if (completedTask != completionSource.Task) + { + throw new TimeoutException( + $"Cloud Firestore seed document '{documentId}' write did not invoke its completion callback within {AsyncTimeout.TotalSeconds} seconds."); + } + + if (!completionInvoked) + { + throw new InvalidOperationException( + $"Cloud Firestore seed document '{documentId}' write completed without throwing, but the completion callback was never marked as invoked."); + } + + var completedError = await completionSource.Task; + if (!ReferenceEquals(callbackError, completedError)) + { + throw new InvalidOperationException( + $"Cloud Firestore seed document '{documentId}' write callback state did not match the completed task payload."); + } + + if (completedError is not null) + { + throw new InvalidOperationException( + $"Cloud Firestore seed document '{documentId}' write reached native completion with Firebase error {FormatNSError(completedError)}."); + } + } + + async Task GetServerCountAsync(Query query, string label) + { + var completionSource = new TaskCompletionSource<(QuerySnapshot? Snapshot, NSError? Error)>(TaskCreationOptions.RunContinuationsAsynchronously); + var completionInvoked = false; + QuerySnapshot? callbackSnapshot = null; + NSError? callbackError = null; + + query.GetDocuments(FirestoreSource.Server, (snapshot, error) => + { + completionInvoked = true; + callbackSnapshot = snapshot; + callbackError = error; + completionSource.TrySetResult((snapshot, error)); + }); + + var completedTask = await Task.WhenAny(completionSource.Task, Task.Delay(AsyncTimeout)); + if (completedTask != completionSource.Task) + { + throw new TimeoutException( + $"Cloud Firestore {label} did not invoke its completion callback within {AsyncTimeout.TotalSeconds} seconds."); + } + + if (!completionInvoked) + { + throw new InvalidOperationException( + $"Cloud Firestore {label} completed without throwing, but the completion callback was never marked as invoked."); + } + + var (completedSnapshot, completedError) = await completionSource.Task; + if (!ReferenceEquals(callbackSnapshot, completedSnapshot) || !ReferenceEquals(callbackError, completedError)) + { + throw new InvalidOperationException($"Cloud Firestore {label} callback state did not match the completed task payload."); + } + + if (completedError is not null) + { + throw new InvalidOperationException( + $"Cloud Firestore {label} reached native completion with Firebase error {FormatNSError(completedError)}."); + } + + if (completedSnapshot is null) + { + throw new InvalidOperationException($"Cloud Firestore {label} completed without either a snapshot or an NSError."); + } + + if (marshaledException is not null) + { + throw new InvalidOperationException( + $"Cloud Firestore {label} completed, but Runtime.MarshalObjectiveCException captured unexpected NSException.Name '{marshaledException.Name}'. " + + $"Reason: {FormatDetail(marshaledException.Reason)}. Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}."); + } + + return (int)completedSnapshot.Count; + } + + static void RequireCount(int actual, int expected, string label) + { + if (actual != expected) + { + throw new InvalidOperationException( + $"Cloud Firestore {label} returned {actual} documents; expected {expected} after deterministic seed writes."); + } + } + } +#endif + #if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFIRESTORE_AGGREGATE_QUERY static async Task VerifyCloudFirestoreAggregateQueryAsync() { @@ -1285,10 +1602,6 @@ void OnMarshalObjectiveCException(object? sender, MarshalObjectiveCExceptionEven } } - static string FormatNSError(NSError error) - { - return $"{error.Domain} ({error.Code}): {error.LocalizedDescription}"; - } #endif #if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFUNCTIONS_USEFUNCTIONSEMULATORORIGIN @@ -1446,6 +1759,11 @@ void OnMarshalObjectiveCException(object? sender, MarshalObjectiveCExceptionEven } #endif + static string FormatNSError(Foundation.NSError error) + { + return $"{error.Domain} ({error.Code}): {error.LocalizedDescription}"; + } + static string FormatDetail(string? value) { return string.IsNullOrWhiteSpace(value) ? "" : value; diff --git a/tests/E2E/Firebase.Foundation/runtime-drift-cases.json b/tests/E2E/Firebase.Foundation/runtime-drift-cases.json index 83f9ebb5..23aa1190 100644 --- a/tests/E2E/Firebase.Foundation/runtime-drift-cases.json +++ b/tests/E2E/Firebase.Foundation/runtime-drift-cases.json @@ -104,6 +104,17 @@ } ] }, + { + "id": "cloudfirestore-query-filters", + "method": "VerifyCloudFirestoreQueryFiltersAsync", + "bindingPackage": "AdamE.Firebase.iOS.CloudFirestore", + "packages": [ + { + "id": "AdamE.Firebase.iOS.CloudFirestore", + "version": "12.6.0" + } + ] + }, { "id": "cloudfunctions-usefunctionsemulatororigin", "method": "VerifyCloudFunctionsUseFunctionsEmulatorOriginAsync",