diff --git a/docs/firestore/usage/index.md b/docs/firestore/usage/index.md index 39afcbbf62..2a22aad2ee 100644 --- a/docs/firestore/usage/index.md +++ b/docs/firestore/usage/index.md @@ -239,6 +239,43 @@ firestore() To learn more about all of the querying capabilities Cloud Firestore has to offer, view the [Firebase documentation](https://firebase.google.com/docs/firestore/query-data/queries). +It is now possible to use the `Filter` instance to make queries. They can be used with the existing query API. +For example, you could chain like so: + +```js +const snapshot = await firestore() + .collection('Users') + .where(Filter('user', '==', 'Tim')) + .where('email', '==', 'tim@example.com') + .get(); +``` + +You can use the `Filter.and()` static method to make logical AND queries: + +```js +const snapshot = await firestore() + .collection('Users') + .where(Filter.and(Filter('user', '==', 'Tim'), Filter('email', '==', 'tim@example.com'))) + .get(); +``` + +You can use the `Filter.or()` static method to make logical OR queries: + +```js +const snapshot = await firestore() + .collection('Users') + .where( + Filter.or( + Filter.and(Filter('user', '==', 'Tim'), Filter('email', '==', 'tim@example.com')), + Filter.and(Filter('user', '==', 'Dave'), Filter('email', '==', 'dave@example.com')), + ), + ) + .get(); +``` + +For an understanding of what queries are possible, please consult the query limitation documentation on the official +[Firebase Firestore documentation](https://firebase.google.com/docs/firestore/query-data/queries#limits_on_or_queries). + #### Limiting To limit the number of documents returned from a query, use the `limit` method on a collection reference: @@ -317,7 +354,6 @@ with an ID of `DEF`. Cloud Firestore does not support the following types of queries: - Queries with range filters on different fields, as described in the previous section. -- Logical OR queries. In this case, you should create a separate query for each OR condition and merge the query results in your app. ## Writing Data diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreQuery.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreQuery.java index ca2291c078..18eaef2e2b 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreQuery.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreQuery.java @@ -26,6 +26,7 @@ import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.Tasks; import com.google.firebase.firestore.FieldPath; +import com.google.firebase.firestore.Filter; import com.google.firebase.firestore.Query; import com.google.firebase.firestore.QuerySnapshot; import com.google.firebase.firestore.Source; @@ -65,62 +66,139 @@ private void applyFilters(ReadableArray filters) { for (int i = 0; i < filters.size(); i++) { ReadableMap filter = filters.getMap(i); - ArrayList fieldPathArray = Objects.requireNonNull(filter).getArray("fieldPath").toArrayList(); - String[] segmentArray = (String[]) fieldPathArray.toArray(new String[0]); + if (filter.hasKey("fieldPath")) { + ArrayList fieldPathArray = + Objects.requireNonNull(Objects.requireNonNull(filter).getArray("fieldPath")) + .toArrayList(); + String[] segmentArray = (String[]) fieldPathArray.toArray(new String[0]); + + FieldPath fieldPath = FieldPath.of(Objects.requireNonNull(segmentArray)); + String operator = filter.getString("operator"); + ReadableArray arrayValue = filter.getArray("value"); + Object value = parseTypeMap(query.getFirestore(), Objects.requireNonNull(arrayValue)); + + switch (Objects.requireNonNull(operator)) { + case "EQUAL": + query = query.whereEqualTo(Objects.requireNonNull(fieldPath), value); + break; + case "NOT_EQUAL": + query = query.whereNotEqualTo(Objects.requireNonNull(fieldPath), value); + break; + case "GREATER_THAN": + query = + query.whereGreaterThan( + Objects.requireNonNull(fieldPath), Objects.requireNonNull(value)); + break; + case "GREATER_THAN_OR_EQUAL": + query = + query.whereGreaterThanOrEqualTo( + Objects.requireNonNull(fieldPath), Objects.requireNonNull(value)); + break; + case "LESS_THAN": + query = + query.whereLessThan( + Objects.requireNonNull(fieldPath), Objects.requireNonNull(value)); + break; + case "LESS_THAN_OR_EQUAL": + query = + query.whereLessThanOrEqualTo( + Objects.requireNonNull(fieldPath), Objects.requireNonNull(value)); + break; + case "ARRAY_CONTAINS": + query = + query.whereArrayContains( + Objects.requireNonNull(fieldPath), Objects.requireNonNull(value)); + break; + case "ARRAY_CONTAINS_ANY": + query = + query.whereArrayContainsAny( + Objects.requireNonNull(fieldPath), + Objects.requireNonNull((List) value)); + break; + case "IN": + query = + query.whereIn( + Objects.requireNonNull(fieldPath), + Objects.requireNonNull((List) value)); + break; + case "NOT_IN": + query = + query.whereNotIn( + Objects.requireNonNull(fieldPath), + Objects.requireNonNull((List) value)); + break; + } + } else if (filter.hasKey("operator") && filter.hasKey("queries")) { + query = query.where(applyFilterQueries(filter)); + } + } + } + + private Filter applyFilterQueries(ReadableMap filter) { + if (filter.hasKey("fieldPath")) { + String operator = + (String) Objects.requireNonNull(Objects.requireNonNull(filter).getString("operator")); + ReadableMap fieldPathMap = Objects.requireNonNull(filter.getMap("fieldPath")); + ReadableArray segments = Objects.requireNonNull(fieldPathMap.getArray("_segments")); + int arraySize = segments.size(); + String[] segmentArray = new String[arraySize]; - FieldPath fieldPath = FieldPath.of(Objects.requireNonNull(segmentArray)); - String operator = filter.getString("operator"); + for (int i = 0; i < arraySize; i++) { + segmentArray[i] = segments.getString(i); + } + FieldPath fieldPath = FieldPath.of(segmentArray); ReadableArray arrayValue = filter.getArray("value"); + Object value = parseTypeMap(query.getFirestore(), Objects.requireNonNull(arrayValue)); - switch (Objects.requireNonNull(operator)) { + switch (operator) { case "EQUAL": - query = query.whereEqualTo(Objects.requireNonNull(fieldPath), value); - break; + return Filter.equalTo(fieldPath, value); case "NOT_EQUAL": - query = query.whereNotEqualTo(Objects.requireNonNull(fieldPath), value); - break; - case "GREATER_THAN": - query = - query.whereGreaterThan( - Objects.requireNonNull(fieldPath), Objects.requireNonNull(value)); - break; - case "GREATER_THAN_OR_EQUAL": - query = - query.whereGreaterThanOrEqualTo( - Objects.requireNonNull(fieldPath), Objects.requireNonNull(value)); - break; + return Filter.notEqualTo(fieldPath, value); case "LESS_THAN": - query = - query.whereLessThan(Objects.requireNonNull(fieldPath), Objects.requireNonNull(value)); - break; + return Filter.lessThan(fieldPath, value); case "LESS_THAN_OR_EQUAL": - query = - query.whereLessThanOrEqualTo( - Objects.requireNonNull(fieldPath), Objects.requireNonNull(value)); - break; + return Filter.lessThanOrEqualTo(fieldPath, value); + case "GREATER_THAN": + return Filter.greaterThan(fieldPath, value); + case "GREATER_THAN_OR_EQUAL": + return Filter.greaterThanOrEqualTo(fieldPath, value); case "ARRAY_CONTAINS": - query = - query.whereArrayContains( - Objects.requireNonNull(fieldPath), Objects.requireNonNull(value)); - break; + return Filter.arrayContains(fieldPath, value); case "ARRAY_CONTAINS_ANY": - query = - query.whereArrayContainsAny( - Objects.requireNonNull(fieldPath), Objects.requireNonNull((List) value)); - break; + assert value != null; + return Filter.arrayContainsAny(fieldPath, (List) value); case "IN": - query = - query.whereIn( - Objects.requireNonNull(fieldPath), Objects.requireNonNull((List) value)); - break; + assert value != null; + return Filter.inArray(fieldPath, (List) value); case "NOT_IN": - query = - query.whereNotIn( - Objects.requireNonNull(fieldPath), Objects.requireNonNull((List) value)); - break; + assert value != null; + return Filter.notInArray(fieldPath, (List) value); + default: + throw new Error("Invalid operator"); } } + + String operator = Objects.requireNonNull(filter).getString("operator"); + ReadableArray queries = + Objects.requireNonNull(Objects.requireNonNull(filter).getArray("queries")); + ArrayList parsedFilters = new ArrayList<>(); + int arraySize = queries.size(); + for (int i = 0; i < arraySize; i++) { + ReadableMap map = queries.getMap(i); + parsedFilters.add(applyFilterQueries(map)); + } + + if (operator.equals("AND")) { + return Filter.and(parsedFilters.toArray(new Filter[0])); + } + + if (operator.equals("OR")) { + return Filter.or(parsedFilters.toArray(new Filter[0])); + } + + throw new Error("Missing 'Filter' instance return"); } private void applyOrders(ReadableArray orders) { diff --git a/packages/firestore/e2e/Query/where.and.filter.e2e.js b/packages/firestore/e2e/Query/where.and.filter.e2e.js new file mode 100644 index 0000000000..c81b14789e --- /dev/null +++ b/packages/firestore/e2e/Query/where.and.filter.e2e.js @@ -0,0 +1,737 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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. + * + */ +const COLLECTION = 'firestore'; +const { wipe } = require('../helpers'); +let Filter; + +describe(' firestore().collection().where(AND Filters)', function () { + beforeEach(async function () { + Filter = firebase.firestore.Filter; + return await wipe(); + }); + + it('throws if fieldPath string is invalid', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter.and(Filter('.foo.bar', '==', 1), Filter('.foo.bar', '==', 1))); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("'fieldPath' Invalid field path"); + return Promise.resolve(); + } + }); + + it('throws if operator string is invalid', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter.and(Filter('foo.bar', '!', 1), Filter('foo.bar', '!', 1))); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("'opStr' is invalid"); + return Promise.resolve(); + } + }); + + it('throws if query contains multiple array-contains', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.and( + Filter('foo.bar', 'array-contains', 1), + Filter('foo.bar', 'array-contains', 1), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('Queries only support a single array-contains filter'); + return Promise.resolve(); + } + }); + + it('throws if value is not defined', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.and(Filter('foo.bar', 'array-contains'), Filter('foo.bar', 'array-contains')), + ); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("'value' argument expected"); + return Promise.resolve(); + } + }); + + it('throws if null value and no equal operator', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.and(Filter('foo.bar', '==', null), Filter('foo.bar', 'array-contains', null)), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('You can only perform equals comparisons on null'); + return Promise.resolve(); + } + }); + + it('allows null to be used with equal operator', function () { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter.and(Filter('foo.bar', '==', null), Filter('foo.bar', '==', null))); + }); + + it('allows null to be used with not equal operator', function () { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter.and(Filter('foo.bar', '==', null), Filter('foo.bar', '!=', null))); + }); + + it('throws if multiple inequalities on different paths is provided', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter.and(Filter('foo.bar', '>', 123), Filter('bar', '>', 123))); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('All where filters with an inequality'); + return Promise.resolve(); + } + }); + + it('allows inequality on the same path', function () { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.and( + Filter('foo.bar', '>', 123), + Filter(new firebase.firestore.FieldPath('foo', 'bar'), '>', 1234), + ), + ); + }); + + it('throws if in query with no array value', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter.and(Filter('foo.bar', 'in', '123'), Filter('foo.bar', 'in', '123'))); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('A non-empty array is required'); + return Promise.resolve(); + } + }); + + it('throws if array-contains-any query with no array value', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.and( + Filter('foo.bar', 'array-contains-any', '123'), + Filter('foo.bar', 'array-contains-any', '123'), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('A non-empty array is required'); + return Promise.resolve(); + } + }); + + it('throws if in query array length is greater than 10', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.and( + Filter('foo.bar', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), + Filter('foo.bar', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('maximum of 10 elements in the value'); + return Promise.resolve(); + } + }); + + it('throws if query has multiple array-contains-any filter', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.and( + Filter('foo.bar', 'array-contains-any', [1]), + Filter('foo.bar', 'array-contains-any', [1]), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use more than one 'array-contains-any' filter"); + return Promise.resolve(); + } + }); + + it('throws if query has array-contains-any & in filter', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.and(Filter('foo.bar', 'array-contains-any', [1]), Filter('foo.bar', 'in', [2])), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "You cannot use 'in' filters with 'array-contains-any' filters", + ); + return Promise.resolve(); + } + }); + + it('throws if query has multiple in filter', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter.and(Filter('foo.bar', 'in', [1]), Filter('foo.bar', 'in', [2]))); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use more than one 'in' filter"); + return Promise.resolve(); + } + }); + + it('throws if query has in & array-contains-any filter', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.and(Filter('foo.bar', 'in', [1]), Filter('foo.bar', 'array-contains-any', [2])), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "You cannot use 'array-contains-any' filters with 'in' filters", + ); + return Promise.resolve(); + } + }); + + it("should throw error when using 'not-in' operator twice", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where(Filter.and(Filter('foo.bar', 'not-in', [1]), Filter('foo.bar', 'not-in', [2]))); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use more than one 'not-in' filter."); + return Promise.resolve(); + } + }); + + it("should throw error when combining 'not-in' operator with '!=' operator", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where(Filter.and(Filter('foo.bar', '!=', [1]), Filter('foo.bar', 'not-in', [2]))); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "You cannot use 'not-in' filters with '!=' inequality filters", + ); + return Promise.resolve(); + } + }); + + it("should throw error when combining 'not-in' operator with 'in' operator", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where(Filter.and(Filter('foo.bar', 'in', [1]), Filter('foo.bar', 'not-in', [2]))); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use 'not-in' filters with 'in' filters."); + return Promise.resolve(); + } + }); + + it("should throw error when combining 'not-in' operator with 'array-contains-any' operator", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where( + Filter.and(Filter('foo.bar', 'array-contains-any', [1]), Filter('foo.bar', 'not-in', [2])), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "You cannot use 'not-in' filters with 'array-contains-any' filters.", + ); + return Promise.resolve(); + } + }); + + it("should throw error when 'not-in' filter has a list of more than 10 items", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where( + Filter.and( + Filter('foo.bar', '==', 1), + Filter('foo.bar', 'not-in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + 'filters support a maximum of 10 elements in the value array.', + ); + return Promise.resolve(); + } + }); + + it('should throw an error if you use a FieldPath on a filter in conjunction with an orderBy() parameter that is not FieldPath', async function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.and( + Filter(firebase.firestore.FieldPath.documentId(), '==', ['document-id']), + Filter('foo.bar', 'not-in', [1, 2, 3, 4]), + ), + ) + .orderBy('differentOrderBy', 'desc'); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("'FirestoreFieldPath' cannot be used in conjunction"); + return Promise.resolve(); + } + }); + + it("should throw error when using '!=' operator twice ", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where(Filter.and(Filter('foo.bar', '!=', 1), Filter('foo.baz', '!=', 2))); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use more than one '!=' inequality filter."); + return Promise.resolve(); + } + }); + + it("should throw error when combining '!=' operator with any other inequality operator on a different field", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where(Filter.and(Filter('foo.bar', '!=', 1), Filter('differentField', '>', 2))); + return Promise.reject(new Error('Did not throw an Error on >.')); + } catch (error) { + error.message.should.containEql('must be on the same field.'); + } + + try { + ref.where(Filter.and(Filter('foo.bar', '!=', 1), Filter('differentField', '<', 2))); + return Promise.reject(new Error('Did not throw an Error on <.')); + } catch (error) { + error.message.should.containEql('must be on the same field.'); + } + + try { + ref.where(Filter.and(Filter('foo.bar', '!=', 1), Filter('differentField', '<=', 2))); + return Promise.reject(new Error('Did not throw an Error <=.')); + } catch (error) { + error.message.should.containEql('must be on the same field.'); + } + + try { + ref.where(Filter.and(Filter('foo.bar', '!=', 1), Filter('differentField', '>=', 2))); + return Promise.reject(new Error('Did not throw an Error >=.')); + } catch (error) { + error.message.should.containEql('must be on the same field.'); + } + + return Promise.resolve(); + }); + + /* Queries */ + + // Equals and another filter: '==', '>', '>=', '<', '<=', '!=' + + it('returns with where "==" & "==" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar', bar: 'baz' }; + await Promise.all([ + colRef.add({ foo: [1, '1', 'something'] }), + colRef.add(expected), + colRef.add(expected), + ]); + + const snapshot = await colRef + .where(Filter.and(Filter('foo', '==', 'bar'), Filter('bar', '==', 'baz'))) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "==" & "!=" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar', baz: 'baz' }; + const notExpected = { foo: 'bar', baz: 'something' }; + await Promise.all([colRef.add(notExpected), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where(Filter.and(Filter('foo', '==', 'bar'), Filter('baz', '!=', 'something'))) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "==" & ">" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar', population: 200 }; + const notExpected = { foo: 'bar', population: 1 }; + await Promise.all([colRef.add(notExpected), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where(Filter.and(Filter('foo', '==', 'bar'), Filter('population', '>', 2))) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "==" & "<" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar', population: 200 }; + const notExpected = { foo: 'bar', population: 1000 }; + await Promise.all([colRef.add(notExpected), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where(Filter.and(Filter('foo', '==', 'bar'), Filter('population', '<', 201))) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "==" & "<=" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar', population: 200 }; + const notExpected = { foo: 'bar', population: 1000 }; + await Promise.all([colRef.add(notExpected), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where(Filter.and(Filter('foo', '==', 'bar'), Filter('population', '<=', 200))) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "==" & ">=" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar', population: 200 }; + const notExpected = { foo: 'bar', population: 100 }; + await Promise.all([colRef.add(notExpected), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where(Filter.and(Filter('foo', '==', 'bar'), Filter('population', '>=', 200))) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + // Filters using single "array-contains", "array-contains-any", "not-in" and "in" filters + + it('returns with where "array-contains" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/array-contains`); + + const expected = [101, 102]; + const data = { foo: expected }; + + await Promise.all([colRef.add({ foo: [1, 2, 3] }), colRef.add(data), colRef.add(data)]); + + const snapshot = await colRef.where(Filter('foo', 'array-contains', 101)).get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().foo.should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "array-contains-any" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/array-contains-any`); + const expected = [101, 102, 103, 104]; + const data = { foo: expected }; + + await Promise.all([colRef.add({ foo: [1, 2, 3] }), colRef.add(data), colRef.add(data)]); + + const snapshot = await colRef.where(Filter('foo', 'array-contains-any', [120, 101])).get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().foo.should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "not-in" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/not-in`); + const expected = 'bar'; + const data = { foo: expected }; + + await Promise.all([ + colRef.add({ foo: 'not' }), + colRef.add({ foo: 'this' }), + colRef.add(data), + colRef.add(data), + ]); + + const snapshot = await colRef.where(Filter('foo', 'not-in', ['not', 'this'])).get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().foo.should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "in" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/in`); + const expected1 = 'bar'; + const expected2 = 'baz'; + const data1 = { foo: expected1 }; + const data2 = { foo: expected2 }; + + await Promise.all([ + colRef.add({ foo: 'not' }), + colRef.add({ foo: 'this' }), + colRef.add(data1), + colRef.add(data2), + ]); + + const snapshot = await colRef + .where(Filter('foo', 'in', [expected1, expected2])) + .orderBy('foo') + .get(); + + snapshot.size.should.eql(2); + snapshot.docs[0].data().foo.should.eql(expected1); + snapshot.docs[1].data().foo.should.eql(expected2); + }); + + // Using AND query combinations with Equals && "array-contains", "array-contains-any", "not-in" and "in" filters + + it('returns with where "==" & "array-contains" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const match = Date.now(); + await Promise.all([ + colRef.add({ foo: [1, '1', match] }), + colRef.add({ foo: [1, '2', match.toString()], bar: 'baz' }), + colRef.add({ foo: [1, '2', match.toString()], bar: 'baz' }), + ]); + + const snapshot = await colRef + .where( + Filter.and(Filter('foo', 'array-contains', match.toString()), Filter('bar', '==', 'baz')), + ) + .get(); + const expected = [1, '2', match.toString()]; + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().foo.should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "==" & "array-contains-any" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const match = Date.now(); + await Promise.all([ + colRef.add({ foo: [1, '1', match] }), + colRef.add({ foo: [1, '2'], bar: 'baz' }), + colRef.add({ foo: ['2', match.toString()], bar: 'baz' }), + ]); + + const snapshot = await colRef + .where( + Filter.and( + Filter('foo', 'array-contains-any', [match.toString(), 1]), + Filter('bar', '==', 'baz'), + ), + ) + .get(); + + snapshot.size.should.eql(2); + snapshot.docs[0].data().bar.should.equal('baz'); + snapshot.docs[1].data().bar.should.equal('baz'); + }); + + it('returns with where "==" & "not-in" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/not-in`); + + await Promise.all([ + colRef.add({ foo: 'bar', bar: 'baz' }), + colRef.add({ foo: 'thing', bar: 'baz' }), + colRef.add({ foo: 'bar', bar: 'baz' }), + colRef.add({ foo: 'yolo', bar: 'baz' }), + ]); + + const snapshot = await colRef + .where(Filter.and(Filter('foo', 'not-in', ['yolo', 'thing']), Filter('bar', '==', 'baz'))) + .get(); + + snapshot.size.should.eql(2); + snapshot.docs[0].data().foo.should.equal('bar'); + snapshot.docs[1].data().foo.should.equal('bar'); + }); + + it('returns with where "==" & "in" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/in`); + + await Promise.all([ + colRef.add({ foo: 'bar', bar: 'baz' }), + colRef.add({ foo: 'thing', bar: 'baz' }), + colRef.add({ foo: 'yolo', bar: 'baz' }), + ]); + + const snapshot = await colRef + .where(Filter.and(Filter('foo', 'in', ['bar', 'yolo']), Filter('bar', '==', 'baz'))) + .orderBy('foo') + .get(); + + snapshot.size.should.eql(2); + snapshot.docs[0].data().foo.should.equal('bar'); + snapshot.docs[1].data().foo.should.equal('yolo'); + }); + + // Special Filter queries + + it('returns when combining greater than and lesser than on the same nested field', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/greaterandless`); + + await Promise.all([ + colRef.doc('doc1').set({ foo: { bar: 1 } }), + colRef.doc('doc2').set({ foo: { bar: 2 } }), + colRef.doc('doc3').set({ foo: { bar: 3 } }), + ]); + + const snapshot = await colRef + .where(Filter.and(Filter('foo.bar', '>', 1), Filter('foo.bar', '<', 3))) + .get(); + + snapshot.size.should.eql(1); + }); + + it('returns when combining greater than and lesser than on the same nested field using FieldPath', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/greaterandless`); + + await Promise.all([ + colRef.doc('doc1').set({ foo: { bar: 1 } }), + colRef.doc('doc2').set({ foo: { bar: 2 } }), + colRef.doc('doc3').set({ foo: { bar: 3 } }), + ]); + + const snapshot = await colRef + .where(Filter.and(Filter('foo.bar', '>', 1), Filter('foo.bar', '<', 3))) + .orderBy(new firebase.firestore.FieldPath('foo', 'bar')) + .get(); + + snapshot.size.should.eql(1); + }); + + it('returns with a FieldPath', async function () { + const colRef = firebase + .firestore() + .collection(`${COLLECTION}/filter/where-fieldpath${Date.now() + ''}`); + const fieldPath = new firebase.firestore.FieldPath('map', 'foo.bar@gmail.com'); + + await colRef.add({ + map: { + 'foo.bar@gmail.com': true, + }, + }); + await colRef.add({ + map: { + 'bar.foo@gmail.com': true, + }, + }); + + const snapshot = await colRef.where(Filter(fieldPath, '==', true)).get(); + snapshot.size.should.eql(1); // 2nd record should only be returned once + const data = snapshot.docs[0].data(); + should.equal(data.map['foo.bar@gmail.com'], true); + }); +}); diff --git a/packages/firestore/e2e/Query/where.e2e.js b/packages/firestore/e2e/Query/where.e2e.js index 0a62c85b04..2a159f9c23 100644 --- a/packages/firestore/e2e/Query/where.e2e.js +++ b/packages/firestore/e2e/Query/where.e2e.js @@ -25,7 +25,9 @@ describe('firestore().collection().where()', function () { firebase.firestore().collection(COLLECTION).where(123); return Promise.reject(new Error('Did not throw an Error.')); } catch (error) { - error.message.should.containEql("'fieldPath' must be a string or instance of FieldPath"); + error.message.should.containEql( + 'must be a string, instance of FieldPath or instance of Filter', + ); return Promise.resolve(); } }); diff --git a/packages/firestore/e2e/Query/where.filter.e2e.js b/packages/firestore/e2e/Query/where.filter.e2e.js new file mode 100644 index 0000000000..24e4466df0 --- /dev/null +++ b/packages/firestore/e2e/Query/where.filter.e2e.js @@ -0,0 +1,458 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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. + * + */ +const COLLECTION = 'firestore'; +const { wipe } = require('../helpers'); +let Filter; + +describe('firestore().collection().where(Filters)', function () { + beforeEach(async function () { + Filter = firebase.firestore.Filter; + return await wipe(); + }); + + it('throws if fieldPath string is invalid', function () { + try { + firebase.firestore().collection(COLLECTION).where(Filter('.foo.bar', '==', 1)); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("'fieldPath' Invalid field path"); + return Promise.resolve(); + } + }); + + it('throws if operator string is invalid', function () { + try { + firebase.firestore().collection(COLLECTION).where(Filter('foo.bar', '!', 1)); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("'opStr' is invalid"); + return Promise.resolve(); + } + }); + + it('throws if query contains multiple array-contains', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter('foo.bar', 'array-contains', 1)) + .where(Filter('foo.bar', 'array-contains', 1)); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('Queries only support a single array-contains filter'); + return Promise.resolve(); + } + }); + + it('throws if value is not defined', function () { + try { + firebase.firestore().collection(COLLECTION).where(Filter('foo', '==')); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("'value' argument expected"); + return Promise.resolve(); + } + }); + + it('throws if null value and no equal operator', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter('foo.bar', '==', null)) + .where(Filter('foo.bar', 'array-contains', null)); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('You can only perform equals comparisons on null'); + return Promise.resolve(); + } + }); + + it('allows null to be used with equal operator', function () { + firebase.firestore().collection(COLLECTION).where(Filter('foo.bar', '==', null)); + }); + + it('allows null to be used with not equal operator', function () { + firebase.firestore().collection(COLLECTION).where(Filter('foo.bar', '!=', null)); + }); + + it('throws if multiple inequalities on different paths is provided', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter('foo.bar', '>', 123)) + .where(Filter('bar', '>', 123)); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('All where filters with an inequality'); + return Promise.resolve(); + } + }); + + it('allows inequality on the same path', function () { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter('foo.bar', '>', 123)) + .where(Filter(new firebase.firestore.FieldPath('foo', 'bar'), '>', 1234)); + }); + + it('throws if in query with no array value', function () { + try { + firebase.firestore().collection(COLLECTION).where(Filter('foo.bar', 'in', '123')); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('A non-empty array is required'); + return Promise.resolve(); + } + }); + + it('throws if array-contains-any query with no array value', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter('foo.bar', 'array-contains-any', '123')); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('A non-empty array is required'); + return Promise.resolve(); + } + }); + + it('throws if in query array length is greater than 10', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter('foo.bar', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('maximum of 10 elements in the value'); + return Promise.resolve(); + } + }); + + it('throws if query has multiple array-contains-any filter', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter('foo.bar', 'array-contains-any', [1])) + .where(Filter('foo.bar', 'array-contains-any', [1])); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use more than one 'array-contains-any' filter"); + return Promise.resolve(); + } + }); + + it('throws if query has array-contains-any & in filter', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter('foo.bar', 'array-contains-any', [1])) + .where(Filter('foo.bar', 'in', [2])); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "You cannot use 'in' filters with 'array-contains-any' filters", + ); + return Promise.resolve(); + } + }); + + it('throws if query has multiple in filter', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter('foo.bar', 'in', [1])) + .where(Filter('foo.bar', 'in', [2])); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use more than one 'in' filter"); + return Promise.resolve(); + } + }); + + it('throws if query has in & array-contains-any filter', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter('foo.bar', 'in', [1])) + .where(Filter('foo.bar', 'array-contains-any', [2])); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "You cannot use 'array-contains-any' filters with 'in' filters", + ); + return Promise.resolve(); + } + }); + + it("should throw error when using 'not-in' operator twice", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where(Filter('foo.bar', 'not-in', [1])).where(Filter('foo.bar', 'not-in', [2])); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use more than one 'not-in' filter."); + return Promise.resolve(); + } + }); + + it("should throw error when combining 'not-in' operator with '!=' operator", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where(Filter('foo.bar', '!=', [1])).where(Filter('foo.bar', 'not-in', [2])); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "You cannot use 'not-in' filters with '!=' inequality filters", + ); + return Promise.resolve(); + } + }); + + it("should throw error when combining 'not-in' operator with 'in' operator", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where(Filter('foo.bar', 'in', [1])).where(Filter('foo.bar', 'not-in', [2])); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use 'not-in' filters with 'in' filters."); + return Promise.resolve(); + } + }); + + it("should throw error when combining 'not-in' operator with 'array-contains-any' operator", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref + .where(Filter('foo.bar', 'array-contains-any', [1])) + .where(Filter('foo.bar', 'not-in', [2])); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "You cannot use 'not-in' filters with 'array-contains-any' filters.", + ); + return Promise.resolve(); + } + }); + + it("should throw error when 'not-in' filter has a list of more than 10 items", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref + .where(Filter('foo.bar', '==', 1)) + .where(Filter('foo.bar', 'not-in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + 'filters support a maximum of 10 elements in the value array.', + ); + return Promise.resolve(); + } + }); + + it('should throw an error if you use a FieldPath on a filter in conjunction with an orderBy() parameter that is not FieldPath', async function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where(Filter(firebase.firestore.FieldPath.documentId(), '==', ['document-id'])) + .where(Filter('foo.bar', 'not-in', [1, 2, 3, 4])) + .orderBy('differentOrderBy', 'desc'); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("'FirestoreFieldPath' cannot be used in conjunction"); + return Promise.resolve(); + } + }); + + it("should throw error when using '!=' operator twice ", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where(Filter('foo.bar', '!=', 1)).where(Filter('foo.baz', '!=', 2)); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use more than one '!=' inequality filter."); + return Promise.resolve(); + } + }); + + it("should throw error when combining '!=' operator with any other inequality operator on a different field", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where(Filter('differentField', '>', 2)).where(Filter('foo.bar', '!=', 1)); + return Promise.reject(new Error('Did not throw an Error on >.')); + } catch (error) { + error.message.should.containEql('must be on the same field.'); + } + + try { + ref.where(Filter('foo.bar', '!=', 1)).where(Filter('differentField', '<', 2)); + return Promise.reject(new Error('Did not throw an Error on <.')); + } catch (error) { + error.message.should.containEql('must be on the same field.'); + } + + try { + ref.where(Filter('foo.bar', '!=', 1)).where(Filter('differentField', '<=', 2)); + return Promise.reject(new Error('Did not throw an Error <=.')); + } catch (error) { + error.message.should.containEql('must be on the same field.'); + } + + try { + ref.where(Filter('foo.bar', '!=', 1)).where(Filter('differentField', '>=', 2)); + return Promise.reject(new Error('Did not throw an Error >=.')); + } catch (error) { + error.message.should.containEql('must be on the same field.'); + } + + return Promise.resolve(); + }); + + /* Queries */ + + // Single Filters using '==', '>', '>=', '<', '<=', '!=' + + it('returns with where "==" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar' }; + + await Promise.all([ + colRef.add({ foo: [1, '1', 'something'] }), + colRef.add(expected), + colRef.add(expected), + ]); + + const snapshot = await colRef.where(Filter('foo', '==', 'bar')).get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "!=" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/not-equals`); + + const expected = { foo: 'bar' }; + + await Promise.all([ + colRef.add({ foo: 'something' }), + colRef.add(expected), + colRef.add(expected), + ]); + + const snapshot = await colRef.where(Filter('foo', '!=', 'something')).get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where ">" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/greater-than`); + + const expected = { foo: 100 }; + + await Promise.all([colRef.add({ foo: 2 }), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef.where(Filter('foo', '>', 2)).get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "<" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/less-than`); + + const expected = { foo: 2 }; + + await Promise.all([colRef.add({ foo: 100 }), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef.where(Filter('foo', '<', 3)).get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where ">=" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/greater-than-or-equal`); + + const expected = { foo: 100 }; + + await Promise.all([colRef.add({ foo: 2 }), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef.where(Filter('foo', '>=', 100)).get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "<=" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/less-than-or-equal`); + + const expected = { foo: 100 }; + + await Promise.all([colRef.add({ foo: 101 }), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef.where(Filter('foo', '<=', 100)).get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); +}); diff --git a/packages/firestore/e2e/Query/where.or.filter.e2e.js b/packages/firestore/e2e/Query/where.or.filter.e2e.js new file mode 100644 index 0000000000..b8c7dbc657 --- /dev/null +++ b/packages/firestore/e2e/Query/where.or.filter.e2e.js @@ -0,0 +1,1155 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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. + * + */ +const COLLECTION = 'firestore'; +const { wipe } = require('../helpers'); +let Filter; + +describe('firestore().collection().where(OR Filters)', function () { + beforeEach(async function () { + Filter = firebase.firestore.Filter; + return await wipe(); + }); + + it('throws if using nested Filter.or() queries', async function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.or(Filter('foo', '==', 'bar'), Filter('bar', '==', 'foo')), + Filter.or(Filter('foo', '==', 'baz'), Filter('bar', '==', 'baz')), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('OR Filters with nested OR Filters are not supported'); + } + + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and( + Filter.or(Filter('foo', '==', 'bar'), Filter('bar', '==', 'baz')), + Filter('more', '==', 'stuff'), + ), + Filter.and( + Filter.or(Filter('foo', '==', 'bar'), Filter('bar', '==', 'baz')), + Filter('baz', '==', 'foo'), + ), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('OR Filters with nested OR Filters are not supported'); + } + return Promise.resolve(); + }); + + it('throws if fieldPath string is invalid', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and(Filter('.foo.bar', '!=', 1), Filter('.foo.bar', '==', 1)), + Filter.and(Filter('.foo.bar', '!=', 1), Filter('foo.bar', '==', 1)), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("'fieldPath' Invalid field path"); + return Promise.resolve(); + } + }); + + it('throws if operator string is invalid', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and(Filter('foo.bar', '!', 1), Filter('foo.bar', '!', 1)), + Filter.and(Filter('foo.bar', '!', 1), Filter('foo.bar', '!', 1)), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("'opStr' is invalid"); + return Promise.resolve(); + } + }); + + it('throws if query contains multiple array-contains', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and( + Filter('foo.bar', 'array-contains', 1), + Filter('foo.bar', 'array-contains', 1), + ), + Filter.and(Filter('foo.bar', '==', 1), Filter('foo.bar', '==', 2)), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('Queries only support a single array-contains filter'); + return Promise.resolve(); + } + }); + + it('throws if value is not defined', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and(Filter('foo.bar', 'array-contains'), Filter('foo.bar', 'array-contains')), + Filter.and(Filter('foo.bar', '!', 1), Filter('foo.bar', '!', 1)), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("'value' argument expected"); + return Promise.resolve(); + } + }); + + it('throws if null value and no equal operator', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and(Filter('foo.bar', '==', null), Filter('foo.bar', 'array-contains', null)), + Filter.and(Filter('foo.bar', '!', 1), Filter('foo.bar', '!', 1)), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('You can only perform equals comparisons on null'); + return Promise.resolve(); + } + }); + + it('allows null to be used with equal operator', function () { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and(Filter('foo.bar', '==', null), Filter('foo.bar', '==', null)), + Filter.and(Filter('foo.bar', '==', null), Filter('foo.bar', '==', null)), + ), + ); + }); + + it('allows null to be used with not equal operator', function () { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and(Filter('foo.bar', '==', null), Filter('foo.bar', '!=', null)), + Filter.and(Filter('foo.bar', '==', null), Filter('foo.bar', '==', 'something')), + ), + ); + }); + + it('throws if multiple inequalities on different paths is provided', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and(Filter('foo.bar', '>', 123), Filter('bar', '>', 123)), + Filter.and(Filter('foo.bar', '>', 123), Filter('bar', '>', 123)), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('All where filters with an inequality'); + return Promise.resolve(); + } + }); + + it('allows inequality on the same path', function () { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and( + Filter('foo.bar', '>', 123), + Filter(new firebase.firestore.FieldPath('foo', 'bar'), '>', 1234), + ), + Filter.and( + Filter('foo.bar', '>', 123), + Filter(new firebase.firestore.FieldPath('foo', 'bar'), '>', 1234), + ), + ), + ); + }); + + it('throws if in query with no array value', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and(Filter('foo.bar', 'in', '123'), Filter('foo.bar', 'in', '123')), + Filter.and(Filter('foo.bar', 'in', '123'), Filter('foo.bar', 'in', '123')), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('A non-empty array is required'); + return Promise.resolve(); + } + }); + + it('throws if array-contains-any query with no array value', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and( + Filter('foo.bar', 'array-contains-any', '123'), + Filter('foo.bar', 'array-contains-any', '123'), + ), + Filter.and( + Filter('foo.bar', 'array-contains-any', '123'), + Filter('foo.bar', 'array-contains-any', '123'), + ), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('A non-empty array is required'); + return Promise.resolve(); + } + }); + + it('throws if in query array length is greater than 10', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and( + Filter('foo.bar', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), + Filter('foo.bar', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), + ), + Filter.and( + Filter('foo.bar', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), + Filter('foo.bar', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), + ), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('maximum of 10 elements in the value'); + return Promise.resolve(); + } + }); + + it('throws if query has multiple array-contains-any filter', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and( + Filter('foo.bar', 'array-contains-any', [1]), + Filter('foo.bar', 'array-contains-any', [1]), + ), + Filter.and( + Filter('foo.bar', 'array-contains-any', [1]), + Filter('foo.bar', 'array-contains-any', [1]), + ), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use more than one 'array-contains-any' filter"); + return Promise.resolve(); + } + }); + + it('throws if query has array-contains-any & in filter', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and(Filter('foo.bar', 'array-contains-any', [1]), Filter('foo.bar', 'in', [2])), + Filter.and(Filter('foo.bar', 'array-contains-any', [1]), Filter('foo.bar', 'in', [2])), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "You cannot use 'in' filters with 'array-contains-any' filters", + ); + return Promise.resolve(); + } + }); + + it('throws if query has multiple in filter', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and(Filter('foo.bar', 'in', [1]), Filter('foo.bar', 'in', [2])), + Filter.and(Filter('foo.bar', 'in', [1]), Filter('foo.bar', 'in', [2])), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use more than one 'in' filter"); + return Promise.resolve(); + } + }); + + it('throws if query has in & array-contains-any filter', function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and(Filter('foo.bar', 'in', [1]), Filter('foo.bar', 'array-contains-any', [2])), + Filter.and(Filter('foo.bar', 'in', [1]), Filter('foo.bar', 'array-contains-any', [2])), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "You cannot use 'array-contains-any' filters with 'in' filters", + ); + return Promise.resolve(); + } + }); + + it("should throw error when using 'not-in' operator twice", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where( + Filter.or( + Filter.and(Filter('foo.bar', 'not-in', [1]), Filter('foo.bar', 'not-in', [2])), + Filter.and(Filter('foo.bar', 'not-in', [1]), Filter('foo.bar', 'not-in', [2])), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use more than one 'not-in' filter."); + return Promise.resolve(); + } + }); + + it("should throw error when combining 'not-in' operator with '!=' operator", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where( + Filter.or( + Filter.and(Filter('foo.bar', '!=', [1]), Filter('foo.bar', 'not-in', [2])), + Filter.and(Filter('foo.bar', '!=', [1]), Filter('foo.bar', 'not-in', [2])), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "You cannot use 'not-in' filters with '!=' inequality filters", + ); + return Promise.resolve(); + } + }); + + it("should throw error when combining 'not-in' operator with 'in' operator", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where( + Filter.or( + Filter.and(Filter('foo.bar', 'in', [1]), Filter('foo.bar', 'not-in', [2])), + Filter.and(Filter('foo.bar', 'in', [1]), Filter('foo.bar', 'not-in', [2])), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use 'not-in' filters with 'in' filters."); + return Promise.resolve(); + } + }); + + it("should throw error when combining 'not-in' operator with 'array-contains-any' operator", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where( + Filter.or( + Filter.and( + Filter('foo.bar', 'array-contains-any', [1]), + Filter('foo.bar', 'not-in', [2]), + ), + Filter.and( + Filter('foo.bar', 'array-contains-any', [1]), + Filter('foo.bar', 'not-in', [2]), + ), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + "You cannot use 'not-in' filters with 'array-contains-any' filters.", + ); + return Promise.resolve(); + } + }); + + it("should throw error when 'not-in' filter has a list of more than 10 items", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where( + Filter.or( + Filter.and( + Filter('foo.bar', '==', 1), + Filter('foo.bar', 'not-in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), + ), + Filter.and( + Filter('foo.bar', '==', 1), + Filter('foo.bar', 'not-in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), + ), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + 'filters support a maximum of 10 elements in the value array.', + ); + return Promise.resolve(); + } + }); + + it('should throw an error if you use a FieldPath on a filter in conjunction with an orderBy() parameter that is not FieldPath', async function () { + try { + firebase + .firestore() + .collection(COLLECTION) + .where( + Filter.or( + Filter.and( + Filter(firebase.firestore.FieldPath.documentId(), '==', ['document-id']), + Filter('foo.bar', 'not-in', [1, 2, 3, 4]), + ), + Filter.and( + Filter(firebase.firestore.FieldPath.documentId(), '==', ['document-id']), + Filter('foo.bar', '==', 'something'), + ), + ), + ) + .orderBy('differentOrderBy', 'desc'); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("'FirestoreFieldPath' cannot be used in conjunction"); + return Promise.resolve(); + } + }); + + it("should throw error when using '!=' operator twice ", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where( + Filter.or( + Filter.and(Filter('foo.bar', '!=', 1), Filter('foo.baz', '!=', 2)), + Filter.and(Filter('foo.bar', '!=', 1), Filter('foo.baz', '!=', 2)), + ), + ); + + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("You cannot use more than one '!=' inequality filter."); + return Promise.resolve(); + } + }); + + it("should throw error when combining '!=' operator with any other inequality operator on a different field", async function () { + const ref = firebase.firestore().collection(COLLECTION); + + try { + ref.where( + Filter.or( + Filter.and(Filter('foo.bar', '!=', 1), Filter('differentField', '>', 2)), + Filter.and(Filter('foo.bar', '!=', 1), Filter('differentField', '>', 2)), + ), + ); + return Promise.reject(new Error('Did not throw an Error on >.')); + } catch (error) { + error.message.should.containEql('must be on the same field.'); + } + + try { + ref.where( + Filter.or( + Filter.and(Filter('foo.bar', '!=', 1), Filter('differentField', '<', 2)), + Filter.and(Filter('foo.bar', '!=', 1), Filter('differentField', '<', 2)), + ), + ); + return Promise.reject(new Error('Did not throw an Error on <.')); + } catch (error) { + error.message.should.containEql('must be on the same field.'); + } + + try { + ref.where( + Filter.or( + Filter.and(Filter('foo.bar', '!=', 1), Filter('differentField', '<=', 2)), + Filter.and(Filter('foo.bar', '!=', 1), Filter('differentField', '<=', 2)), + ), + ); + return Promise.reject(new Error('Did not throw an Error <=.')); + } catch (error) { + error.message.should.containEql('must be on the same field.'); + } + + try { + ref.where( + Filter.or( + Filter.and(Filter('foo.bar', '!=', 1), Filter('differentField', '>=', 2)), + Filter.and(Filter('foo.bar', '!=', 1), Filter('differentField', '>=', 2)), + ), + ); + return Promise.reject(new Error('Did not throw an Error >=.')); + } catch (error) { + error.message.should.containEql('must be on the same field.'); + } + + return Promise.resolve(); + }); + + /* Queries */ + + // OR queries without ANDs + + // Equals OR another filter that works: '==', '>', '>=', '<', '<=', '!=' + + it('returns with where "==" Filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar' }; + const expected2 = { foo: 'farm' }; + + await Promise.all([ + colRef.add({ foo: 'something' }), + colRef.add(expected), + colRef.add(expected2), + ]); + + const snapshot = await colRef + .where(Filter.or(Filter('foo', '==', 'bar'), Filter('foo', '==', 'farm'))) + .get(); + + snapshot.size.should.eql(2); + const results = snapshot.docs.map(doc => doc.data().foo); + results.should.containEql('bar'); + results.should.containEql('farm'); + }); + + it('returns with where ">" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/greater-than`); + + const expected = { foo: 100 }; + + await Promise.all([colRef.add({ foo: 2 }), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where(Filter.or(Filter('foo', '>', 2), Filter('foo', '==', 30))) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "<" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/less-than`); + + const expected = { foo: 2 }; + + await Promise.all([colRef.add({ foo: 100 }), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where(Filter.or(Filter('foo', '<', 3), Filter('foo', '==', 22))) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where ">=" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/greater-than-or-equal`); + + const expected = { foo: 100 }; + + await Promise.all([colRef.add({ foo: 2 }), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where(Filter.or(Filter('foo', '>=', 100), Filter('foo', '==', 45))) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "<=" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/less-than-or-equal`); + + const expected = { foo: 100 }; + + await Promise.all([colRef.add({ foo: 101 }), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where(Filter.or(Filter('foo', '<=', 100), Filter('foo', '==', 90))) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + // // Equals OR another filter that works: "array-contains", "in", "array-contains-any", "not-in" + + it('returns "array-contains" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/array-contains`); + + const expected = { foo: 'bar', something: [1, 2, 3] }; + + await Promise.all([ + colRef.add({ foo: 'something' }), + colRef.add(expected), + colRef.add(expected), + ]); + + const snapshot = await colRef + .where(Filter.or(Filter('foo', '==', 'not-this'), Filter('something', 'array-contains', 2))) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + const data = s.data(); + data.foo.should.eql('bar'); + data.something[0].should.eql(1); + data.something[1].should.eql(2); + data.something[2].should.eql(3); + }); + }); + + it('returns "array-contains-any" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/array-contains-any`); + + const expected = { foo: 'bar', something: [1, 2, 3] }; + + await Promise.all([ + colRef.add({ foo: 'something' }), + colRef.add(expected), + colRef.add(expected), + ]); + + const snapshot = await colRef + .where( + Filter.or( + Filter('foo', '==', 'not-this'), + Filter('something', 'array-contains-any', [2, 45]), + ), + ) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + const data = s.data(); + data.foo.should.eql('bar'); + data.something[0].should.eql(1); + data.something[1].should.eql(2); + data.something[2].should.eql(3); + }); + }); + + it('returns with where "not-in" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/not-in`); + const expected = 'bar'; + const data = { foo: expected }; + + await Promise.all([ + colRef.add({ foo: 'not' }), + colRef.add({ foo: 'this' }), + colRef.add(data), + colRef.add(data), + ]); + + const snapshot = await colRef + .where(Filter.or(Filter('foo', 'not-in', ['not', 'this']), Filter('foo', '==', 'not-this'))) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().foo.should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "in" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/in`); + const expected1 = 'bar'; + const expected2 = 'baz'; + const data1 = { foo: expected1 }; + const data2 = { foo: expected2 }; + + await Promise.all([ + colRef.add({ foo: 'not' }), + colRef.add({ foo: 'this' }), + colRef.add(data1), + colRef.add(data2), + ]); + + const snapshot = await colRef + .where( + Filter.or(Filter('foo', 'in', [expected1, expected2]), Filter('foo', '==', 'not-this')), + ) + .get(); + + snapshot.size.should.eql(2); + const results = snapshot.docs.map(d => d.data().foo); + results.should.containEql(expected1); + results.should.containEql(expected2); + }); + + // OR queries with ANDs. Equals and: '==', '>', '>=', '<', '<=', '!=' + it('returns with where "==" && "==" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar', bar: 'baz' }; + await Promise.all([ + colRef.add({ foo: [1, '1', 'something'] }), + colRef.add(expected), + colRef.add(expected), + ]); + + const snapshot = await colRef + .where( + Filter.or( + Filter.and(Filter('foo', '==', 'bar'), Filter('bar', '==', 'baz')), + Filter.and(Filter('blah', '==', 'blah'), Filter('not', '==', 'this')), + ), + ) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "==" & "!=" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar', baz: 'baz' }; + const notExpected = { foo: 'bar', baz: 'something' }; + await Promise.all([colRef.add(notExpected), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where(Filter.and(Filter('foo', '==', 'bar'), Filter('baz', '!=', 'something'))) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "==" & ">" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals-not-equals`); + + const expected = { foo: 'bar', population: 200 }; + const notExpected = { foo: 'bar', population: 1 }; + await Promise.all([colRef.add(notExpected), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where( + Filter.or( + Filter.and(Filter('foo', '==', 'bar'), Filter('population', '>', 2)), + Filter.and(Filter('foo', '==', 'not-this'), Filter('population', '>', 199)), + ), + ) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "==" & "<" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar', population: 200 }; + const notExpected = { foo: 'bar', population: 1000 }; + await Promise.all([colRef.add(notExpected), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where( + Filter.or( + Filter.and(Filter('foo', '==', 'bar'), Filter('population', '<', 201)), + Filter.and(Filter('foo', '==', 'not-this'), Filter('population', '<', 201)), + ), + ) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "==" & "<=" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar', population: 200 }; + const notExpected = { foo: 'bar', population: 1000 }; + await Promise.all([colRef.add(notExpected), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where( + Filter.or( + Filter.and(Filter('foo', '==', 'bar'), Filter('population', '<=', 200)), + Filter.and(Filter('foo', '==', 'not-this'), Filter('population', '<=', 200)), + ), + ) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "==" & ">=" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar', population: 200 }; + const notExpected = { foo: 'bar', population: 100 }; + await Promise.all([colRef.add(notExpected), colRef.add(expected), colRef.add(expected)]); + + const snapshot = await colRef + .where( + Filter.or( + Filter.and(Filter('foo', '==', 'bar'), Filter('population', '>=', 200)), + Filter.and(Filter('foo', '==', 'not-this'), Filter('population', '>=', 200)), + ), + ) + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + // Using OR and AND query combinations with Equals && "array-contains", "array-contains-any", "not-in" and "in" filters + + it('returns with where "==" & "array-contains" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const match = Date.now(); + await Promise.all([ + colRef.add({ foo: [1, '1', match] }), + colRef.add({ foo: [1, '2', match.toString()], bar: 'baz' }), + colRef.add({ foo: [1, '2', match.toString()], bar: 'baz' }), + ]); + + const snapshot = await colRef + .where( + Filter.or( + Filter.and(Filter('foo', 'array-contains', match.toString()), Filter('bar', '==', 'baz')), + Filter.and(Filter('foo', '==', 'not-this'), Filter('bar', '==', 'baz')), + ), + ) + .get(); + const expected = [1, '2', match.toString()]; + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().foo.should.eql(jet.contextify(expected)); + }); + }); + + it('returns with where "==" & "array-contains-any" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const match = Date.now(); + await Promise.all([ + colRef.add({ foo: [1, '1', match] }), + colRef.add({ foo: [1, '2'], bar: 'baz' }), + colRef.add({ foo: ['2', match.toString()], bar: 'baz' }), + ]); + + const snapshot = await colRef + .where( + Filter.or( + Filter.and( + Filter('foo', 'array-contains-any', [match.toString(), 1]), + Filter('bar', '==', 'baz'), + ), + Filter.and(Filter('foo', '==', 'not-this'), Filter('bar', '==', 'baz')), + ), + ) + .get(); + + snapshot.size.should.eql(2); + snapshot.docs[0].data().bar.should.equal('baz'); + snapshot.docs[1].data().bar.should.equal('baz'); + }); + + it('returns with where "==" & "not-in" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/not-in`); + + await Promise.all([ + colRef.add({ foo: 'bar', bar: 'baz' }), + colRef.add({ foo: 'thing', bar: 'baz' }), + colRef.add({ foo: 'bar', bar: 'baz' }), + colRef.add({ foo: 'yolo', bar: 'baz' }), + ]); + + const snapshot = await colRef + .where( + Filter.or( + Filter.and(Filter('foo', 'not-in', ['yolo', 'thing']), Filter('bar', '==', 'baz')), + Filter.and(Filter('foo', '==', 'not-this'), Filter('bar', '==', 'baz')), + ), + ) + .get(); + + snapshot.size.should.eql(2); + snapshot.docs[0].data().foo.should.equal('bar'); + snapshot.docs[1].data().foo.should.equal('bar'); + }); + + it('returns with where "==" & "in" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/in`); + + await Promise.all([ + colRef.add({ foo: 'bar', bar: 'baz' }), + colRef.add({ foo: 'thing', bar: 'baz' }), + colRef.add({ foo: 'yolo', bar: 'baz' }), + ]); + + const snapshot = await colRef + .where( + Filter.or( + Filter.and(Filter('foo', 'in', ['bar', 'yolo']), Filter('bar', '==', 'baz')), + Filter.and(Filter('foo', '==', 'not-this'), Filter('bar', '==', 'baz')), + ), + ) + .get(); + + snapshot.size.should.eql(2); + const result = snapshot.docs.map(d => d.data().foo); + result.should.containEql('bar'); + result.should.containEql('yolo'); + }); + + // Backwards compatibility Filter queries. Add where() queries and also use multiple where() queries with Filters to check it works + + it('backwards compatible with existing where() "==" && "==" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const expected = { foo: 'bar', bar: 'baz', existing: 'where' }; + await Promise.all([ + colRef.add({ foo: [1, '1', 'something'] }), + colRef.add(expected), + colRef.add(expected), + ]); + + const snapshot = await colRef + .where( + Filter.or( + Filter.and(Filter('foo', '==', 'bar'), Filter('bar', '==', 'baz')), + Filter.and(Filter('blah', '==', 'blah'), Filter('not', '==', 'this')), + ), + ) + .where('existing', '==', 'where') + .get(); + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().should.eql(jet.contextify(expected)); + }); + }); + + it('backwards compatible with existing where() query, returns with where "==" & "array-contains" filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const match = Date.now(); + await Promise.all([ + colRef.add({ foo: [1, '1', match] }), + colRef.add({ foo: [1, '2', match.toString()], bar: 'baz', existing: 'where' }), + colRef.add({ foo: [1, '2', match.toString()], bar: 'baz', existing: 'where' }), + ]); + + const snapshot = await colRef + .where( + Filter.or( + Filter.and(Filter('foo', 'array-contains', match.toString()), Filter('bar', '==', 'baz')), + Filter.and(Filter('foo', '==', 'not-this'), Filter('bar', '==', 'baz')), + ), + ) + .where('existing', '==', 'where') + .get(); + const expected = [1, '2', match.toString()]; + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().foo.should.eql(jet.contextify(expected)); + }); + }); + + it('backwards compatible whilst chaining Filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const match = Date.now(); + await Promise.all([ + colRef.add({ foo: [1, '1', match] }), + colRef.add({ + foo: [1, '2', match.toString()], + bar: 'baz', + existing: 'where', + another: 'filter', + }), + colRef.add({ + foo: [1, '2', match.toString()], + bar: 'baz', + existing: 'where', + another: 'filter', + }), + ]); + + const snapshot = await colRef + .where( + Filter.or( + Filter.and(Filter('foo', 'array-contains', match.toString()), Filter('bar', '==', 'baz')), + Filter.and(Filter('foo', '==', 'not-this'), Filter('bar', '==', 'baz')), + ), + ) + .where('existing', '==', 'where') + .where(Filter('another', '==', 'filter')) + .get(); + const expected = [1, '2', match.toString()]; + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().foo.should.eql(jet.contextify(expected)); + }); + }); + + it('backwards compatible whilst chaining AND Filter', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/filter/equals`); + + const match = Date.now(); + await Promise.all([ + colRef.add({ foo: [1, '1', match] }), + colRef.add({ + foo: [1, '2', match.toString()], + bar: 'baz', + existing: 'where', + another: 'filter', + chain: 'and', + }), + colRef.add({ + foo: [1, '2', match.toString()], + bar: 'baz', + existing: 'where', + another: 'filter', + chain: 'and', + }), + ]); + + const snapshot = await colRef + .where( + Filter.or( + Filter.and(Filter('foo', 'array-contains', match.toString()), Filter('bar', '==', 'baz')), + Filter.and(Filter('foo', '==', 'not-this'), Filter('bar', '==', 'baz')), + ), + ) + .where('existing', '==', 'where') + .where(Filter.and(Filter('another', '==', 'filter'), Filter('chain', '==', 'and'))) + .get(); + const expected = [1, '2', match.toString()]; + + snapshot.size.should.eql(2); + snapshot.forEach(s => { + s.data().foo.should.eql(jet.contextify(expected)); + }); + }); +}); diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreQuery.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreQuery.m index b69d2ce38a..8044060281 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreQuery.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreQuery.m @@ -51,33 +51,98 @@ - (void)buildQuery { - (void)applyFilters { for (NSDictionary *filter in _filters) { - NSArray *fieldPathArray = filter[@"fieldPath"]; + if (filter[@"fieldPath"]) { + NSArray *fieldPathArray = filter[@"fieldPath"]; + + FIRFieldPath *fieldPath = [[FIRFieldPath alloc] initWithFields:fieldPathArray]; + NSString *operator= filter[@"operator"]; + id value = [RNFBFirestoreSerialize parseTypeMap:_firestore typeMap:filter[@"value"]]; + if ([operator isEqualToString:@"EQUAL"]) { + _query = [_query queryWhereFieldPath:fieldPath isEqualTo:value]; + } else if ([operator isEqualToString:@"NOT_EQUAL"]) { + _query = [_query queryWhereFieldPath:fieldPath isNotEqualTo:value]; + } else if ([operator isEqualToString:@"GREATER_THAN"]) { + _query = [_query queryWhereFieldPath:fieldPath isGreaterThan:value]; + } else if ([operator isEqualToString:@"GREATER_THAN_OR_EQUAL"]) { + _query = [_query queryWhereFieldPath:fieldPath isGreaterThanOrEqualTo:value]; + } else if ([operator isEqualToString:@"LESS_THAN"]) { + _query = [_query queryWhereFieldPath:fieldPath isLessThan:value]; + } else if ([operator isEqualToString:@"LESS_THAN_OR_EQUAL"]) { + _query = [_query queryWhereFieldPath:fieldPath isLessThanOrEqualTo:value]; + } else if ([operator isEqualToString:@"ARRAY_CONTAINS"]) { + _query = [_query queryWhereFieldPath:fieldPath arrayContains:value]; + } else if ([operator isEqualToString:@"IN"]) { + _query = [_query queryWhereFieldPath:fieldPath in:value]; + } else if ([operator isEqualToString:@"ARRAY_CONTAINS_ANY"]) { + _query = [_query queryWhereFieldPath:fieldPath arrayContainsAny:value]; + } else if ([operator isEqualToString:@"NOT_IN"]) { + _query = [_query queryWhereFieldPath:fieldPath notIn:value]; + } + } else if (filter[@"operator"] && filter[@"queries"]) { + // Filter query + FIRFilter *generatedFilter = [self _applyFilterQueries:filter]; + _query = [_query queryWhereFilter:generatedFilter]; + } else { + @throw + [NSException exceptionWithName:@"InvalidOperator" + reason:@"The correct signature for a filter has not been parsed" + userInfo:nil]; + } + } +} + +- (FIRFilter *)_applyFilterQueries:(NSDictionary *)map { + if ([map objectForKey:@"fieldPath"]) { + NSString *operator= map[@"operator"]; + NSArray *fieldPathArray = map[@"fieldPath"][@"_segments"]; + FIRFieldPath *fieldPath = [[FIRFieldPath alloc] initWithFields:fieldPathArray]; - NSString *operator= filter[@"operator"]; - id value = [RNFBFirestoreSerialize parseTypeMap:_firestore typeMap:filter[@"value"]]; + id value = [RNFBFirestoreSerialize parseTypeMap:_firestore typeMap:map[@"value"]]; if ([operator isEqualToString:@"EQUAL"]) { - _query = [_query queryWhereFieldPath:fieldPath isEqualTo:value]; + return [FIRFilter filterWhereFieldPath:fieldPath isEqualTo:value]; } else if ([operator isEqualToString:@"NOT_EQUAL"]) { - _query = [_query queryWhereFieldPath:fieldPath isNotEqualTo:value]; - } else if ([operator isEqualToString:@"GREATER_THAN"]) { - _query = [_query queryWhereFieldPath:fieldPath isGreaterThan:value]; - } else if ([operator isEqualToString:@"GREATER_THAN_OR_EQUAL"]) { - _query = [_query queryWhereFieldPath:fieldPath isGreaterThanOrEqualTo:value]; + return [FIRFilter filterWhereFieldPath:fieldPath isNotEqualTo:value]; } else if ([operator isEqualToString:@"LESS_THAN"]) { - _query = [_query queryWhereFieldPath:fieldPath isLessThan:value]; + return [FIRFilter filterWhereFieldPath:fieldPath isLessThan:value]; } else if ([operator isEqualToString:@"LESS_THAN_OR_EQUAL"]) { - _query = [_query queryWhereFieldPath:fieldPath isLessThanOrEqualTo:value]; + return [FIRFilter filterWhereFieldPath:fieldPath isLessThanOrEqualTo:value]; + } else if ([operator isEqualToString:@"GREATER_THAN"]) { + return [FIRFilter filterWhereFieldPath:fieldPath isGreaterThan:value]; + } else if ([operator isEqualToString:@"GREATER_THAN_OR_EQUAL"]) { + return [FIRFilter filterWhereFieldPath:fieldPath isGreaterThanOrEqualTo:value]; } else if ([operator isEqualToString:@"ARRAY_CONTAINS"]) { - _query = [_query queryWhereFieldPath:fieldPath arrayContains:value]; - } else if ([operator isEqualToString:@"IN"]) { - _query = [_query queryWhereFieldPath:fieldPath in:value]; + return [FIRFilter filterWhereFieldPath:fieldPath arrayContains:value]; } else if ([operator isEqualToString:@"ARRAY_CONTAINS_ANY"]) { - _query = [_query queryWhereFieldPath:fieldPath arrayContainsAny:value]; + return [FIRFilter filterWhereFieldPath:fieldPath arrayContainsAny:value]; + } else if ([operator isEqualToString:@"IN"]) { + return [FIRFilter filterWhereFieldPath:fieldPath in:value]; } else if ([operator isEqualToString:@"NOT_IN"]) { - _query = [_query queryWhereFieldPath:fieldPath notIn:value]; + return [FIRFilter filterWhereFieldPath:fieldPath notIn:value]; + } else { + @throw [NSException exceptionWithName:@"InvalidOperator" + reason:@"Invalid operator" + userInfo:nil]; } } + + NSString *op = map[@"operator"]; + NSArray *> *queries = map[@"queries"]; + NSMutableArray *parsedFilters = [NSMutableArray array]; + + for (NSDictionary *query in queries) { + [parsedFilters addObject:[self _applyFilterQueries:query]]; + } + + if ([op isEqual:@"AND"]) { + return [FIRFilter andFilterWithFilters:parsedFilters]; + } + + if ([op isEqualToString:@"OR"]) { + return [FIRFilter orFilterWithFilters:parsedFilters]; + } + + @throw [NSException exceptionWithName:@"InvalidOperator" reason:@"Invalid operator" userInfo:nil]; } - (void)applyOrders { diff --git a/packages/firestore/lib/FirestoreFilter.js b/packages/firestore/lib/FirestoreFilter.js new file mode 100644 index 0000000000..92ca9d9c13 --- /dev/null +++ b/packages/firestore/lib/FirestoreFilter.js @@ -0,0 +1,151 @@ +import { isString, isNull, isUndefined, isArray } from '@react-native-firebase/app/lib/common'; +import { fromDotSeparatedString } from './FirestoreFieldPath'; +import { generateNativeData } from './utils/serialize'; +import { OPERATORS } from './FirestoreQueryModifiers'; +const AND_QUERY = 'AND'; +const OR_QUERY = 'OR'; + +export function Filter(fieldPath, operator, value) { + return new _Filter(fieldPath, operator, value); +} + +export function _Filter(fieldPath, operator, value, filterOperator, queries) { + if ([AND_QUERY, OR_QUERY].includes(filterOperator)) { + // AND or OR Filter (list of Filters) + this.operator = filterOperator; + this.queries = queries; + + this._toMap = function _toMap() { + return { + operator: this.operator, + queries: this.queries.map(query => query._toMap()), + }; + }; + + return this; + } else { + // Filter + this.fieldPath = fieldPath; + this.operator = operator; + this.value = value; + + this._toMap = function _toMap() { + return { + fieldPath: this.fieldPath, + operator: this.operator, + value: this.value, + }; + }; + + return this; + } +} + +Filter.and = function and(...queries) { + if (queries.length > 10 || queries.length < 2) { + throw new Error(`Expected 2-10 instances of Filter, but got ${queries.length} Filters`); + } + + const validateFilters = queries.every(filter => filter instanceof _Filter); + + if (!validateFilters) { + throw new Error('Expected every argument to be an instance of Filter'); + } + + return new _Filter(null, null, null, AND_QUERY, queries); +}; + +function hasOrOperator(obj) { + return obj.operator === 'OR' || (Array.isArray(obj.queries) && obj.queries.some(hasOrOperator)); +} + +Filter.or = function or(...queries) { + if (queries.length > 10 || queries.length < 2) { + throw new Error(`Expected 2-10 instances of Filter, but got ${queries.length} Filters`); + } + + const validateFilters = queries.every(filter => filter instanceof _Filter); + + if (!validateFilters) { + throw new Error('Expected every argument to be an instance of Filter'); + } + + const hasOr = queries.some(hasOrOperator); + + if (hasOr) { + throw new Error('OR Filters with nested OR Filters are not supported'); + } + + return new _Filter(null, null, null, OR_QUERY, queries); +}; + +function mapFieldQuery({ fieldPath, operator, value, queries }, modifiers) { + if (operator === AND_QUERY || operator === OR_QUERY) { + return { + operator, + queries: queries.map(filter => mapFieldQuery(filter, modifiers)), + }; + } + + let path; + if (isString(fieldPath)) { + try { + path = fromDotSeparatedString(fieldPath); + } catch (e) { + throw new Error(`first argument of Filter(*,_ , _) 'fieldPath' ${e.message}.`); + } + } else { + path = fieldPath; + } + + if (!modifiers.isValidOperator(operator)) { + throw new Error( + "second argument of Filter(*,_ , _) 'opStr' is invalid. Expected one of '==', '>', '>=', '<', '<=', '!=', 'array-contains', 'not-in', 'array-contains-any' or 'in'.", + ); + } + + if (isUndefined(value)) { + throw new Error("third argument of Filter(*,_ , _) 'value' argument expected."); + } + + if ( + isNull(value) && + !modifiers.isEqualOperator(operator) && + !modifiers.isNotEqualOperator(operator) + ) { + throw new Error( + "third argument of Filter(*,_ , _) 'value' is invalid. You can only perform equals comparisons on null", + ); + } + + if (modifiers.isInOperator(operator)) { + if (!isArray(value) || !value.length) { + throw new Error( + `third argument of Filter(*,_ , _) 'value' is invalid. A non-empty array is required for '${operator}' filters.`, + ); + } + + if (value.length > 10) { + throw new Error( + `third argument of Filter(*,_ , _) 'value' is invalid. '${operator}' filters support a maximum of 10 elements in the value array.`, + ); + } + } + + return { + fieldPath: path, + operator: OPERATORS[operator], + value: generateNativeData(value, true), + }; +} + +export function generateFilters(filter, modifiers) { + const filterMap = filter._toMap(); + + const queriesMaps = filterMap.queries.map(filter => mapFieldQuery(filter, modifiers)); + + return { + operator: filterMap.operator, + queries: queriesMaps, + }; +} diff --git a/packages/firestore/lib/FirestoreQuery.js b/packages/firestore/lib/FirestoreQuery.js index b7c9ce2f45..541f609637 100644 --- a/packages/firestore/lib/FirestoreQuery.js +++ b/packages/firestore/lib/FirestoreQuery.js @@ -28,6 +28,7 @@ import FirestoreDocumentSnapshot from './FirestoreDocumentSnapshot'; import FirestoreFieldPath, { fromDotSeparatedString } from './FirestoreFieldPath'; import FirestoreQuerySnapshot from './FirestoreQuerySnapshot'; import { parseSnapshotArgs } from './utils'; +import { _Filter, generateFilters } from './FirestoreFilter'; let _id = 0; @@ -406,62 +407,79 @@ export default class FirestoreQuery { ); } - where(fieldPath, opStr, value) { - if (!isString(fieldPath) && !(fieldPath instanceof FirestoreFieldPath)) { + where(fieldPathOrFilter, opStr, value) { + if ( + !isString(fieldPathOrFilter) && + !(fieldPathOrFilter instanceof FirestoreFieldPath) && + !(fieldPathOrFilter instanceof _Filter) + ) { throw new Error( - "firebase.firestore().collection().where(*) 'fieldPath' must be a string or instance of FieldPath.", + "firebase.firestore().collection().where(*) 'fieldPath' must be a string, instance of FieldPath or instance of Filter.", ); } - let path; - - if (isString(fieldPath)) { - try { - path = fromDotSeparatedString(fieldPath); - } catch (e) { - throw new Error(`firebase.firestore().collection().where(*) 'fieldPath' ${e.message}.`); - } + let modifiers; + if (fieldPathOrFilter instanceof _Filter && fieldPathOrFilter.queries) { + //AND or OR filter + const filters = generateFilters(fieldPathOrFilter, this._modifiers); + modifiers = this._modifiers._copy().filterWhere(filters); } else { - path = fieldPath; - } - - if (!this._modifiers.isValidOperator(opStr)) { - throw new Error( - "firebase.firestore().collection().where(_, *) 'opStr' is invalid. Expected one of '==', '>', '>=', '<', '<=', '!=', 'array-contains', 'not-in', 'array-contains-any' or 'in'.", - ); - } + if (fieldPathOrFilter instanceof _Filter) { + // Standard Filter. Usual path. + opStr = fieldPathOrFilter.operator; + value = fieldPathOrFilter.value; + fieldPathOrFilter = fieldPathOrFilter.fieldPath; + } + let path; - if (isUndefined(value)) { - throw new Error( - "firebase.firestore().collection().where(_, _, *) 'value' argument expected.", - ); - } + if (isString(fieldPathOrFilter)) { + try { + path = fromDotSeparatedString(fieldPathOrFilter); + } catch (e) { + throw new Error(`firebase.firestore().collection().where(*) 'fieldPath' ${e.message}.`); + } + } else { + path = fieldPathOrFilter; + } - if ( - isNull(value) && - !this._modifiers.isEqualOperator(opStr) && - !this._modifiers.isNotEqualOperator(opStr) - ) { - throw new Error( - "firebase.firestore().collection().where(_, _, *) 'value' is invalid. You can only perform equals comparisons on null", - ); - } + if (!this._modifiers.isValidOperator(opStr)) { + throw new Error( + "firebase.firestore().collection().where(_, *) 'opStr' is invalid. Expected one of '==', '>', '>=', '<', '<=', '!=', 'array-contains', 'not-in', 'array-contains-any' or 'in'.", + ); + } - if (this._modifiers.isInOperator(opStr)) { - if (!isArray(value) || !value.length) { + if (isUndefined(value)) { throw new Error( - `firebase.firestore().collection().where(_, _, *) 'value' is invalid. A non-empty array is required for '${opStr}' filters.`, + "firebase.firestore().collection().where(_, _, *) 'value' argument expected.", ); } - if (value.length > 10) { + if ( + isNull(value) && + !this._modifiers.isEqualOperator(opStr) && + !this._modifiers.isNotEqualOperator(opStr) + ) { throw new Error( - `firebase.firestore().collection().where(_, _, *) 'value' is invalid. '${opStr}' filters support a maximum of 10 elements in the value array.`, + "firebase.firestore().collection().where(_, _, *) 'value' is invalid. You can only perform equals comparisons on null", ); } - } - const modifiers = this._modifiers._copy().where(path, opStr, value); + if (this._modifiers.isInOperator(opStr)) { + if (!isArray(value) || !value.length) { + throw new Error( + `firebase.firestore().collection().where(_, _, *) 'value' is invalid. A non-empty array is required for '${opStr}' filters.`, + ); + } + + if (value.length > 10) { + throw new Error( + `firebase.firestore().collection().where(_, _, *) 'value' is invalid. '${opStr}' filters support a maximum of 10 elements in the value array.`, + ); + } + } + + modifiers = this._modifiers._copy().where(path, opStr, value); + } try { modifiers.validateWhere(); diff --git a/packages/firestore/lib/FirestoreQueryModifiers.js b/packages/firestore/lib/FirestoreQueryModifiers.js index 0488a7737d..70dee4a497 100644 --- a/packages/firestore/lib/FirestoreQueryModifiers.js +++ b/packages/firestore/lib/FirestoreQueryModifiers.js @@ -19,7 +19,7 @@ import { isNumber } from '@react-native-firebase/app/lib/common'; import FirestoreFieldPath, { DOCUMENT_ID } from './FirestoreFieldPath'; import { buildNativeArray, generateNativeData } from './utils/serialize'; -const OPERATORS = { +export const OPERATORS = { '==': 'EQUAL', '>': 'GREATER_THAN', '>=': 'GREATER_THAN_OR_EQUAL', @@ -57,6 +57,14 @@ export default class FirestoreQueryModifiers { this._startAfter = undefined; this._endAt = undefined; this._endBefore = undefined; + + // Pulled out of function to preserve their state + this.hasInequality = false; + this.hasNotEqual = false; + this.hasArrayContains = false; + this.hasArrayContainsAny = false; + this.hasIn = false; + this.hasNotIn = false; } _copy() { @@ -221,118 +229,129 @@ export default class FirestoreQueryModifiers { return this; } + filterWhere(filter) { + this._filters = this._filters.concat(filter); + return this; + } + validateWhere() { - let hasInequality; - let hasNotEqual; + if (this._filters.length > 0) { + this._filterCheck(this._filters); + } + } + + _filterCheck(filters) { + for (let i = 0; i < filters.length; i++) { + const filter = filters[i]; + + if (filter.queries) { + // Recursively check sub-queries for Filters + this._filterCheck(filter.queries); + // If it is a Filter query, skip the rest of the loop + continue; + } - for (let i = 0; i < this._filters.length; i++) { - const filter = this._filters[i]; // Skip if no inequality if (!INEQUALITY[filter.operator]) { continue; } if (filter.operator === OPERATORS['!=']) { - if (hasNotEqual) { + if (this.hasNotEqual) { throw new Error("Invalid query. You cannot use more than one '!=' inequality filter."); } //needs to set hasNotEqual = true before setting first hasInequality = filter. It is used in a condition check later - hasNotEqual = true; + this.hasNotEqual = true; } // Set the first inequality - if (!hasInequality) { - hasInequality = filter; + if (!this.hasInequality) { + this.hasInequality = filter; continue; } // Check the set value is the same as the new one - if (INEQUALITY[filter.operator] && hasInequality) { - if (hasInequality.fieldPath._toPath() !== filter.fieldPath._toPath()) { + if (INEQUALITY[filter.operator] && this.hasInequality) { + if (this.hasInequality.fieldPath._toPath() !== filter.fieldPath._toPath()) { throw new Error( - `Invalid query. All where filters with an inequality (<, <=, >, != or >=) must be on the same field. But you have inequality filters on '${hasInequality.fieldPath._toPath()}' and '${filter.fieldPath._toPath()}'`, + `Invalid query. All where filters with an inequality (<, <=, >, != or >=) must be on the same field. But you have inequality filters on '${this.hasInequality.fieldPath._toPath()}' and '${filter.fieldPath._toPath()}'`, ); } } } - let hasArrayContains; - let hasArrayContainsAny; - let hasIn; - let hasNotIn; - - for (let i = 0; i < this._filters.length; i++) { - const filter = this._filters[i]; + for (let i = 0; i < filters.length; i++) { + const filter = filters[i]; if (filter.operator === OPERATORS['array-contains']) { - if (hasArrayContains) { + if (this.hasArrayContains) { throw new Error('Invalid query. Queries only support a single array-contains filter.'); } - hasArrayContains = true; + this.hasArrayContains = true; } if (filter.operator === OPERATORS['array-contains-any']) { - if (hasArrayContainsAny) { + if (this.hasArrayContainsAny) { throw new Error( "Invalid query. You cannot use more than one 'array-contains-any' filter.", ); } - if (hasIn) { + if (this.hasIn) { throw new Error( "Invalid query. You cannot use 'array-contains-any' filters with 'in' filters.", ); } - if (hasNotIn) { + if (this.hasNotIn) { throw new Error( "Invalid query. You cannot use 'array-contains-any' filters with 'not-in' filters.", ); } - hasArrayContainsAny = true; + this.hasArrayContainsAny = true; } if (filter.operator === OPERATORS.in) { - if (hasIn) { + if (this.hasIn) { throw new Error("Invalid query. You cannot use more than one 'in' filter."); } - if (hasArrayContainsAny) { + if (this.hasArrayContainsAny) { throw new Error( "Invalid query. You cannot use 'in' filters with 'array-contains-any' filters.", ); } - if (hasNotIn) { + if (this.hasNotIn) { throw new Error("Invalid query. You cannot use 'in' filters with 'not-in' filters."); } - hasIn = true; + this.hasIn = true; } if (filter.operator === OPERATORS['not-in']) { - if (hasNotIn) { + if (this.hasNotIn) { throw new Error("Invalid query. You cannot use more than one 'not-in' filter."); } - if (hasNotEqual) { + if (this.hasNotEqual) { throw new Error( "Invalid query. You cannot use 'not-in' filters with '!=' inequality filters", ); } - if (hasIn) { + if (this.hasIn) { throw new Error("Invalid query. You cannot use 'not-in' filters with 'in' filters."); } - if (hasArrayContainsAny) { + if (this.hasArrayContainsAny) { throw new Error( "Invalid query. You cannot use 'not-in' filters with 'array-contains-any' filters.", ); } - hasNotIn = true; + this.hasNotIn = true; } } } @@ -356,6 +375,10 @@ export default class FirestoreQueryModifiers { } validateOrderBy() { + this._validateOrderByCheck(this._filters); + } + + _validateOrderByCheck(filters) { // Ensure order hasn't been called on the same field if (this._orders.length > 1) { const orders = this._orders.map($ => $.fieldPath._toPath()); @@ -367,13 +390,20 @@ export default class FirestoreQueryModifiers { } // Skip if no where filters - if (this._filters.length === 0) { + if (filters.length === 0) { return; } // Ensure the first order field path is equal to the inequality filter field path - for (let i = 0; i < this._filters.length; i++) { - const filter = this._filters[i]; + for (let i = 0; i < filters.length; i++) { + const filter = filters[i]; + + if (filter.queries) { + // Recursively check sub-queries for Filters + this._validateOrderByCheck(filter.queries); + // If it is a Filter query, skip the rest of the loop + continue; + } const filterFieldPath = filter.fieldPath._toPath(); for (let k = 0; k < this._orders.length; k++) { diff --git a/packages/firestore/lib/FirestoreStatics.js b/packages/firestore/lib/FirestoreStatics.js index 7dbaeb17a1..3f67672c51 100644 --- a/packages/firestore/lib/FirestoreStatics.js +++ b/packages/firestore/lib/FirestoreStatics.js @@ -21,13 +21,14 @@ import FirestoreFieldPath from './FirestoreFieldPath'; import FirestoreFieldValue from './FirestoreFieldValue'; import FirestoreGeoPoint from './FirestoreGeoPoint'; import FirestoreTimestamp from './FirestoreTimestamp'; - +import { Filter } from './FirestoreFilter'; export default { Blob: FirestoreBlob, FieldPath: FirestoreFieldPath, FieldValue: FirestoreFieldValue, GeoPoint: FirestoreGeoPoint, Timestamp: FirestoreTimestamp, + Filter: Filter, CACHE_SIZE_UNLIMITED: -1, diff --git a/packages/firestore/lib/index.d.ts b/packages/firestore/lib/index.d.ts index 2321c98529..98f0154312 100644 --- a/packages/firestore/lib/index.d.ts +++ b/packages/firestore/lib/index.d.ts @@ -49,6 +49,47 @@ import { ReactNativeFirebase } from '@react-native-firebase/app'; */ export namespace FirebaseFirestoreTypes { import FirebaseModule = ReactNativeFirebase.FirebaseModule; + /** + * An instance of Filter used to generate Firestore Filter queries. + */ + + export type QueryFilterType = 'OR' | 'AND'; + + export interface QueryFilterConstraint { + fieldPath: keyof T | FieldPath; + operator: WhereFilterOp; + value: any; + } + + export interface QueryCompositeFilterConstraint { + operator: QueryFilterType; + queries: QueryFilterConstraint[]; + } + /** + * The Filter functions used to generate an instance of Filter. + */ + export interface FilterFunction { + /** + * The Filter function used to generate an instance of Filter. + * e.g. Filter('name', '==', 'Ada') + */ + (fieldPath: keyof T | FieldPath, operator: WhereFilterOp, value: any): QueryFilterConstraint; + /** + * The Filter.or() static function used to generate a logical OR query using multiple Filter instances. + * e.g. Filter.or(Filter('name', '==', 'Ada'), Filter('name', '==', 'Bob')) + */ + or(...queries: QueryFilterConstraint[]): QueryCompositeFilterConstraint; + /** + * The Filter.and() static function used to generate a logical AND query using multiple Filter instances. + * e.g. Filter.and(Filter('name', '==', 'Ada'), Filter('name', '==', 'Bob')) + */ + and(...queries: QueryFilterConstraint[]): QueryCompositeFilterConstraint; + } + /** + * The Filter function used to generate an instance of Filter. + * e.g. Filter('name', '==', 'Ada') + */ + export const Filter: FilterFunction; /** * An immutable object representing an array of bytes. diff --git a/tests/ios/testing.xcodeproj/xcshareddata/xcschemes/testing.xcscheme b/tests/ios/testing.xcodeproj/xcshareddata/xcschemes/testing.xcscheme index 2220ac722d..616a5856bf 100644 --- a/tests/ios/testing.xcodeproj/xcshareddata/xcschemes/testing.xcscheme +++ b/tests/ios/testing.xcodeproj/xcshareddata/xcschemes/testing.xcscheme @@ -59,13 +59,6 @@ ReferencedContainer = "container:testing.xcodeproj"> - - - -