Skip to content

Commit

Permalink
Implement startAfter and endBefore for RTDB queries (#4232)
Browse files Browse the repository at this point in the history
  • Loading branch information
jmwski authored Jan 14, 2021
1 parent 92a7f43 commit cb835e7
Show file tree
Hide file tree
Showing 9 changed files with 1,528 additions and 54 deletions.
5 changes: 5 additions & 0 deletions .changeset/hip-glasses-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@firebase/database': minor
---

Add `startAfter` and `endBefore` filters for paginating RTDB queries.
4 changes: 3 additions & 1 deletion packages/database/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"browser": "dist/index.esm.js",
"module": "dist/index.esm.js",
"esm2017": "dist/index.esm2017.js",
"files": ["dist"],
"files": [
"dist"
],
"scripts": {
"lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'",
"lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'",
Expand Down
55 changes: 52 additions & 3 deletions packages/database/src/api/Query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,21 @@ export class Query {
'or equalTo() must be a string.';
if (params.hasStart()) {
const startName = params.getIndexStartName();
if (startName !== MIN_NAME) {
if (
startName !== MIN_NAME &&
!(params.hasStartAfter() && startName === MAX_NAME)
) {
throw new Error(tooManyArgsError);
} else if (typeof startNode !== 'string') {
throw new Error(wrongArgTypeError);
}
}
if (params.hasEnd()) {
const endName = params.getIndexEndName();
if (endName !== MAX_NAME) {
if (
endName !== MAX_NAME &&
!(params.hasEndBefore() && endName === MIN_NAME)
) {
throw new Error(tooManyArgsError);
} else if (typeof endNode !== 'string') {
throw new Error(wrongArgTypeError);
Expand Down Expand Up @@ -526,6 +532,28 @@ export class Query {
value = null;
name = null;
}

return new Query(this.repo, this.path, newParams, this.orderByCalled_);
}

startAfter(
value: number | string | boolean | null = null,
name?: string | null
): Query {
validateArgCount('Query.startAfter', 0, 2, arguments.length);
validateFirebaseDataArg('Query.startAfter', 1, value, this.path, false);
validateKey('Query.startAfter', 2, name, true);

const newParams = this.queryParams_.startAfter(value, name);
Query.validateLimit_(newParams);
Query.validateQueryEndpoints_(newParams);
if (this.queryParams_.hasStart()) {
throw new Error(
'Query.startAfter: Starting point was already set (by another call to startAt, startAfter ' +
'or equalTo).'
);
}

return new Query(this.repo, this.path, newParams, this.orderByCalled_);
}

Expand All @@ -547,7 +575,28 @@ export class Query {
Query.validateQueryEndpoints_(newParams);
if (this.queryParams_.hasEnd()) {
throw new Error(
'Query.endAt: Ending point was already set (by another call to endAt or ' +
'Query.endAt: Ending point was already set (by another call to endAt, endBefore, or ' +
'equalTo).'
);
}

return new Query(this.repo, this.path, newParams, this.orderByCalled_);
}

endBefore(
value: number | string | boolean | null = null,
name?: string | null
): Query {
validateArgCount('Query.endBefore', 0, 2, arguments.length);
validateFirebaseDataArg('Query.endBefore', 1, value, this.path, false);
validateKey('Query.endBefore', 2, name, true);

const newParams = this.queryParams_.endBefore(value, name);
Query.validateLimit_(newParams);
Query.validateQueryEndpoints_(newParams);
if (this.queryParams_.hasEnd()) {
throw new Error(
'Query.endBefore: Ending point was already set (by another call to endAt, endBefore, or ' +
'equalTo).'
);
}
Expand Down
101 changes: 97 additions & 4 deletions packages/database/src/core/util/NextPushId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@
*/

import { assert } from '@firebase/util';
import {
tryParseInt,
MAX_NAME,
MIN_NAME,
INTEGER_32_MIN,
INTEGER_32_MAX
} from '../util/util';

// Modeled after base64 web-safe chars, but ordered by ASCII.
const PUSH_CHARS =
'-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz';

const MIN_PUSH_CHAR = '-';

const MAX_PUSH_CHAR = 'z';

const MAX_KEY_LEN = 786;

/**
* Fancy ID generator that creates 20-character string identifiers with the
Expand All @@ -32,10 +49,6 @@ import { assert } from '@firebase/util';
* in the case of a timestamp collision).
*/
export const nextPushId = (function () {
// Modeled after base64 web-safe chars, but ordered by ASCII.
const PUSH_CHARS =
'-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz';

// Timestamp of last push, used to prevent local collisions if you push twice
// in one ms.
let lastPushTime = 0;
Expand Down Expand Up @@ -82,3 +95,83 @@ export const nextPushId = (function () {
return id;
};
})();

export const successor = function (key: string) {
if (key === '' + INTEGER_32_MAX) {
// See https://firebase.google.com/docs/database/web/lists-of-data#data-order
return MIN_PUSH_CHAR;
}
const keyAsInt: number = tryParseInt(key);
if (keyAsInt != null) {
return '' + (keyAsInt + 1);
}
const next = new Array(key.length);

for (let i = 0; i < next.length; i++) {
next[i] = key.charAt(i);
}

if (next.length < MAX_KEY_LEN) {
next.push(MIN_PUSH_CHAR);
return next.join('');
}

let i = next.length - 1;

while (i >= 0 && next[i] === MAX_PUSH_CHAR) {
i--;
}

// `successor` was called on the largest possible key, so return the
// MAX_NAME, which sorts larger than all keys.
if (i === -1) {
return MAX_NAME;
}

const source = next[i];
const sourcePlusOne = PUSH_CHARS.charAt(PUSH_CHARS.indexOf(source) + 1);
next[i] = sourcePlusOne;

return next.slice(0, i + 1).join('');
};

// `key` is assumed to be non-empty.
export const predecessor = function (key: string) {
if (key === '' + INTEGER_32_MIN) {
return MIN_NAME;
}
const keyAsInt: number = tryParseInt(key);
if (keyAsInt != null) {
return '' + (keyAsInt - 1);
}
const next = new Array(key.length);
for (let i = 0; i < next.length; i++) {
next[i] = key.charAt(i);
}
// If `key` ends in `MIN_PUSH_CHAR`, the largest key lexicographically
// smaller than `key`, is `key[0:key.length - 1]`. The next key smaller
// than that, `predecessor(predecessor(key))`, is
//
// `key[0:key.length - 2] + (key[key.length - 1] - 1) + \
// { MAX_PUSH_CHAR repeated MAX_KEY_LEN - (key.length - 1) times }
//
// analogous to increment/decrement for base-10 integers.
//
// This works because lexigographic comparison works character-by-character,
// using length as a tie-breaker if one key is a prefix of the other.
if (next[next.length - 1] === MIN_PUSH_CHAR) {
if (next.length === 1) {
// See https://firebase.google.com/docs/database/web/lists-of-data#orderbykey
return '' + INTEGER_32_MAX;
}
delete next[next.length - 1];
return next.join('');
}
// Replace the last character with it's immediate predecessor, and
// fill the suffix of the key with MAX_PUSH_CHAR. This is the
// lexicographically largest possible key smaller than `key`.
next[next.length - 1] = PUSH_CHARS.charAt(
PUSH_CHARS.indexOf(next[next.length - 1]) - 1
);
return next.join('') + MAX_PUSH_CHAR.repeat(MAX_KEY_LEN - next.length);
};
12 changes: 11 additions & 1 deletion packages/database/src/core/util/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,16 @@ export const errorForServerCode = function (code: string, query: Query): Error {
*/
export const INTEGER_REGEXP_ = new RegExp('^-?(0*)\\d{1,10}$');

/**
* For use in keys, the minimum possible 32-bit integer.
*/
export const INTEGER_32_MIN = -2147483648;

/**
* For use in kyes, the maximum possible 32-bit integer.
*/
export const INTEGER_32_MAX = 2147483647;

/**
* If the string contains a 32-bit integer, return it. Else return null.
* @param {!string} str
Expand All @@ -559,7 +569,7 @@ export const INTEGER_REGEXP_ = new RegExp('^-?(0*)\\d{1,10}$');
export const tryParseInt = function (str: string): number | null {
if (INTEGER_REGEXP_.test(str)) {
const intVal = Number(str);
if (intVal >= -2147483648 && intVal <= 2147483647) {
if (intVal >= INTEGER_32_MIN && intVal <= INTEGER_32_MAX) {
return intVal;
}
}
Expand Down
35 changes: 35 additions & 0 deletions packages/database/src/core/view/QueryParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import { assert, stringify } from '@firebase/util';
import { MIN_NAME, MAX_NAME } from '../util/util';
import { predecessor, successor } from '../util/NextPushId';
import { KEY_INDEX } from '../snap/indexes/KeyIndex';
import { PRIORITY_INDEX } from '../snap/indexes/PriorityIndex';
import { VALUE_INDEX } from '../snap/indexes/ValueIndex';
Expand All @@ -37,8 +38,10 @@ export class QueryParams {
private limitSet_ = false;
private startSet_ = false;
private startNameSet_ = false;
private startAfterSet_ = false;
private endSet_ = false;
private endNameSet_ = false;
private endBeforeSet_ = false;

private limit_ = 0;
private viewFrom_ = '';
Expand Down Expand Up @@ -98,6 +101,14 @@ export class QueryParams {
return this.startSet_;
}

hasStartAfter(): boolean {
return this.startAfterSet_;
}

hasEndBefore(): boolean {
return this.endBeforeSet_;
}

/**
* @return {boolean} True if it would return from left.
*/
Expand Down Expand Up @@ -277,6 +288,18 @@ export class QueryParams {
return newParams;
}

startAfter(indexValue: unknown, key?: string | null): QueryParams {
let childKey: string;
if (key == null) {
childKey = MAX_NAME;
} else {
childKey = successor(key);
}
const params: QueryParams = this.startAt(indexValue, childKey);
params.startAfterSet_ = true;
return params;
}

/**
* @param {*} indexValue
* @param {?string=} key
Expand All @@ -299,6 +322,18 @@ export class QueryParams {
return newParams;
}

endBefore(indexValue: unknown, key?: string | null): QueryParams {
let childKey: string;
if (key == null) {
childKey = MIN_NAME;
} else {
childKey = predecessor(key);
}
const params: QueryParams = this.endAt(indexValue, childKey);
params.endBeforeSet_ = true;
return params;
}

/**
* @param {!Index} index
* @return {!QueryParams}
Expand Down
Loading

0 comments on commit cb835e7

Please sign in to comment.