diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Filter.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/Filter.java index 70e6c77c00d..4843259e4a0 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Filter.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Filter.java @@ -17,37 +17,56 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; -import com.google.firebase.firestore.core.FieldFilter; +import com.google.firebase.firestore.core.FieldFilter.Operator; +import com.google.firestore.v1.StructuredQuery; +import java.util.Arrays; +import java.util.List; /** @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY) public class Filter { - private final FieldPath field; - private final FieldFilter.Operator operator; - private final Object value; + static class UnaryFilter extends Filter { + private final FieldPath field; + private final Operator operator; + private final Object value; - private Filter(@NonNull FieldPath field, FieldFilter.Operator operator, Object value) { - this.field = field; - this.operator = operator; - this.value = value; - } + public UnaryFilter(FieldPath field, Operator operator, @Nullable Object value) { + this.field = field; + this.operator = operator; + this.value = value; + } - /** @hide */ - @RestrictTo(RestrictTo.Scope.LIBRARY) - public FieldPath getField() { - return field; - } + public FieldPath getField() { + return field; + } + + public Operator getOperator() { + return operator; + } - /** @hide */ - @RestrictTo(RestrictTo.Scope.LIBRARY) - public FieldFilter.Operator getOperator() { - return operator; + @Nullable + public Object getValue() { + return value; + } } - /** @hide */ - @RestrictTo(RestrictTo.Scope.LIBRARY) - public Object getValue() { - return value; + static class CompositeFilter extends Filter { + private final List filters; + private final StructuredQuery.CompositeFilter.Operator operator; + + public CompositeFilter( + @NonNull List filters, StructuredQuery.CompositeFilter.Operator operator) { + this.filters = filters; + this.operator = operator; + } + + public List getFilters() { + return filters; + } + + public StructuredQuery.CompositeFilter.Operator getOperator() { + return operator; + } } @NonNull @@ -57,7 +76,7 @@ public static Filter equalTo(@NonNull String field, @Nullable Object value) { @NonNull public static Filter equalTo(@NonNull FieldPath fieldPath, @Nullable Object value) { - return new Filter(fieldPath, FieldFilter.Operator.EQUAL, value); + return new UnaryFilter(fieldPath, Operator.EQUAL, value); } @NonNull @@ -67,7 +86,7 @@ public static Filter notEqualTo(@NonNull String field, @Nullable Object value) { @NonNull public static Filter notEqualTo(@NonNull FieldPath fieldPath, @Nullable Object value) { - return new Filter(fieldPath, FieldFilter.Operator.NOT_EQUAL, value); + return new UnaryFilter(fieldPath, Operator.NOT_EQUAL, value); } @NonNull @@ -77,7 +96,7 @@ public static Filter greaterThan(@NonNull String field, @Nullable Object value) @NonNull public static Filter greaterThan(@NonNull FieldPath fieldPath, @Nullable Object value) { - return new Filter(fieldPath, FieldFilter.Operator.GREATER_THAN, value); + return new UnaryFilter(fieldPath, Operator.GREATER_THAN, value); } @NonNull @@ -87,7 +106,7 @@ public static Filter greaterThanOrEqualTo(@NonNull String field, @Nullable Objec @NonNull public static Filter greaterThanOrEqualTo(@NonNull FieldPath fieldPath, @Nullable Object value) { - return new Filter(fieldPath, FieldFilter.Operator.GREATER_THAN_OR_EQUAL, value); + return new UnaryFilter(fieldPath, Operator.GREATER_THAN_OR_EQUAL, value); } @NonNull @@ -97,7 +116,7 @@ public static Filter lessThan(@NonNull String field, @Nullable Object value) { @NonNull public static Filter lessThan(@NonNull FieldPath fieldPath, @Nullable Object value) { - return new Filter(fieldPath, FieldFilter.Operator.LESS_THAN, value); + return new UnaryFilter(fieldPath, Operator.LESS_THAN, value); } @NonNull @@ -107,7 +126,7 @@ public static Filter lessThanOrEqualTo(@NonNull String field, @Nullable Object v @NonNull public static Filter lessThanOrEqualTo(@NonNull FieldPath fieldPath, @Nullable Object value) { - return new Filter(fieldPath, FieldFilter.Operator.LESS_THAN_OR_EQUAL, value); + return new UnaryFilter(fieldPath, Operator.LESS_THAN_OR_EQUAL, value); } @NonNull @@ -117,7 +136,7 @@ public static Filter arrayContains(@NonNull String field, @Nullable Object value @NonNull public static Filter arrayContains(@NonNull FieldPath fieldPath, @Nullable Object value) { - return new Filter(fieldPath, FieldFilter.Operator.ARRAY_CONTAINS, value); + return new UnaryFilter(fieldPath, Operator.ARRAY_CONTAINS, value); } @NonNull @@ -127,7 +146,7 @@ public static Filter arrayContainsAny(@NonNull String field, @Nullable Object va @NonNull public static Filter arrayContainsAny(@NonNull FieldPath fieldPath, @Nullable Object value) { - return new Filter(fieldPath, FieldFilter.Operator.ARRAY_CONTAINS_ANY, value); + return new UnaryFilter(fieldPath, Operator.ARRAY_CONTAINS_ANY, value); } @NonNull @@ -137,7 +156,7 @@ public static Filter inArray(@NonNull String field, @Nullable Object value) { @NonNull public static Filter inArray(@NonNull FieldPath fieldPath, @Nullable Object value) { - return new Filter(fieldPath, FieldFilter.Operator.IN, value); + return new UnaryFilter(fieldPath, Operator.IN, value); } @NonNull @@ -147,6 +166,19 @@ public static Filter notInArray(@NonNull String field, @Nullable Object value) { @NonNull public static Filter notInArray(@NonNull FieldPath fieldPath, @Nullable Object value) { - return new Filter(fieldPath, FieldFilter.Operator.NOT_IN, value); + return new UnaryFilter(fieldPath, Operator.NOT_IN, value); + } + + @NonNull + public static Filter or(Filter... filters) { + // TODO(orquery): Change this to Operator.OR once it is available. + return new CompositeFilter( + Arrays.asList(filters), StructuredQuery.CompositeFilter.Operator.OPERATOR_UNSPECIFIED); + } + + @NonNull + public static Filter and(Filter... filters) { + return new CompositeFilter( + Arrays.asList(filters), StructuredQuery.CompositeFilter.Operator.AND); } } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Query.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/Query.java index 306a923f9f1..7c8f863a3b4 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Query.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Query.java @@ -28,6 +28,7 @@ import com.google.firebase.firestore.core.ActivityScope; import com.google.firebase.firestore.core.AsyncEventListener; import com.google.firebase.firestore.core.Bound; +import com.google.firebase.firestore.core.CompositeFilter; import com.google.firebase.firestore.core.EventManager.ListenOptions; import com.google.firebase.firestore.core.FieldFilter; import com.google.firebase.firestore.core.FieldFilter.Operator; @@ -387,15 +388,16 @@ public Query whereNotIn(@NonNull FieldPath fieldPath, @NonNull List parsedFilters = new ArrayList<>(); + for (Filter filter : compositeFilterData.getFilters()) { + com.google.firebase.firestore.core.Filter parsedFilter = parseFilter(filter); + if (!parsedFilter.getFilters().isEmpty()) { + parsedFilters.add(parsedFilter); + } + } + + // For composite filters containing 1 filter, return the only filter. + // For example: AND(FieldFilter1) == FieldFilter1 + if (parsedFilters.size() == 1) { + return parsedFilters.get(0); + } + return new CompositeFilter(parsedFilters, compositeFilterData.getOperator()); + } + + /** + * Takes a filter whose value has not been parsed, parses the value object and returns a + * FieldFilter or CompositeFilter with parsed values. + */ + private com.google.firebase.firestore.core.Filter parseFilter(Filter filter) { + hardAssert( + filter instanceof Filter.UnaryFilter || filter instanceof Filter.CompositeFilter, + "Parsing is only supported for Filter.UnaryFilter and Filter.CompositeFilter."); + if (filter instanceof Filter.UnaryFilter) { + return parseFieldFilter((Filter.UnaryFilter) filter); + } + return parseCompositeFilter((Filter.CompositeFilter) filter); + } + // TODO(orquery): This method will become public API. Change visibility and add documentation. private Query where(Filter filter) { - return new Query( - query.filter(parseFieldFilter(filter.getField(), filter.getOperator(), filter.getValue())), - firestore); + com.google.firebase.firestore.core.Filter parsedFilter = parseFilter(filter); + if (parsedFilter.getFilters().isEmpty()) { + // Return the existing query if not adding any more filters (e.g. an empty composite filter). + return this; + } + validateNewFilter(parsedFilter); + return new Query(query.filter(parsedFilter), firestore); } private void validateOrderByField(com.google.firebase.firestore.model.FieldPath field) { @@ -553,44 +594,71 @@ private List conflictingOps(Operator op) { } } - private void validateNewFilter(com.google.firebase.firestore.core.Filter filter) { - if (filter instanceof FieldFilter) { - FieldFilter fieldFilter = (FieldFilter) filter; - Operator filterOp = fieldFilter.getOperator(); - if (fieldFilter.isInequality()) { - com.google.firebase.firestore.model.FieldPath existingInequality = query.inequalityField(); - com.google.firebase.firestore.model.FieldPath newInequality = fieldFilter.getField(); - - if (existingInequality != null && !existingInequality.equals(newInequality)) { - throw new IllegalArgumentException( - String.format( - "All where filters with an inequality (notEqualTo, notIn, lessThan, " - + "lessThanOrEqualTo, greaterThan, or greaterThanOrEqualTo) must be on the " - + "same field. But you have filters on '%s' and '%s'", - existingInequality.canonicalString(), newInequality.canonicalString())); - } - com.google.firebase.firestore.model.FieldPath firstOrderByField = - query.getFirstOrderByField(); - if (firstOrderByField != null) { - validateOrderByFieldMatchesInequality(firstOrderByField, newInequality); - } + /** Checks that adding the given field filter to the given query yields a valid query */ + private void validateNewFieldFilter( + com.google.firebase.firestore.core.Query query, + com.google.firebase.firestore.core.FieldFilter fieldFilter) { + Operator filterOp = fieldFilter.getOperator(); + if (fieldFilter.isInequality()) { + com.google.firebase.firestore.model.FieldPath existingInequality = query.inequalityField(); + com.google.firebase.firestore.model.FieldPath newInequality = fieldFilter.getField(); + + if (existingInequality != null && !existingInequality.equals(newInequality)) { + throw new IllegalArgumentException( + String.format( + "All where filters with an inequality (notEqualTo, notIn, lessThan, " + + "lessThanOrEqualTo, greaterThan, or greaterThanOrEqualTo) must be on the " + + "same field. But you have filters on '%s' and '%s'", + existingInequality.canonicalString(), newInequality.canonicalString())); } - Operator conflictingOp = query.findFilterOperator(conflictingOps(filterOp)); - if (conflictingOp != null) { - // We special case when it's a duplicate op to give a slightly clearer error message. - if (conflictingOp == filterOp) { - throw new IllegalArgumentException( - "Invalid Query. You cannot use more than one '" + filterOp.toString() + "' filter."); - } else { - throw new IllegalArgumentException( - "Invalid Query. You cannot use '" - + filterOp.toString() - + "' filters with '" - + conflictingOp.toString() - + "' filters."); + com.google.firebase.firestore.model.FieldPath firstOrderByField = + query.getFirstOrderByField(); + if (firstOrderByField != null) { + validateOrderByFieldMatchesInequality(firstOrderByField, newInequality); + } + } + Operator conflictingOp = findFilterWithOperator(query.getFilters(), conflictingOps(filterOp)); + if (conflictingOp != null) { + // We special case when it's a duplicate op to give a slightly clearer error message. + if (conflictingOp == filterOp) { + throw new IllegalArgumentException( + "Invalid Query. You cannot use more than one '" + filterOp.toString() + "' filter."); + } else { + throw new IllegalArgumentException( + "Invalid Query. You cannot use '" + + filterOp.toString() + + "' filters with '" + + conflictingOp.toString() + + "' filters."); + } + } + } + + /** Checks that adding the given filter to the current query is valid */ + private void validateNewFilter(com.google.firebase.firestore.core.Filter filter) { + com.google.firebase.firestore.core.Query testQuery = query; + for (FieldFilter subfilter : filter.getFlattenedFilters()) { + validateNewFieldFilter(testQuery, subfilter); + testQuery = query.filter(subfilter); + } + } + + /** + * Checks if any of the provided filter operators are included in the given list of filters and + * returns the first one that is, or null if none are. + */ + @Nullable + private Operator findFilterWithOperator( + List filters, List operators) { + for (com.google.firebase.firestore.core.Filter filter : filters) { + if (filter instanceof FieldFilter) { + Operator filterOp = ((FieldFilter) filter).getOperator(); + if (operators.contains(filterOp)) { + return filterOp; } } } + return null; } /** diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/CompositeFilter.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/CompositeFilter.java new file mode 100644 index 00000000000..580c71cc09c --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/CompositeFilter.java @@ -0,0 +1,135 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.firestore.core; + +import android.text.TextUtils; +import androidx.annotation.Nullable; +import com.google.firebase.firestore.model.Document; +import com.google.firebase.firestore.model.FieldPath; +import com.google.firebase.firestore.util.Function; +import com.google.firestore.v1.StructuredQuery.CompositeFilter.Operator; +import java.util.ArrayList; +import java.util.List; + +/** Represents a filter that is the conjunction or disjunction of other filters. */ +public class CompositeFilter extends Filter { + private final List filters; + private final Operator operator; + + public CompositeFilter(List filters, Operator operator) { + this.filters = filters; + this.operator = operator; + } + + @Override + public List getFilters() { + return filters; + } + + public Operator getOperator() { + return operator; + } + + @Override + public List getFlattenedFilters() { + // TODO(orquery): memoize this result if this method is used more than once. + List result = new ArrayList<>(); + for (Filter subfilter : filters) { + result.addAll(subfilter.getFlattenedFilters()); + } + return result; + } + + /** + * Returns the first inequality filter contained within this composite filter. Returns {@code + * null} if it does not contain any inequalities. + */ + @Override + public FieldPath getFirstInequalityField() { + FieldFilter found = findFirstMatchingFilter(f -> f.isInequality()); + if (found != null) { + return found.getField(); + } + return null; + } + + public boolean isConjunction() { + return operator == Operator.AND; + } + + public boolean isDisjunction() { + // TODO(orquery): Replace with Operator.OR. + return operator == Operator.OPERATOR_UNSPECIFIED; + } + + /** + * Performs a depth-first search to find and return the first FieldFilter in the composite filter + * that satisfies the condition. Returns {@code null} if none of the FieldFilters satisfy the + * condition. + */ + @Nullable + private FieldFilter findFirstMatchingFilter(Function condition) { + for (Filter filter : filters) { + if (filter instanceof FieldFilter && condition.apply(((FieldFilter) filter))) { + return (FieldFilter) filter; + } else if (filter instanceof CompositeFilter) { + FieldFilter found = ((CompositeFilter) filter).findFirstMatchingFilter(condition); + if (found != null) { + return found; + } + } + } + return null; + } + + @Override + public boolean matches(Document doc) { + if (isConjunction()) { + // For conjunctions, all filters must match, so return false if any filter doesn't match. + for (Filter filter : filters) { + if (!filter.matches(doc)) { + return false; + } + } + return true; + } else { + // For disjunctions, at least one filter should match. + for (Filter filter : filters) { + if (filter.matches(doc)) { + return true; + } + } + return false; + } + } + + @Override + public String getCanonicalId() { + // TODO(orquery): Add special case for flat AND filters. + + List canonicalIds = new ArrayList<>(); + for (Filter filter : filters) canonicalIds.add(filter.getCanonicalId()); + StringBuilder builder = new StringBuilder(); + builder.append(isConjunction() ? "and(" : "or("); + TextUtils.join(",", canonicalIds); + builder.append(")"); + return builder.toString(); + } + + @Override + public String toString() { + return getCanonicalId(); + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FieldFilter.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FieldFilter.java index ce528bb9788..e57a5369dd7 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FieldFilter.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FieldFilter.java @@ -22,6 +22,8 @@ import com.google.firebase.firestore.util.Assert; import com.google.firestore.v1.Value; import java.util.Arrays; +import java.util.Collections; +import java.util.List; /** Represents a filter to be applied to query. */ public class FieldFilter extends Filter { @@ -158,6 +160,26 @@ public String getCanonicalId() { return getField().canonicalString() + getOperator().toString() + Values.canonicalId(getValue()); } + @Override + public List getFlattenedFilters() { + // This is already a field filter, so we return a list of size one. + return Collections.singletonList(this); + } + + @Override + public List getFilters() { + // This is the only filter within this object, so we return a list of size one. + return Collections.singletonList(this); + } + + @Override + public FieldPath getFirstInequalityField() { + if (isInequality()) { + return getField(); + } + return null; + } + @Override public String toString() { return field.canonicalString() + " " + operator + " " + Values.canonicalId(value); diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Filter.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Filter.java index 6f3ab2a7700..b69708f5245 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Filter.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Filter.java @@ -14,7 +14,10 @@ package com.google.firebase.firestore.core; +import androidx.annotation.Nullable; import com.google.firebase.firestore.model.Document; +import com.google.firebase.firestore.model.FieldPath; +import java.util.List; public abstract class Filter { /** Returns true if a document matches the filter. */ @@ -22,4 +25,14 @@ public abstract class Filter { /** A unique ID identifying the filter; used when serializing queries. */ public abstract String getCanonicalId(); + + /** Returns a list of all field filters that are contained within this filter */ + public abstract List getFlattenedFilters(); + + /** Returns a list of all filters that are contained within this filter */ + public abstract List getFilters(); + + /** Returns the field of the first filter that's an inequality, or null if none. */ + @Nullable + public abstract FieldPath getFirstInequalityField(); } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Query.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Query.java index b508b68b23f..896e8292cdb 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Query.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Query.java @@ -17,7 +17,6 @@ import static com.google.firebase.firestore.util.Assert.hardAssert; import androidx.annotation.Nullable; -import com.google.firebase.firestore.core.FieldFilter.Operator; import com.google.firebase.firestore.core.OrderBy.Direction; import com.google.firebase.firestore.model.Document; import com.google.firebase.firestore.model.DocumentKey; @@ -201,28 +200,9 @@ public FieldPath getFirstOrderByField() { @Nullable public FieldPath inequalityField() { for (Filter filter : filters) { - if (filter instanceof FieldFilter) { - FieldFilter fieldfilter = (FieldFilter) filter; - if (fieldfilter.isInequality()) { - return fieldfilter.getField(); - } - } - } - return null; - } - - /** - * Checks if any of the provided filter operators are included in the query and returns the first - * one that is, or null if none are. - */ - @Nullable - public Operator findFilterOperator(List operators) { - for (Filter filter : filters) { - if (filter instanceof FieldFilter) { - Operator filterOp = ((FieldFilter) filter).getOperator(); - if (operators.contains(filterOp)) { - return filterOp; - } + FieldPath result = filter.getFirstInequalityField(); + if (result != null) { + return result; } } return null; @@ -236,11 +216,7 @@ public Operator findFilterOperator(List operators) { */ public Query filter(Filter filter) { hardAssert(!isDocumentQuery(), "No filter is allowed for document query"); - FieldPath newInequalityField = null; - if (filter instanceof FieldFilter && ((FieldFilter) filter).isInequality()) { - newInequalityField = ((FieldFilter) filter).getField(); - } - + FieldPath newInequalityField = filter.getFirstInequalityField(); FieldPath queryInequalityField = inequalityField(); Assert.hardAssert( queryInequalityField == null @@ -543,6 +519,16 @@ public Target toTarget() { return this.memoizedTarget; } + /** Returns true if the query contains any composite filters (AND/OR). Returns false otherwise. */ + public boolean containsCompositeFilters() { + for (Filter filter : filters) { + if (filter instanceof CompositeFilter) { + return true; + } + } + return false; + } + /** * Returns a canonical string representing this query. This should match the iOS and Android * canonical ids for a query exactly. diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/QueryEngine.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/QueryEngine.java index e078d706491..b3ef29dd168 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/QueryEngine.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/QueryEngine.java @@ -105,7 +105,8 @@ public ImmutableSortedMap getDocumentsMatchingQuery( */ private @Nullable ImmutableSortedMap performQueryUsingIndex( Query query, Target target) { - if (query.matchesAllDocuments()) { + // TODO(orquery): Update this condition when we are able to serve or queries from the index. + if (query.matchesAllDocuments() || query.containsCompositeFilters()) { // Don't use index queries that can be executed by scanning the collection. return null; } @@ -130,7 +131,8 @@ public ImmutableSortedMap getDocumentsMatchingQuery( Query query, ImmutableSortedSet remoteKeys, SnapshotVersion lastLimboFreeSnapshotVersion) { - if (query.matchesAllDocuments()) { + // TODO(orquery): Update this condition when we are able to serve or queries from the index. + if (query.matchesAllDocuments() || query.containsCompositeFilters()) { // Don't use index queries that can be executed by scanning the collection. return null; }