Skip to content

Commit eaa5170

Browse files
Port TargetIndexMatcher (#5975)
1 parent 3fe2c4a commit eaa5170

File tree

2 files changed

+899
-0
lines changed

2 files changed

+899
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/**
2+
* @license
3+
* Copyright 2022 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import {
19+
Direction,
20+
FieldFilter,
21+
Operator,
22+
OrderBy,
23+
Target
24+
} from '../core/target';
25+
import { debugAssert } from '../util/assert';
26+
27+
import {
28+
FieldIndex,
29+
fieldIndexGetArraySegment,
30+
fieldIndexGetDirectionalSegments,
31+
IndexKind,
32+
IndexSegment
33+
} from './field_index';
34+
35+
/**
36+
* A light query planner for Firestore.
37+
*
38+
* This class matches a `FieldIndex` against a Firestore Query `Target`. It
39+
* determines whether a given index can be used to serve the specified target.
40+
*
41+
* The following table showcases some possible index configurations:
42+
*
43+
* Query | Index
44+
* -----------------------------------------------------------------------------
45+
* where('a', '==', 'a').where('b', '==', 'b') | a ASC, b DESC
46+
* where('a', '==', 'a').where('b', '==', 'b') | a ASC
47+
* where('a', '==', 'a').where('b', '==', 'b') | b DESC
48+
* where('a', '>=', 'a').orderBy('a') | a ASC
49+
* where('a', '>=', 'a').orderBy('a', 'desc') | a DESC
50+
* where('a', '>=', 'a').orderBy('a').orderBy('b') | a ASC, b ASC
51+
* where('a', '>=', 'a').orderBy('a').orderBy('b') | a ASC
52+
* where('a', 'array-contains', 'a').orderBy('b') | a CONTAINS, b ASCENDING
53+
* where('a', 'array-contains', 'a').orderBy('b') | a CONTAINS
54+
*/
55+
export class TargetIndexMatcher {
56+
// The collection ID (or collection group) of the query target.
57+
private readonly collectionId: string;
58+
// The single inequality filter of the target (if it exists).
59+
private readonly inequalityFilter?: FieldFilter;
60+
// The list of equality filters of the target.
61+
private readonly equalityFilters: FieldFilter[];
62+
// The list of orderBys of the target.
63+
private readonly orderBys: OrderBy[];
64+
65+
constructor(target: Target) {
66+
this.collectionId =
67+
target.collectionGroup != null
68+
? target.collectionGroup
69+
: target.path.lastSegment();
70+
this.orderBys = target.orderBy;
71+
this.equalityFilters = [];
72+
73+
for (const filter of target.filters) {
74+
const fieldFilter = filter as FieldFilter;
75+
if (fieldFilter.isInequality()) {
76+
debugAssert(
77+
!this.inequalityFilter ||
78+
this.inequalityFilter.field.isEqual(fieldFilter.field),
79+
'Only a single inequality is supported'
80+
);
81+
this.inequalityFilter = fieldFilter;
82+
} else {
83+
this.equalityFilters.push(fieldFilter);
84+
}
85+
}
86+
}
87+
88+
/**
89+
* Returns whether the index can be used to serve the TargetIndexMatcher's
90+
* target.
91+
*
92+
* An index is considered capable of serving the target when:
93+
* - The target uses all index segments for its filters and orderBy clauses.
94+
* The target can have additional filter and orderBy clauses, but not
95+
* fewer.
96+
* - If an ArrayContains/ArrayContainsAnyfilter is used, the index must also
97+
* have a corresponding `CONTAINS` segment.
98+
* - All directional index segments can be mapped to the target as a series of
99+
* equality filters, a single inequality filter and a series of orderBy
100+
* clauses.
101+
* - The segments that represent the equality filters may appear out of order.
102+
* - The optional segment for the inequality filter must appear after all
103+
* equality segments.
104+
* - The segments that represent that orderBy clause of the target must appear
105+
* in order after all equality and inequality segments. Single orderBy
106+
* clauses cannot be skipped, but a continuous orderBy suffix may be
107+
* omitted.
108+
*/
109+
servedByIndex(index: FieldIndex): boolean {
110+
debugAssert(
111+
index.collectionGroup === this.collectionId,
112+
'Collection IDs do not match'
113+
);
114+
115+
// If there is an array element, find a matching filter.
116+
const arraySegment = fieldIndexGetArraySegment(index);
117+
if (
118+
arraySegment !== undefined &&
119+
!this.hasMatchingEqualityFilter(arraySegment)
120+
) {
121+
return false;
122+
}
123+
124+
const segments = fieldIndexGetDirectionalSegments(index);
125+
let segmentIndex = 0;
126+
let orderBysIndex = 0;
127+
128+
// Process all equalities first. Equalities can appear out of order.
129+
for (; segmentIndex < segments.length; ++segmentIndex) {
130+
// We attempt to greedily match all segments to equality filters. If a
131+
// filter matches an index segment, we can mark the segment as used.
132+
// Since it is not possible to use the same field path in both an equality
133+
// and inequality/oderBy clause, we do not have to consider the possibility
134+
// that a matching equality segment should instead be used to map to an
135+
// inequality filter or orderBy clause.
136+
if (!this.hasMatchingEqualityFilter(segments[segmentIndex])) {
137+
// If we cannot find a matching filter, we need to verify whether the
138+
// remaining segments map to the target's inequality and its orderBy
139+
// clauses.
140+
break;
141+
}
142+
}
143+
144+
// If we already have processed all segments, all segments are used to serve
145+
// the equality filters and we do not need to map any segments to the
146+
// target's inequality and orderBy clauses.
147+
if (segmentIndex === segments.length) {
148+
return true;
149+
}
150+
151+
// If there is an inequality filter, the next segment must match both the
152+
// filter and the first orderBy clause.
153+
if (this.inequalityFilter !== undefined) {
154+
const segment = segments[segmentIndex];
155+
if (
156+
!this.matchesFilter(this.inequalityFilter, segment) ||
157+
!this.matchesOrderBy(this.orderBys[orderBysIndex++], segment)
158+
) {
159+
return false;
160+
}
161+
++segmentIndex;
162+
}
163+
164+
// All remaining segments need to represent the prefix of the target's
165+
// orderBy.
166+
for (; segmentIndex < segments.length; ++segmentIndex) {
167+
const segment = segments[segmentIndex];
168+
if (
169+
orderBysIndex >= this.orderBys.length ||
170+
!this.matchesOrderBy(this.orderBys[orderBysIndex++], segment)
171+
) {
172+
return false;
173+
}
174+
}
175+
176+
return true;
177+
}
178+
179+
private hasMatchingEqualityFilter(segment: IndexSegment): boolean {
180+
for (const filter of this.equalityFilters) {
181+
if (this.matchesFilter(filter, segment)) {
182+
return true;
183+
}
184+
}
185+
return false;
186+
}
187+
188+
private matchesFilter(
189+
filter: FieldFilter | undefined,
190+
segment: IndexSegment
191+
): boolean {
192+
if (filter === undefined || !filter.field.isEqual(segment.fieldPath)) {
193+
return false;
194+
}
195+
const isArrayOperator =
196+
filter.op === Operator.ARRAY_CONTAINS ||
197+
filter.op === Operator.ARRAY_CONTAINS_ANY;
198+
return (segment.kind === IndexKind.CONTAINS) === isArrayOperator;
199+
}
200+
201+
private matchesOrderBy(orderBy: OrderBy, segment: IndexSegment): boolean {
202+
if (!orderBy.field.isEqual(segment.fieldPath)) {
203+
return false;
204+
}
205+
return (
206+
(segment.kind === IndexKind.ASCENDING &&
207+
orderBy.dir === Direction.ASCENDING) ||
208+
(segment.kind === IndexKind.DESCENDING &&
209+
orderBy.dir === Direction.DESCENDING)
210+
);
211+
}
212+
}

0 commit comments

Comments
 (0)