Skip to content

Commit 17661dc

Browse files
authored
Do not apply SQL LIMIT if a partial index is used. (#3583)
* Do not apply SQL LIMIT if a partial index is used. * Alternate implementation. * Address feedback. * Address feedback. * Fix bug in `servedByIndex`. * Consider array-contains segments when counting matching segments. * Rename TargetIndexType to IndexType.
1 parent 9ab121f commit 17661dc

File tree

9 files changed

+233
-53
lines changed

9 files changed

+233
-53
lines changed

firebase-firestore/src/main/java/com/google/firebase/firestore/core/Target.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
package com.google.firebase.firestore.core;
1616

17+
import static com.google.firebase.firestore.core.FieldFilter.Operator.ARRAY_CONTAINS;
18+
import static com.google.firebase.firestore.core.FieldFilter.Operator.ARRAY_CONTAINS_ANY;
1719
import static com.google.firebase.firestore.model.Values.max;
1820
import static com.google.firebase.firestore.model.Values.min;
1921

@@ -29,8 +31,10 @@
2931
import java.util.ArrayList;
3032
import java.util.Collection;
3133
import java.util.Collections;
34+
import java.util.HashSet;
3235
import java.util.LinkedHashMap;
3336
import java.util.List;
37+
import java.util.Set;
3438

3539
/**
3640
* A Target represents the WatchTarget representation of a Query, which is used by the LocalStore
@@ -369,6 +373,37 @@ public Direction getKeyOrder() {
369373
return this.orderBys.get(this.orderBys.size() - 1).getDirection();
370374
}
371375

376+
/** Returns the number of segments of a perfect index for this target. */
377+
public int getSegmentCount() {
378+
Set<FieldPath> fields = new HashSet<>();
379+
boolean hasArraySegment = false;
380+
for (Filter filter : filters) {
381+
for (FieldFilter subFilter : filter.getFlattenedFilters()) {
382+
// __name__ is not an explicit segment of any index, so we don't need to count it.
383+
if (subFilter.getField().isKeyField()) {
384+
continue;
385+
}
386+
387+
// ARRAY_CONTAINS or ARRAY_CONTAINS_ANY filters must be counted separately. For instance,
388+
// it is possible to have an index for "a ARRAY a ASC". Even though these are on the same
389+
// field, they should be counted as two separate segments in an index.
390+
if (subFilter.getOperator().equals(ARRAY_CONTAINS)
391+
|| subFilter.getOperator().equals(ARRAY_CONTAINS_ANY)) {
392+
hasArraySegment = true;
393+
} else {
394+
fields.add(subFilter.getField());
395+
}
396+
}
397+
}
398+
for (OrderBy orderBy : orderBys) {
399+
// __name__ is not an explicit segment of any index, so we don't need to count it.
400+
if (!orderBy.getField().isKeyField()) {
401+
fields.add(orderBy.getField());
402+
}
403+
}
404+
return fields.size() + (hasArraySegment ? 1 : 0);
405+
}
406+
372407
/** Returns a canonical string representing this target. */
373408
public String getCanonicalId() {
374409
if (memoizedCanonicalId != null) {

firebase-firestore/src/main/java/com/google/firebase/firestore/local/IndexManager.java

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@
3232
* Collection Group queries.
3333
*/
3434
public interface IndexManager {
35+
/** Represents the index state as it relates to a particular target. */
36+
public enum IndexType {
37+
/** Indicates that no index could be found for serving the target. */
38+
NONE,
39+
/**
40+
* Indicates that only a "partial index" could be found for serving the target. A partial index
41+
* is one which does not have a segment for every filter/orderBy in the target.
42+
*/
43+
PARTIAL,
44+
/**
45+
* Indicates that a "full index" could be found for serving the target. A full index is one
46+
* which has a segment for every filter/orderBy in the target.
47+
*/
48+
FULL
49+
}
3550

3651
/** Initializes the IndexManager. */
3752
void start();
@@ -83,12 +98,8 @@ public interface IndexManager {
8398
/** Returns the minimum offset for the given collection group. */
8499
IndexOffset getMinOffset(String collectionGroup);
85100

86-
/**
87-
* Returns an index that can be used to serve the provided target. Returns {@code null} if no
88-
* index is configured.
89-
*/
90-
@Nullable
91-
FieldIndex getFieldIndex(Target target);
101+
/** Returns the type of index (if any) that can be used to serve the given target */
102+
IndexType getIndexType(Target target);
92103

93104
/**
94105
* Returns the documents that match the given target based on the provided index or {@code null}

firebase-firestore/src/main/java/com/google/firebase/firestore/local/MemoryIndexManager.java

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,6 @@ public void deleteFieldIndex(FieldIndex index) {
6060
// Field indices are not supported with memory persistence.
6161
}
6262

63-
@Nullable
64-
@Override
65-
public FieldIndex getFieldIndex(Target target) {
66-
// Field indices are not supported with memory persistence.
67-
return null;
68-
}
69-
7063
@Override
7164
@Nullable
7265
public List<DocumentKey> getDocumentsMatchingTarget(Target target) {
@@ -108,6 +101,11 @@ public IndexOffset getMinOffset(String collectionGroup) {
108101
return IndexOffset.NONE;
109102
}
110103

104+
@Override
105+
public IndexType getIndexType(Target target) {
106+
return IndexType.NONE;
107+
}
108+
111109
@Override
112110
public void updateIndexEntries(ImmutableSortedMap<DocumentKey, Document> documents) {
113111
// Field indices are not supported with memory persistence.

firebase-firestore/src/main/java/com/google/firebase/firestore/local/QueryEngine.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.google.firebase.database.collection.ImmutableSortedSet;
2222
import com.google.firebase.firestore.core.Query;
2323
import com.google.firebase.firestore.core.Target;
24+
import com.google.firebase.firestore.local.IndexManager.IndexType;
2425
import com.google.firebase.firestore.model.Document;
2526
import com.google.firebase.firestore.model.DocumentKey;
2627
import com.google.firebase.firestore.model.FieldIndex;
@@ -110,11 +111,27 @@ public ImmutableSortedMap<DocumentKey, Document> getDocumentsMatchingQuery(
110111
return null;
111112
}
112113

113-
List<DocumentKey> keys = indexManager.getDocumentsMatchingTarget(target);
114-
if (keys == null) {
114+
IndexType indexType = indexManager.getIndexType(target);
115+
116+
if (indexType.equals(IndexType.NONE)) {
117+
// The target cannot be served from any index.
115118
return null;
116119
}
117120

121+
if (indexType.equals(IndexType.PARTIAL)) {
122+
// We cannot apply a limit for targets that are served using a partial index.
123+
// If a partial index will be used to serve the target, the query may return a superset of
124+
// documents that match the target (e.g. if the index doesn't include all the target's
125+
// filters), or may return the correct set of documents in the wrong order (e.g. if the index
126+
// doesn't include a segment for one of the orderBys). Therefore a limit should not be applied
127+
// in such cases.
128+
query = query.limitToFirst(Target.NO_LIMIT);
129+
target = query.toTarget();
130+
}
131+
132+
List<DocumentKey> keys = indexManager.getDocumentsMatchingTarget(target);
133+
hardAssert(keys != null, "index manager must return results for partial and full indexes.");
134+
118135
ImmutableSortedMap<DocumentKey, Document> indexedDocuments =
119136
localDocumentsView.getDocuments(keys);
120137
IndexOffset offset = indexManager.getMinOffset(target);

firebase-firestore/src/main/java/com/google/firebase/firestore/local/SQLiteIndexManager.java

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import static java.lang.Math.max;
2424

2525
import android.text.TextUtils;
26+
import android.util.Pair;
2627
import androidx.annotation.Nullable;
2728
import com.google.firebase.Timestamp;
2829
import com.google.firebase.database.collection.ImmutableSortedMap;
@@ -311,11 +312,30 @@ public IndexOffset getMinOffset(String collectionGroup) {
311312
return getMinOffset(fieldIndexes);
312313
}
313314

315+
@Override
316+
public IndexType getIndexType(Target target) {
317+
for (Target subTarget : getSubTargets(target)) {
318+
Pair<FieldIndex, Integer> indexAndSegmentCount = getFieldIndexAndSegmentCount(subTarget);
319+
if (indexAndSegmentCount == null) {
320+
return IndexType.NONE;
321+
}
322+
323+
Integer indexSegmentCount = indexAndSegmentCount.second;
324+
if (indexSegmentCount < subTarget.getSegmentCount()) {
325+
return IndexType.PARTIAL;
326+
}
327+
}
328+
return IndexType.FULL;
329+
}
330+
314331
@Override
315332
public IndexOffset getMinOffset(Target target) {
316333
List<FieldIndex> fieldIndexes = new ArrayList<>();
317334
for (Target subTarget : getSubTargets(target)) {
318-
fieldIndexes.add(getFieldIndex(subTarget));
335+
Pair<FieldIndex, Integer> indexAndSegmentCount = getFieldIndexAndSegmentCount(subTarget);
336+
if (indexAndSegmentCount != null) {
337+
fieldIndexes.add(indexAndSegmentCount.first);
338+
}
319339
}
320340
return getMinOffset(fieldIndexes);
321341
}
@@ -448,11 +468,12 @@ public List<DocumentKey> getDocumentsMatchingTarget(Target target) {
448468
List<Object> bindings = new ArrayList<>();
449469

450470
for (Target subTarget : getSubTargets(target)) {
451-
FieldIndex fieldIndex = getFieldIndex(subTarget);
452-
if (fieldIndex == null) {
471+
Pair<FieldIndex, Integer> indexAndSegmentCount = getFieldIndexAndSegmentCount(subTarget);
472+
if (indexAndSegmentCount == null) {
453473
return null;
454474
}
455475

476+
FieldIndex fieldIndex = indexAndSegmentCount.first;
456477
@Nullable List<Value> arrayValues = subTarget.getArrayValues(fieldIndex);
457478
@Nullable Collection<Value> notInValues = subTarget.getNotInValues(fieldIndex);
458479
@Nullable Bound lowerBound = subTarget.getLowerBound(fieldIndex);
@@ -491,7 +512,8 @@ public List<DocumentKey> getDocumentsMatchingTarget(Target target) {
491512

492513
String queryString =
493514
"SELECT DISTINCT document_key FROM (" + TextUtils.join(" UNION ", subQueries) + ")";
494-
if (target.getLimit() != -1) {
515+
516+
if (target.hasLimit()) {
495517
queryString = queryString + " LIMIT " + target.getLimit();
496518
}
497519

@@ -547,9 +569,6 @@ private Object[] generateQueryAndBindings(
547569
StringBuilder sql = repeatSequence(statement, statementCount, " UNION ");
548570
sql.append("ORDER BY directional_value, document_key ");
549571
sql.append(target.getKeyOrder().equals(Direction.ASCENDING) ? "asc " : "desc ");
550-
if (target.getLimit() != -1) {
551-
sql.append("LIMIT ").append(target.getLimit()).append(" ");
552-
}
553572

554573
if (notIn != null) {
555574
// Wrap the statement in a NOT-IN call.
@@ -607,9 +626,13 @@ private Object[] fillBounds(
607626
return bindArgs;
608627
}
609628

629+
/**
630+
* Returns an index that can be used to serve the provided target, as well as the number of index
631+
* segments that matched the target filters and orderBys. Returns {@code null} if no index is
632+
* configured.
633+
*/
610634
@Nullable
611-
@Override
612-
public FieldIndex getFieldIndex(Target target) {
635+
public Pair<FieldIndex, Integer> getFieldIndexAndSegmentCount(Target target) {
613636
hardAssert(started, "IndexManager not started");
614637

615638
TargetIndexMatcher targetIndexMatcher = new TargetIndexMatcher(target);
@@ -623,21 +646,20 @@ public FieldIndex getFieldIndex(Target target) {
623646
return null;
624647
}
625648

626-
List<FieldIndex> matchingIndexes = new ArrayList<>();
649+
// Return the index with the most number of segments.
650+
int maxNumMatchingSegments = -1;
651+
FieldIndex matchingIndex = null;
627652
for (FieldIndex fieldIndex : collectionIndexes) {
628-
boolean matches = targetIndexMatcher.servedByIndex(fieldIndex);
629-
if (matches) {
630-
matchingIndexes.add(fieldIndex);
653+
int numMatchingSegments = targetIndexMatcher.servedByIndex(fieldIndex);
654+
if (numMatchingSegments > maxNumMatchingSegments) {
655+
maxNumMatchingSegments = numMatchingSegments;
656+
matchingIndex = fieldIndex;
631657
}
632658
}
633-
634-
if (matchingIndexes.isEmpty()) {
659+
if (maxNumMatchingSegments == -1) {
635660
return null;
636661
}
637-
638-
// Return the index with the most number of segments
639-
return Collections.max(
640-
matchingIndexes, (l, r) -> Integer.compare(l.getSegments().size(), r.getSegments().size()));
662+
return new Pair<>(matchingIndex, maxNumMatchingSegments);
641663
}
642664

643665
/**

firebase-firestore/src/main/java/com/google/firebase/firestore/model/TargetIndexMatcher.java

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ public TargetIndexMatcher(Target target) {
111111
}
112112

113113
/**
114-
* Returns whether the index can be used to serve the TargetIndexMatcher's target.
114+
* Returns the number of index segments that match the TargetIndexMatcher's target. Returns -1 if
115+
* the index cannot be used to serve the target.
115116
*
116117
* <p>An index is considered capable of serving the target when:
117118
*
@@ -131,21 +132,26 @@ public TargetIndexMatcher(Target target) {
131132
*
132133
* @throws AssertionError if the index is for a different collection
133134
*/
134-
public boolean servedByIndex(FieldIndex index) {
135+
public int servedByIndex(FieldIndex index) {
135136
hardAssert(index.getCollectionGroup().equals(collectionId), "Collection IDs do not match");
137+
int numMatchingSegments = 0;
136138

137139
// If there is an array element, find a matching filter.
138140
FieldIndex.Segment arraySegment = index.getArraySegment();
139-
if (arraySegment != null && !hasMatchingEqualityFilter(arraySegment)) {
140-
return false;
141+
if (arraySegment != null) {
142+
if (hasMatchingEqualityFilter(arraySegment)) {
143+
++numMatchingSegments;
144+
} else {
145+
return -1;
146+
}
141147
}
142148

143149
Iterator<OrderBy> orderBys = this.orderBys.iterator();
144150
List<FieldIndex.Segment> segments = index.getDirectionalSegments();
145151
int segmentIndex = 0;
146152

147153
// Process all equalities first. Equalities can appear out of order.
148-
for (; segmentIndex < segments.size(); ++segmentIndex) {
154+
for (; segmentIndex < segments.size(); ++segmentIndex, ++numMatchingSegments) {
149155
// We attempt to greedily match all segments to equality filters. If a filter matches an
150156
// index segment, we can mark the segment as used. Since it is not possible to use the same
151157
// field path in both an equality and inequality/oderBy clause, we do not have to consider the
@@ -162,28 +168,29 @@ public boolean servedByIndex(FieldIndex index) {
162168
// filters and we do not need to map any segments to the target's inequality and orderBy
163169
// clauses.
164170
if (segmentIndex == segments.size()) {
165-
return true;
171+
return numMatchingSegments;
166172
}
167173

168174
// If there is an inequality filter, the next segment must match both the filter and the first
169175
// orderBy clause.
170176
if (inequalityFilter != null) {
171177
FieldIndex.Segment segment = segments.get(segmentIndex);
172178
if (!matchesFilter(inequalityFilter, segment) || !matchesOrderBy(orderBys.next(), segment)) {
173-
return false;
179+
return -1;
174180
}
175181
++segmentIndex;
182+
++numMatchingSegments;
176183
}
177184

178185
// All remaining segments need to represent the prefix of the target's orderBy.
179-
for (; segmentIndex < segments.size(); ++segmentIndex) {
186+
for (; segmentIndex < segments.size(); ++segmentIndex, ++numMatchingSegments) {
180187
FieldIndex.Segment segment = segments.get(segmentIndex);
181188
if (!orderBys.hasNext() || !matchesOrderBy(orderBys.next(), segment)) {
182-
return false;
189+
return -1;
183190
}
184191
}
185192

186-
return true;
193+
return numMatchingSegments;
187194
}
188195

189196
private boolean hasMatchingEqualityFilter(FieldIndex.Segment segment) {

firebase-firestore/src/test/java/com/google/firebase/firestore/local/SQLiteIndexManagerTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,7 @@ public void testArrayContainsDoesNotMatchNonArray() {
524524
public void testNoMatchingFilter() {
525525
setUpSingleValueFilter();
526526
Query query = query("coll").filter(filter("unknown", "==", true));
527-
assertNull(indexManager.getFieldIndex(query.toTarget()));
527+
assertEquals(indexManager.getIndexType(query.toTarget()), IndexManager.IndexType.NONE);
528528
assertNull(indexManager.getDocumentsMatchingTarget(query.toTarget()));
529529
}
530530

@@ -1002,7 +1002,7 @@ private void addDoc(String key, Map<String, Object> data) {
10021002

10031003
private void verifyResults(Query query, String... documents) {
10041004
Target target = query.toTarget();
1005-
Iterable<DocumentKey> results = indexManager.getDocumentsMatchingTarget(target);
1005+
List<DocumentKey> results = indexManager.getDocumentsMatchingTarget(target);
10061006
assertNotNull("Target cannot be served from index.", results);
10071007
List<DocumentKey> keys =
10081008
Arrays.stream(documents).map(TestUtil::key).collect(Collectors.toList());

0 commit comments

Comments
 (0)