diff --git a/README.md b/README.md index 970c84fb8f..82685dad9d 100644 --- a/README.md +++ b/README.md @@ -49,20 +49,20 @@ If you are using Maven without BOM, add this to your dependencies: If you are using Gradle 5.x or later, add this to your dependencies ```Groovy -implementation platform('com.google.cloud:libraries-bom:25.4.0') +implementation platform('com.google.cloud:libraries-bom:26.0.0') implementation 'com.google.cloud:google-cloud-firestore' ``` If you are using Gradle without BOM, add this to your dependencies ```Groovy -implementation 'com.google.cloud:google-cloud-firestore:3.2.0' +implementation 'com.google.cloud:google-cloud-firestore:3.3.0' ``` If you are using SBT, add this to your dependencies ```Scala -libraryDependencies += "com.google.cloud" % "google-cloud-firestore" % "3.2.0" +libraryDependencies += "com.google.cloud" % "google-cloud-firestore" % "3.3.0" ``` ## Authentication diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Filter.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Filter.java new file mode 100644 index 0000000000..0e25187dd5 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Filter.java @@ -0,0 +1,184 @@ +/* + * Copyright 2022 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.cloud.firestore; + +import com.google.firestore.v1.StructuredQuery; +import com.google.firestore.v1.StructuredQuery.FieldFilter.Operator; +import java.util.Arrays; +import java.util.List; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** @hide */ +public class Filter { + static class UnaryFilter extends Filter { + private final FieldPath field; + private final Operator operator; + private final Object value; + + public UnaryFilter(FieldPath field, Operator operator, @Nullable Object value) { + this.field = field; + this.operator = operator; + this.value = value; + } + + public FieldPath getField() { + return field; + } + + public Operator getOperator() { + return operator; + } + + @Nullable + 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 + public static Filter equalTo(@Nonnull String field, @Nullable Object value) { + return equalTo(FieldPath.fromDotSeparatedString(field), value); + } + + @Nonnull + public static Filter equalTo(@Nonnull FieldPath fieldPath, @Nullable Object value) { + return new UnaryFilter(fieldPath, Operator.EQUAL, value); + } + + @Nonnull + public static Filter notEqualTo(@Nonnull String field, @Nullable Object value) { + return notEqualTo(FieldPath.fromDotSeparatedString(field), value); + } + + @Nonnull + public static Filter notEqualTo(@Nonnull FieldPath fieldPath, @Nullable Object value) { + return new UnaryFilter(fieldPath, Operator.NOT_EQUAL, value); + } + + @Nonnull + public static Filter greaterThan(@Nonnull String field, @Nullable Object value) { + return greaterThan(FieldPath.fromDotSeparatedString(field), value); + } + + @Nonnull + public static Filter greaterThan(@Nonnull FieldPath fieldPath, @Nullable Object value) { + return new UnaryFilter(fieldPath, Operator.GREATER_THAN, value); + } + + @Nonnull + public static Filter greaterThanOrEqualTo(@Nonnull String field, @Nullable Object value) { + return greaterThanOrEqualTo(FieldPath.fromDotSeparatedString(field), value); + } + + @Nonnull + public static Filter greaterThanOrEqualTo(@Nonnull FieldPath fieldPath, @Nullable Object value) { + return new UnaryFilter(fieldPath, Operator.GREATER_THAN_OR_EQUAL, value); + } + + @Nonnull + public static Filter lessThan(@Nonnull String field, @Nullable Object value) { + return lessThan(FieldPath.fromDotSeparatedString(field), value); + } + + @Nonnull + public static Filter lessThan(@Nonnull FieldPath fieldPath, @Nullable Object value) { + return new UnaryFilter(fieldPath, Operator.LESS_THAN, value); + } + + @Nonnull + public static Filter lessThanOrEqualTo(@Nonnull String field, @Nullable Object value) { + return lessThanOrEqualTo(FieldPath.fromDotSeparatedString(field), value); + } + + @Nonnull + public static Filter lessThanOrEqualTo(@Nonnull FieldPath fieldPath, @Nullable Object value) { + return new UnaryFilter(fieldPath, Operator.LESS_THAN_OR_EQUAL, value); + } + + @Nonnull + public static Filter arrayContains(@Nonnull String field, @Nullable Object value) { + return arrayContains(FieldPath.fromDotSeparatedString(field), value); + } + + @Nonnull + public static Filter arrayContains(@Nonnull FieldPath fieldPath, @Nullable Object value) { + return new UnaryFilter(fieldPath, Operator.ARRAY_CONTAINS, value); + } + + @Nonnull + public static Filter arrayContainsAny(@Nonnull String field, @Nullable Object value) { + return arrayContainsAny(FieldPath.fromDotSeparatedString(field), value); + } + + @Nonnull + public static Filter arrayContainsAny(@Nonnull FieldPath fieldPath, @Nullable Object value) { + return new UnaryFilter(fieldPath, Operator.ARRAY_CONTAINS_ANY, value); + } + + @Nonnull + public static Filter inArray(@Nonnull String field, @Nullable Object value) { + return inArray(FieldPath.fromDotSeparatedString(field), value); + } + + @Nonnull + public static Filter inArray(@Nonnull FieldPath fieldPath, @Nullable Object value) { + return new UnaryFilter(fieldPath, Operator.IN, value); + } + + @Nonnull + public static Filter notInArray(@Nonnull String field, @Nullable Object value) { + return notInArray(FieldPath.fromDotSeparatedString(field), value); + } + + @Nonnull + public static Filter notInArray(@Nonnull FieldPath fieldPath, @Nullable Object 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/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java index 048f5ca695..5218b54e45 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java @@ -50,6 +50,7 @@ import com.google.firestore.v1.StructuredQuery; import com.google.firestore.v1.StructuredQuery.CollectionSelector; import com.google.firestore.v1.StructuredQuery.CompositeFilter; +import com.google.firestore.v1.StructuredQuery.FieldFilter.Operator; import com.google.firestore.v1.StructuredQuery.FieldReference; import com.google.firestore.v1.StructuredQuery.Filter; import com.google.firestore.v1.StructuredQuery.Order; @@ -60,6 +61,7 @@ import io.opencensus.trace.AttributeValue; import io.opencensus.trace.Tracing; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.Iterator; import java.util.List; @@ -97,38 +99,138 @@ StructuredQuery.Direction getDirection() { } } - abstract static class FieldFilter { - protected final FieldReference fieldReference; + abstract static class FilterInternal { + /** Returns a list of all field filters that are contained within this filter */ + abstract List getFlattenedFilters(); - FieldFilter(FieldReference fieldReference) { - this.fieldReference = fieldReference; - } + /** Returns a list of all filters that are contained within this filter */ + abstract List getFilters(); - static FieldFilter fromProto(StructuredQuery.Filter filter) { - Preconditions.checkArgument( - !filter.hasCompositeFilter(), "Cannot deserialize nested composite filters"); + /** Returns the field of the first filter that's an inequality, or null if none. */ + @Nullable + abstract FieldReference getFirstInequalityField(); + + /** Returns the proto representation of this filter */ + abstract Filter toProto(); + + static FilterInternal fromProto(StructuredQuery.Filter filter) { + if (filter.hasUnaryFilter()) { + return new UnaryFilterInternal( + filter.getUnaryFilter().getField(), filter.getUnaryFilter().getOp()); + } if (filter.hasFieldFilter()) { - return new ComparisonFilter( + return new ComparisonFilterInternal( filter.getFieldFilter().getField(), filter.getFieldFilter().getOp(), filter.getFieldFilter().getValue()); - } else { - Preconditions.checkState(filter.hasUnaryFilter(), "Expected unary of field filter"); - return new UnaryFilter(filter.getUnaryFilter().getField(), filter.getUnaryFilter().getOp()); } + + // `filter` must be a composite filter. + Preconditions.checkArgument(filter.hasCompositeFilter(), "Unknown filter type."); + CompositeFilter compositeFilter = filter.getCompositeFilter(); + // A composite filter with only 1 sub-filter should be reduced to its sub-filter. + if (compositeFilter.getFiltersCount() == 1) { + return FilterInternal.fromProto(compositeFilter.getFiltersList().get(0)); + } + List filters = new ArrayList<>(); + for (StructuredQuery.Filter subfilter : compositeFilter.getFiltersList()) { + filters.add(FilterInternal.fromProto(subfilter)); + } + return new CompositeFilterInternal(filters, compositeFilter.getOp()); + } + } + + static class CompositeFilterInternal extends FilterInternal { + private final List filters; + private final StructuredQuery.CompositeFilter.Operator operator; + + // Memoized list of all field filters that can be found by traversing the tree of filters + // contained in this composite filter. + private List memoizedFlattenedFilters; + + public CompositeFilterInternal( + List filters, StructuredQuery.CompositeFilter.Operator operator) { + this.filters = filters; + this.operator = operator; + } + + @Override + public List getFilters() { + return filters; + } + + @Nullable + @Override + public FieldReference getFirstInequalityField() { + for (FieldFilterInternal fieldFilter : getFlattenedFilters()) { + if (fieldFilter.isInequalityFilter()) { + return fieldFilter.fieldReference; + } + } + return null; + } + + public boolean isConjunction() { + return operator == CompositeFilter.Operator.AND; + } + + @Override + public List getFlattenedFilters() { + if (memoizedFlattenedFilters != null) { + return memoizedFlattenedFilters; + } + memoizedFlattenedFilters = new ArrayList<>(); + for (FilterInternal subfilter : filters) { + memoizedFlattenedFilters.addAll(subfilter.getFlattenedFilters()); + } + return memoizedFlattenedFilters; + } + + @Override + Filter toProto() { + // A composite filter that contains one sub-filter is equivalent to the sub-filter. + if (filters.size() == 1) { + return filters.get(0).toProto(); + } + + Filter.Builder protoFilter = Filter.newBuilder(); + StructuredQuery.CompositeFilter.Builder compositeFilter = + StructuredQuery.CompositeFilter.newBuilder(); + compositeFilter.setOp(operator); + for (FilterInternal filter : filters) { + compositeFilter.addFilters(filter.toProto()); + } + protoFilter.setCompositeFilter(compositeFilter.build()); + return protoFilter.build(); + } + } + + abstract static class FieldFilterInternal extends FilterInternal { + protected final FieldReference fieldReference; + + FieldFilterInternal(FieldReference fieldReference) { + this.fieldReference = fieldReference; } abstract boolean isInequalityFilter(); - abstract Filter toProto(); + public List getFilters() { + return Collections.singletonList(this); + } + + @Override + public List getFlattenedFilters() { + return Collections.singletonList(this); + } } - private static class UnaryFilter extends FieldFilter { + private static class UnaryFilterInternal extends FieldFilterInternal { private final StructuredQuery.UnaryFilter.Operator operator; - UnaryFilter(FieldReference fieldReference, StructuredQuery.UnaryFilter.Operator operator) { + UnaryFilterInternal( + FieldReference fieldReference, StructuredQuery.UnaryFilter.Operator operator) { super(fieldReference); this.operator = operator; } @@ -138,6 +240,12 @@ boolean isInequalityFilter() { return false; } + @Nullable + @Override + public FieldReference getFirstInequalityField() { + return null; + } + Filter toProto() { Filter.Builder result = Filter.newBuilder(); result.getUnaryFilterBuilder().setField(fieldReference).setOp(operator); @@ -149,20 +257,20 @@ public boolean equals(Object o) { if (this == o) { return true; } - if (!(o instanceof UnaryFilter)) { + if (!(o instanceof UnaryFilterInternal)) { return false; } - UnaryFilter other = (UnaryFilter) o; + UnaryFilterInternal other = (UnaryFilterInternal) o; return Objects.equals(fieldReference, other.fieldReference) && Objects.equals(operator, other.operator); } } - static class ComparisonFilter extends FieldFilter { + static class ComparisonFilterInternal extends FieldFilterInternal { final StructuredQuery.FieldFilter.Operator operator; final Value value; - ComparisonFilter( + ComparisonFilterInternal( FieldReference fieldReference, StructuredQuery.FieldFilter.Operator operator, Value value) { super(fieldReference); this.value = value; @@ -177,6 +285,15 @@ boolean isInequalityFilter() { || operator.equals(LESS_THAN_OR_EQUAL); } + @Nullable + @Override + public FieldReference getFirstInequalityField() { + if (isInequalityFilter()) { + return fieldReference; + } + return null; + } + Filter toProto() { Filter.Builder result = Filter.newBuilder(); result.getFieldFilterBuilder().setField(fieldReference).setValue(value).setOp(operator); @@ -188,10 +305,10 @@ public boolean equals(Object o) { if (this == o) { return true; } - if (!(o instanceof ComparisonFilter)) { + if (!(o instanceof ComparisonFilterInternal)) { return false; } - ComparisonFilter other = (ComparisonFilter) o; + ComparisonFilterInternal other = (ComparisonFilterInternal) o; return Objects.equals(fieldReference, other.fieldReference) && Objects.equals(operator, other.operator) && Objects.equals(value, other.value); @@ -252,7 +369,7 @@ abstract static class QueryOptions { abstract @Nullable Cursor getEndCursor(); - abstract ImmutableList getFieldFilters(); + abstract ImmutableList getFilters(); abstract ImmutableList getFieldOrders(); @@ -272,7 +389,7 @@ static Builder builder() { .setAllDescendants(false) .setLimitType(LimitType.First) .setFieldOrders(ImmutableList.of()) - .setFieldFilters(ImmutableList.of()) + .setFilters(ImmutableList.of()) .setFieldProjections(ImmutableList.of()) .setKindless(false) .setRequireConsistency(true); @@ -298,7 +415,7 @@ abstract static class Builder { abstract Builder setEndCursor(@Nullable Cursor value); - abstract Builder setFieldFilters(ImmutableList value); + abstract Builder setFilters(ImmutableList value); abstract Builder setFieldOrders(ImmutableList value); @@ -348,9 +465,10 @@ private ImmutableList createImplicitOrderBy() { // If no explicit ordering is specified, use the first inequality to define an implicit order. if (implicitOrders.isEmpty()) { - for (FieldFilter fieldFilter : options.getFieldFilters()) { - if (fieldFilter.isInequalityFilter()) { - implicitOrders.add(new FieldOrder(fieldFilter.fieldReference, Direction.ASCENDING)); + for (FilterInternal filter : options.getFilters()) { + FieldReference fieldReference = filter.getFirstInequalityField(); + if (fieldReference != null) { + implicitOrders.add(new FieldOrder(fieldReference, Direction.ASCENDING)); break; } } @@ -499,23 +617,7 @@ public Query whereEqualTo(@Nonnull String field, @Nullable Object value) { */ @Nonnull public Query whereEqualTo(@Nonnull FieldPath fieldPath, @Nullable Object value) { - Preconditions.checkState( - options.getStartCursor() == null && options.getEndCursor() == null, - "Cannot call whereEqualTo() after defining a boundary with startAt(), " - + "startAfter(), endBefore() or endAt()."); - - if (isUnaryComparison(value)) { - Builder newOptions = options.toBuilder(); - StructuredQuery.UnaryFilter.Operator op = - value == null - ? StructuredQuery.UnaryFilter.Operator.IS_NULL - : StructuredQuery.UnaryFilter.Operator.IS_NAN; - UnaryFilter newFieldFilter = new UnaryFilter(fieldPath.toProto(), op); - newOptions.setFieldFilters(append(options.getFieldFilters(), newFieldFilter)); - return new Query(rpcContext, newOptions.build()); - } else { - return whereHelper(fieldPath, EQUAL, value); - } + return where(new com.google.cloud.firestore.Filter.UnaryFilter(fieldPath, EQUAL, value)); } /** @@ -541,23 +643,7 @@ public Query whereNotEqualTo(@Nonnull String field, @Nullable Object value) { */ @Nonnull public Query whereNotEqualTo(@Nonnull FieldPath fieldPath, @Nullable Object value) { - Preconditions.checkState( - options.getStartCursor() == null && options.getEndCursor() == null, - "Cannot call whereNotEqualTo() after defining a boundary with startAt(), " - + "startAfter(), endBefore() or endAt()."); - - if (isUnaryComparison(value)) { - Builder newOptions = options.toBuilder(); - StructuredQuery.UnaryFilter.Operator op = - value == null - ? StructuredQuery.UnaryFilter.Operator.IS_NOT_NULL - : StructuredQuery.UnaryFilter.Operator.IS_NOT_NAN; - UnaryFilter newFieldFilter = new UnaryFilter(fieldPath.toProto(), op); - newOptions.setFieldFilters(append(options.getFieldFilters(), newFieldFilter)); - return new Query(rpcContext, newOptions.build()); - } else { - return whereHelper(fieldPath, NOT_EQUAL, value); - } + return where(new com.google.cloud.firestore.Filter.UnaryFilter(fieldPath, NOT_EQUAL, value)); } /** @@ -583,11 +669,7 @@ public Query whereLessThan(@Nonnull String field, @Nonnull Object value) { */ @Nonnull public Query whereLessThan(@Nonnull FieldPath fieldPath, @Nonnull Object value) { - Preconditions.checkState( - options.getStartCursor() == null && options.getEndCursor() == null, - "Cannot call whereLessThan() after defining a boundary with startAt(), " - + "startAfter(), endBefore() or endAt()."); - return whereHelper(fieldPath, LESS_THAN, value); + return where(new com.google.cloud.firestore.Filter.UnaryFilter(fieldPath, LESS_THAN, value)); } /** @@ -613,11 +695,8 @@ public Query whereLessThanOrEqualTo(@Nonnull String field, @Nonnull Object value */ @Nonnull public Query whereLessThanOrEqualTo(@Nonnull FieldPath fieldPath, @Nonnull Object value) { - Preconditions.checkState( - options.getStartCursor() == null && options.getEndCursor() == null, - "Cannot call whereLessThanOrEqualTo() after defining a boundary with startAt(), " - + "startAfter(), endBefore() or endAt()."); - return whereHelper(fieldPath, LESS_THAN_OR_EQUAL, value); + return where( + new com.google.cloud.firestore.Filter.UnaryFilter(fieldPath, LESS_THAN_OR_EQUAL, value)); } /** @@ -643,11 +722,7 @@ public Query whereGreaterThan(@Nonnull String field, @Nonnull Object value) { */ @Nonnull public Query whereGreaterThan(@Nonnull FieldPath fieldPath, @Nonnull Object value) { - Preconditions.checkState( - options.getStartCursor() == null && options.getEndCursor() == null, - "Cannot call whereGreaterThan() after defining a boundary with startAt(), " - + "startAfter(), endBefore() or endAt()."); - return whereHelper(fieldPath, GREATER_THAN, value); + return where(new com.google.cloud.firestore.Filter.UnaryFilter(fieldPath, GREATER_THAN, value)); } /** @@ -673,11 +748,8 @@ public Query whereGreaterThanOrEqualTo(@Nonnull String field, @Nonnull Object va */ @Nonnull public Query whereGreaterThanOrEqualTo(@Nonnull FieldPath fieldPath, @Nonnull Object value) { - Preconditions.checkState( - options.getStartCursor() == null && options.getEndCursor() == null, - "Cannot call whereGreaterThanOrEqualTo() after defining a boundary with startAt(), " - + "startAfter(), endBefore() or endAt()."); - return whereHelper(fieldPath, GREATER_THAN_OR_EQUAL, value); + return where( + new com.google.cloud.firestore.Filter.UnaryFilter(fieldPath, GREATER_THAN_OR_EQUAL, value)); } /** @@ -711,11 +783,8 @@ public Query whereArrayContains(@Nonnull String field, @Nonnull Object value) { */ @Nonnull public Query whereArrayContains(@Nonnull FieldPath fieldPath, @Nonnull Object value) { - Preconditions.checkState( - options.getStartCursor() == null && options.getEndCursor() == null, - "Cannot call whereArrayContains() after defining a boundary with startAt(), " - + "startAfter(), endBefore() or endAt()."); - return whereHelper(fieldPath, ARRAY_CONTAINS, value); + return where( + new com.google.cloud.firestore.Filter.UnaryFilter(fieldPath, ARRAY_CONTAINS, value)); } /** @@ -733,11 +802,7 @@ public Query whereArrayContains(@Nonnull FieldPath fieldPath, @Nonnull Object va @Nonnull public Query whereArrayContainsAny( @Nonnull String field, @Nonnull List values) { - Preconditions.checkState( - options.getStartCursor() == null && options.getEndCursor() == null, - "Cannot call whereArrayContainsAny() after defining a boundary with startAt(), " - + "startAfter(), endBefore() or endAt()."); - return whereHelper(FieldPath.fromDotSeparatedString(field), ARRAY_CONTAINS_ANY, values); + return whereArrayContainsAny(FieldPath.fromDotSeparatedString(field), values); } /** @@ -755,11 +820,8 @@ public Query whereArrayContainsAny( @Nonnull public Query whereArrayContainsAny( @Nonnull FieldPath fieldPath, @Nonnull List values) { - Preconditions.checkState( - options.getStartCursor() == null && options.getEndCursor() == null, - "Cannot call whereArrayContainsAny() after defining a boundary with startAt(), " - + "startAfter(), endBefore() or endAt()."); - return whereHelper(fieldPath, ARRAY_CONTAINS_ANY, values); + return where( + new com.google.cloud.firestore.Filter.UnaryFilter(fieldPath, ARRAY_CONTAINS_ANY, values)); } /** @@ -775,11 +837,7 @@ public Query whereArrayContainsAny( */ @Nonnull public Query whereIn(@Nonnull String field, @Nonnull List values) { - Preconditions.checkState( - options.getStartCursor() == null && options.getEndCursor() == null, - "Cannot call whereIn() after defining a boundary with startAt(), " - + "startAfter(), endBefore() or endAt()."); - return whereHelper(FieldPath.fromDotSeparatedString(field), IN, values); + return whereIn(FieldPath.fromDotSeparatedString(field), values); } /** @@ -795,11 +853,7 @@ public Query whereIn(@Nonnull String field, @Nonnull List valu */ @Nonnull public Query whereIn(@Nonnull FieldPath fieldPath, @Nonnull List values) { - Preconditions.checkState( - options.getStartCursor() == null && options.getEndCursor() == null, - "Cannot call whereIn() after defining a boundary with startAt(), " - + "startAfter(), endBefore() or endAt()."); - return whereHelper(fieldPath, IN, values); + return where(new com.google.cloud.firestore.Filter.UnaryFilter(fieldPath, IN, values)); } /** @@ -815,11 +869,7 @@ public Query whereIn(@Nonnull FieldPath fieldPath, @Nonnull List values) { - Preconditions.checkState( - options.getStartCursor() == null && options.getEndCursor() == null, - "Cannot call whereNotIn() after defining a boundary with startAt(), " - + "startAfter(), endBefore() or endAt()."); - return whereHelper(FieldPath.fromDotSeparatedString(field), NOT_IN, values); + return whereNotIn(FieldPath.fromDotSeparatedString(field), values); } /** @@ -835,49 +885,99 @@ public Query whereNotIn(@Nonnull String field, @Nonnull List v */ @Nonnull public Query whereNotIn(@Nonnull FieldPath fieldPath, @Nonnull List values) { + return where(new com.google.cloud.firestore.Filter.UnaryFilter(fieldPath, NOT_IN, values)); + } + + // TODO(orquery): This method will become public API. Change visibility and add documentation. + Query where(com.google.cloud.firestore.Filter filter) { Preconditions.checkState( options.getStartCursor() == null && options.getEndCursor() == null, - "Cannot call whereNotIn() after defining a boundary with startAt(), " + "Cannot call a where() clause after defining a boundary with startAt(), " + "startAfter(), endBefore() or endAt()."); - return whereHelper(fieldPath, NOT_IN, values); + FilterInternal 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; + } + Builder newOptions = options.toBuilder(); + newOptions.setFilters(append(options.getFilters(), parsedFilter)); + return new Query(rpcContext, newOptions.build()); } - private Query whereHelper( - FieldPath fieldPath, StructuredQuery.FieldFilter.Operator operator, Object value) { - Preconditions.checkArgument( - !isUnaryComparison(value), - "Cannot use '%s' in field comparison. Use an equality filter instead.", - value); + FilterInternal parseFilter(com.google.cloud.firestore.Filter filter) { + if (filter instanceof com.google.cloud.firestore.Filter.UnaryFilter) { + return parseFieldFilter((com.google.cloud.firestore.Filter.UnaryFilter) filter); + } + return parseCompositeFilter((com.google.cloud.firestore.Filter.CompositeFilter) filter); + } - if (fieldPath.equals(FieldPath.DOCUMENT_ID)) { - if (operator == ARRAY_CONTAINS || operator == ARRAY_CONTAINS_ANY) { + private FieldFilterInternal parseFieldFilter( + com.google.cloud.firestore.Filter.UnaryFilter fieldFilterData) { + Object value = fieldFilterData.getValue(); + Operator operator = fieldFilterData.getOperator(); + FieldPath fieldPath = fieldFilterData.getField(); + + if (isUnaryComparison(value)) { + if (operator.equals(EQUAL) || operator.equals(NOT_EQUAL)) { + StructuredQuery.UnaryFilter.Operator unaryOp = + operator.equals(EQUAL) + ? (value == null + ? StructuredQuery.UnaryFilter.Operator.IS_NULL + : StructuredQuery.UnaryFilter.Operator.IS_NAN) + : (value == null + ? StructuredQuery.UnaryFilter.Operator.IS_NOT_NULL + : StructuredQuery.UnaryFilter.Operator.IS_NOT_NAN); + return new UnaryFilterInternal(fieldPath.toProto(), unaryOp); + } else { throw new IllegalArgumentException( String.format( - "Invalid query. You cannot perform '%s' queries on FieldPath.documentId().", - operator.toString())); - } else if (operator == IN | operator == NOT_IN) { - if (!(value instanceof List) || ((List) value).isEmpty()) { + "Cannot use '%s' in field comparison. Use an equality filter instead.", value)); + } + } else { + if (fieldPath.equals(FieldPath.DOCUMENT_ID)) { + if (operator.equals(ARRAY_CONTAINS) || operator.equals(ARRAY_CONTAINS_ANY)) { throw new IllegalArgumentException( String.format( - "Invalid Query. A non-empty array is required for '%s' filters.", + "Invalid query. You cannot perform '%s' queries on FieldPath.documentId().", operator.toString())); + } else if (operator.equals(IN) || operator.equals(NOT_IN)) { + if (!(value instanceof List) || ((List) value).isEmpty()) { + throw new IllegalArgumentException( + String.format( + "Invalid Query. A non-empty array is required for '%s' filters.", + operator.toString())); + } + List referenceList = new ArrayList<>(); + for (Object arrayValue : (List) value) { + Object convertedValue = this.convertReference(arrayValue); + referenceList.add(convertedValue); + } + value = referenceList; + } else { + value = this.convertReference(value); } - List referenceList = new ArrayList<>(); - for (Object arrayValue : (List) value) { - Object convertedValue = this.convertReference(arrayValue); - referenceList.add(convertedValue); - } - value = referenceList; - } else { - value = this.convertReference(value); } + return new ComparisonFilterInternal( + fieldPath.toProto(), operator, encodeValue(fieldPath, value)); } + } - Builder newOptions = options.toBuilder(); - ComparisonFilter newFieldFilter = - new ComparisonFilter(fieldPath.toProto(), operator, encodeValue(fieldPath, value)); - newOptions.setFieldFilters(append(options.getFieldFilters(), newFieldFilter)); - return new Query(rpcContext, newOptions.build()); + private FilterInternal parseCompositeFilter( + com.google.cloud.firestore.Filter.CompositeFilter compositeFilterData) { + List parsedFilters = new ArrayList<>(); + for (com.google.cloud.firestore.Filter filter : compositeFilterData.getFilters()) { + FilterInternal 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 CompositeFilterInternal(parsedFilters, compositeFilterData.getOperator()); } /** @@ -1264,25 +1364,11 @@ private StructuredQuery.Builder buildWithoutClientTranslation() { collectionSelector.setAllDescendants(options.getAllDescendants()); structuredQuery.addFrom(collectionSelector); - if (options.getFieldFilters().size() == 1) { - Filter filter = options.getFieldFilters().get(0).toProto(); - if (filter.hasFieldFilter()) { - structuredQuery.getWhereBuilder().setFieldFilter(filter.getFieldFilter()); - } else { - Preconditions.checkState( - filter.hasUnaryFilter(), "Expected a UnaryFilter or a FieldFilter."); - structuredQuery.getWhereBuilder().setUnaryFilter(filter.getUnaryFilter()); - } - } else if (options.getFieldFilters().size() > 1) { - Filter.Builder filter = Filter.newBuilder(); - StructuredQuery.CompositeFilter.Builder compositeFilter = - StructuredQuery.CompositeFilter.newBuilder(); - compositeFilter.setOp(CompositeFilter.Operator.AND); - for (FieldFilter fieldFilter : options.getFieldFilters()) { - compositeFilter.addFilters(fieldFilter.toProto()); - } - filter.setCompositeFilter(compositeFilter.build()); - structuredQuery.setWhere(filter.build()); + // There's an implicit AND operation between the top-level query filters. + if (!options.getFilters().isEmpty()) { + FilterInternal filter = + new CompositeFilterInternal(options.getFilters(), CompositeFilter.Operator.AND); + structuredQuery.setWhere(filter.toProto()); } if (!options.getFieldOrders().isEmpty()) { @@ -1409,16 +1495,15 @@ private static Query fromProto(FirestoreRpcContext rpcContext, RunQueryReques queryOptions.setAllDescendants(structuredQuery.getFrom(0).getAllDescendants()); if (structuredQuery.hasWhere()) { - Filter where = structuredQuery.getWhere(); - if (where.hasCompositeFilter()) { - CompositeFilter compositeFilter = where.getCompositeFilter(); - ImmutableList.Builder fieldFilters = ImmutableList.builder(); - for (Filter filter : compositeFilter.getFiltersList()) { - fieldFilters.add(FieldFilter.fromProto(filter)); - } - queryOptions.setFieldFilters(fieldFilters.build()); + FilterInternal filter = FilterInternal.fromProto(structuredQuery.getWhere()); + + // There's an implicit AND operation between the top-level query filters. + if (filter instanceof CompositeFilterInternal + && ((CompositeFilterInternal) filter).isConjunction()) { + queryOptions.setFilters( + new ImmutableList.Builder().addAll(filter.getFilters()).build()); } else { - queryOptions.setFieldFilters(ImmutableList.of(FieldFilter.fromProto(where))); + queryOptions.setFilters(ImmutableList.of(filter)); } } diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java index 38f853e7e8..219c5702fa 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java @@ -574,6 +574,32 @@ public static StructuredQuery filter( return filter(operator, path, string(value)); } + public static StructuredQuery.Filter fieldFilter( + String path, StructuredQuery.FieldFilter.Operator operator, String value) { + StructuredQuery.FieldFilter.Builder builder = + FieldFilter.newBuilder() + .setField(StructuredQuery.FieldReference.newBuilder().setFieldPath(path)) + .setOp(operator) + .setValue(Value.newBuilder().setStringValue(value).build()); + return StructuredQuery.Filter.newBuilder().setFieldFilter(builder).build(); + } + + public static StructuredQuery.Filter andFilters(StructuredQuery.Filter... filters) { + return compositeFilter(CompositeFilter.Operator.AND, Arrays.asList(filters)); + } + + public static StructuredQuery.Filter orFilters(StructuredQuery.Filter... filters) { + // TODO(orquery): Replace this with Operator.OR once it's available. + return compositeFilter(CompositeFilter.Operator.OPERATOR_UNSPECIFIED, Arrays.asList(filters)); + } + + private static StructuredQuery.Filter compositeFilter( + StructuredQuery.CompositeFilter.Operator operator, List filters) { + StructuredQuery.CompositeFilter.Builder builder = + StructuredQuery.CompositeFilter.newBuilder().setOp(operator).addAllFilters(filters); + return StructuredQuery.Filter.newBuilder().setCompositeFilter(builder).build(); + } + public static StructuredQuery filter( StructuredQuery.FieldFilter.Operator operator, String path, Value value) { StructuredQuery.Builder structuredQuery = StructuredQuery.newBuilder(); diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/QueryTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/QueryTest.java index 061d345f6d..c98f05a2cc 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/QueryTest.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/QueryTest.java @@ -16,14 +16,18 @@ package com.google.cloud.firestore; +import static com.google.cloud.firestore.Filter.*; import static com.google.cloud.firestore.LocalFirestoreHelper.COLLECTION_ID; import static com.google.cloud.firestore.LocalFirestoreHelper.DOCUMENT_NAME; import static com.google.cloud.firestore.LocalFirestoreHelper.DOCUMENT_PATH; import static com.google.cloud.firestore.LocalFirestoreHelper.SINGLE_FIELD_SNAPSHOT; +import static com.google.cloud.firestore.LocalFirestoreHelper.andFilters; import static com.google.cloud.firestore.LocalFirestoreHelper.endAt; +import static com.google.cloud.firestore.LocalFirestoreHelper.fieldFilter; import static com.google.cloud.firestore.LocalFirestoreHelper.filter; import static com.google.cloud.firestore.LocalFirestoreHelper.limit; import static com.google.cloud.firestore.LocalFirestoreHelper.offset; +import static com.google.cloud.firestore.LocalFirestoreHelper.orFilters; import static com.google.cloud.firestore.LocalFirestoreHelper.order; import static com.google.cloud.firestore.LocalFirestoreHelper.query; import static com.google.cloud.firestore.LocalFirestoreHelper.queryResponse; @@ -46,14 +50,15 @@ import com.google.api.gax.rpc.ResponseObserver; import com.google.api.gax.rpc.ServerStreamingCallable; import com.google.cloud.Timestamp; -import com.google.cloud.firestore.Query.ComparisonFilter; -import com.google.cloud.firestore.Query.FieldFilter; +import com.google.cloud.firestore.Query.ComparisonFilterInternal; +import com.google.cloud.firestore.Query.FilterInternal; import com.google.cloud.firestore.spi.v1.FirestoreRpc; import com.google.common.io.BaseEncoding; import com.google.firestore.v1.ArrayValue; import com.google.firestore.v1.RunQueryRequest; import com.google.firestore.v1.RunQueryResponse; import com.google.firestore.v1.StructuredQuery; +import com.google.firestore.v1.StructuredQuery.CollectionSelector; import com.google.firestore.v1.StructuredQuery.Direction; import com.google.firestore.v1.StructuredQuery.FieldFilter.Operator; import com.google.firestore.v1.Value; @@ -330,6 +335,42 @@ public void withFieldPathFilter() throws Exception { } } + @Test + public void withCompositeFilter() throws Exception { + doAnswer(queryResponse()) + .when(firestoreMock) + .streamRequest( + runQuery.capture(), + streamObserverCapture.capture(), + Matchers.any()); + + // a == 10 && (b==20 || c==30 || (d==40 && e>50) || f==60) + query + .where( + and( + equalTo("a", "10"), + or( + equalTo("b", "20"), + equalTo("c", "30"), + and(equalTo("d", "40"), greaterThan("e", "50")), + and(equalTo("f", "60")), + or(and())))) + .get() + .get(); + + StructuredQuery.Filter a = fieldFilter("a", Operator.EQUAL, "10"); + StructuredQuery.Filter b = fieldFilter("b", Operator.EQUAL, "20"); + StructuredQuery.Filter c = fieldFilter("c", Operator.EQUAL, "30"); + StructuredQuery.Filter d = fieldFilter("d", Operator.EQUAL, "40"); + StructuredQuery.Filter e = fieldFilter("e", Operator.GREATER_THAN, "50"); + StructuredQuery.Filter f = fieldFilter("f", Operator.EQUAL, "60"); + StructuredQuery.Builder structuredQuery = StructuredQuery.newBuilder(); + structuredQuery.setWhere(andFilters(a, orFilters(b, c, andFilters(d, e), f))); + structuredQuery.addFrom(CollectionSelector.newBuilder().setCollectionId("coll").build()); + + assertEquals(structuredQuery.build(), runQuery.getValue().getStructuredQuery()); + } + @Test public void inQueriesWithReferenceArray() throws Exception { doAnswer(queryResponse()) @@ -1260,6 +1301,66 @@ public void serializationTest() { assertSerialization(query); } + @Test + public void serializationTestWithEmptyCompositeFilter() { + assertSerialization(query); + query.where(or()); + assertSerialization(query); + query.where(and()); + assertSerialization(query); + query.where(and(or(and(or())))); + assertSerialization(query); + } + + @Test + public void serializationTestWithSingleFilterCompositeFilters() { + // Test the special handling of a composite filter that has only 1 filter inside it. Such filter + // is equivalent to its sub-filter. For example: AND(a==10) is the same as a==10. + assertSerialization(query); + // a == 10 + query.where(or(equalTo("a", 10))); + assertSerialization(query); + + // b > 20 + query.where(and(greaterThan("b", 20))); + assertSerialization(query); + + // c == 30 + query.where(or(and(or(and(equalTo("c", 30)))))); + assertSerialization(query); + } + + @Test + public void serializationTestWithNestedCompositeFilters() { + assertSerialization(query); + // a IN [1,2] + query.where(inArray("a", Arrays.asList(1, 2))); + assertSerialization(query); + // a IN [1,2] && (b==20 || c==30 || (d==40 && e>50)) || f==60 + query.where( + or( + equalTo("b", 20), + equalTo("c", 30), + and(equalTo("d", 40), greaterThan("e", 50)), + and(equalTo("f", 60)), + or(and()))); + assertSerialization(query); + query = query.orderBy("l"); + assertSerialization(query); + query = query.startAt("o"); + assertSerialization(query); + query = query.startAfter("p"); + assertSerialization(query); + query = query.endBefore("q"); + assertSerialization(query); + query = query.endAt("r"); + assertSerialization(query); + query = query.limit(8); + assertSerialization(query); + query = query.offset(9); + assertSerialization(query); + } + private void assertSerialization(Query query) { RunQueryRequest runQueryRequest = query.toProto(); Query deserializedQuery = Query.fromProto(firestoreMock, runQueryRequest); @@ -1320,15 +1421,11 @@ public void ensureFromProtoWorksWithAProxy() throws InvalidProtocolBufferExcepti ResourcePath path = query.options.getParentPath(); assertEquals("projects/test-project/databases/(default)/documents", path.getName()); assertEquals("testing-collection", query.options.getCollectionId()); - FieldFilter next = query.options.getFieldFilters().iterator().next(); - assertEquals("enabled", next.fieldReference.getFieldPath()); - - if (next instanceof ComparisonFilter) { - ComparisonFilter comparisonFilter = (ComparisonFilter) next; - assertFalse(comparisonFilter.isInequalityFilter()); - assertEquals(Value.newBuilder().setBooleanValue(true).build(), comparisonFilter.value); - } else { - fail("expect filter to be a comparison filter"); - } + FilterInternal next = query.options.getFilters().iterator().next(); + assertTrue(next instanceof ComparisonFilterInternal); + ComparisonFilterInternal comparisonFilter = (ComparisonFilterInternal) next; + assertEquals("enabled", comparisonFilter.fieldReference.getFieldPath()); + assertFalse(comparisonFilter.isInequalityFilter()); + assertEquals(Value.newBuilder().setBooleanValue(true).build(), comparisonFilter.value); } }