From 8b3ede93899e4e412057fafd5fd7ecc19268615f Mon Sep 17 00:00:00 2001 From: David Echomgbe Date: Sat, 28 Sep 2024 11:10:14 -0400 Subject: [PATCH] Fix order by multiple fields and Filter support. This PR addresses the following: 1. Fixes an issue when ordering by multiple fields with different sort orders 2. Closes #293 with support for Filter.or and Filter.and --- lib/src/mock_query.dart | 321 ++++++++++++++------ test/mock_query_test.dart | 604 +++++++++++++++++++++++++++++++++++++- 2 files changed, 826 insertions(+), 99 deletions(-) diff --git a/lib/src/mock_query.dart b/lib/src/mock_query.dart index e3b6068..f5da702 100644 --- a/lib/src/mock_query.dart +++ b/lib/src/mock_query.dart @@ -71,59 +71,71 @@ class MockQuery extends FakeQueryWithParent { @override Query orderBy(dynamic field, {bool descending = false}) { if (parameters['orderedBy'] == null) parameters['orderedBy'] = []; + if (parameters['orderedByDirection'] == null) { + parameters['orderedByDirection'] = []; + } parameters['orderedBy'].add(field); + parameters['orderedByDirection'].add(descending); return MockQuery(this, (docs) { final sortedList = List.of(docs); final fields = (parameters['orderedBy'] ?? []); - for (var index = 0; index < fields.length; index++) { - sortedList.sort((d1, d2) { - final field = fields[index]; - // no need to sort if previous order by value are different - final shouldSort = index == 0 || - d1.get(fields[index - 1]) == d2.get(fields[index - 1]); - if (!shouldSort) { - return 0; - } + final directions = (parameters['orderedByDirection'] ?? []); - dynamic value1; - if (field is String) { - try { - value1 = d1.get(field) as Comparable; - } catch (error) { - // This catch catches the case when the key/value does not exist - // and the case when the value is null, and as a result not a - // Comparable. - value1 = null; - } - } else if (field == FieldPath.documentId) { - value1 = d1.id; - } - dynamic value2; - if (field is String) { - try { - value2 = d2.get(field); - } catch (error) { - // This catch catches only the case when the key/value does not - // exist. - value2 = null; - } - } else if (field == FieldPath.documentId) { - value2 = d2.id; - } - if (value1 == null && value2 == null) { - return 0; - } - // Return null values first. - if (value1 == null) { - return -1; + if (fields.isEmpty) { + return sortedList; + } + + int doCompare(dynamic field, bool descending, DocumentSnapshot d1, + DocumentSnapshot d2) { + dynamic value1; + if (field is String) { + try { + value1 = d1.get(field) as Comparable; + } catch (error) { + // This catch catches the case when the key/value does not exist + // and the case when the value is null, and as a result not a + // Comparable. + value1 = null; } - if (value2 == null) { - return 1; + } else if (field == FieldPath.documentId) { + value1 = d1.id; + } + dynamic value2; + if (field is String) { + try { + value2 = d2.get(field); + } catch (error) { + // This catch catches only the case when the key/value does not + // exist. + value2 = null; } - final compare = value1.compareTo(value2); - return descending ? -compare : compare; - }); + } else if (field == FieldPath.documentId) { + value2 = d2.id; + } + if (value1 == null && value2 == null) { + return 0; + } + // Return null values first. + if (value1 == null) { + return -1; + } + if (value2 == null) { + return 1; + } + final compare = value1.compareTo(value2); + return descending ? -compare : compare; } + + sortedList.sort((d1, d2) { + var compare = doCompare(fields.first, directions.first, d1, d2); + var index = 1; + while (compare == 0 && index < fields.length) { + compare = doCompare(fields[index], directions[index], d1, d2); + index++; + } + return compare; + }); + return sortedList; }); } @@ -239,55 +251,182 @@ class MockQuery extends FakeQueryWithParent { Iterable? whereIn, Iterable? whereNotIn, bool? isNull}) { - final operation = (List> docs) => - docs.where((document) { - dynamic value; - if (field is String || field is FieldPath) { - // DocumentSnapshot.get can throw StateError - // if field cannot be found. In query it does not matter, - // so catch and set value to null. - try { - value = document.get(field); - } on StateError catch (_) { - value = null; - } - } else if (field == FieldPath.documentId) { - value = document.id; - - // transform any DocumentReference in the query to id. - isEqualTo = transformValue(isEqualTo, documentReferenceToId); - isNotEqualTo = transformValue(isNotEqualTo, documentReferenceToId); - isLessThan = transformValue(isLessThan, documentReferenceToId); - isLessThanOrEqualTo = - transformValue(isLessThanOrEqualTo, documentReferenceToId); - isGreaterThan = - transformValue(isGreaterThan, documentReferenceToId); - isGreaterThanOrEqualTo = - transformValue(isGreaterThanOrEqualTo, documentReferenceToId); - arrayContains = - transformValue(arrayContains, documentReferenceToId); - arrayContainsAny = - transformValue(arrayContainsAny, documentReferenceToId); - whereIn = transformValue(whereIn, documentReferenceToId); - whereNotIn = transformValue(whereNotIn, documentReferenceToId); - isNull = transformValue(isNull, documentReferenceToId); - } - return _valueMatchesQuery(value, - isEqualTo: isEqualTo, - isNotEqualTo: isNotEqualTo, - isLessThan: isLessThan, - isLessThanOrEqualTo: isLessThanOrEqualTo, - isGreaterThan: isGreaterThan, - isGreaterThanOrEqualTo: isGreaterThanOrEqualTo, - arrayContains: arrayContains, - arrayContainsAny: arrayContainsAny, - whereIn: whereIn, - whereNotIn: whereNotIn, - isNull: isNull); - }).toList(); + if (field is Filter) { + final predicate = _buildFilterPredicate(field.toJson()); + return MockQuery(this, (docs) => docs.where(predicate).toList()); + } + + final operation = (List> docs) => docs + .where((document) => _wherePredicate(document, field, + isEqualTo: isEqualTo, + isNotEqualTo: isNotEqualTo, + isLessThan: isLessThan, + isLessThanOrEqualTo: isLessThanOrEqualTo, + isGreaterThan: isGreaterThan, + isGreaterThanOrEqualTo: isGreaterThanOrEqualTo, + arrayContains: arrayContains, + arrayContainsAny: arrayContainsAny, + whereIn: whereIn, + whereNotIn: whereNotIn, + isNull: isNull)) + .toList(); return MockQuery(this, operation); } + bool Function(DocumentSnapshot document) _buildFilterPredicate( + Map filterMap) { + // FilterQuery + if (filterMap.containsKey('fieldPath')) { + Object? isEqualTo; + Object? isNotEqualTo; + Object? isLessThan; + Object? isLessThanOrEqualTo; + Object? isGreaterThan; + Object? isGreaterThanOrEqualTo; + Object? arrayContains; + Iterable? arrayContainsAny; + Iterable? whereIn; + Iterable? whereNotIn; + bool? isNull; + + switch (filterMap['op']) { + case '==': + if (filterMap['value'] == null) { + isNull = true; + } else { + isEqualTo = filterMap['value']; + } + break; + case '!=': + if (filterMap['value'] == null) { + isNull = false; + } else { + isNotEqualTo = filterMap['value']; + } + break; + case '<': + isLessThan = filterMap['value']; + break; + case '<=': + isLessThanOrEqualTo = filterMap['value']; + break; + case '>': + isGreaterThan = filterMap['value']; + break; + case '>=': + isGreaterThanOrEqualTo = filterMap['value']; + break; + case 'array-contains': + arrayContains = filterMap['value']; + break; + case 'array-contains-any': + arrayContainsAny = filterMap['value'] as List; + break; + case 'in': + whereIn = filterMap['value'] as List; + break; + case 'not-in': + whereNotIn = filterMap['value'] as List; + break; + default: + throw UnimplementedError( + 'Operator ${filterMap['op']} is not yet supported'); + } + + return (document) => _wherePredicate( + document, + filterMap['fieldPath']!, + isEqualTo: isEqualTo, + isNotEqualTo: isNotEqualTo, + isLessThan: isLessThan, + isLessThanOrEqualTo: isLessThanOrEqualTo, + isGreaterThan: isGreaterThan, + isGreaterThanOrEqualTo: isGreaterThanOrEqualTo, + arrayContains: arrayContains, + arrayContainsAny: arrayContainsAny, + whereIn: whereIn, + whereNotIn: whereNotIn, + isNull: isNull, + ); + } + + // FilterOperator + + final queries = (filterMap['queries'] as List).cast>(); + final predicates = )>[]; + + for (final queryMap in queries) { + predicates.add(_buildFilterPredicate(queryMap)); + } + + if (filterMap['op'].toString().toLowerCase() == 'or') { + // OR operator + return (document) => predicates.any((predicate) => predicate(document)); + } else { + // AND operator + return (document) => predicates.every((predicate) => predicate(document)); + } + } + + bool _wherePredicate( + DocumentSnapshot document, + Object field, { + dynamic isEqualTo, + dynamic isNotEqualTo, + dynamic isLessThan, + dynamic isLessThanOrEqualTo, + dynamic isGreaterThan, + dynamic isGreaterThanOrEqualTo, + dynamic arrayContains, + Iterable? arrayContainsAny, + Iterable? whereIn, + Iterable? whereNotIn, + bool? isNull, + }) { + dynamic value; + if (field is String || field is FieldPath) { + // DocumentSnapshot.get can throw StateError + // if field cannot be found. In query it does not matter, + // so catch and set value to null. + try { + value = document.get(field); + } on StateError catch (_) { + value = null; + } + } else if (field == FieldPath.documentId) { + value = document.id; + + // transform any DocumentReference in the query to id. + isEqualTo = transformValue(isEqualTo, documentReferenceToId); + isNotEqualTo = transformValue(isNotEqualTo, documentReferenceToId); + isLessThan = transformValue(isLessThan, documentReferenceToId); + isLessThanOrEqualTo = + transformValue(isLessThanOrEqualTo, documentReferenceToId); + isGreaterThan = transformValue(isGreaterThan, documentReferenceToId); + isGreaterThanOrEqualTo = + transformValue(isGreaterThanOrEqualTo, documentReferenceToId); + arrayContains = transformValue(arrayContains, documentReferenceToId); + arrayContainsAny = + transformValue(arrayContainsAny, documentReferenceToId); + whereIn = transformValue(whereIn, documentReferenceToId); + whereNotIn = transformValue(whereNotIn, documentReferenceToId); + isNull = transformValue(isNull, documentReferenceToId); + } + + return _valueMatchesQuery(value, + isEqualTo: isEqualTo, + isNotEqualTo: isNotEqualTo, + isLessThan: isLessThan, + isLessThanOrEqualTo: isLessThanOrEqualTo, + isGreaterThan: isGreaterThan, + isGreaterThanOrEqualTo: isGreaterThanOrEqualTo, + arrayContains: arrayContains, + arrayContainsAny: arrayContainsAny, + whereIn: whereIn, + whereNotIn: whereNotIn, + isNull: isNull); + } + bool _valueMatchesQuery(dynamic value, {dynamic isEqualTo, dynamic isNotEqualTo, diff --git a/test/mock_query_test.dart b/test/mock_query_test.dart index fe39e0d..efb601e 100644 --- a/test/mock_query_test.dart +++ b/test/mock_query_test.dart @@ -63,6 +63,45 @@ void main() { emits(QuerySnapshotMatcher([]))); }); + test('Where Filter(field, isGreaterThan: ...)', () async { + final instance = FakeFirebaseFirestore(); + final now = DateTime.now(); + await instance.collection('messages').add({ + 'content': 'hello!', + 'uid': uid, + 'timestamp': now, + }); + // Test that there is one result. + expect( + instance + .collection('messages') + .where(Filter('timestamp', + isGreaterThan: now.subtract(Duration(seconds: 1)))) + .snapshots(), + emits(QuerySnapshotMatcher([ + DocumentSnapshotMatcher.onData({ + 'content': 'hello!', + 'uid': uid, + 'timestamp': Timestamp.fromDate(now), + }) + ]))); + // Filter out everything and check that there is no result. + expect( + instance + .collection('messages') + .where(Filter('timestamp', + isGreaterThan: now.add(Duration(seconds: 1)))) + .snapshots(), + emits(QuerySnapshotMatcher([]))); + // Test on missing properties. + expect( + instance + .collection('messages') + .where(Filter('length', isGreaterThan: 5)) + .snapshots(), + emits(QuerySnapshotMatcher([]))); + }); + test('isLessThanOrEqualTo', () async { final instance = FakeFirebaseFirestore(); final now = DateTime.now(); @@ -144,6 +183,88 @@ void main() { emits(QuerySnapshotMatcher([]))); }); + test('isLessThanOrEqualTo with Filter', () async { + final instance = FakeFirebaseFirestore(); + final now = DateTime.now(); + final before = now.subtract(Duration(seconds: 1)); + final after = now.add(Duration(seconds: 1)); + await instance.collection('messages').add({ + 'content': 'before', + 'timestamp': before, + }); + await instance.collection('messages').add({ + 'content': 'during', + 'timestamp': now, + }); + await instance.collection('messages').add({ + 'content': 'after', + 'timestamp': after, + }); + // Test filtering. + expect( + instance + .collection('messages') + .where(Filter('timestamp', isLessThanOrEqualTo: now)) + .snapshots(), + emits(QuerySnapshotMatcher([ + DocumentSnapshotMatcher.onData({ + 'content': 'before', + 'timestamp': Timestamp.fromDate(before), + }), + DocumentSnapshotMatcher.onData({ + 'content': 'during', + 'timestamp': Timestamp.fromDate(now), + }), + ]))); + expect( + instance + .collection('messages') + .where(Filter('timestamp', + isLessThanOrEqualTo: now.subtract(Duration(seconds: 2)))) + .snapshots(), + emits(QuerySnapshotMatcher([]))); + expect( + instance + .collection('messages') + .where(Filter('timestamp', isLessThan: now)) + .snapshots(), + emits(QuerySnapshotMatcher([ + DocumentSnapshotMatcher.onData({ + 'content': 'before', + 'timestamp': Timestamp.fromDate(before), + }), + ]))); + expect( + instance + .collection('messages') + .where(Filter('timestamp', + isLessThan: now.subtract(Duration(seconds: 2)))) + .snapshots(), + emits(QuerySnapshotMatcher([]))); + expect( + instance + .collection('messages') + .where(Filter('timestamp', isGreaterThanOrEqualTo: now)) + .snapshots(), + emits(QuerySnapshotMatcher([ + DocumentSnapshotMatcher.onData({ + 'content': 'during', + 'timestamp': Timestamp.fromDate(now), + }), + DocumentSnapshotMatcher.onData({ + 'content': 'after', + 'timestamp': Timestamp.fromDate(after), + }), + ]))); + expect( + instance + .collection('messages') + .where(Filter('timestamp', + isGreaterThanOrEqualTo: now.add(Duration(seconds: 2)))) + .snapshots(), + emits(QuerySnapshotMatcher([]))); + }); + test('isEqualTo, orderBy, limit and getDocuments', () async { final instance = FakeFirebaseFirestore(); final now = DateTime.now(); @@ -178,6 +299,40 @@ void main() { expect(snapshot.docs.first.get('tag'), equals('mostrecent')); }); + test('isEqualTo, orderBy, limit and getDocuments with Filter', () async { + final instance = FakeFirebaseFirestore(); + final now = DateTime.now(); + final bookmarks = + instance.collection('users').doc(uid).collection('bookmarks'); + await bookmarks.add({ + 'hidden': false, + 'timestamp': now, + }); + await bookmarks.add({ + 'tag': 'mostrecent', + 'hidden': false, + 'timestamp': now.add(Duration(days: 1)), + }); + await bookmarks.add({ + 'hidden': false, + 'timestamp': now, + }); + await bookmarks.add({ + 'hidden': true, + 'timestamp': now, + }); + final snapshot = (await instance + .collection('users') + .doc(uid) + .collection('bookmarks') + .where(Filter('hidden', isEqualTo: false)) + .orderBy('timestamp', descending: true) + .limit(2) + .get()); + expect(snapshot.docs.length, equals(2)); + expect(snapshot.docs.first.get('tag'), equals('mostrecent')); + }); + test('isNotEqualTo where clause', () async { final instance = FakeFirebaseFirestore(); final collection = instance.collection('test'); @@ -199,6 +354,27 @@ void main() { expect(hiddenSnapshot.docs.first.get('id'), equals('HIDDEN')); }); + test('isNotEqualTo where clause with Filter', () async { + final instance = FakeFirebaseFirestore(); + final collection = instance.collection('test'); + await collection.add({'hidden': false, 'id': 'HIDDEN'}); + await collection.add({'hidden': true, 'id': 'VISIBLE'}); + + final visibleSnapshot = (await instance + .collection('test') + .where(Filter('hidden', isNotEqualTo: false)) + .get()); + expect(visibleSnapshot.docs.length, equals(1)); + expect(visibleSnapshot.docs.first.get('id'), equals('VISIBLE')); + + final hiddenSnapshot = (await instance + .collection('test') + .where(Filter('hidden', isNotEqualTo: true)) + .get()); + expect(hiddenSnapshot.docs.length, equals(1)); + expect(hiddenSnapshot.docs.first.get('id'), equals('HIDDEN')); + }); + group('isNotEqualTo where clause for non existent', () { test('with no nesting', () async { final instance = FakeFirebaseFirestore(); @@ -296,6 +472,37 @@ void main() { expect(isNullFieldSnapshot.docs.first.get('name'), equals('Tom')); }); + test('isNull where clause with Filter', () async { + final instance = FakeFirebaseFirestore(); + await instance + .collection('contestants') + .add({'name': 'Alice', 'country': 'USA', 'experience': '5'}); + + await instance + .collection('contestants') + .add({'name': 'Tom', 'country': 'USA'}); + + final nonNullFieldSnapshot = (await instance + .collection('contestants') + .where(Filter('country', isNull: false)) + .get()); + expect(nonNullFieldSnapshot.docs.length, equals(2)); + + final isNotNullFieldSnapshot = (await instance + .collection('contestants') + .where(Filter('experience', isNull: false)) + .get()); + expect(isNotNullFieldSnapshot.docs.length, equals(1)); + expect(isNotNullFieldSnapshot.docs.first.get('name'), equals('Alice')); + + final isNullFieldSnapshot = (await instance + .collection('contestants') + .where(Filter('experience', isNull: true)) + .get()); + expect(isNullFieldSnapshot.docs.length, equals(1)); + expect(isNullFieldSnapshot.docs.first.get('name'), equals('Tom')); + }); + test('orderBy returns documents with null fields first', () async { final instance = FakeFirebaseFirestore(); await instance.collection('usercourses').add({'completed_at': null}); @@ -399,6 +606,55 @@ void main() { })); }); + test('Where clause resolves composed keys with Filter', () async { + final instance = FakeFirebaseFirestore(); + await instance.collection('contestants').add({ + 'name': 'Alice', + 'country': 'USA', + 'skills': {'cycling': '1', 'running': ''} + }); + + instance + .collection('contestants') + .where(Filter('skills.cycling', isGreaterThan: '')) + .snapshots() + .listen(expectAsync1((QuerySnapshot snapshot) { + expect(snapshot.docs.length, equals(1)); + })); + + instance + .collection('contestants') + .where(Filter('skills.cycling', isEqualTo: '1')) + .snapshots() + .listen(expectAsync1((QuerySnapshot snapshot) { + expect(snapshot.docs.length, equals(1)); + })); + + instance + .collection('contestants') + .where(Filter('skills.running', isGreaterThan: '')) + .snapshots() + .listen(expectAsync1((QuerySnapshot snapshot) { + expect(snapshot.docs.length, equals(0)); + })); + + instance + .collection('contestants') + .where(Filter('skills.swimming', isEqualTo: '1')) + .snapshots() + .listen(expectAsync1((QuerySnapshot snapshot) { + expect(snapshot.docs.length, equals(0)); + })); + + instance + .collection('contestants') + .where(Filter('skills.swimming', isGreaterThanOrEqualTo: '1')) + .snapshots() + .listen(expectAsync1((QuerySnapshot snapshot) { + expect(snapshot.docs.length, equals(0)); + })); + }); + test('arrayContains', () async { final instance = FakeFirebaseFirestore(); await instance.collection('posts').add({ @@ -431,7 +687,58 @@ void main() { })); }); - test('arrayContainsAny', () async { + test('arrayContainsAny', () async { + final instance = FakeFirebaseFirestore(); + await instance.collection('posts').add({ + 'name': 'Post #1', + 'tags': ['mostrecent', 'interesting', 'coolstuff'], + 'commenters': [111, 222, 333], + }); + await instance.collection('posts').add({ + 'name': 'Post #2', + 'tags': ['mostrecent'], + 'commenters': [111, 222], + }); + await instance.collection('posts').add({ + 'name': 'Post #3', + 'tags': ['mostrecent'], + 'commenters': [111], + }); + await instance.collection('posts').add({ + 'name': 'Post #4', + 'tags': ['mostrecent', 'interesting'], + 'commenters': [222, 333] + }); + instance + .collection('posts') + .where( + 'tags', + arrayContainsAny: toIterable(['interesting', 'mostrecent']), + ) + .snapshots() + .listen(expectAsync1((QuerySnapshot snapshot) { + expect(snapshot.docs.length, equals(4)); + })); + instance + .collection('posts') + .where('commenters', arrayContainsAny: toIterable([222, 333])) + .snapshots() + .listen(expectAsync1((QuerySnapshot snapshot) { + expect(snapshot.docs.length, equals(3)); + })); + instance + .collection('posts') + .where( + 'commenters', + arrayContainsAny: Iterable.generate(31, (index) => index), + ) + .snapshots() + .listen(null, onError: expectAsync1((error) { + expect(error, isA()); + })); + }); + + test('arrayContainsAny with Filter', () async { final instance = FakeFirebaseFirestore(); await instance.collection('posts').add({ 'name': 'Post #1', @@ -455,27 +762,25 @@ void main() { }); instance .collection('posts') - .where( + .where(Filter( 'tags', arrayContainsAny: toIterable(['interesting', 'mostrecent']), - ) + )) .snapshots() .listen(expectAsync1((QuerySnapshot snapshot) { expect(snapshot.docs.length, equals(4)); })); instance .collection('posts') - .where('commenters', arrayContainsAny: toIterable([222, 333])) + .where(Filter('commenters', arrayContainsAny: toIterable([222, 333]))) .snapshots() .listen(expectAsync1((QuerySnapshot snapshot) { expect(snapshot.docs.length, equals(3)); })); instance .collection('posts') - .where( - 'commenters', - arrayContainsAny: Iterable.generate(31, (index) => index), - ) + .where(Filter('commenters', + arrayContainsAny: Iterable.generate(31, (index) => index))) .snapshots() .listen(null, onError: expectAsync1((error) { expect(error, isA()); @@ -536,6 +841,47 @@ void main() { })); }); + test('whereIn with Filter', () async { + final instance = FakeFirebaseFirestore(); + await instance.collection('contestants').add({ + 'name': 'Alice', + 'country': 'USA', + 'skills': ['cycling', 'running'] + }); + await instance.collection('contestants').add({ + 'name': 'Bob', + 'country': 'Japan', + 'skills': ['gymnastics', 'swimming'] + }); + await instance.collection('contestants').add({ + 'name': 'Celina', + 'country': 'India', + 'skills': ['swimming', 'running'] + }); + instance + .collection('contestants') + .where(Filter('country', whereIn: toIterable(['Japan', 'India']))) + .snapshots() + .listen(expectAsync1((QuerySnapshot snapshot) { + expect(snapshot.docs.length, equals(2)); + })); + instance + .collection('contestants') + .where(Filter('country', whereIn: toIterable(['USA']))) + .snapshots() + .listen(expectAsync1((QuerySnapshot snapshot) { + expect(snapshot.docs.length, equals(1)); + })); + instance + .collection('contestants') + .where(Filter('country', + whereIn: Iterable.generate(31, (index) => 'A_$index'))) + .snapshots() + .listen(null, onError: expectAsync1((error) { + expect(error, isA()); + })); + }); + test('whereNotIn', () async { final instance = FakeFirebaseFirestore(); await instance.collection('contestants').add({ @@ -680,6 +1026,69 @@ void main() { })); }); + test('Filter.and where queries return the correct snapshots', () async { + final instance = FakeFirebaseFirestore(); + final bookmarks = + instance.collection('users').doc(uid).collection('bookmarks'); + await bookmarks.add({ + 'hidden': false, + }); + await bookmarks.add({ + 'tag': 'mostrecent', + 'hidden': false, + }); + await bookmarks.add({ + 'hidden': false, + }); + await bookmarks.add({ + 'tag': 'mostrecent', + 'hidden': true, + }); + instance + .collection('users') + .doc(uid) + .collection('bookmarks') + .where(Filter.and(Filter('hidden', isEqualTo: false), + Filter('tag', isEqualTo: 'mostrecent'))) + .snapshots() + .listen(expectAsync1((QuerySnapshot snapshot) { + expect(snapshot.docs.length, equals(1)); + expect(snapshot.docs.first.get('tag'), equals('mostrecent')); + })); + }); + + test('Filter.or where queries return the correct snapshots', () async { + final instance = FakeFirebaseFirestore(); + final bookmarks = + instance.collection('users').doc(uid).collection('bookmarks'); + await bookmarks.add({ + 'hidden': false, + }); + await bookmarks.add({ + 'tag': 'mostrecent', + 'hidden': false, + }); + await bookmarks.add({ + 'hidden': false, + }); + await bookmarks.add({ + 'tag': 'mostrecent', + 'hidden': true, + }); + instance + .collection('users') + .doc(uid) + .collection('bookmarks') + .where(Filter.or(Filter('hidden', isEqualTo: true), + Filter('tag', isEqualTo: 'mostrecent'))) + .snapshots() + .listen(expectAsync1((QuerySnapshot snapshot) { + expect(snapshot.docs.length, equals(2)); + expect(snapshot.docs.first.get('tag'), equals('mostrecent')); + expect(snapshot.docs.map((d) => d.get('hidden')), equals([false, true])); + })); + }); + test('Collection reference should not hold query result', () async { final instance = FakeFirebaseFirestore(); @@ -887,6 +1296,33 @@ void main() { expect(querySnapshot.docs, hasLength(1)); }); + test('chaining where Filter and startAfterDocument return correct documents', + () async { + final instance = FakeFirebaseFirestore(); + + await instance.collection('messages').doc().set({'username': 'Bob'}); + + await instance //Start after this doc + .collection('messages') + .doc(uid) + .set({'username': 'Bob'}); + + await instance.collection('messages').doc().set({'username': 'John'}); + + await instance.collection('messages').doc().set({'username': 'Bob'}); + + final documentSnapshot = + await instance.collection('messages').doc(uid).get(); + + final querySnapshot = await instance + .collection('messages') + .where(Filter('username', isEqualTo: 'Bob')) + .startAfterDocument(documentSnapshot) + .get(); + + expect(querySnapshot.docs, hasLength(1)); + }); + test('startAfterDocument throws if the document doesn\'t exist', () async { final instance = FakeFirebaseFirestore(); @@ -1269,6 +1705,64 @@ void main() { ]); }); + test('orderBy with second field descending', () async { + final instance = FakeFirebaseFirestore(); + + await instance.collection('cities').doc().set({ + 'name': 'Los Angeles', + 'state': 'California', + }); + + await instance.collection('cities').doc().set({ + 'name': 'Springfield', + 'state': 'Wisconsin', + }); + + await instance.collection('cities').doc().set({ + 'name': 'Springfield', + 'state': 'Missouri', + }); + + await instance.collection('cities').doc().set({ + 'name': 'Springfield', + 'state': 'Massachusetts', + }); + + await instance.collection('cities').doc().set({ + 'name': 'Washington', + 'state': 'Washington', + }); + + final snapshots = await instance + .collection('cities') + .orderBy('name') + .orderBy('state', descending: true) + .get(); + + expect(snapshots.docs.toData(), [ + { + 'name': 'Los Angeles', + 'state': 'California', + }, + { + 'name': 'Springfield', + 'state': 'Wisconsin', + }, + { + 'name': 'Springfield', + 'state': 'Missouri', + }, + { + 'name': 'Springfield', + 'state': 'Massachusetts', + }, + { + 'name': 'Washington', + 'state': 'Washington', + } + ]); + }); + test('startAt', () async { final instance = FakeFirebaseFirestore(); @@ -1923,4 +2417,98 @@ void main() { }, SetOptions(merge: true)); await Future.wait([op5, op6]); }); + + test('Filter.or', () async { + final instance = FakeFirebaseFirestore(); + + await instance.collection('cities').doc().set({ + 'name': 'Los Angeles', + 'state': 'California', + }); + + await instance.collection('cities').doc().set({ + 'name': 'Springfield', + 'state': 'Wisconsin', + }); + + await instance.collection('cities').doc().set({ + 'name': 'Springfield', + 'state': 'Missouri', + }); + + await instance.collection('cities').doc().set({ + 'name': 'Springfield', + 'state': 'Massachusetts', + }); + + await instance.collection('cities').doc().set({ + 'name': 'Washington', + 'state': 'Washington', + }); + + instance + .collection('cities') + .where(Filter.or( + Filter('name', isEqualTo: 'Washington'), + Filter('state', isEqualTo: 'Missouri'), + Filter('state', isEqualTo: 'California'), + )) + .snapshots() + .listen(expectAsync1((QuerySnapshot snapshot) { + expect(snapshot.docs.length, equals(3)); + expect(snapshot.docs.map((d) => d.get('state')), + equals(['California', 'Missouri', 'Washington'])); + })); + }); + + test('Filter.or with Filter.and', () async { + final instance = FakeFirebaseFirestore(); + + await instance.collection('cities').doc().set({ + 'name': 'Los Angeles', + 'state': 'California', + }); + + await instance.collection('cities').doc().set({ + 'name': 'Springfield', + 'state': 'Wisconsin', + }); + + await instance.collection('cities').doc().set({ + 'name': 'Springfield', + 'state': 'Missouri', + }); + + await instance.collection('cities').doc().set({ + 'name': 'Springfield', + 'state': 'Massachusetts', + }); + + await instance.collection('cities').doc().set({ + 'name': 'Washington', + 'state': 'Washington', + }); + + instance + .collection('cities') + .where(Filter.or( + Filter.and( + Filter('name', isEqualTo: 'Washington'), + Filter('state', isEqualTo: 'Washington'), + ), + Filter.and( + Filter('name', isEqualTo: 'Springfield'), + Filter('state', isEqualTo: 'Springfield'), + ), + Filter.and( + Filter('name', isEqualTo: 'California'), + Filter('state', isEqualTo: 'California'), + ), + )) + .snapshots() + .listen(expectAsync1((QuerySnapshot snapshot) { + expect(snapshot.docs.length, equals(1)); + expect(snapshot.docs.first.get('name'), equals('Washington')); + })); + }); }