Skip to content

Commit cb835e7

Browse files
authored
Implement startAfter and endBefore for RTDB queries (#4232)
1 parent 92a7f43 commit cb835e7

File tree

9 files changed

+1528
-54
lines changed

9 files changed

+1528
-54
lines changed

.changeset/hip-glasses-grin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@firebase/database': minor
3+
---
4+
5+
Add `startAfter` and `endBefore` filters for paginating RTDB queries.

packages/database/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
"browser": "dist/index.esm.js",
88
"module": "dist/index.esm.js",
99
"esm2017": "dist/index.esm2017.js",
10-
"files": ["dist"],
10+
"files": [
11+
"dist"
12+
],
1113
"scripts": {
1214
"lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'",
1315
"lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'",

packages/database/src/api/Query.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,21 @@ export class Query {
101101
'or equalTo() must be a string.';
102102
if (params.hasStart()) {
103103
const startName = params.getIndexStartName();
104-
if (startName !== MIN_NAME) {
104+
if (
105+
startName !== MIN_NAME &&
106+
!(params.hasStartAfter() && startName === MAX_NAME)
107+
) {
105108
throw new Error(tooManyArgsError);
106109
} else if (typeof startNode !== 'string') {
107110
throw new Error(wrongArgTypeError);
108111
}
109112
}
110113
if (params.hasEnd()) {
111114
const endName = params.getIndexEndName();
112-
if (endName !== MAX_NAME) {
115+
if (
116+
endName !== MAX_NAME &&
117+
!(params.hasEndBefore() && endName === MIN_NAME)
118+
) {
113119
throw new Error(tooManyArgsError);
114120
} else if (typeof endNode !== 'string') {
115121
throw new Error(wrongArgTypeError);
@@ -526,6 +532,28 @@ export class Query {
526532
value = null;
527533
name = null;
528534
}
535+
536+
return new Query(this.repo, this.path, newParams, this.orderByCalled_);
537+
}
538+
539+
startAfter(
540+
value: number | string | boolean | null = null,
541+
name?: string | null
542+
): Query {
543+
validateArgCount('Query.startAfter', 0, 2, arguments.length);
544+
validateFirebaseDataArg('Query.startAfter', 1, value, this.path, false);
545+
validateKey('Query.startAfter', 2, name, true);
546+
547+
const newParams = this.queryParams_.startAfter(value, name);
548+
Query.validateLimit_(newParams);
549+
Query.validateQueryEndpoints_(newParams);
550+
if (this.queryParams_.hasStart()) {
551+
throw new Error(
552+
'Query.startAfter: Starting point was already set (by another call to startAt, startAfter ' +
553+
'or equalTo).'
554+
);
555+
}
556+
529557
return new Query(this.repo, this.path, newParams, this.orderByCalled_);
530558
}
531559

@@ -547,7 +575,28 @@ export class Query {
547575
Query.validateQueryEndpoints_(newParams);
548576
if (this.queryParams_.hasEnd()) {
549577
throw new Error(
550-
'Query.endAt: Ending point was already set (by another call to endAt or ' +
578+
'Query.endAt: Ending point was already set (by another call to endAt, endBefore, or ' +
579+
'equalTo).'
580+
);
581+
}
582+
583+
return new Query(this.repo, this.path, newParams, this.orderByCalled_);
584+
}
585+
586+
endBefore(
587+
value: number | string | boolean | null = null,
588+
name?: string | null
589+
): Query {
590+
validateArgCount('Query.endBefore', 0, 2, arguments.length);
591+
validateFirebaseDataArg('Query.endBefore', 1, value, this.path, false);
592+
validateKey('Query.endBefore', 2, name, true);
593+
594+
const newParams = this.queryParams_.endBefore(value, name);
595+
Query.validateLimit_(newParams);
596+
Query.validateQueryEndpoints_(newParams);
597+
if (this.queryParams_.hasEnd()) {
598+
throw new Error(
599+
'Query.endBefore: Ending point was already set (by another call to endAt, endBefore, or ' +
551600
'equalTo).'
552601
);
553602
}

packages/database/src/core/util/NextPushId.ts

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,23 @@
1616
*/
1717

1818
import { assert } from '@firebase/util';
19+
import {
20+
tryParseInt,
21+
MAX_NAME,
22+
MIN_NAME,
23+
INTEGER_32_MIN,
24+
INTEGER_32_MAX
25+
} from '../util/util';
26+
27+
// Modeled after base64 web-safe chars, but ordered by ASCII.
28+
const PUSH_CHARS =
29+
'-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz';
30+
31+
const MIN_PUSH_CHAR = '-';
32+
33+
const MAX_PUSH_CHAR = 'z';
34+
35+
const MAX_KEY_LEN = 786;
1936

2037
/**
2138
* Fancy ID generator that creates 20-character string identifiers with the
@@ -32,10 +49,6 @@ import { assert } from '@firebase/util';
3249
* in the case of a timestamp collision).
3350
*/
3451
export const nextPushId = (function () {
35-
// Modeled after base64 web-safe chars, but ordered by ASCII.
36-
const PUSH_CHARS =
37-
'-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz';
38-
3952
// Timestamp of last push, used to prevent local collisions if you push twice
4053
// in one ms.
4154
let lastPushTime = 0;
@@ -82,3 +95,83 @@ export const nextPushId = (function () {
8295
return id;
8396
};
8497
})();
98+
99+
export const successor = function (key: string) {
100+
if (key === '' + INTEGER_32_MAX) {
101+
// See https://firebase.google.com/docs/database/web/lists-of-data#data-order
102+
return MIN_PUSH_CHAR;
103+
}
104+
const keyAsInt: number = tryParseInt(key);
105+
if (keyAsInt != null) {
106+
return '' + (keyAsInt + 1);
107+
}
108+
const next = new Array(key.length);
109+
110+
for (let i = 0; i < next.length; i++) {
111+
next[i] = key.charAt(i);
112+
}
113+
114+
if (next.length < MAX_KEY_LEN) {
115+
next.push(MIN_PUSH_CHAR);
116+
return next.join('');
117+
}
118+
119+
let i = next.length - 1;
120+
121+
while (i >= 0 && next[i] === MAX_PUSH_CHAR) {
122+
i--;
123+
}
124+
125+
// `successor` was called on the largest possible key, so return the
126+
// MAX_NAME, which sorts larger than all keys.
127+
if (i === -1) {
128+
return MAX_NAME;
129+
}
130+
131+
const source = next[i];
132+
const sourcePlusOne = PUSH_CHARS.charAt(PUSH_CHARS.indexOf(source) + 1);
133+
next[i] = sourcePlusOne;
134+
135+
return next.slice(0, i + 1).join('');
136+
};
137+
138+
// `key` is assumed to be non-empty.
139+
export const predecessor = function (key: string) {
140+
if (key === '' + INTEGER_32_MIN) {
141+
return MIN_NAME;
142+
}
143+
const keyAsInt: number = tryParseInt(key);
144+
if (keyAsInt != null) {
145+
return '' + (keyAsInt - 1);
146+
}
147+
const next = new Array(key.length);
148+
for (let i = 0; i < next.length; i++) {
149+
next[i] = key.charAt(i);
150+
}
151+
// If `key` ends in `MIN_PUSH_CHAR`, the largest key lexicographically
152+
// smaller than `key`, is `key[0:key.length - 1]`. The next key smaller
153+
// than that, `predecessor(predecessor(key))`, is
154+
//
155+
// `key[0:key.length - 2] + (key[key.length - 1] - 1) + \
156+
// { MAX_PUSH_CHAR repeated MAX_KEY_LEN - (key.length - 1) times }
157+
//
158+
// analogous to increment/decrement for base-10 integers.
159+
//
160+
// This works because lexigographic comparison works character-by-character,
161+
// using length as a tie-breaker if one key is a prefix of the other.
162+
if (next[next.length - 1] === MIN_PUSH_CHAR) {
163+
if (next.length === 1) {
164+
// See https://firebase.google.com/docs/database/web/lists-of-data#orderbykey
165+
return '' + INTEGER_32_MAX;
166+
}
167+
delete next[next.length - 1];
168+
return next.join('');
169+
}
170+
// Replace the last character with it's immediate predecessor, and
171+
// fill the suffix of the key with MAX_PUSH_CHAR. This is the
172+
// lexicographically largest possible key smaller than `key`.
173+
next[next.length - 1] = PUSH_CHARS.charAt(
174+
PUSH_CHARS.indexOf(next[next.length - 1]) - 1
175+
);
176+
return next.join('') + MAX_PUSH_CHAR.repeat(MAX_KEY_LEN - next.length);
177+
};

packages/database/src/core/util/util.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,16 @@ export const errorForServerCode = function (code: string, query: Query): Error {
551551
*/
552552
export const INTEGER_REGEXP_ = new RegExp('^-?(0*)\\d{1,10}$');
553553

554+
/**
555+
* For use in keys, the minimum possible 32-bit integer.
556+
*/
557+
export const INTEGER_32_MIN = -2147483648;
558+
559+
/**
560+
* For use in kyes, the maximum possible 32-bit integer.
561+
*/
562+
export const INTEGER_32_MAX = 2147483647;
563+
554564
/**
555565
* If the string contains a 32-bit integer, return it. Else return null.
556566
* @param {!string} str
@@ -559,7 +569,7 @@ export const INTEGER_REGEXP_ = new RegExp('^-?(0*)\\d{1,10}$');
559569
export const tryParseInt = function (str: string): number | null {
560570
if (INTEGER_REGEXP_.test(str)) {
561571
const intVal = Number(str);
562-
if (intVal >= -2147483648 && intVal <= 2147483647) {
572+
if (intVal >= INTEGER_32_MIN && intVal <= INTEGER_32_MAX) {
563573
return intVal;
564574
}
565575
}

packages/database/src/core/view/QueryParams.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import { assert, stringify } from '@firebase/util';
1919
import { MIN_NAME, MAX_NAME } from '../util/util';
20+
import { predecessor, successor } from '../util/NextPushId';
2021
import { KEY_INDEX } from '../snap/indexes/KeyIndex';
2122
import { PRIORITY_INDEX } from '../snap/indexes/PriorityIndex';
2223
import { VALUE_INDEX } from '../snap/indexes/ValueIndex';
@@ -37,8 +38,10 @@ export class QueryParams {
3738
private limitSet_ = false;
3839
private startSet_ = false;
3940
private startNameSet_ = false;
41+
private startAfterSet_ = false;
4042
private endSet_ = false;
4143
private endNameSet_ = false;
44+
private endBeforeSet_ = false;
4245

4346
private limit_ = 0;
4447
private viewFrom_ = '';
@@ -98,6 +101,14 @@ export class QueryParams {
98101
return this.startSet_;
99102
}
100103

104+
hasStartAfter(): boolean {
105+
return this.startAfterSet_;
106+
}
107+
108+
hasEndBefore(): boolean {
109+
return this.endBeforeSet_;
110+
}
111+
101112
/**
102113
* @return {boolean} True if it would return from left.
103114
*/
@@ -277,6 +288,18 @@ export class QueryParams {
277288
return newParams;
278289
}
279290

291+
startAfter(indexValue: unknown, key?: string | null): QueryParams {
292+
let childKey: string;
293+
if (key == null) {
294+
childKey = MAX_NAME;
295+
} else {
296+
childKey = successor(key);
297+
}
298+
const params: QueryParams = this.startAt(indexValue, childKey);
299+
params.startAfterSet_ = true;
300+
return params;
301+
}
302+
280303
/**
281304
* @param {*} indexValue
282305
* @param {?string=} key
@@ -299,6 +322,18 @@ export class QueryParams {
299322
return newParams;
300323
}
301324

325+
endBefore(indexValue: unknown, key?: string | null): QueryParams {
326+
let childKey: string;
327+
if (key == null) {
328+
childKey = MIN_NAME;
329+
} else {
330+
childKey = predecessor(key);
331+
}
332+
const params: QueryParams = this.endAt(indexValue, childKey);
333+
params.endBeforeSet_ = true;
334+
return params;
335+
}
336+
302337
/**
303338
* @param {!Index} index
304339
* @return {!QueryParams}

0 commit comments

Comments
 (0)