From ab6d22639f0611863e0e5c1171a1398093b1e539 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 31 Mar 2021 13:42:54 -0600 Subject: [PATCH 1/5] Add events to database@exp --- .../.idea/runConfigurations/All_Tests.xml | 2 +- packages/database/exp/index.ts | 21 +- packages/database/src/api/Reference.ts | 478 ++--------- packages/database/src/api/test_access.ts | 2 +- .../database/src/core/PersistentConnection.ts | 42 +- .../database/src/core/ReadonlyRestClient.ts | 27 +- packages/database/src/core/Repo.ts | 26 +- packages/database/src/core/ServerActions.ts | 8 +- packages/database/src/core/SyncPoint.ts | 43 +- packages/database/src/core/SyncTree.ts | 82 +- packages/database/src/core/util/util.ts | 8 +- packages/database/src/core/view/Event.ts | 13 +- .../database/src/core/view/EventGenerator.ts | 6 +- .../src/core/view/EventRegistration.ts | 107 +++ .../database/src/core/view/QueryParams.ts | 2 +- packages/database/src/core/view/View.ts | 12 +- packages/database/src/exp/DataSnapshot.ts | 105 --- packages/database/src/exp/Database.ts | 16 +- packages/database/src/exp/Query.ts | 315 ------- packages/database/src/exp/Reference.ts | 91 +- packages/database/src/exp/Reference_impl.ts | 810 ++++++++++++++++++ packages/database/test/datasnapshot.test.ts | 8 +- .../database/test/exp/integration.test.ts | 12 +- packages/database/test/query.test.ts | 2 +- 24 files changed, 1182 insertions(+), 1056 deletions(-) delete mode 100644 packages/database/src/exp/DataSnapshot.ts delete mode 100644 packages/database/src/exp/Query.ts create mode 100644 packages/database/src/exp/Reference_impl.ts diff --git a/packages/database/.idea/runConfigurations/All_Tests.xml b/packages/database/.idea/runConfigurations/All_Tests.xml index 9a5747271e7..08aebabf99e 100644 --- a/packages/database/.idea/runConfigurations/All_Tests.xml +++ b/packages/database/.idea/runConfigurations/All_Tests.xml @@ -14,4 +14,4 @@ test/{,!(browser)/**/}*.test.ts - \ No newline at end of file + diff --git a/packages/database/exp/index.ts b/packages/database/exp/index.ts index ea34f7402fb..37dddea0055 100644 --- a/packages/database/exp/index.ts +++ b/packages/database/exp/index.ts @@ -27,21 +27,20 @@ export { getDatabase, goOffline, goOnline, - ref, - refFromURL, useDatabaseEmulator } from '../src/exp/Database'; export { - OnDisconnect, + Query, Reference, - ThenableReference + ListenOptions, + Unsubscribe, + ThenableReference, + OnDisconnect } from '../src/exp/Reference'; -export { DataSnapshot } from '../src/exp/DataSnapshot'; export { - ListenOptions, - Query, QueryConstraint, - Unsubscribe, + DataSnapshot, + EventType, endAt, endBefore, equalTo, @@ -60,8 +59,10 @@ export { orderByValue, query, startAfter, - startAt -} from '../src/exp/Query'; + startAt, + ref, + refFromURL +} from '../src/exp/Reference_impl'; export { increment, serverTimestamp } from '../src/exp/ServerValue'; export { runTransaction, TransactionOptions } from '../src/exp/Transaction'; diff --git a/packages/database/src/api/Reference.ts b/packages/database/src/api/Reference.ts index 27f8d7d3e9f..a6a75bf1c0e 100644 --- a/packages/database/src/api/Reference.ts +++ b/packages/database/src/api/Reference.ts @@ -16,21 +16,18 @@ */ import { + assert, + Compat, Deferred, + errorPrefix, validateArgCount, validateCallback, - contains, - validateContextObject, - errorPrefix, - assert, - Compat + validateContextObject } from '@firebase/util'; import { Repo, - repoAddEventCallbackForQuery, repoGetValue, - repoRemoveEventCallbackForQuery, repoServerTime, repoSetWithPriority, repoStartTransaction, @@ -41,7 +38,6 @@ import { PathIndex } from '../core/snap/indexes/PathIndex'; import { PRIORITY_INDEX } from '../core/snap/indexes/PriorityIndex'; import { VALUE_INDEX } from '../core/snap/indexes/ValueIndex'; import { Node } from '../core/snap/Node'; -import { syncPointSetReferenceConstructor } from '../core/SyncPoint'; import { nextPushId } from '../core/util/NextPushId'; import { Path, @@ -53,7 +49,7 @@ import { pathParent, pathToUrlEncodedString } from '../core/util/Path'; -import { MAX_NAME, MIN_NAME, ObjectToUniqueKey, warn } from '../core/util/util'; +import { MAX_NAME, MIN_NAME, warn } from '../core/util/util'; import { isValidPriority, validateBoolean, @@ -66,30 +62,33 @@ import { validateRootPathString, validateWritablePath } from '../core/util/validation'; -import { Change } from '../core/view/Change'; -import { CancelEvent, DataEvent, Event, EventType } from '../core/view/Event'; +import { UserCallback } from '../core/view/EventRegistration'; import { QueryParams, queryParamsEndAt, queryParamsEndBefore, - queryParamsGetQueryObject, queryParamsLimitToFirst, queryParamsLimitToLast, queryParamsOrderBy, queryParamsStartAfter, queryParamsStartAt } from '../core/view/QueryParams'; -import { DataSnapshot as ExpDataSnapshot } from '../exp/DataSnapshot'; -import { Reference as ExpReference } from '../exp/Reference'; +import { + DataSnapshot as ExpDataSnapshot, + off, + onChildAdded, + onChildChanged, + onChildMoved, + onChildRemoved, + onValue, + QueryImpl, + ReferenceImpl, + EventType +} from '../exp/Reference_impl'; import { Database } from './Database'; import { OnDisconnect } from './onDisconnect'; import { TransactionResult } from './TransactionResult'; - -export interface ReferenceConstructor { - new (database: Database, path: Path): Reference; -} - /** * Class representing a firebase data snapshot. It wraps a SnapshotNode and * surfaces the public methods (val, forEach, etc.) we want to expose. @@ -234,300 +233,14 @@ export interface SnapshotCallback { (dataSnapshot: DataSnapshot, previousChildName?: string | null): unknown; } -/** - * A wrapper class that converts events from the database@exp SDK to the legacy - * Database SDK. Events are not converted directly as event registration relies - * on reference comparison of the original user callback (see `matches()`). - */ -export class ExpSnapshotCallback { - constructor( - private readonly _database: Database, - private readonly _userCallback: SnapshotCallback - ) {} - - callback( - thisArg: unknown, - expDataSnapshot: ExpDataSnapshot, - previousChildName?: string | null - ): unknown { - return this._userCallback.call( - thisArg, - new DataSnapshot(this._database, expDataSnapshot), - previousChildName - ); - } - - matches(exp: ExpSnapshotCallback): boolean { - return this._userCallback === exp._userCallback; - } -} - -/** - * An EventRegistration is basically an event type ('value', 'child_added', etc.) and a callback - * to be notified of that type of event. - * - * That said, it can also contain a cancel callback to be notified if the event is canceled. And - * currently, this code is organized around the idea that you would register multiple child_ callbacks - * together, as a single EventRegistration. Though currently we don't do that. - */ -export interface EventRegistration { - /** - * True if this container has a callback to trigger for this event type - */ - respondsTo(eventType: string): boolean; - - createEvent(change: Change, query: Query): Event; - - /** - * Given event data, return a function to trigger the user's callback - */ - getEventRunner(eventData: Event): () => void; - - createCancelEvent(error: Error, path: Path): CancelEvent | null; - - matches(other: EventRegistration): boolean; - - /** - * False basically means this is a "dummy" callback container being used as a sentinel - * to remove all callback containers of a particular type. (e.g. if the user does - * ref.off('value') without specifying a specific callback). - * - * (TODO: Rework this, since it's hacky) - * - */ - hasAnyCallback(): boolean; -} - -/** - * Represents registration for 'value' events. - */ -export class ValueEventRegistration implements EventRegistration { - constructor( - private callback_: ExpSnapshotCallback | null, - private cancelCallback_: ((e: Error) => void) | null, - private context_: {} | null - ) {} - - /** - * @inheritDoc - */ - respondsTo(eventType: string): boolean { - return eventType === 'value'; - } - - /** - * @inheritDoc - */ - createEvent(change: Change, query: Query): DataEvent { - const index = query.getQueryParams().getIndex(); - return new DataEvent( - 'value', - this, - new ExpDataSnapshot( - change.snapshotNode, - new ExpReference(query.getRef().database.repo_, query.getRef().path), - index - ) - ); - } - - /** - * @inheritDoc - */ - getEventRunner(eventData: CancelEvent | DataEvent): () => void { - const ctx = this.context_; - if (eventData.getEventType() === 'cancel') { - assert( - this.cancelCallback_, - 'Raising a cancel event on a listener with no cancel callback' - ); - const cancelCB = this.cancelCallback_; - return function () { - // We know that error exists, we checked above that this is a cancel event - cancelCB.call(ctx, (eventData as CancelEvent).error); - }; - } else { - const cb = this.callback_; - return function () { - cb.callback(ctx, (eventData as DataEvent).snapshot); - }; - } - } - - /** - * @inheritDoc - */ - createCancelEvent(error: Error, path: Path): CancelEvent | null { - if (this.cancelCallback_) { - return new CancelEvent(this, error, path); - } else { - return null; - } - } - - /** - * @inheritDoc - */ - matches(other: EventRegistration): boolean { - if (!(other instanceof ValueEventRegistration)) { - return false; - } else if (!other.callback_ || !this.callback_) { - // If no callback specified, we consider it to match any callback. - return true; - } else { - return ( - other.callback_.matches(this.callback_) && - other.context_ === this.context_ - ); - } - } - - /** - * @inheritDoc - */ - hasAnyCallback(): boolean { - return this.callback_ !== null; - } -} - -/** - * Represents the registration of 1 or more child_xxx events. - * - * Currently, it is always exactly 1 child_xxx event, but the idea is we might let you - * register a group of callbacks together in the future. - */ -export class ChildEventRegistration implements EventRegistration { - constructor( - private callbacks_: { - [child: string]: ExpSnapshotCallback; - } | null, - private cancelCallback_: ((e: Error) => void) | null, - private context_?: {} - ) {} - - /** - * @inheritDoc - */ - respondsTo(eventType: string): boolean { - let eventToCheck = - eventType === 'children_added' ? 'child_added' : eventType; - eventToCheck = - eventToCheck === 'children_removed' ? 'child_removed' : eventToCheck; - return contains(this.callbacks_, eventToCheck); - } - - /** - * @inheritDoc - */ - createCancelEvent(error: Error, path: Path): CancelEvent | null { - if (this.cancelCallback_) { - return new CancelEvent(this, error, path); - } else { - return null; - } - } - - /** - * @inheritDoc - */ - createEvent(change: Change, query: Query): DataEvent { - assert(change.childName != null, 'Child events should have a childName.'); - const ref = query.getRef().child(change.childName); - const index = query.getQueryParams().getIndex(); - return new DataEvent( - change.type as EventType, - this, - new ExpDataSnapshot( - change.snapshotNode, - new ExpReference(ref.repo, ref.path), - index - ), - change.prevName - ); - } - - /** - * @inheritDoc - */ - getEventRunner(eventData: CancelEvent | DataEvent): () => void { - const ctx = this.context_; - if (eventData.getEventType() === 'cancel') { - assert( - this.cancelCallback_, - 'Raising a cancel event on a listener with no cancel callback' - ); - const cancelCB = this.cancelCallback_; - return function () { - // We know that error exists, we checked above that this is a cancel event - cancelCB.call(ctx, (eventData as CancelEvent).error); - }; - } else { - const cb = this.callbacks_[(eventData as DataEvent).eventType]; - return function () { - cb.callback( - ctx, - (eventData as DataEvent).snapshot, - (eventData as DataEvent).prevName - ); - }; - } - } - - /** - * @inheritDoc - */ - matches(other: EventRegistration): boolean { - if (other instanceof ChildEventRegistration) { - if (!this.callbacks_ || !other.callbacks_) { - return true; - } else if (this.context_ === other.context_) { - const otherKeys = Object.keys(other.callbacks_); - const thisKeys = Object.keys(this.callbacks_); - const otherCount = otherKeys.length; - const thisCount = thisKeys.length; - if (otherCount === thisCount) { - // If count is 1, do an exact match on eventType, if either is defined but null, it's a match. - // If event types don't match, not a match - // If count is not 1, exact match across all - - if (otherCount === 1) { - const otherKey = otherKeys[0]; - const thisKey = thisKeys[0]; - return ( - thisKey === otherKey && - (!other.callbacks_[otherKey] || - !this.callbacks_[thisKey] || - other.callbacks_[otherKey].matches(this.callbacks_[thisKey])) - ); - } else { - // Exact match on each key. - return thisKeys.every( - eventType => - other.callbacks_[eventType] === this.callbacks_[eventType] - ); - } - } - } - } - - return false; - } - - /** - * @inheritDoc - */ - hasAnyCallback(): boolean { - return this.callbacks_ !== null; - } -} - /** * A Query represents a filter to be applied to a firebase location. This object purely represents the * query expression (and exposes our public API to build the query). The actual query logic is in ViewBase.js. * * Since every Firebase reference is a query, Firebase inherits from this object. */ -export class Query { +export class Query implements Compat { + readonly _delegate: QueryImpl; readonly repo: Repo; constructor( @@ -537,6 +250,12 @@ export class Query { private orderByCalled_: boolean ) { this.repo = database.repo_; + this._delegate = new QueryImpl( + this.repo, + path, + queryParams_, + orderByCalled_ + ); } /** @@ -630,10 +349,6 @@ export class Query { } } - getQueryParams(): QueryParams { - return this.queryParams_; - } - getRef(): Reference { validateArgCount('Query.ref', 0, 0, arguments.length); // This is a slight hack. We cannot goog.require('fb.api.Firebase'), since Firebase requires fb.api.Query. @@ -649,7 +364,6 @@ export class Query { context?: object | null ): SnapshotCallback { validateArgCount('Query.on', 2, 4, arguments.length); - validateEventType('Query.on', 1, eventType, false); validateCallback('Query.on', 2, callback, false); const ret = Query.getCancelAndContextArgs_( @@ -657,41 +371,40 @@ export class Query { cancelCallbackOrContext, context ); - const expCallback = new ExpSnapshotCallback(this.database, callback); - if (eventType === 'value') { - this.onValueEvent(expCallback, ret.cancel, ret.context); - } else { - const callbacks: { [k: string]: ExpSnapshotCallback } = {}; - callbacks[eventType] = expCallback; - this.onChildEvent(callbacks, ret.cancel, ret.context); + const valueCallback: UserCallback = (expSnapshot, previousChildName?) => { + callback.call( + ret.context, + new DataSnapshot(this.database, expSnapshot), + previousChildName + ); + }; + valueCallback.userCallback = callback; + valueCallback.context = ret.context; + const cancelCallback = ret.cancel?.bind(ret.context); + + switch (eventType) { + case 'value': + onValue(this._delegate, valueCallback, cancelCallback); + return callback; + case 'child_added': + onChildAdded(this._delegate, valueCallback, cancelCallback); + return callback; + case 'child_removed': + onChildRemoved(this._delegate, valueCallback, cancelCallback); + return callback; + case 'child_changed': + onChildChanged(this._delegate, valueCallback, cancelCallback); + return callback; + case 'child_moved': + onChildMoved(this._delegate, valueCallback, cancelCallback); + return callback; + default: + throw new Error( + errorPrefix('Query.on', 1, false) + + 'must be a valid event type = "value", "child_added", "child_removed", ' + + '"child_changed", or "child_moved".' + ); } - return callback; - } - - protected onValueEvent( - callback: ExpSnapshotCallback, - cancelCallback: ((a: Error) => void) | null, - context: object | null - ) { - const container = new ValueEventRegistration( - callback, - cancelCallback || null, - context || null - ); - repoAddEventCallbackForQuery(this.database.repo_, this, container); - } - - protected onChildEvent( - callbacks: { [k: string]: ExpSnapshotCallback }, - cancelCallback: ((a: Error) => unknown) | null, - context: object | null - ) { - const container = new ChildEventRegistration( - callbacks, - cancelCallback, - context - ); - repoAddEventCallbackForQuery(this.database.repo_, this, container); } off( @@ -703,39 +416,27 @@ export class Query { validateEventType('Query.off', 1, eventType, true); validateCallback('Query.off', 2, callback, true); validateContextObject('Query.off', 3, context, true); - let container: EventRegistration | null = null; - let callbacks: { [k: string]: ExpSnapshotCallback } | null = null; - - const expCallback = callback - ? new ExpSnapshotCallback(this.database, callback) - : null; - if (eventType === 'value') { - container = new ValueEventRegistration( - expCallback, - null, - context || null - ); - } else if (eventType) { - if (callback) { - callbacks = {}; - callbacks[eventType] = expCallback; - } - container = new ChildEventRegistration(callbacks, null, context || null); + if (callback) { + const valueCallback: UserCallback = () => {}; + valueCallback.userCallback = callback; + valueCallback.context = context; + off(this._delegate, eventType as EventType, valueCallback); + } else { + off(this._delegate, eventType as EventType | undefined); } - repoRemoveEventCallbackForQuery(this.database.repo_, this, container); } /** * Get the server-value for this query, or return a cached value if not connected. */ get(): Promise { - return repoGetValue(this.database.repo_, this).then(node => { + return repoGetValue(this.database.repo_, this._delegate).then(node => { return new DataSnapshot( this.database, new ExpDataSnapshot( node, - new ExpReference(this.getRef().database.repo_, this.getRef().path), - this.getQueryParams().getIndex() + new ReferenceImpl(this.getRef().database.repo_, this.getRef().path), + this._delegate._queryParams.getIndex() ) ); }); @@ -1073,19 +774,6 @@ export class Query { return this.toString(); } - /** - * An object representation of the query parameters used by this Query. - */ - queryObject(): object { - return queryParamsGetQueryObject(this.queryParams_); - } - - queryIdentifier(): string { - const obj = this.queryObject(); - const id = ObjectToUniqueKey(obj); - return id === '{}' ? 'default' : id; - } - /** * Return true if this query and the provided query are equivalent; otherwise, return false. */ @@ -1100,7 +788,7 @@ export class Query { const sameRepo = this.database.repo_ === other.database.repo_; const samePath = pathEquals(this.path, other.path); const sameQueryIdentifier = - this.queryIdentifier() === other.queryIdentifier(); + this._delegate._queryIdentifier === other._delegate._queryIdentifier; return sameRepo && samePath && sameQueryIdentifier; } @@ -1114,11 +802,11 @@ export class Query { fnName: string, cancelOrContext?: ((a: Error) => void) | object | null, context?: object | null - ): { cancel: ((a: Error) => void) | null; context: object | null } { + ): { cancel: ((a: Error) => void) | undefined; context: object | undefined } { const ret: { cancel: ((a: Error) => void) | null; context: object | null; - } = { cancel: null, context: null }; + } = { cancel: undefined, context: undefined }; if (cancelOrContext && context) { ret.cancel = cancelOrContext as (a: Error) => void; validateCallback(fnName, 3, ret.cancel, true); @@ -1147,7 +835,9 @@ export class Query { } } -export class Reference extends Query { +export class Reference extends Query implements Compat { + readonly _delegate: ReferenceImpl; + then: Promise['then']; catch: Promise['catch']; @@ -1220,7 +910,7 @@ export class Reference extends Query { const deferred = new Deferred(); repoSetWithPriority( - this.database.repo_, + this.repo, this.path, newVal, /*priority=*/ null, @@ -1259,7 +949,7 @@ export class Reference extends Query { validateCallback('Reference.update', 2, onComplete, true); const deferred = new Deferred(); repoUpdate( - this.database.repo_, + this.repo, this.path, objectToMerge as { [k: string]: unknown }, deferred.wrapCallback(onComplete) @@ -1294,7 +984,7 @@ export class Reference extends Query { const deferred = new Deferred(); repoSetWithPriority( - this.database.repo_, + this.repo, this.path, newVal, newPriority, @@ -1361,7 +1051,7 @@ export class Reference extends Query { this.database, new ExpDataSnapshot( node, - new ExpReference(this.database.repo_, this.path), + new ReferenceImpl(this.database.repo_, this.path), PRIORITY_INDEX ) ); @@ -1448,7 +1138,7 @@ export class Reference extends Query { onDisconnect(): OnDisconnect { validateWritablePath('Reference.onDisconnect', this.path); - return new OnDisconnect(this.database.repo_, this.path); + return new OnDisconnect(this.repo, this.path); } get key(): string | null { @@ -1463,11 +1153,3 @@ export class Reference extends Query { return this.getRoot(); } } - -/** - * Define reference constructor in various modules - * - * We are doing this here to avoid several circular - * dependency issues - */ -syncPointSetReferenceConstructor(Reference); diff --git a/packages/database/src/api/test_access.ts b/packages/database/src/api/test_access.ts index d9792b6d7d8..bd67b2ab9e5 100644 --- a/packages/database/src/api/test_access.ts +++ b/packages/database/src/api/test_access.ts @@ -64,7 +64,7 @@ export const hijackHash = function (newHash: () => string) { export const ConnectionTarget = RepoInfo; export const queryIdentifier = function (query: Query) { - return query.queryIdentifier(); + return query._delegate._queryIdentifier; }; /** diff --git a/packages/database/src/core/PersistentConnection.ts b/packages/database/src/core/PersistentConnection.ts index 233fce612bf..2cae60fcfec 100644 --- a/packages/database/src/core/PersistentConnection.ts +++ b/packages/database/src/core/PersistentConnection.ts @@ -29,7 +29,6 @@ import { Deferred } from '@firebase/util'; -import { Query } from '../api/Reference'; import { Connection } from '../realtime/Connection'; import { AuthTokenProvider } from './AuthTokenProvider'; @@ -40,6 +39,7 @@ import { Path } from './util/Path'; import { error, log, logWrapper, warn, ObjectToUniqueKey } from './util/util'; import { VisibilityMonitor } from './util/VisibilityMonitor'; import { SDK_VERSION } from './version'; +import { QueryContext } from './view/EventRegistration'; const RECONNECT_MIN_DELAY = 1000; const RECONNECT_MAX_DELAY_DEFAULT = 60 * 5 * 1000; // 5 minutes in milliseconds (Case: 1858) @@ -57,7 +57,7 @@ interface ListenSpec { hashFn(): string; - query: Query; + query: QueryContext; tag: number | null; } @@ -190,11 +190,11 @@ export class PersistentConnection extends ServerActions { } } - get(query: Query): Promise { + get(query: QueryContext): Promise { const deferred = new Deferred(); const request = { - p: query.path.toString(), - q: query.queryObject() + p: query._path.toString(), + q: query._queryObject }; const outstandingGet = { action: 'g', @@ -245,20 +245,19 @@ export class PersistentConnection extends ServerActions { * @inheritDoc */ listen( - query: Query, + query: QueryContext, currentHashFn: () => string, tag: number | null, onComplete: (a: string, b: unknown) => void ) { - const queryId = query.queryIdentifier(); - const pathString = query.path.toString(); + const queryId = query._queryIdentifier; + const pathString = query._path.toString(); this.log_('Listen called for ' + pathString + ' ' + queryId); if (!this.listens.has(pathString)) { this.listens.set(pathString, new Map()); } assert( - query.getQueryParams().isDefault() || - !query.getQueryParams().loadsAllData(), + query._queryParams.isDefault() || !query._queryParams.loadsAllData(), 'listen() called for non-default but complete query' ); assert( @@ -294,8 +293,8 @@ export class PersistentConnection extends ServerActions { private sendListen_(listenSpec: ListenSpec) { const query = listenSpec.query; - const pathString = query.path.toString(); - const queryId = query.queryIdentifier(); + const pathString = query._path.toString(); + const queryId = query._queryIdentifier; this.log_('Listen on ' + pathString + ' for ' + queryId); const req: { [k: string]: unknown } = { /*path*/ p: pathString }; @@ -303,7 +302,7 @@ export class PersistentConnection extends ServerActions { // Only bother to send query if it's non-default. if (listenSpec.tag) { - req['q'] = query.queryObject(); + req['q'] = query._queryObject; req['t'] = listenSpec.tag; } @@ -334,14 +333,14 @@ export class PersistentConnection extends ServerActions { }); } - private static warnOnListenWarnings_(payload: unknown, query: Query) { + private static warnOnListenWarnings_(payload: unknown, query: QueryContext) { if (payload && typeof payload === 'object' && contains(payload, 'w')) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const warnings = safeGet(payload as any, 'w'); if (Array.isArray(warnings) && ~warnings.indexOf('no_index')) { const indexSpec = - '".indexOn": "' + query.getQueryParams().getIndex().toString() + '"'; - const indexPath = query.path.toString(); + '".indexOn": "' + query._queryParams.getIndex().toString() + '"'; + const indexPath = query._path.toString(); warn( `Using an unspecified index. Your data will be downloaded and ` + `filtered on the client. Consider adding ${indexSpec} at ` + @@ -419,20 +418,19 @@ export class PersistentConnection extends ServerActions { /** * @inheritDoc */ - unlisten(query: Query, tag: number | null) { - const pathString = query.path.toString(); - const queryId = query.queryIdentifier(); + unlisten(query: QueryContext, tag: number | null) { + const pathString = query._path.toString(); + const queryId = query._queryIdentifier; this.log_('Unlisten called for ' + pathString + ' ' + queryId); assert( - query.getQueryParams().isDefault() || - !query.getQueryParams().loadsAllData(), + query._queryParams.isDefault() || !query._queryParams.loadsAllData(), 'unlisten() called for non-default but complete query' ); const listen = this.removeListen_(pathString, queryId); if (listen && this.connected_) { - this.sendUnlisten_(pathString, queryId, query.queryObject(), tag); + this.sendUnlisten_(pathString, queryId, query._queryObject, tag); } } diff --git a/packages/database/src/core/ReadonlyRestClient.ts b/packages/database/src/core/ReadonlyRestClient.ts index 1609c8708ed..d9cfceac113 100644 --- a/packages/database/src/core/ReadonlyRestClient.ts +++ b/packages/database/src/core/ReadonlyRestClient.ts @@ -23,12 +23,11 @@ import { Deferred } from '@firebase/util'; -import { Query } from '../api/Reference'; - import { AuthTokenProvider } from './AuthTokenProvider'; import { RepoInfo } from './RepoInfo'; import { ServerActions } from './ServerActions'; import { logWrapper, warn } from './util/util'; +import { QueryContext } from './view/EventRegistration'; import { queryParamsToRestQueryStringParameters } from './view/QueryParams'; /** @@ -50,15 +49,15 @@ export class ReadonlyRestClient extends ServerActions { */ private listens_: { [k: string]: object } = {}; - static getListenId_(query: Query, tag?: number | null): string { + static getListenId_(query: QueryContext, tag?: number | null): string { if (tag !== undefined) { return 'tag$' + tag; } else { assert( - query.getQueryParams().isDefault(), + query._queryParams.isDefault(), "should have a tag if it's not a default query." ); - return query.path.toString(); + return query._path.toString(); } } @@ -81,15 +80,13 @@ export class ReadonlyRestClient extends ServerActions { /** @inheritDoc */ listen( - query: Query, + query: QueryContext, currentHashFn: () => string, tag: number | null, onComplete: (a: string, b: unknown) => void ) { - const pathString = query.path.toString(); - this.log_( - 'Listen called for ' + pathString + ' ' + query.queryIdentifier() - ); + const pathString = query._path.toString(); + this.log_('Listen called for ' + pathString + ' ' + query._queryIdentifier); // Mark this listener so we can tell if it's removed. const listenId = ReadonlyRestClient.getListenId_(query, tag); @@ -97,7 +94,7 @@ export class ReadonlyRestClient extends ServerActions { this.listens_[listenId] = thisListen; const queryStringParameters = queryParamsToRestQueryStringParameters( - query.getQueryParams() + query._queryParams ); this.restRequest_( @@ -132,17 +129,17 @@ export class ReadonlyRestClient extends ServerActions { } /** @inheritDoc */ - unlisten(query: Query, tag: number | null) { + unlisten(query: QueryContext, tag: number | null) { const listenId = ReadonlyRestClient.getListenId_(query, tag); delete this.listens_[listenId]; } - get(query: Query): Promise { + get(query: QueryContext): Promise { const queryStringParameters = queryParamsToRestQueryStringParameters( - query.getQueryParams() + query._queryParams ); - const pathString = query.path.toString(); + const pathString = query._path.toString(); const deferred = new Deferred(); diff --git a/packages/database/src/core/Repo.ts b/packages/database/src/core/Repo.ts index 8b325ef96a7..bf47d50bb71 100644 --- a/packages/database/src/core/Repo.ts +++ b/packages/database/src/core/Repo.ts @@ -25,7 +25,6 @@ import { } from '@firebase/util'; import { FirebaseAppLike } from '../api/Database'; -import { EventRegistration, Query } from '../api/Reference'; import { AuthTokenProvider } from './AuthTokenProvider'; import { PersistentConnection } from './PersistentConnection'; @@ -104,6 +103,7 @@ import { eventQueueRaiseEventsAtPath, eventQueueRaiseEventsForChangedPath } from './view/EventQueue'; +import { EventRegistration, QueryContext } from './view/EventRegistration'; const INTERRUPT_REASON = 'repo_interrupt'; @@ -280,13 +280,13 @@ export function repoStart(repo: Repo): void { repo.infoSyncTree_ = new SyncTree({ startListening: (query, tag, currentHashFn, onComplete) => { let infoEvents: Event[] = []; - const node = repo.infoData_.getNode(query.path); + const node = repo.infoData_.getNode(query._path); // This is possibly a hack, but we have different semantics for .info endpoints. We don't raise null events // on initial data... if (!node.isEmpty()) { infoEvents = syncTreeApplyServerOverwrite( repo.infoSyncTree_, - query.path, + query._path, node ); setTimeout(() => { @@ -305,7 +305,7 @@ export function repoStart(repo: Repo): void { const events = onComplete(status, data); eventQueueRaiseEventsForChangedPath( repo.eventQueue_, - query.path, + query._path, events ); }); @@ -449,7 +449,7 @@ function repoGetNextWriteId(repo: Repo): number { * * @param query - The query to surface a value for. */ -export function repoGetValue(repo: Repo, query: Query): Promise { +export function repoGetValue(repo: Repo, query: QueryContext): Promise { // Only active queries are cached. There is no persisted cache. const cached = syncTreeGetServerValue(repo.serverSyncTree_, query); if (cached != null) { @@ -460,10 +460,10 @@ export function repoGetValue(repo: Repo, query: Query): Promise { const node = nodeFromJSON(payload as string); const events = syncTreeApplyServerOverwrite( repo.serverSyncTree_, - query.path, + query._path, node ); - eventQueueRaiseEventsAtPath(repo.eventQueue_, query.path, events); + eventQueueRaiseEventsAtPath(repo.eventQueue_, query._path, events); return Promise.resolve(node); }, err => { @@ -726,11 +726,11 @@ export function repoOnDisconnectUpdate( export function repoAddEventCallbackForQuery( repo: Repo, - query: Query, + query: QueryContext, eventRegistration: EventRegistration ): void { let events; - if (pathGetFront(query.path) === '.info') { + if (pathGetFront(query._path) === '.info') { events = syncTreeAddEventRegistration( repo.infoSyncTree_, query, @@ -743,18 +743,18 @@ export function repoAddEventCallbackForQuery( eventRegistration ); } - eventQueueRaiseEventsAtPath(repo.eventQueue_, query.path, events); + eventQueueRaiseEventsAtPath(repo.eventQueue_, query._path, events); } export function repoRemoveEventCallbackForQuery( repo: Repo, - query: Query, + query: QueryContext, eventRegistration: EventRegistration ): void { // These are guaranteed not to raise events, since we're not passing in a cancelError. However, we can future-proof // a little bit by handling the return values anyways. let events; - if (pathGetFront(query.path) === '.info') { + if (pathGetFront(query._path) === '.info') { events = syncTreeRemoveEventRegistration( repo.infoSyncTree_, query, @@ -767,7 +767,7 @@ export function repoRemoveEventCallbackForQuery( eventRegistration ); } - eventQueueRaiseEventsAtPath(repo.eventQueue_, query.path, events); + eventQueueRaiseEventsAtPath(repo.eventQueue_, query._path, events); } export function repoInterrupt(repo: Repo): void { diff --git a/packages/database/src/core/ServerActions.ts b/packages/database/src/core/ServerActions.ts index 0f707b67b83..7a8d3fa1225 100644 --- a/packages/database/src/core/ServerActions.ts +++ b/packages/database/src/core/ServerActions.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Query } from '../api/Reference'; +import { QueryContext } from './view/EventRegistration'; /** * Interface defining the set of actions that can be performed against the Firebase server @@ -25,7 +25,7 @@ import { Query } from '../api/Reference'; */ export abstract class ServerActions { abstract listen( - query: Query, + query: QueryContext, currentHashFn: () => string, tag: number | null, onComplete: (a: string, b: unknown) => void @@ -34,12 +34,12 @@ export abstract class ServerActions { /** * Remove a listen. */ - abstract unlisten(query: Query, tag: number | null): void; + abstract unlisten(query: QueryContext, tag: number | null): void; /** * Get the server value satisfying this query. */ - abstract get(query: Query): Promise; + abstract get(query: QueryContext): Promise; put( pathString: string, diff --git a/packages/database/src/core/SyncPoint.ts b/packages/database/src/core/SyncPoint.ts index ed9a124f542..b001865084f 100644 --- a/packages/database/src/core/SyncPoint.ts +++ b/packages/database/src/core/SyncPoint.ts @@ -17,11 +17,7 @@ import { assert } from '@firebase/util'; -import { - EventRegistration, - Query, - ReferenceConstructor -} from '../api/Reference'; +import { ReferenceConstructor } from '../exp/Reference'; import { Operation } from './operation/Operation'; import { ChildrenNode } from './snap/ChildrenNode'; @@ -29,6 +25,7 @@ import { Node } from './snap/Node'; import { Path } from './util/Path'; import { CacheNode } from './view/CacheNode'; import { Event } from './view/Event'; +import { EventRegistration, QueryContext } from './view/EventRegistration'; import { View, viewAddEventRegistration, @@ -126,12 +123,12 @@ export function syncPointApplyOperation( */ export function syncPointGetView( syncPoint: SyncPoint, - query: Query, + query: QueryContext, writesCache: WriteTreeRef, serverCache: Node | null, serverCacheComplete: boolean ): View { - const queryId = query.queryIdentifier(); + const queryId = query._queryIdentifier; const view = syncPoint.views.get(queryId); if (!view) { // TODO: make writesCache take flag for complete server node @@ -173,7 +170,7 @@ export function syncPointGetView( */ export function syncPointAddEventRegistration( syncPoint: SyncPoint, - query: Query, + query: QueryContext, eventRegistration: EventRegistration, writesCache: WriteTreeRef, serverCache: Node | null, @@ -186,8 +183,8 @@ export function syncPointAddEventRegistration( serverCache, serverCacheComplete ); - if (!syncPoint.views.has(query.queryIdentifier())) { - syncPoint.views.set(query.queryIdentifier(), view); + if (!syncPoint.views.has(query._queryIdentifier)) { + syncPoint.views.set(query._queryIdentifier, view); } // This is guaranteed to exist now, we just created anything that was missing viewAddEventRegistration(view, eventRegistration); @@ -206,12 +203,12 @@ export function syncPointAddEventRegistration( */ export function syncPointRemoveEventRegistration( syncPoint: SyncPoint, - query: Query, + query: QueryContext, eventRegistration: EventRegistration | null, cancelError?: Error -): { removed: Query[]; events: Event[] } { - const queryId = query.queryIdentifier(); - const removed: Query[] = []; +): { removed: QueryContext[]; events: Event[] } { + const queryId = query._queryIdentifier; + const removed: QueryContext[] = []; let cancelEvents: Event[] = []; const hadCompleteView = syncPointHasCompleteView(syncPoint); if (queryId === 'default') { @@ -224,7 +221,7 @@ export function syncPointRemoveEventRegistration( syncPoint.views.delete(viewQueryId); // We'll deal with complete views later. - if (!view.query.getQueryParams().loadsAllData()) { + if (!view.query._queryParams.loadsAllData()) { removed.push(view.query); } } @@ -240,7 +237,7 @@ export function syncPointRemoveEventRegistration( syncPoint.views.delete(queryId); // We'll deal with complete views later. - if (!view.query.getQueryParams().loadsAllData()) { + if (!view.query._queryParams.loadsAllData()) { removed.push(view.query); } } @@ -250,7 +247,7 @@ export function syncPointRemoveEventRegistration( if (hadCompleteView && !syncPointHasCompleteView(syncPoint)) { // We removed our last complete view. removed.push( - new (syncPointGetReferenceConstructor())(query.database, query.path) + new (syncPointGetReferenceConstructor())(query._repo, query._path) ); } @@ -260,7 +257,7 @@ export function syncPointRemoveEventRegistration( export function syncPointGetQueryViews(syncPoint: SyncPoint): View[] { const result = []; for (const view of syncPoint.views.values()) { - if (!view.query.getQueryParams().loadsAllData()) { + if (!view.query._queryParams.loadsAllData()) { result.push(view); } } @@ -284,20 +281,20 @@ export function syncPointGetCompleteServerCache( export function syncPointViewForQuery( syncPoint: SyncPoint, - query: Query + query: QueryContext ): View | null { - const params = query.getQueryParams(); + const params = query._queryParams; if (params.loadsAllData()) { return syncPointGetCompleteView(syncPoint); } else { - const queryId = query.queryIdentifier(); + const queryId = query._queryIdentifier; return syncPoint.views.get(queryId); } } export function syncPointViewExistsForQuery( syncPoint: SyncPoint, - query: Query + query: QueryContext ): boolean { return syncPointViewForQuery(syncPoint, query) != null; } @@ -308,7 +305,7 @@ export function syncPointHasCompleteView(syncPoint: SyncPoint): boolean { export function syncPointGetCompleteView(syncPoint: SyncPoint): View | null { for (const view of syncPoint.views.values()) { - if (view.query.getQueryParams().loadsAllData()) { + if (view.query._queryParams.loadsAllData()) { return view; } } diff --git a/packages/database/src/core/SyncTree.ts b/packages/database/src/core/SyncTree.ts index 948e713be52..6d7c0da8526 100644 --- a/packages/database/src/core/SyncTree.ts +++ b/packages/database/src/core/SyncTree.ts @@ -17,7 +17,7 @@ import { assert } from '@firebase/util'; -import { EventRegistration, Query } from '../api/Reference'; +import { ReferenceConstructor } from '../exp/Reference'; import { AckUserWrite } from './operation/AckUserWrite'; import { ListenComplete } from './operation/ListenComplete'; @@ -56,6 +56,7 @@ import { import { each, errorForServerCode } from './util/util'; import { CacheNode } from './view/CacheNode'; import { Event } from './view/Event'; +import { EventRegistration, QueryContext } from './view/EventRegistration'; import { View, viewGetCompleteNode, viewGetServerCache } from './view/View'; import { newWriteTree, @@ -69,6 +70,23 @@ import { writeTreeRemoveWrite } from './WriteTree'; +let referenceConstructor: ReferenceConstructor; + +export function syncTreeSetReferenceConstructor( + val: ReferenceConstructor +): void { + assert( + !referenceConstructor, + '__referenceConstructor has already been defined' + ); + referenceConstructor = val; +} + +function syncTreeGetReferenceConstructor(): ReferenceConstructor { + assert(referenceConstructor, 'Reference.ts has not been loaded'); + return referenceConstructor; +} + /** * @typedef {{ * startListening: function( @@ -83,13 +101,13 @@ import { */ export interface ListenProvider { startListening( - query: Query, + query: QueryContext, tag: number | null, hashFn: () => string, onComplete: (a: string, b?: unknown) => Event[] ): Event[]; - stopListening(a: Query, b: number | null): void; + stopListening(a: QueryContext, b: number | null): void; } /** @@ -315,12 +333,12 @@ export function syncTreeApplyTaggedListenComplete( */ export function syncTreeRemoveEventRegistration( syncTree: SyncTree, - query: Query, + query: QueryContext, eventRegistration: EventRegistration | null, cancelError?: Error ): Event[] { // Find the syncPoint first. Then deal with whether or not it has matching listeners - const path = query.path; + const path = query._path; const maybeSyncPoint = syncTree.syncPointTree_.get(path); let cancelEvents: Event[] = []; // A removal on a default query affects all queries at that location. A removal on an indexed query, even one without @@ -328,7 +346,7 @@ export function syncTreeRemoveEventRegistration( // not loadsAllData(). if ( maybeSyncPoint && - (query.queryIdentifier() === 'default' || + (query._queryIdentifier === 'default' || syncPointViewExistsForQuery(maybeSyncPoint, query)) ) { const removedAndEvents = syncPointRemoveEventRegistration( @@ -351,7 +369,7 @@ export function syncTreeRemoveEventRegistration( const removingDefault = -1 !== removed.findIndex(query => { - return query.getQueryParams().loadsAllData(); + return query._queryParams.loadsAllData(); }); const covered = syncTree.syncPointTree_.findOnPath( path, @@ -397,7 +415,7 @@ export function syncTreeRemoveEventRegistration( defaultTag ); } else { - removed.forEach((queryToRemove: Query) => { + removed.forEach((queryToRemove: QueryContext) => { const tagToRemove = syncTree.queryToTagMap.get( syncTreeMakeQueryKey_(queryToRemove) ); @@ -482,10 +500,10 @@ export function syncTreeApplyTaggedQueryMerge( */ export function syncTreeAddEventRegistration( syncTree: SyncTree, - query: Query, + query: QueryContext, eventRegistration: EventRegistration ): Event[] { - const path = query.path; + const path = query._path; let serverCache: Node | null = null; let foundAncestorDefaultView = false; @@ -531,7 +549,7 @@ export function syncTreeAddEventRegistration( } const viewAlreadyExists = syncPointViewExistsForQuery(syncPoint, query); - if (!viewAlreadyExists && !query.getQueryParams().loadsAllData()) { + if (!viewAlreadyExists && !query._queryParams.loadsAllData()) { // We need to track a tag for this query const queryKey = syncTreeMakeQueryKey_(query); assert( @@ -600,9 +618,9 @@ export function syncTreeCalcCompleteEventCache( export function syncTreeGetServerValue( syncTree: SyncTree, - query: Query + query: QueryContext ): Node | null { - const path = query.path; + const path = query._path; let serverCache: Node | null = null; // Any covering writes will necessarily be at the root, so really all we need to find is the server cache. // Consider optimizing this once there's a better understanding of what actual behavior will be. @@ -625,7 +643,7 @@ export function syncTreeGetServerValue( : null; const writesCache: WriteTreeRef | null = writeTreeChildWrites( syncTree.pendingWriteTree_, - query.path + query._path ); const view: View = syncPointGetView( syncPoint, @@ -774,9 +792,9 @@ function syncTreeCreateListenerForView_( onComplete: (status: string): Event[] => { if (status === 'ok') { if (tag) { - return syncTreeApplyTaggedListenComplete(syncTree, query.path, tag); + return syncTreeApplyTaggedListenComplete(syncTree, query._path, tag); } else { - return syncTreeApplyListenComplete(syncTree, query.path); + return syncTreeApplyListenComplete(syncTree, query._path); } } else { // If a listen failed, kill all of the listeners here, not just the one that triggered the error. @@ -796,7 +814,10 @@ function syncTreeCreateListenerForView_( /** * Return the tag associated with the given query. */ -function syncTreeTagForQuery_(syncTree: SyncTree, query: Query): number | null { +function syncTreeTagForQuery_( + syncTree: SyncTree, + query: QueryContext +): number | null { const queryKey = syncTreeMakeQueryKey_(query); return syncTree.queryToTagMap.get(queryKey); } @@ -804,8 +825,8 @@ function syncTreeTagForQuery_(syncTree: SyncTree, query: Query): number | null { /** * Given a query, computes a "queryKey" suitable for use in our queryToTagMap_. */ -function syncTreeMakeQueryKey_(query: Query): string { - return query.path.toString() + '$' + query.queryIdentifier(); +function syncTreeMakeQueryKey_(query: QueryContext): string { + return query._path.toString() + '$' + query._queryIdentifier; } /** @@ -882,24 +903,21 @@ function syncTreeCollectDistinctViewsForSubTree_( * * @return The normalized query */ -function syncTreeQueryForListening_(query: Query): Query { - if ( - query.getQueryParams().loadsAllData() && - !query.getQueryParams().isDefault() - ) { +function syncTreeQueryForListening_(query: QueryContext): QueryContext { + if (query._queryParams.loadsAllData() && !query._queryParams.isDefault()) { // We treat queries that load all data as default queries // Cast is necessary because ref() technically returns Firebase which is actually fb.api.Firebase which inherits // from Query - return query.getRef()!; + return new (syncTreeGetReferenceConstructor())(query._repo, query._path); } else { return query; } } -function syncTreeRemoveTags_(syncTree: SyncTree, queries: Query[]) { +function syncTreeRemoveTags_(syncTree: SyncTree, queries: QueryContext[]) { for (let j = 0; j < queries.length; ++j) { const removedQuery = queries[j]; - if (!removedQuery.getQueryParams().loadsAllData()) { + if (!removedQuery._queryParams.loadsAllData()) { // We should have a tag for this const removedQueryKey = syncTreeMakeQueryKey_(removedQuery); const removedQueryTag = syncTree.queryToTagMap.get(removedQueryKey); @@ -923,10 +941,10 @@ function syncTreeGetNextQueryTag_(): number { */ function syncTreeSetupListener_( syncTree: SyncTree, - query: Query, + query: QueryContext, view: View ): Event[] { - const path = query.path; + const path = query._path; const tag = syncTreeTagForQuery_(syncTree, query); const listener = syncTreeCreateListenerForView_(syncTree, view); @@ -947,7 +965,7 @@ function syncTreeSetupListener_( ); } else { // Shadow everything at or below this location, this is a default listener. - const queriesToStop = subtree.fold( + const queriesToStop = subtree.fold( (relativePath, maybeChildSyncPoint, childMap) => { if ( !pathIsEmpty(relativePath) && @@ -957,7 +975,7 @@ function syncTreeSetupListener_( return [syncPointGetCompleteView(maybeChildSyncPoint).query]; } else { // No default listener here, flatten any deeper queries into an array - let queries: Query[] = []; + let queries: QueryContext[] = []; if (maybeChildSyncPoint) { queries = queries.concat( syncPointGetQueryViews(maybeChildSyncPoint).map( @@ -965,7 +983,7 @@ function syncTreeSetupListener_( ) ); } - each(childMap, (_key: string, childQueries: Query[]) => { + each(childMap, (_key: string, childQueries: QueryContext[]) => { queries = queries.concat(childQueries); }); return queries; diff --git a/packages/database/src/core/util/util.ts b/packages/database/src/core/util/util.ts index 57fad9d0ae8..4208b0529d9 100644 --- a/packages/database/src/core/util/util.ts +++ b/packages/database/src/core/util/util.ts @@ -25,8 +25,8 @@ import { isNodeSdk } from '@firebase/util'; -import { Query } from '../../api/Reference'; import { SessionStorage } from '../storage/storage'; +import { QueryContext } from '../view/EventRegistration'; declare const window: Window; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -470,7 +470,7 @@ export const isWindowsStoreApp = function (): boolean { /** * Converts a server error code to a Javascript Error */ -export const errorForServerCode = function (code: string, query: Query): Error { +export function errorForServerCode(code: string, query: QueryContext): Error { let reason = 'Unknown Error'; if (code === 'too_big') { reason = @@ -483,12 +483,12 @@ export const errorForServerCode = function (code: string, query: Query): Error { } const error = new Error( - code + ' at ' + query.path.toString() + ': ' + reason + code + ' at ' + query._path.toString() + ': ' + reason ); // eslint-disable-next-line @typescript-eslint/no-explicit-any (error as any).code = code.toUpperCase(); return error; -}; +} /** * Used to test for integer-looking strings diff --git a/packages/database/src/core/view/Event.ts b/packages/database/src/core/view/Event.ts index cba342eda6a..307510b27e6 100644 --- a/packages/database/src/core/view/Event.ts +++ b/packages/database/src/core/view/Event.ts @@ -17,10 +17,11 @@ import { stringify } from '@firebase/util'; -import { EventRegistration } from '../../api/Reference'; -import { DataSnapshot as ExpDataSnapshot } from '../../exp/DataSnapshot'; +import { DataSnapshot as ExpDataSnapshot } from '../../exp/Reference_impl'; import { Path } from '../util/Path'; +import { EventRegistration } from './EventRegistration'; + /** * Encapsulates the data needed to raise an event * @interface @@ -37,10 +38,10 @@ export interface Event { export type EventType = | 'value' - | ' child_added' - | ' child_changed' - | ' child_moved' - | ' child_removed'; + | 'child_added' + | 'child_changed' + | 'child_moved' + | 'child_removed'; /** * Encapsulates the data needed to raise an event diff --git a/packages/database/src/core/view/EventGenerator.ts b/packages/database/src/core/view/EventGenerator.ts index cfc0cbb0c49..e78fb14ef19 100644 --- a/packages/database/src/core/view/EventGenerator.ts +++ b/packages/database/src/core/view/EventGenerator.ts @@ -17,12 +17,12 @@ import { assertionError } from '@firebase/util'; -import { EventRegistration, Query } from '../../api/Reference'; import { Index } from '../snap/indexes/Index'; import { NamedNode, Node } from '../snap/Node'; import { Change, ChangeType, changeChildMoved } from './Change'; import { Event } from './Event'; +import { EventRegistration, QueryContext } from './EventRegistration'; /** * An EventGenerator is used to convert "raw" changes (Change) as computed by the @@ -33,8 +33,8 @@ import { Event } from './Event'; export class EventGenerator { index_: Index; - constructor(public query_: Query) { - this.index_ = this.query_.getQueryParams().getIndex(); + constructor(public query_: QueryContext) { + this.index_ = this.query_._queryParams.getIndex(); } } diff --git a/packages/database/src/core/view/EventRegistration.ts b/packages/database/src/core/view/EventRegistration.ts index b53b848f574..cd9ff28985a 100644 --- a/packages/database/src/core/view/EventRegistration.ts +++ b/packages/database/src/core/view/EventRegistration.ts @@ -14,3 +14,110 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { assert } from '@firebase/util'; + +import { DataSnapshot } from '../../exp/Reference_impl'; +import { Repo } from '../Repo'; +import { Path } from '../util/Path'; + +import { Change } from './Change'; +import { CancelEvent, Event } from './Event'; +import { QueryParams } from './QueryParams'; + +/** + * A user callback. Callbacks issues from the Legacy SDK maintain references + * to the original user-issued callbacks, which allows equality + * comparison by reference even though this callbacks are wrapped before + * they can be passed to the firebase@exp SDK. + */ +export interface UserCallback { + (dataSnapshot: DataSnapshot, previousChildName?: string | null): unknown; + userCallback?: unknown; + context?: object | null; +} + +/** + * A wrapper class that converts events from the database@exp SDK to the legacy + * Database SDK. Events are not converted directly as event registration relies + * on reference comparison of the original user callback (see `matches()`) and + * relies on equality of the legacy SDK's `context` object. + */ +export class CallbackContext { + constructor( + private readonly snapshotCallback: UserCallback, + private readonly cancelCallback?: (error: Error) => unknown + ) {} + + onValue( + expDataSnapshot: DataSnapshot, + previousChildName?: string | null + ): void { + this.snapshotCallback.call(null, expDataSnapshot, previousChildName); + } + + onCancel(error: Error): void { + assert( + this.hasCancelCallback, + 'Raising a cancel event on a listener with no cancel callback' + ); + return this.cancelCallback.call(null, error); + } + + get hasCancelCallback(): boolean { + return !!this.cancelCallback; + } + + matches(other: CallbackContext): boolean { + return ( + this.snapshotCallback === other.snapshotCallback || + (this.snapshotCallback.userCallback === + other.snapshotCallback.userCallback && + this.snapshotCallback.context === other.snapshotCallback.context) + ); + } +} + +export interface QueryContext { + readonly _queryIdentifier: string; + readonly _queryObject: object; + readonly _repo: Repo; + readonly _path: Path; + readonly _queryParams: QueryParams; +} + +/** + * An EventRegistration is basically an event type ('value', 'child_added', etc.) and a callback + * to be notified of that type of event. + * + * That said, it can also contain a cancel callback to be notified if the event is canceled. And + * currently, this code is organized around the idea that you would register multiple child_ callbacks + * together, as a single EventRegistration. Though currently we don't do that. + */ +export interface EventRegistration { + /** + * True if this container has a callback to trigger for this event type + */ + respondsTo(eventType: string): boolean; + + createEvent(change: Change, query: QueryContext): Event; + + /** + * Given event data, return a function to trigger the user's callback + */ + getEventRunner(eventData: Event): () => void; + + createCancelEvent(error: Error, path: Path): CancelEvent | null; + + matches(other: EventRegistration): boolean; + + /** + * False basically means this is a "dummy" callback container being used as a sentinel + * to remove all callback containers of a particular type. (e.g. if the user does + * ref.off('value') without specifying a specific callback). + * + * (TODO: Rework this, since it's hacky) + * + */ + hasAnyCallback(): boolean; +} diff --git a/packages/database/src/core/view/QueryParams.ts b/packages/database/src/core/view/QueryParams.ts index 6812e4d56b6..33477032fb0 100644 --- a/packages/database/src/core/view/QueryParams.ts +++ b/packages/database/src/core/view/QueryParams.ts @@ -23,7 +23,7 @@ import { PathIndex } from '../snap/indexes/PathIndex'; import { PRIORITY_INDEX } from '../snap/indexes/PriorityIndex'; import { VALUE_INDEX } from '../snap/indexes/ValueIndex'; import { predecessor, successor } from '../util/NextPushId'; -import { MIN_NAME, MAX_NAME } from '../util/util'; +import { MAX_NAME, MIN_NAME } from '../util/util'; import { IndexedFilter } from './filter/IndexedFilter'; import { LimitedFilter } from './filter/LimitedFilter'; diff --git a/packages/database/src/core/view/View.ts b/packages/database/src/core/view/View.ts index 2eec1bc90bc..1c5804a0eb1 100644 --- a/packages/database/src/core/view/View.ts +++ b/packages/database/src/core/view/View.ts @@ -17,7 +17,6 @@ import { assert } from '@firebase/util'; -import { EventRegistration, Query } from '../../api/Reference'; import { Operation, OperationType } from '../operation/Operation'; import { ChildrenNode } from '../snap/ChildrenNode'; import { PRIORITY_INDEX } from '../snap/indexes/PriorityIndex'; @@ -32,6 +31,7 @@ import { EventGenerator, eventGeneratorGenerateEventsForChanges } from './EventGenerator'; +import { EventRegistration, QueryContext } from './EventRegistration'; import { IndexedFilter } from './filter/IndexedFilter'; import { queryParamsGetNodeFilter } from './QueryParams'; import { @@ -62,8 +62,8 @@ export class View { eventRegistrations_: EventRegistration[] = []; eventGenerator_: EventGenerator; - constructor(private query_: Query, initialViewCache: ViewCache) { - const params = this.query_.getQueryParams(); + constructor(private query_: QueryContext, initialViewCache: ViewCache) { + const params = this.query_._queryParams; const indexFilter = new IndexedFilter(params.getIndex()); const filter = queryParamsGetNodeFilter(params); @@ -99,7 +99,7 @@ export class View { this.eventGenerator_ = new EventGenerator(this.query_); } - get query(): Query { + get query(): QueryContext { return this.query_; } } @@ -121,7 +121,7 @@ export function viewGetCompleteServerCache( // If this isn't a "loadsAllData" view, then cache isn't actually a complete cache and // we need to see if it contains the child we're interested in. if ( - view.query.getQueryParams().loadsAllData() || + view.query._queryParams.loadsAllData() || (!pathIsEmpty(path) && !cache.getImmediateChild(pathGetFront(path)).isEmpty()) ) { @@ -158,7 +158,7 @@ export function viewRemoveEventRegistration( eventRegistration == null, 'A cancel should cancel all event registrations.' ); - const path = view.query.path; + const path = view.query._path; view.eventRegistrations_.forEach(registration => { const maybeEvent = registration.createCancelEvent(cancelError, path); if (maybeEvent) { diff --git a/packages/database/src/exp/DataSnapshot.ts b/packages/database/src/exp/DataSnapshot.ts deleted file mode 100644 index f41b885e6fa..00000000000 --- a/packages/database/src/exp/DataSnapshot.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @license - * Copyright 2020 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. - */ - -import { ChildrenNode } from '../core/snap/ChildrenNode'; -import { Index } from '../core/snap/indexes/Index'; -import { PRIORITY_INDEX } from '../core/snap/indexes/PriorityIndex'; -import { Node } from '../core/snap/Node'; -import { Path } from '../core/util/Path'; - -import { child, Reference } from './Reference'; - -export class DataSnapshot { - /** - * @param _node A SnapshotNode to wrap. - * @param ref The ref of the location this snapshot came from. - * @param _index The iteration order for this snapshot - */ - constructor( - readonly _node: Node, - readonly ref: Reference, - readonly _index: Index - ) {} - - get priority(): string | number | null { - // typecast here because we never return deferred values or internal priorities (MAX_PRIORITY) - return this._node.getPriority().val() as string | number | null; - } - - get key(): string | null { - return this.ref.key; - } - - get size(): number { - return this._node.numChildren(); - } - - child(path: string): DataSnapshot { - const childPath = new Path(path); - const childRef = child(this.ref, path); - return new DataSnapshot( - this._node.getChild(childPath), - childRef, - PRIORITY_INDEX - ); - } - - exists(): boolean { - return !this._node.isEmpty(); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - exportVal(): any { - return this._node.val(true); - } - - forEach(action: (child: DataSnapshot) => boolean | void): boolean { - if (this._node.isLeafNode()) { - return false; - } - - const childrenNode = this._node as ChildrenNode; - // Sanitize the return value to a boolean. ChildrenNode.forEachChild has a weird return type... - return !!childrenNode.forEachChild(this._index, (key, node) => { - return action( - new DataSnapshot(node, child(this.ref, key), PRIORITY_INDEX) - ); - }); - } - - hasChild(path: string): boolean { - const childPath = new Path(path); - return !this._node.getChild(childPath).isEmpty(); - } - - hasChildren(): boolean { - if (this._node.isLeafNode()) { - return false; - } else { - return !this._node.isEmpty(); - } - } - - toJSON(): object | null { - return this.exportVal(); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - val(): any { - return this._node.val(); - } -} diff --git a/packages/database/src/exp/Database.ts b/packages/database/src/exp/Database.ts index e2624adecb4..a53dc7b4cef 100644 --- a/packages/database/src/exp/Database.ts +++ b/packages/database/src/exp/Database.ts @@ -20,13 +20,14 @@ import { _FirebaseService, _getProvider, FirebaseApp } from '@firebase/app-exp'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { Provider } from '@firebase/component'; -import { Reference } from '../api/Reference'; +import { Repo } from '../core/Repo'; /** * Class representing a Firebase Realtime Database. */ export class FirebaseDatabase implements _FirebaseService { readonly 'type' = 'database'; + _repo: Repo; constructor( readonly app: FirebaseApp, @@ -77,19 +78,6 @@ export function goOnline(db: FirebaseDatabase): void { return {} as any; } -export function ref( - db: FirebaseDatabase, - path?: string | Reference -): Reference { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function refFromURL(db: FirebaseDatabase, url: string): Reference { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - export function enableLogging( logger?: boolean | ((message: string) => unknown), persistent?: boolean diff --git a/packages/database/src/exp/Query.ts b/packages/database/src/exp/Query.ts deleted file mode 100644 index 4d1c01e1484..00000000000 --- a/packages/database/src/exp/Query.ts +++ /dev/null @@ -1,315 +0,0 @@ -/** - * @license - * Copyright 2020 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. - */ - -import { DataSnapshot } from './DataSnapshot'; -import { Reference } from './Reference'; - -export class Query { - protected constructor() {} - ref: Reference; - - isEqual(other: Query | null): boolean { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; - } - - toJSON(): object { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; - } - - toString(): string { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; - } -} - -export function get(query: Query): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export type Unsubscribe = () => {}; -export interface ListenOptions { - readonly onlyOnce?: boolean; -} - -export function onValue( - query: Query, - callback: (snapshot: DataSnapshot) => unknown, - cancelCallback?: (error: Error) => unknown -): Unsubscribe; -export function onValue( - query: Query, - callback: (snapshot: DataSnapshot) => unknown, - options: ListenOptions -): Unsubscribe; -export function onValue( - query: Query, - callback: (snapshot: DataSnapshot) => unknown, - cancelCallback: (error: Error) => unknown, - options: ListenOptions -): Unsubscribe; -export function onValue( - query: Query, - callback: (snapshot: DataSnapshot) => unknown, - cancelCallbackOrListenOptions?: ((error: Error) => unknown) | ListenOptions, - options?: ListenOptions -): Unsubscribe { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function onChildAdded( - query: Query, - callback: ( - snapshot: DataSnapshot, - previousChildName?: string | null - ) => unknown, - cancelCallback?: (error: Error) => unknown -): Unsubscribe; -export function onChildAdded( - query: Query, - callback: ( - snapshot: DataSnapshot, - previousChildName: string | null - ) => unknown, - options: ListenOptions -): Unsubscribe; -export function onChildAdded( - query: Query, - callback: ( - snapshot: DataSnapshot, - previousChildName: string | null - ) => unknown, - cancelCallback: (error: Error) => unknown, - options: ListenOptions -): Unsubscribe; -export function onChildAdded( - query: Query, - callback: ( - snapshot: DataSnapshot, - previousChildName: string | null - ) => unknown, - cancelCallbackOrListenOptions?: ((error: Error) => unknown) | ListenOptions, - options?: ListenOptions -): Unsubscribe { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function onChildChanged( - query: Query, - callback: ( - snapshot: DataSnapshot, - previousChildName: string | null - ) => unknown, - cancelCallback?: (error: Error) => unknown -): Unsubscribe; -export function onChildChanged( - query: Query, - callback: ( - snapshot: DataSnapshot, - previousChildName: string | null - ) => unknown, - options: ListenOptions -): Unsubscribe; -export function onChildChanged( - query: Query, - callback: ( - snapshot: DataSnapshot, - previousChildName: string | null - ) => unknown, - cancelCallback: (error: Error) => unknown, - options: ListenOptions -): Unsubscribe; -export function onChildChanged( - query: Query, - callback: ( - snapshot: DataSnapshot, - previousChildName: string | null - ) => unknown, - cancelCallbackOrListenOptions?: ((error: Error) => unknown) | ListenOptions, - options?: ListenOptions -): Unsubscribe { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function onChildMoved( - query: Query, - callback: ( - snapshot: DataSnapshot, - previousChildName: string | null - ) => unknown, - cancelCallback?: (error: Error) => unknown -): Unsubscribe; -export function onChildMoved( - query: Query, - callback: ( - snapshot: DataSnapshot, - previousChildName: string | null - ) => unknown, - options: ListenOptions -): Unsubscribe; -export function onChildMoved( - query: Query, - callback: ( - snapshot: DataSnapshot, - previousChildName: string | null - ) => unknown, - cancelCallback: (error: Error) => unknown, - options: ListenOptions -): Unsubscribe; -export function onChildMoved( - query: Query, - callback: ( - snapshot: DataSnapshot, - previousChildName: string | null - ) => unknown, - cancelCallbackOrListenOptions?: ((error: Error) => unknown) | ListenOptions, - options?: ListenOptions -): Unsubscribe { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function onChildRemoved( - query: Query, - callback: (snapshot: DataSnapshot) => unknown, - cancelCallback?: (error: Error) => unknown -): Unsubscribe; -export function onChildRemoved( - query: Query, - callback: (snapshot: DataSnapshot) => unknown, - options: ListenOptions -): Unsubscribe; -export function onChildRemoved( - query: Query, - callback: (snapshot: DataSnapshot) => unknown, - cancelCallback: (error: Error) => unknown, - options: ListenOptions -): Unsubscribe; -export function onChildRemoved( - query: Query, - callback: (snapshot: DataSnapshot) => unknown, - cancelCallbackOrListenOptions?: ((error: Error) => unknown) | ListenOptions, - options?: ListenOptions -): Unsubscribe { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function off( - query: Query, - callback?: ( - snapshot: DataSnapshot, - previousChildName?: string | null - ) => unknown -): void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export interface QueryConstraint { - type: - | 'endAt' - | 'endBefore' - | 'startAt' - | 'startAfter' - | 'limitToFirst' - | 'limitToLast' - | 'orderByChild' - | 'orderByKey' - | 'orderByPriority' - | 'orderByValue' - | 'equalTo'; -} -export function endAt( - value: number | string | boolean | null, - key?: string -): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function endBefore( - value: number | string | boolean | null, - key?: string -): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function startAt( - value: number | string | boolean | null, - key?: string -): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function startAfter( - value: number | string | boolean | null, - key?: string -): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function limitToFirst(limit: number): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function limitToLast(limit: number): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function orderByChild(path: string): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function orderByKey(): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function orderByPriority(): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function orderByValue(): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function equalTo( - value: number | string | boolean | null, - key?: string -): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function query(query: Query, ...constraints: QueryConstraint[]): Query { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} diff --git a/packages/database/src/exp/Reference.ts b/packages/database/src/exp/Reference.ts index 887800b3f8d..0d518a677ab 100644 --- a/packages/database/src/exp/Reference.ts +++ b/packages/database/src/exp/Reference.ts @@ -1,6 +1,10 @@ +import { Repo } from '../core/Repo'; +import { Path } from '../core/util/Path'; +import { QueryContext } from '../core/view/EventRegistration'; + /** * @license - * Copyright 2020 Google LLC + * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,36 +19,16 @@ * limitations under the License. */ -import { Repo } from '../core/Repo'; -import { - Path, - pathChild, - pathGetBack, - pathIsEmpty, - pathParent -} from '../core/util/Path'; - -import { Query } from './Query'; - -export class Reference extends Query { - root: Reference; - - constructor(readonly _repo: Repo, readonly _path: Path) { - super(); - } - - get key(): string | null { - if (pathIsEmpty(this._path)) { - return null; - } else { - return pathGetBack(this._path); - } - } +export interface Query extends QueryContext { + readonly ref: Reference; + isEqual(other: Query | null): boolean; + toJSON(): object; + toString(): string; +} - get parent(): Reference | null { - const parentPath = pathParent(this._path); - return parentPath === null ? null : new Reference(this._repo, parentPath); - } +export interface Reference extends Query { + readonly key: string | null; + readonly parent: Reference | null; } export interface OnDisconnect { @@ -62,49 +46,12 @@ export interface ThenableReference extends Reference, Pick, 'then' | 'catch'> {} -export function child(ref: Reference, path: string): Reference { - // TODO: Accept Compat class - return new Reference(ref._repo, pathChild(ref._path, path)); -} - -export function onDisconnect(ref: Reference): OnDisconnect { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function push(ref: Reference, value?: unknown): ThenableReference { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function remove(ref: Reference): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function set(ref: Reference, value: unknown): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} - -export function setPriority( - ref: Reference, - priority: string | number | null -): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; -} +export type Unsubscribe = () => void; -export function setWithPriority( - ref: Reference, - newVal: unknown, - newPriority: string | number | null -): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; +export interface ListenOptions { + readonly onlyOnce?: boolean; } -export function update(ref: Reference, values: object): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; +export interface ReferenceConstructor { + new (repo: Repo, path: Path): Reference; } diff --git a/packages/database/src/exp/Reference_impl.ts b/packages/database/src/exp/Reference_impl.ts new file mode 100644 index 00000000000..ae94c824d5d --- /dev/null +++ b/packages/database/src/exp/Reference_impl.ts @@ -0,0 +1,810 @@ +/** + * @license + * Copyright 2020 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. + */ + +import { assert, contains } from '@firebase/util'; + +import { + Repo, + repoAddEventCallbackForQuery, + repoRemoveEventCallbackForQuery +} from '../core/Repo'; +import { ChildrenNode } from '../core/snap/ChildrenNode'; +import { Index } from '../core/snap/indexes'; +import { PRIORITY_INDEX } from '../core/snap/indexes/PriorityIndex'; +import { Node } from '../core/snap/Node'; +import { syncPointSetReferenceConstructor } from '../core/SyncPoint'; +import { syncTreeSetReferenceConstructor } from '../core/SyncTree'; +import { + Path, + pathChild, + pathGetBack, + pathIsEmpty, + pathParent +} from '../core/util/Path'; +import { ObjectToUniqueKey } from '../core/util/util'; +import { Change } from '../core/view/Change'; +import { CancelEvent, DataEvent, EventType } from '../core/view/Event'; +import { + CallbackContext, + EventRegistration, + QueryContext +} from '../core/view/EventRegistration'; +import { + QueryParams, + queryParamsGetQueryObject +} from '../core/view/QueryParams'; + +import { FirebaseDatabase } from './Database'; +import { + ListenOptions, + OnDisconnect, + Query as Query, + Reference as Reference, + ThenableReference, + Unsubscribe +} from './Reference'; + +export class QueryImpl implements Query, QueryContext { + /** + * @hideconstructor + */ + constructor( + readonly _repo: Repo, + readonly _path: Path, + readonly _queryParams: QueryParams, + private readonly _orderByCalled: boolean + ) {} + + get key(): string | null { + if (pathIsEmpty(this._path)) { + return null; + } else { + return pathGetBack(this._path); + } + } + + get ref(): Reference { + return new ReferenceImpl(this._repo, this._path); + } + + get _queryIdentifier(): string { + const obj = queryParamsGetQueryObject(this._queryParams); + const id = ObjectToUniqueKey(obj); + return id === '{}' ? 'default' : id; + } + + /** + * An object representation of the query parameters used by this Query. + */ + get _queryObject(): object { + return queryParamsGetQueryObject(this._queryParams); + } + + isEqual(other: QueryImpl | null): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; + } + + toJSON(): object { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; + } + + toString(): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; + } +} + +export class ReferenceImpl extends QueryImpl implements Reference { + root: Reference; + + /** @hideconstructor */ + constructor(repo: Repo, path: Path) { + super(repo, path, new QueryParams(), false); + } + + get parent(): Reference | null { + const parentPath = pathParent(this._path); + return parentPath === null + ? null + : new ReferenceImpl(this._repo, parentPath); + } +} + +export class DataSnapshot { + /** + * @param _node A SnapshotNode to wrap. + * @param ref The location this snapshot came from. + * @param _index The iteration order for this snapshot + * @hideconstructor + */ + constructor( + readonly _node: Node, + readonly ref: Reference, + readonly _index: Index + ) {} + + get priority(): string | number | null { + // typecast here because we never return deferred values or internal priorities (MAX_PRIORITY) + return this._node.getPriority().val() as string | number | null; + } + + get key(): string | null { + return this.ref.key; + } + + get size(): number { + return this._node.numChildren(); + } + + child(path: string): DataSnapshot { + const childPath = new Path(path); + const childRef = child(this.ref, path); + return new DataSnapshot( + this._node.getChild(childPath), + childRef, + PRIORITY_INDEX + ); + } + + exists(): boolean { + return !this._node.isEmpty(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + exportVal(): any { + return this._node.val(true); + } + + forEach(action: (child: DataSnapshot) => boolean | void): boolean { + if (this._node.isLeafNode()) { + return false; + } + + const childrenNode = this._node as ChildrenNode; + // Sanitize the return value to a boolean. ChildrenNode.forEachChild has a weird return type... + return !!childrenNode.forEachChild(this._index, (key, node) => { + return action( + new DataSnapshot(node, child(this.ref, key), PRIORITY_INDEX) + ); + }); + } + + hasChild(path: string): boolean { + const childPath = new Path(path); + return !this._node.getChild(childPath).isEmpty(); + } + + hasChildren(): boolean { + if (this._node.isLeafNode()) { + return false; + } else { + return !this._node.isEmpty(); + } + } + + toJSON(): object | null { + return this.exportVal(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + val(): any { + return this._node.val(); + } +} + +export function ref(db: FirebaseDatabase, path?: string): Reference { + return new ReferenceImpl(db._repo, new Path(path || '')); +} + +export function refFromURL(db: FirebaseDatabase, url: string): Reference { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function child(ref: Reference, path: string): Reference { + // TODO: Accept Compat class + return new ReferenceImpl(ref._repo, pathChild(ref._path, path)); +} + +export function onDisconnect(ref: Reference): OnDisconnect { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function push(ref: Reference, value?: unknown): ThenableReference { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function remove(ref: Reference): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function set(ref: Reference, value: unknown): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function setPriority( + ref: Reference, + priority: string | number | null +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function setWithPriority( + ref: Reference, + newVal: unknown, + newPriority: string | number | null +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function update(ref: Reference, values: object): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function get(query: Query): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +/** + * Represents registration for 'value' events. + */ +export class ValueEventRegistration implements EventRegistration { + constructor(private callbackContext: CallbackContext) {} + + /** + * @inheritDoc + */ + respondsTo(eventType: string): boolean { + return eventType === 'value'; + } + + /** + * @inheritDoc + */ + createEvent(change: Change, query: QueryContext): DataEvent { + const index = query._queryParams.getIndex(); + return new DataEvent( + 'value', + this, + new DataSnapshot( + change.snapshotNode, + new ReferenceImpl(query._repo, query._path), + index + ) + ); + } + + /** + * @inheritDoc + */ + getEventRunner(eventData: CancelEvent | DataEvent): () => void { + if (eventData.getEventType() === 'cancel') { + return () => + this.callbackContext.onCancel((eventData as CancelEvent).error); + } else { + return () => + this.callbackContext.onValue((eventData as DataEvent).snapshot, null); + } + } + + /** + * @inheritDoc + */ + createCancelEvent(error: Error, path: Path): CancelEvent | null { + if (this.callbackContext.hasCancelCallback) { + return new CancelEvent(this, error, path); + } else { + return null; + } + } + + /** + * @inheritDoc + */ + matches(other: EventRegistration): boolean { + if (!(other instanceof ValueEventRegistration)) { + return false; + } else if (!other.callbackContext || !this.callbackContext) { + // If no callback specified, we consider it to match any callback. + return true; + } else { + return other.callbackContext.matches(this.callbackContext); + } + } + + /** + * @inheritDoc + */ + hasAnyCallback(): boolean { + return this.callbackContext !== null; + } +} + +/** + * Represents the registration of 1 or more child_xxx events. + * + * Currently, it is always exactly 1 child_xxx event, but the idea is we might let you + * register a group of callbacks together in the future. + */ +export class ChildEventRegistration implements EventRegistration { + constructor( + private callbacks: { + [child: string]: CallbackContext; + } | null + ) {} + + /** + * @inheritDoc + */ + respondsTo(eventType: string): boolean { + let eventToCheck = + eventType === 'children_added' ? 'child_added' : eventType; + eventToCheck = + eventToCheck === 'children_removed' ? 'child_removed' : eventToCheck; + return contains(this.callbacks, eventToCheck); + } + + /** + * @inheritDoc + */ + createCancelEvent(error: Error, path: Path): CancelEvent | null { + if (this.callbacks['cancel'].hasCancelCallback) { + return new CancelEvent(this, error, path); + } else { + return null; + } + } + + /** + * @inheritDoc + */ + createEvent(change: Change, query: QueryContext): DataEvent { + assert(change.childName != null, 'Child events should have a childName.'); + const childRef = child( + new ReferenceImpl(query._repo, query._path), + change.childName + ); + const index = query._queryParams.getIndex(); + return new DataEvent( + change.type as EventType, + this, + new DataSnapshot(change.snapshotNode, childRef, index), + change.prevName + ); + } + + /** + * @inheritDoc + */ + getEventRunner(eventData: CancelEvent | DataEvent): () => void { + if (eventData.getEventType() === 'cancel') { + const cancelCB = this.callbacks['cancel']; + return () => cancelCB.onCancel((eventData as CancelEvent).error); + } else { + const cb = this.callbacks[(eventData as DataEvent).eventType]; + return () => + cb.onValue( + (eventData as DataEvent).snapshot, + (eventData as DataEvent).prevName + ); + } + } + + /** + * @inheritDoc + */ + matches(other: EventRegistration): boolean { + if (other instanceof ChildEventRegistration) { + if (!this.callbacks || !other.callbacks) { + return true; + } else { + const otherKeys = Object.keys(other.callbacks); + const thisKeys = Object.keys(this.callbacks); + const otherCount = otherKeys.length; + const thisCount = thisKeys.length; + if (otherCount === thisCount) { + // If count is 1, do an exact match on eventType, if either is defined but null, it's a match. + // If event types don't match, not a match + // If count is not 1, exact match across all + + if (otherCount === 1) { + const otherKey = otherKeys[0]; + const thisKey = thisKeys[0]; + return ( + thisKey === otherKey && + (!other.callbacks[otherKey] || + !this.callbacks[thisKey] || + other.callbacks[otherKey].matches(this.callbacks[thisKey])) + ); + } else { + // Exact match on each key. + return thisKeys.every(eventType => + other.callbacks[eventType].matches(this.callbacks[eventType]) + ); + } + } + } + } + + return false; + } + + /** + * @inheritDoc + */ + hasAnyCallback(): boolean { + return this.callbacks !== null; + } +} + +function addEventListener( + query: Query, + eventType: EventType, + callback: ( + snapshot: DataSnapshot, + previousChildName: string | null + ) => unknown, + cancelCallbackOrListenOptions: ((error: Error) => unknown) | ListenOptions, + options: ListenOptions +) { + let cancelCallback: ((error: Error) => unknown) | undefined; + if (typeof cancelCallbackOrListenOptions === 'object') { + cancelCallback = undefined; + options = cancelCallbackOrListenOptions; + } + if (typeof cancelCallbackOrListenOptions === 'function') { + cancelCallback = cancelCallbackOrListenOptions; + } + + const callbackContext = new CallbackContext( + callback, + cancelCallback || undefined + ); + const container = + eventType === 'value' + ? new ValueEventRegistration(callbackContext) + : new ChildEventRegistration({ + [eventType]: callbackContext + }); + repoAddEventCallbackForQuery(query._repo, query, container); + return () => repoRemoveEventCallbackForQuery(query._repo, query, container); +} + +export function onValue( + query: Query, + callback: (snapshot: DataSnapshot) => unknown, + cancelCallback?: (error: Error) => unknown +): Unsubscribe; +export function onValue( + query: Query, + callback: (snapshot: DataSnapshot) => unknown, + options: ListenOptions +): Unsubscribe; +export function onValue( + query: Query, + callback: (snapshot: DataSnapshot) => unknown, + cancelCallback: (error: Error) => unknown, + options: ListenOptions +): Unsubscribe; +export function onValue( + query: Query, + callback: (snapshot: DataSnapshot) => unknown, + cancelCallbackOrListenOptions?: ((error: Error) => unknown) | ListenOptions, + options?: ListenOptions +): Unsubscribe { + return addEventListener( + query, + 'value', + callback, + cancelCallbackOrListenOptions, + options + ); +} + +export function onChildAdded( + query: Query, + callback: ( + snapshot: DataSnapshot, + previousChildName?: string | null + ) => unknown, + cancelCallback?: (error: Error) => unknown +): Unsubscribe; +export function onChildAdded( + query: Query, + callback: ( + snapshot: DataSnapshot, + previousChildName: string | null + ) => unknown, + options: ListenOptions +): Unsubscribe; +export function onChildAdded( + query: Query, + callback: ( + snapshot: DataSnapshot, + previousChildName: string | null + ) => unknown, + cancelCallback: (error: Error) => unknown, + options: ListenOptions +): Unsubscribe; +export function onChildAdded( + query: Query, + callback: ( + snapshot: DataSnapshot, + previousChildName: string | null + ) => unknown, + cancelCallbackOrListenOptions?: ((error: Error) => unknown) | ListenOptions, + options?: ListenOptions +): Unsubscribe { + return addEventListener( + query, + 'child_added', + callback, + cancelCallbackOrListenOptions, + options + ); +} + +export function onChildChanged( + query: Query, + callback: ( + snapshot: DataSnapshot, + previousChildName: string | null + ) => unknown, + cancelCallback?: (error: Error) => unknown +): Unsubscribe; +export function onChildChanged( + query: Query, + callback: ( + snapshot: DataSnapshot, + previousChildName: string | null + ) => unknown, + options: ListenOptions +): Unsubscribe; +export function onChildChanged( + query: Query, + callback: ( + snapshot: DataSnapshot, + previousChildName: string | null + ) => unknown, + cancelCallback: (error: Error) => unknown, + options: ListenOptions +): Unsubscribe; +export function onChildChanged( + query: Query, + callback: ( + snapshot: DataSnapshot, + previousChildName: string | null + ) => unknown, + cancelCallbackOrListenOptions?: ((error: Error) => unknown) | ListenOptions, + options?: ListenOptions +): Unsubscribe { + return addEventListener( + query, + 'child_changed', + callback, + cancelCallbackOrListenOptions, + options + ); +} + +export function onChildMoved( + query: Query, + callback: ( + snapshot: DataSnapshot, + previousChildName: string | null + ) => unknown, + cancelCallback?: (error: Error) => unknown +): Unsubscribe; +export function onChildMoved( + query: Query, + callback: ( + snapshot: DataSnapshot, + previousChildName: string | null + ) => unknown, + options: ListenOptions +): Unsubscribe; +export function onChildMoved( + query: Query, + callback: ( + snapshot: DataSnapshot, + previousChildName: string | null + ) => unknown, + cancelCallback: (error: Error) => unknown, + options: ListenOptions +): Unsubscribe; +export function onChildMoved( + query: Query, + callback: ( + snapshot: DataSnapshot, + previousChildName: string | null + ) => unknown, + cancelCallbackOrListenOptions?: ((error: Error) => unknown) | ListenOptions, + options?: ListenOptions +): Unsubscribe { + return addEventListener( + query, + 'child_moved', + callback, + cancelCallbackOrListenOptions, + options + ); +} + +export function onChildRemoved( + query: Query, + callback: (snapshot: DataSnapshot) => unknown, + cancelCallback?: (error: Error) => unknown +): Unsubscribe; +export function onChildRemoved( + query: Query, + callback: (snapshot: DataSnapshot) => unknown, + options: ListenOptions +): Unsubscribe; +export function onChildRemoved( + query: Query, + callback: (snapshot: DataSnapshot) => unknown, + cancelCallback: (error: Error) => unknown, + options: ListenOptions +): Unsubscribe; +export function onChildRemoved( + query: Query, + callback: (snapshot: DataSnapshot) => unknown, + cancelCallbackOrListenOptions?: ((error: Error) => unknown) | ListenOptions, + options?: ListenOptions +): Unsubscribe { + return addEventListener( + query, + 'child_removed', + callback, + cancelCallbackOrListenOptions, + options + ); +} + +export { EventType }; + +export function off( + query: Query, + eventType?: EventType, + callback?: ( + snapshot: DataSnapshot, + previousChildName?: string | null + ) => unknown +): void { + let container: EventRegistration | null = null; + let callbacks: { [k: string]: CallbackContext } | null = null; + const expCallback = callback ? new CallbackContext(callback) : null; + if (eventType === 'value') { + container = new ValueEventRegistration(expCallback); + } else if (eventType) { + if (callback) { + callbacks = {}; + callbacks[eventType] = expCallback; + } + container = new ChildEventRegistration(callbacks); + } + repoRemoveEventCallbackForQuery(query._repo, query, container); +} + +export interface QueryConstraint { + type: + | 'endAt' + | 'endBefore' + | 'startAt' + | 'startAfter' + | 'limitToFirst' + | 'limitToLast' + | 'orderByChild' + | 'orderByKey' + | 'orderByPriority' + | 'orderByValue' + | 'equalTo'; +} + +export function endAt( + value: number | string | boolean | null, + key?: string +): QueryConstraint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function endBefore( + value: number | string | boolean | null, + key?: string +): QueryConstraint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function startAt( + value: number | string | boolean | null, + key?: string +): QueryConstraint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function startAfter( + value: number | string | boolean | null, + key?: string +): QueryConstraint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function limitToFirst(limit: number): QueryConstraint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function limitToLast(limit: number): QueryConstraint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function orderByChild(path: string): QueryConstraint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function orderByKey(): QueryConstraint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function orderByPriority(): QueryConstraint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function orderByValue(): QueryConstraint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function equalTo( + value: number | string | boolean | null, + key?: string +): QueryConstraint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +export function query(query: Query, ...constraints: QueryConstraint[]): Query { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {} as any; +} + +/** + * Define reference constructor in various modules + * + * We are doing this here to avoid several circular + * dependency issues + */ +syncPointSetReferenceConstructor(ReferenceImpl); +syncTreeSetReferenceConstructor(ReferenceImpl); diff --git a/packages/database/test/datasnapshot.test.ts b/packages/database/test/datasnapshot.test.ts index 7b57397286d..3afdfde8ddf 100644 --- a/packages/database/test/datasnapshot.test.ts +++ b/packages/database/test/datasnapshot.test.ts @@ -20,8 +20,7 @@ import { expect } from 'chai'; import { DataSnapshot, Reference } from '../src/api/Reference'; import { PRIORITY_INDEX } from '../src/core/snap/indexes/PriorityIndex'; import { nodeFromJSON } from '../src/core/snap/nodeFromJSON'; -import { DataSnapshot as ExpDataSnapshot } from '../src/exp/DataSnapshot'; -import { Reference as ExpReference } from '../src/exp/Reference'; +import { DataSnapshot as ExpDataSnapshot } from '../src/exp/Reference_impl'; import { getRandomNode } from './helpers/util'; @@ -30,11 +29,10 @@ describe('DataSnapshot Tests', () => { const snapshotForJSON = function (json) { const dummyRef = getRandomNode() as Reference; return new DataSnapshot( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - {} as any, + dummyRef.database, new ExpDataSnapshot( nodeFromJSON(json), - new ExpReference(dummyRef.repo, dummyRef.path), + dummyRef._delegate, PRIORITY_INDEX ) ); diff --git a/packages/database/test/exp/integration.test.ts b/packages/database/test/exp/integration.test.ts index c602ee24b4b..36f90e971d3 100644 --- a/packages/database/test/exp/integration.test.ts +++ b/packages/database/test/exp/integration.test.ts @@ -20,12 +20,14 @@ import { initializeApp, deleteApp } from '@firebase/app-exp'; import { expect } from 'chai'; import { + get, getDatabase, goOffline, goOnline, ref, refFromURL } from '../../exp/index'; +import { set } from '../../src/exp/Reference_impl'; import { DATABASE_ADDRESS, DATABASE_URL } from '../helpers/util'; export function createTestApp() { @@ -65,27 +67,27 @@ describe.skip('Database Tests', () => { it('Can set and ge tref', async () => { const db = getDatabase(defaultApp); - await ref(db, 'foo/bar').set('foobar'); - const snap = await ref(db, 'foo/bar').get(); + await set(ref(db, 'foo/bar'), 'foobar'); + const snap = await get(ref(db, 'foo/bar')); expect(snap.val()).to.equal('foobar'); }); it('Can get refFromUrl', async () => { const db = getDatabase(defaultApp); - await refFromURL(db, `${DATABASE_ADDRESS}/foo/bar`).get(); + await get(refFromURL(db, `${DATABASE_ADDRESS}/foo/bar`)); }); it('Can goOffline/goOnline', async () => { const db = getDatabase(defaultApp); goOffline(db); try { - await ref(db, 'foo/bar').get(); + await get(ref(db, 'foo/bar')); expect.fail('Should have failed since we are offline'); } catch (e) { expect(e.message).to.equal('Error: Client is offline.'); } goOnline(db); - await ref(db, 'foo/bar').get(); + await get(ref(db, 'foo/bar')); }); it('Can delete app', async () => { diff --git a/packages/database/test/query.test.ts b/packages/database/test/query.test.ts index 8bcd6138cee..76fd4f7b940 100644 --- a/packages/database/test/query.test.ts +++ b/packages/database/test/query.test.ts @@ -365,7 +365,7 @@ describe('Query Tests', () => { it('Query.queryIdentifier works.', () => { const path = getRandomNode() as Reference; const queryId = function (query) { - return query.queryIdentifier(query); + return query._delegate._queryIdentifier; }; expect(queryId(path)).to.equal('default'); From 038dea4156e2000e727974869201c20742850275 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 31 Mar 2021 13:57:16 -0600 Subject: [PATCH 2/5] Update Index import once again --- packages/database/src/exp/Reference_impl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/database/src/exp/Reference_impl.ts b/packages/database/src/exp/Reference_impl.ts index ae94c824d5d..242324a81a3 100644 --- a/packages/database/src/exp/Reference_impl.ts +++ b/packages/database/src/exp/Reference_impl.ts @@ -23,7 +23,7 @@ import { repoRemoveEventCallbackForQuery } from '../core/Repo'; import { ChildrenNode } from '../core/snap/ChildrenNode'; -import { Index } from '../core/snap/indexes'; +import { Index } from '../core/snap/indexes/Index'; import { PRIORITY_INDEX } from '../core/snap/indexes/PriorityIndex'; import { Node } from '../core/snap/Node'; import { syncPointSetReferenceConstructor } from '../core/SyncPoint'; From d7391dc052b87a174042e5b617514c8dfe29d3d2 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 31 Mar 2021 16:53:44 -0600 Subject: [PATCH 3/5] Compat and @exp class for Database --- .../.idea/runConfigurations/All_Tests.xml | 4 +- packages/database/exp/index.ts | 7 +- packages/database/index.node.ts | 9 +- packages/database/index.ts | 9 +- packages/database/src/api/Database.ts | 287 ++---------------- packages/database/src/api/Reference.ts | 43 +-- packages/database/src/api/internal.ts | 11 +- packages/database/src/api/test_access.ts | 2 +- .../database/src/core/AuthTokenProvider.ts | 11 +- packages/database/src/core/Repo.ts | 12 +- packages/database/src/exp/Database.ts | 239 ++++++++++++++- packages/database/src/exp/Reference_impl.ts | 34 ++- packages/database/test/database.test.ts | 24 +- 13 files changed, 346 insertions(+), 346 deletions(-) diff --git a/packages/database/.idea/runConfigurations/All_Tests.xml b/packages/database/.idea/runConfigurations/All_Tests.xml index 08aebabf99e..539f46872f9 100644 --- a/packages/database/.idea/runConfigurations/All_Tests.xml +++ b/packages/database/.idea/runConfigurations/All_Tests.xml @@ -7,6 +7,8 @@ true + + bdd --require ts-node/register/type-check --require index.node.ts --timeout 5000 @@ -14,4 +16,4 @@ test/{,!(browser)/**/}*.test.ts - + \ No newline at end of file diff --git a/packages/database/exp/index.ts b/packages/database/exp/index.ts index 37dddea0055..22e55e7e584 100644 --- a/packages/database/exp/index.ts +++ b/packages/database/exp/index.ts @@ -20,7 +20,10 @@ import { _registerComponent, registerVersion } from '@firebase/app-exp'; import { Component, ComponentType } from '@firebase/component'; import { version } from '../package.json'; -import { FirebaseDatabase } from '../src/exp/Database'; +import { + FirebaseDatabase, + repoManagerDatabaseFromApp +} from '../src/exp/Database'; export { enableLogging, @@ -79,7 +82,7 @@ function registerDatabase(): void { (container, { instanceIdentifier: url }) => { const app = container.getProvider('app-exp').getImmediate()!; const authProvider = container.getProvider('auth-internal'); - return new FirebaseDatabase(app, authProvider, url); + return repoManagerDatabaseFromApp(app, authProvider, url); }, ComponentType.PUBLIC ).setMultipleInstances(true) diff --git a/packages/database/index.node.ts b/packages/database/index.node.ts index a1214d8abc0..de1a4c37d97 100644 --- a/packages/database/index.node.ts +++ b/packages/database/index.node.ts @@ -24,12 +24,13 @@ import { CONSTANTS, isNodeSdk } from '@firebase/util'; import { Client } from 'faye-websocket'; import { name, version } from './package.json'; -import { Database, repoManagerDatabaseFromApp } from './src/api/Database'; +import { Database } from './src/api/Database'; import * as INTERNAL from './src/api/internal'; import { DataSnapshot, Query, Reference } from './src/api/Reference'; import * as TEST_ACCESS from './src/api/test_access'; import { enableLogging } from './src/core/util/util'; import { setSDKVersion } from './src/core/version'; +import { repoManagerDatabaseFromApp } from './src/exp/Database'; import { setWebSocketImpl } from './src/realtime/WebSocketConnection'; setWebSocketImpl(Client); @@ -86,8 +87,10 @@ export function registerDatabase(instance: FirebaseNamespace) { // getImmediate for FirebaseApp will always succeed const app = container.getProvider('app').getImmediate(); const authProvider = container.getProvider('auth-internal'); - - return repoManagerDatabaseFromApp(app, authProvider, url, undefined); + return new Database( + repoManagerDatabaseFromApp(app, authProvider, url), + app + ); }, ComponentType.PUBLIC ) diff --git a/packages/database/index.ts b/packages/database/index.ts index c6c06675d80..442be97313b 100644 --- a/packages/database/index.ts +++ b/packages/database/index.ts @@ -24,12 +24,13 @@ import * as types from '@firebase/database-types'; import { isNodeSdk } from '@firebase/util'; import { name, version } from './package.json'; -import { Database, repoManagerDatabaseFromApp } from './src/api/Database'; +import { Database } from './src/api/Database'; import * as INTERNAL from './src/api/internal'; import { DataSnapshot, Query, Reference } from './src/api/Reference'; import * as TEST_ACCESS from './src/api/test_access'; import { enableLogging } from './src/core/util/util'; import { setSDKVersion } from './src/core/version'; +import { repoManagerDatabaseFromApp } from './src/exp/Database'; const ServerValue = Database.ServerValue; @@ -46,8 +47,10 @@ export function registerDatabase(instance: FirebaseNamespace) { // getImmediate for FirebaseApp will always succeed const app = container.getProvider('app').getImmediate(); const authProvider = container.getProvider('auth-internal'); - - return repoManagerDatabaseFromApp(app, authProvider, url, undefined); + return new Database( + repoManagerDatabaseFromApp(app, authProvider, url), + app + ); }, ComponentType.PUBLIC ) diff --git a/packages/database/src/api/Database.ts b/packages/database/src/api/Database.ts index c922f00f1b3..7ba48480ac6 100644 --- a/packages/database/src/api/Database.ts +++ b/packages/database/src/api/Database.ts @@ -15,202 +15,25 @@ * limitations under the License. */ // eslint-disable-next-line import/no-extraneous-dependencies -import { FirebaseApp as FirebaseAppExp } from '@firebase/app-exp'; + import { FirebaseApp } from '@firebase/app-types'; import { FirebaseService } from '@firebase/app-types/private'; -import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; -import { Provider } from '@firebase/component'; -import { safeGet, validateArgCount } from '@firebase/util'; +import { validateArgCount, Compat } from '@firebase/util'; import { - AuthTokenProvider, - EmulatorAdminTokenProvider, - FirebaseAuthTokenProvider -} from '../core/AuthTokenProvider'; -import { Repo, repoInterrupt, repoResume, repoStart } from '../core/Repo'; -import { RepoInfo } from '../core/RepoInfo'; -import { parseRepoInfo } from '../core/util/libs/parser'; -import { pathIsEmpty, newEmptyPath } from '../core/util/Path'; -import { fatal, log } from '../core/util/util'; -import { validateUrl } from '../core/util/validation'; + FirebaseDatabase as ExpDatabase, + goOnline, + useDatabaseEmulator, + goOffline +} from '../exp/Database'; +import { ref, refFromURL } from '../exp/Reference_impl'; import { Reference } from './Reference'; -/** - * This variable is also defined in the firebase node.js admin SDK. Before - * modifying this definition, consult the definition in: - * - * https://github.com/firebase/firebase-admin-node - * - * and make sure the two are consistent. - */ -const FIREBASE_DATABASE_EMULATOR_HOST_VAR = 'FIREBASE_DATABASE_EMULATOR_HOST'; - -/** - * Intersection type that allows the SDK to be used from firebase-exp and - * firebase v8. - */ -export type FirebaseAppLike = FirebaseApp | FirebaseAppExp; - -/** - * Creates and caches Repo instances. - */ -const repos: { - [appName: string]: { - [dbUrl: string]: Repo; - }; -} = {}; - -/** - * If true, new Repos will be created to use ReadonlyRestClient (for testing purposes). - */ -let useRestClient = false; - -/** - * Update an existing repo in place to point to a new host/port. - */ -export function repoManagerApplyEmulatorSettings( - repo: Repo, - host: string, - port: number -): void { - repo.repoInfo_ = new RepoInfo( - `${host}:${port}`, - /* secure= */ false, - repo.repoInfo_.namespace, - repo.repoInfo_.webSocketOnly, - repo.repoInfo_.nodeAdmin, - repo.repoInfo_.persistenceKey, - repo.repoInfo_.includeNamespaceInQueryParams - ); - - if (repo.repoInfo_.nodeAdmin) { - repo.authTokenProvider_ = new EmulatorAdminTokenProvider(); - } -} - -/** - * This function should only ever be called to CREATE a new database instance. - */ -export function repoManagerDatabaseFromApp( - app: FirebaseAppLike, - authProvider: Provider, - url?: string, - nodeAdmin?: boolean -): Database { - let dbUrl: string | undefined = url || app.options.databaseURL; - if (dbUrl === undefined) { - if (!app.options.projectId) { - fatal( - "Can't determine Firebase Database URL. Be sure to include " + - ' a Project ID when calling firebase.initializeApp().' - ); - } - - log('Using default host for project ', app.options.projectId); - dbUrl = `${app.options.projectId}-default-rtdb.firebaseio.com`; - } - - let parsedUrl = parseRepoInfo(dbUrl, nodeAdmin); - let repoInfo = parsedUrl.repoInfo; - - let isEmulator: boolean; - - let dbEmulatorHost: string | undefined = undefined; - if (typeof process !== 'undefined') { - dbEmulatorHost = process.env[FIREBASE_DATABASE_EMULATOR_HOST_VAR]; - } - - if (dbEmulatorHost) { - isEmulator = true; - dbUrl = `http://${dbEmulatorHost}?ns=${repoInfo.namespace}`; - parsedUrl = parseRepoInfo(dbUrl, nodeAdmin); - repoInfo = parsedUrl.repoInfo; - } else { - isEmulator = !parsedUrl.repoInfo.secure; - } - - const authTokenProvider = - nodeAdmin && isEmulator - ? new EmulatorAdminTokenProvider() - : new FirebaseAuthTokenProvider(app, authProvider); - - validateUrl('Invalid Firebase Database URL', 1, parsedUrl); - if (!pathIsEmpty(parsedUrl.path)) { - fatal( - 'Database URL must point to the root of a Firebase Database ' + - '(not including a child path).' - ); - } - - const repo = repoManagerCreateRepo(repoInfo, app, authTokenProvider); - return new Database(repo); -} - -/** - * Remove the repo and make sure it is disconnected. - * - */ -export function repoManagerDeleteRepo(repo: Repo): void { - const appRepos = safeGet(repos, repo.app.name); - // This should never happen... - if (!appRepos || safeGet(appRepos, repo.key) !== repo) { - fatal( - `Database ${repo.app.name}(${repo.repoInfo_}) has already been deleted.` - ); - } - repoInterrupt(repo); - delete appRepos[repo.key]; -} - -/** - * Ensures a repo doesn't already exist and then creates one using the - * provided app. - * - * @param repoInfo The metadata about the Repo - * @return The Repo object for the specified server / repoName. - */ -export function repoManagerCreateRepo( - repoInfo: RepoInfo, - app: FirebaseAppLike, - authTokenProvider: AuthTokenProvider -): Repo { - let appRepos = safeGet(repos, app.name); - - if (!appRepos) { - appRepos = {}; - repos[app.name] = appRepos; - } - - let repo = safeGet(appRepos, repoInfo.toURLString()); - if (repo) { - fatal( - 'Database initialized multiple times. Please make sure the format of the database URL matches with each database() call.' - ); - } - repo = new Repo(repoInfo, useRestClient, app, authTokenProvider); - appRepos[repoInfo.toURLString()] = repo; - - return repo; -} - -/** - * Forces us to use ReadonlyRestClient instead of PersistentConnection for new Repos. - */ -export function repoManagerForceRestClient(forceRestClient: boolean): void { - useRestClient = forceRestClient; -} - /** * Class representing a firebase database. */ -export class Database implements FirebaseService { - /** Track if the instance has been used (root or repo accessed) */ - private instanceStarted_: boolean = false; - - /** Backing state for root_ */ - private rootInternal_?: Reference; - +export class Database implements FirebaseService, Compat { static readonly ServerValue = { TIMESTAMP: { '.sv': 'timestamp' @@ -227,43 +50,12 @@ export class Database implements FirebaseService { /** * The constructor should not be called by users of our public API. */ - constructor(private repoInternal_: Repo) { - if (!(repoInternal_ instanceof Repo)) { - fatal( - "Don't call new Database() directly - please use firebase.database()." - ); - } - } + constructor(readonly _delegate: ExpDatabase, readonly app: FirebaseApp) {} INTERNAL = { - delete: async () => { - this.checkDeleted_('delete'); - repoManagerDeleteRepo(this.repo_); - this.repoInternal_ = null; - this.rootInternal_ = null; - } + delete: () => this._delegate._delete() }; - get repo_(): Repo { - if (!this.instanceStarted_) { - repoStart(this.repoInternal_); - this.instanceStarted_ = true; - } - return this.repoInternal_; - } - - get root_(): Reference { - if (!this.rootInternal_) { - this.rootInternal_ = new Reference(this, newEmptyPath()); - } - - return this.rootInternal_; - } - - get app(): FirebaseApp { - return this.repo_.app as FirebaseApp; - } - /** * Modify this instance to communicate with the Realtime Database emulator. * @@ -273,16 +65,7 @@ export class Database implements FirebaseService { * @param port the emulator port (ex: 8080) */ useEmulator(host: string, port: number): void { - this.checkDeleted_('useEmulator'); - if (this.instanceStarted_) { - fatal( - 'Cannot call useEmulator() after instance has already been initialized.' - ); - return; - } - - // Modify the repo to apply emulator settings - repoManagerApplyEmulatorSettings(this.repoInternal_, host, port); + useDatabaseEmulator(this._delegate, host, port); } /** @@ -298,14 +81,14 @@ export class Database implements FirebaseService { ref(path?: string): Reference; ref(path?: Reference): Reference; ref(path?: string | Reference): Reference { - this.checkDeleted_('ref'); validateArgCount('database.ref', 0, 1, arguments.length); - if (path instanceof Reference) { - return this.refFromURL(path.toString()); + const childRef = refFromURL(this._delegate, path.toString()); + return new Reference(this, childRef._path); + } else { + const childRef = ref(this._delegate, path); + return new Reference(this, childRef._path); } - - return path !== undefined ? this.root_.child(path) : this.root_; } /** @@ -315,48 +98,20 @@ export class Database implements FirebaseService { * @return Firebase reference. */ refFromURL(url: string): Reference { - /** @const {string} */ const apiName = 'database.refFromURL'; - this.checkDeleted_(apiName); validateArgCount(apiName, 1, 1, arguments.length); - const parsedURL = parseRepoInfo(url, this.repo_.repoInfo_.nodeAdmin); - validateUrl(apiName, 1, parsedURL); - - const repoInfo = parsedURL.repoInfo; - if ( - !this.repo_.repoInfo_.isCustomHost() && - repoInfo.host !== this.repo_.repoInfo_.host - ) { - fatal( - apiName + - ': Host name does not match the current database: ' + - '(found ' + - repoInfo.host + - ' but expected ' + - this.repo_.repoInfo_.host + - ')' - ); - } - - return this.ref(parsedURL.path.toString()); - } - - private checkDeleted_(apiName: string) { - if (this.repoInternal_ === null) { - fatal('Cannot call ' + apiName + ' on a deleted database.'); - } + const childRef = refFromURL(this._delegate, url); + return new Reference(this, childRef._path); } // Make individual repo go offline. goOffline(): void { validateArgCount('database.goOffline', 0, 0, arguments.length); - this.checkDeleted_('goOffline'); - repoInterrupt(this.repo_); + return goOffline(this._delegate); } goOnline(): void { validateArgCount('database.goOnline', 0, 0, arguments.length); - this.checkDeleted_('goOnline'); - repoResume(this.repo_); + return goOnline(this._delegate); } } diff --git a/packages/database/src/api/Reference.ts b/packages/database/src/api/Reference.ts index a6a75bf1c0e..b0c194f5255 100644 --- a/packages/database/src/api/Reference.ts +++ b/packages/database/src/api/Reference.ts @@ -249,7 +249,7 @@ export class Query implements Compat { private queryParams_: QueryParams, private orderByCalled_: boolean ) { - this.repo = database.repo_; + this.repo = database._delegate._repo; this._delegate = new QueryImpl( this.repo, path, @@ -430,16 +430,21 @@ export class Query implements Compat { * Get the server-value for this query, or return a cached value if not connected. */ get(): Promise { - return repoGetValue(this.database.repo_, this._delegate).then(node => { - return new DataSnapshot( - this.database, - new ExpDataSnapshot( - node, - new ReferenceImpl(this.getRef().database.repo_, this.getRef().path), - this._delegate._queryParams.getIndex() - ) - ); - }); + return repoGetValue(this.database._delegate._repo, this._delegate).then( + node => { + return new DataSnapshot( + this.database, + new ExpDataSnapshot( + node, + new ReferenceImpl( + this.getRef().database._delegate._repo, + this.getRef().path + ), + this._delegate._queryParams.getIndex() + ) + ); + } + ); } /** @@ -763,7 +768,10 @@ export class Query implements Compat { toString(): string { validateArgCount('Query.toString', 0, 0, arguments.length); - return this.database.repo_.toString() + pathToUrlEncodedString(this.path); + return ( + this.database._delegate._repo.toString() + + pathToUrlEncodedString(this.path) + ); } // Do not create public documentation. This is intended to make JSON serialization work but is otherwise unnecessary @@ -785,7 +793,8 @@ export class Query implements Compat { throw new Error(error); } - const sameRepo = this.database.repo_ === other.database.repo_; + const sameRepo = + this.database._delegate._repo === other.database._delegate._repo; const samePath = pathEquals(this.path, other.path); const sameQueryIdentifier = this._delegate._queryIdentifier === other._delegate._queryIdentifier; @@ -1051,7 +1060,7 @@ export class Reference extends Query implements Compat { this.database, new ExpDataSnapshot( node, - new ReferenceImpl(this.database.repo_, this.path), + new ReferenceImpl(this.database._delegate._repo, this.path), PRIORITY_INDEX ) ); @@ -1071,7 +1080,7 @@ export class Reference extends Query implements Compat { }; repoStartTransaction( - this.database.repo_, + this.database._delegate._repo, this.path, transactionUpdate, promiseComplete, @@ -1093,7 +1102,7 @@ export class Reference extends Query implements Compat { const deferred = new Deferred(); repoSetWithPriority( - this.database.repo_, + this.database._delegate._repo, pathChild(this.path, '.priority'), priority, null, @@ -1108,7 +1117,7 @@ export class Reference extends Query implements Compat { validateFirebaseDataArg('Reference.push', 1, value, this.path, true); validateCallback('Reference.push', 2, onComplete, true); - const now = repoServerTime(this.database.repo_); + const now = repoServerTime(this.database._delegate._repo); const name = nextPushId(now); // push() returns a ThennableReference whose promise is fulfilled with a regular Reference. diff --git a/packages/database/src/api/internal.ts b/packages/database/src/api/internal.ts index d64a2c5318f..4400a0fe23b 100644 --- a/packages/database/src/api/internal.ts +++ b/packages/database/src/api/internal.ts @@ -34,10 +34,11 @@ import { repoStatsIncrementCounter } from '../core/Repo'; import { setSDKVersion } from '../core/version'; +import { repoManagerDatabaseFromApp } from '../exp/Database'; import { BrowserPollConnection } from '../realtime/BrowserPollConnection'; import { WebSocketConnection } from '../realtime/WebSocketConnection'; -import { repoManagerDatabaseFromApp } from './Database'; +import { Database } from './Database'; import { Reference } from './Reference'; /** @@ -129,11 +130,9 @@ export function initStandalone({ ); return { - instance: repoManagerDatabaseFromApp( - app, - authProvider, - url, - nodeAdmin + instance: new Database( + repoManagerDatabaseFromApp(app, authProvider, url, nodeAdmin), + app ) as types.Database, namespace }; diff --git a/packages/database/src/api/test_access.ts b/packages/database/src/api/test_access.ts index bd67b2ab9e5..9e523158e10 100644 --- a/packages/database/src/api/test_access.ts +++ b/packages/database/src/api/test_access.ts @@ -17,9 +17,9 @@ import { PersistentConnection } from '../core/PersistentConnection'; import { RepoInfo } from '../core/RepoInfo'; +import { repoManagerForceRestClient } from '../exp/Database'; import { Connection } from '../realtime/Connection'; -import { repoManagerForceRestClient } from './Database'; import { Query } from './Reference'; export const DataConnection = PersistentConnection; diff --git a/packages/database/src/core/AuthTokenProvider.ts b/packages/database/src/core/AuthTokenProvider.ts index 27912fe9224..1662eea2770 100644 --- a/packages/database/src/core/AuthTokenProvider.ts +++ b/packages/database/src/core/AuthTokenProvider.ts @@ -22,8 +22,6 @@ import { } from '@firebase/auth-interop-types'; import { Provider } from '@firebase/component'; -import { FirebaseAppLike } from '../api/Database'; - import { log, warn } from './util/util'; export interface AuthTokenProvider { @@ -39,7 +37,8 @@ export interface AuthTokenProvider { export class FirebaseAuthTokenProvider implements AuthTokenProvider { private auth_: FirebaseAuthInternal | null = null; constructor( - private app_: FirebaseAppLike, + private appName_: string, + private firebaseOptions_: object, private authProvider_: Provider ) { this.auth_ = authProvider_.getImmediate({ optional: true }); @@ -87,15 +86,15 @@ export class FirebaseAuthTokenProvider implements AuthTokenProvider { notifyForInvalidToken(): void { let errorMessage = 'Provided authentication credentials for the app named "' + - this.app_.name + + this.appName_ + '" are invalid. This usually indicates your app was not ' + 'initialized correctly. '; - if ('credential' in this.app_.options) { + if ('credential' in this.firebaseOptions_) { errorMessage += 'Make sure the "credential" property provided to initializeApp() ' + 'is authorized to access the specified "databaseURL" and is from the correct ' + 'project.'; - } else if ('serviceAccount' in this.app_.options) { + } else if ('serviceAccount' in this.firebaseOptions_) { errorMessage += 'Make sure the "serviceAccount" property provided to initializeApp() ' + 'is authorized to access the specified "databaseURL" and is from the correct ' + diff --git a/packages/database/src/core/Repo.ts b/packages/database/src/core/Repo.ts index bf47d50bb71..ab5ff3f5933 100644 --- a/packages/database/src/core/Repo.ts +++ b/packages/database/src/core/Repo.ts @@ -24,8 +24,6 @@ import { stringify } from '@firebase/util'; -import { FirebaseAppLike } from '../api/Database'; - import { AuthTokenProvider } from './AuthTokenProvider'; import { PersistentConnection } from './PersistentConnection'; import { ReadonlyRestClient } from './ReadonlyRestClient'; @@ -188,7 +186,6 @@ export class Repo { constructor( public repoInfo_: RepoInfo, public forceRestClient_: boolean, - public app: FirebaseAppLike, public authTokenProvider_: AuthTokenProvider ) { // This key is intentionally not updated if RepoInfo is later changed or replaced @@ -205,7 +202,11 @@ export class Repo { } } -export function repoStart(repo: Repo): void { +export function repoStart( + repo: Repo, + appId: string, + authOverride?: object +): void { repo.stats_ = statsManagerGetCollection(repo.repoInfo_); if (repo.forceRestClient_ || beingCrawled()) { @@ -225,7 +226,6 @@ export function repoStart(repo: Repo): void { // Minor hack: Fire onConnect immediately, since there's no actual connection. setTimeout(() => repoOnConnectStatus(repo, /* connectStatus= */ true), 0); } else { - const authOverride = repo.app.options['databaseAuthVariableOverride']; // Validate authOverride if (typeof authOverride !== 'undefined' && authOverride !== null) { if (typeof authOverride !== 'object') { @@ -242,7 +242,7 @@ export function repoStart(repo: Repo): void { repo.persistentConnection_ = new PersistentConnection( repo.repoInfo_, - repo.app.options.appId, + appId, ( pathString: string, data: unknown, diff --git a/packages/database/src/exp/Database.ts b/packages/database/src/exp/Database.ts index a53dc7b4cef..f9fd114b1c5 100644 --- a/packages/database/src/exp/Database.ts +++ b/packages/database/src/exp/Database.ts @@ -19,25 +19,225 @@ import { _FirebaseService, _getProvider, FirebaseApp } from '@firebase/app-exp'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { Provider } from '@firebase/component'; +import { getModularInstance } from '@firebase/util'; -import { Repo } from '../core/Repo'; +import { + AuthTokenProvider, + EmulatorAdminTokenProvider, + FirebaseAuthTokenProvider +} from '../core/AuthTokenProvider'; +import { Repo, repoInterrupt, repoResume, repoStart } from '../core/Repo'; +import { RepoInfo } from '../core/RepoInfo'; +import { parseRepoInfo } from '../core/util/libs/parser'; +import { newEmptyPath, pathIsEmpty } from '../core/util/Path'; +import { fatal, log } from '../core/util/util'; +import { validateUrl } from '../core/util/validation'; + +import { Reference } from './Reference'; +import { ReferenceImpl } from './Reference_impl'; + +/** + * This variable is also defined in the firebase node.js admin SDK. Before + * modifying this definition, consult the definition in: + * + * https://github.com/firebase/firebase-admin-node + * + * and make sure the two are consistent. + */ +const FIREBASE_DATABASE_EMULATOR_HOST_VAR = 'FIREBASE_DATABASE_EMULATOR_HOST'; + +/** + * Creates and caches Repo instances. + */ +const repos: { + [appName: string]: { + [dbUrl: string]: Repo; + }; +} = {}; + +/** + * If true, new Repos will be created to use ReadonlyRestClient (for testing purposes). + */ +let useRestClient = false; + +/** + * Update an existing repo in place to point to a new host/port. + */ +function repoManagerApplyEmulatorSettings( + repo: Repo, + host: string, + port: number +): void { + repo.repoInfo_ = new RepoInfo( + `${host}:${port}`, + /* secure= */ false, + repo.repoInfo_.namespace, + repo.repoInfo_.webSocketOnly, + repo.repoInfo_.nodeAdmin, + repo.repoInfo_.persistenceKey, + repo.repoInfo_.includeNamespaceInQueryParams + ); + + if (repo.repoInfo_.nodeAdmin) { + repo.authTokenProvider_ = new EmulatorAdminTokenProvider(); + } +} + +/** + * This function should only ever be called to CREATE a new database instance. + */ +export function repoManagerDatabaseFromApp( + app: FirebaseApp, + authProvider: Provider, + url?: string, + nodeAdmin?: boolean +): FirebaseDatabase { + let dbUrl: string | undefined = url || app.options.databaseURL; + if (dbUrl === undefined) { + if (!app.options.projectId) { + fatal( + "Can't determine Firebase Database URL. Be sure to include " + + ' a Project ID when calling firebase.initializeApp().' + ); + } + + log('Using default host for project ', app.options.projectId); + dbUrl = `${app.options.projectId}-default-rtdb.firebaseio.com`; + } + + let parsedUrl = parseRepoInfo(dbUrl, nodeAdmin); + let repoInfo = parsedUrl.repoInfo; + + let isEmulator: boolean; + + let dbEmulatorHost: string | undefined = undefined; + if (typeof process !== 'undefined') { + dbEmulatorHost = process.env[FIREBASE_DATABASE_EMULATOR_HOST_VAR]; + } + + if (dbEmulatorHost) { + isEmulator = true; + dbUrl = `http://${dbEmulatorHost}?ns=${repoInfo.namespace}`; + parsedUrl = parseRepoInfo(dbUrl, nodeAdmin); + repoInfo = parsedUrl.repoInfo; + } else { + isEmulator = !parsedUrl.repoInfo.secure; + } + + const authTokenProvider = + nodeAdmin && isEmulator + ? new EmulatorAdminTokenProvider() + : new FirebaseAuthTokenProvider(app.name, app.options, authProvider); + + validateUrl('Invalid Firebase Database URL', 1, parsedUrl); + if (!pathIsEmpty(parsedUrl.path)) { + fatal( + 'Database URL must point to the root of a Firebase Database ' + + '(not including a child path).' + ); + } + + const repo = repoManagerCreateRepo(repoInfo, app, authTokenProvider); + return new FirebaseDatabase(repo, app); +} + +/** + * Remove the repo and make sure it is disconnected. + * + */ +function repoManagerDeleteRepo(repo: Repo, appName: string): void { + const appRepos = repos[appName]; + // This should never happen... + if (!appRepos || appRepos[repo.key] !== repo) { + fatal(`Database ${appName}(${repo.repoInfo_}) has already been deleted.`); + } + repoInterrupt(repo); + delete appRepos[repo.key]; +} + +/** + * Ensures a repo doesn't already exist and then creates one using the + * provided app. + * + * @param repoInfo The metadata about the Repo + * @return The Repo object for the specified server / repoName. + */ +function repoManagerCreateRepo( + repoInfo: RepoInfo, + app: FirebaseApp, + authTokenProvider: AuthTokenProvider +): Repo { + let appRepos = repos[app.name]; + + if (!appRepos) { + appRepos = {}; + repos[app.name] = appRepos; + } + + let repo = appRepos[repoInfo.toURLString()]; + if (repo) { + fatal( + 'Database initialized multiple times. Please make sure the format of the database URL matches with each database() call.' + ); + } + repo = new Repo(repoInfo, useRestClient, authTokenProvider); + appRepos[repoInfo.toURLString()] = repo; + + return repo; +} + +/** + * Forces us to use ReadonlyRestClient instead of PersistentConnection for new Repos. + */ +export function repoManagerForceRestClient(forceRestClient: boolean): void { + useRestClient = forceRestClient; +} /** * Class representing a Firebase Realtime Database. */ export class FirebaseDatabase implements _FirebaseService { readonly 'type' = 'database'; - _repo: Repo; - constructor( - readonly app: FirebaseApp, - authProvider: Provider, - databaseUrl?: string - ) {} + /** Track if the instance has been used (root or repo accessed) */ + _instanceStarted: boolean = false; + + /** Backing state for root_ */ + private _rootInternal?: Reference; + + constructor(private _repoInternal: Repo, readonly app: FirebaseApp) {} + + get _repo(): Repo { + if (!this._instanceStarted) { + repoStart( + this._repoInternal, + this.app.options.appId, + this.app.options['databaseAuthVariableOverride'] + ); + this._instanceStarted = true; + } + return this._repoInternal; + } + + get _root(): Reference { + if (!this._rootInternal) { + this._rootInternal = new ReferenceImpl(this._repo, newEmptyPath()); + } + return this._rootInternal; + } _delete(): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + this._checkNotDeleted('delete'); + repoManagerDeleteRepo(this._repo, this.app.name); + this._repoInternal = null; + this._rootInternal = null; + return Promise.resolve(); + } + + _checkNotDeleted(apiName: string) { + if (this._rootInternal === null) { + fatal('Cannot call ' + apiName + ' on a deleted database.'); + } } } @@ -64,18 +264,27 @@ export function useDatabaseEmulator( host: string, port: number ): void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + db = getModularInstance(db); + db._checkNotDeleted('useEmulator'); + if (db._instanceStarted) { + fatal( + 'Cannot call useEmulator() after instance has already been initialized.' + ); + } + // Modify the repo to apply emulator settings + repoManagerApplyEmulatorSettings(db._repo, host, port); } export function goOffline(db: FirebaseDatabase): void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + db = getModularInstance(db); + db._checkNotDeleted('goOffline'); + repoInterrupt(db._repo); } export function goOnline(db: FirebaseDatabase): void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + db = getModularInstance(db); + db._checkNotDeleted('goOnline'); + repoResume(db._repo); } export function enableLogging( diff --git a/packages/database/src/exp/Reference_impl.ts b/packages/database/src/exp/Reference_impl.ts index 242324a81a3..2064d74ca6d 100644 --- a/packages/database/src/exp/Reference_impl.ts +++ b/packages/database/src/exp/Reference_impl.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { assert, contains } from '@firebase/util'; +import { assert, contains, getModularInstance } from '@firebase/util'; import { Repo, @@ -28,6 +28,7 @@ import { PRIORITY_INDEX } from '../core/snap/indexes/PriorityIndex'; import { Node } from '../core/snap/Node'; import { syncPointSetReferenceConstructor } from '../core/SyncPoint'; import { syncTreeSetReferenceConstructor } from '../core/SyncTree'; +import { parseRepoInfo } from '../core/util/libs/parser'; import { Path, pathChild, @@ -35,7 +36,8 @@ import { pathIsEmpty, pathParent } from '../core/util/Path'; -import { ObjectToUniqueKey } from '../core/util/util'; +import { fatal, ObjectToUniqueKey } from '../core/util/util'; +import { validateUrl } from '../core/util/validation'; import { Change } from '../core/view/Change'; import { CancelEvent, DataEvent, EventType } from '../core/view/Event'; import { @@ -209,12 +211,34 @@ export class DataSnapshot { } export function ref(db: FirebaseDatabase, path?: string): Reference { - return new ReferenceImpl(db._repo, new Path(path || '')); + db = getModularInstance(db); + db._checkNotDeleted('ref'); + return path !== undefined ? child(db._root, path) : db._root; } export function refFromURL(db: FirebaseDatabase, url: string): Reference { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + db = getModularInstance(db); + db._checkNotDeleted('refFromURL'); + const parsedURL = parseRepoInfo(url, db._repo.repoInfo_.nodeAdmin); + validateUrl('refFromURL', 1, parsedURL); + + const repoInfo = parsedURL.repoInfo; + if ( + !db._repo.repoInfo_.isCustomHost() && + repoInfo.host !== db._repo.repoInfo_.host + ) { + fatal( + 'refFromURL' + + ': Host name does not match the current database: ' + + '(found ' + + repoInfo.host + + ' but expected ' + + db._repo.repoInfo_.host + + ')' + ); + } + + return ref(db, parsedURL.path.toString()); } export function child(ref: Reference, path: string): Reference { diff --git a/packages/database/test/database.test.ts b/packages/database/test/database.test.ts index c909eccfee9..e89d98e373f 100644 --- a/packages/database/test/database.test.ts +++ b/packages/database/test/database.test.ts @@ -38,12 +38,6 @@ describe('Database Tests', () => { expect(db).not.to.be.null; }); - it('Illegal to call constructor', () => { - expect(() => { - const db = new (firebase as any).database.Database('url'); - }).to.throw(/don't call new Database/i); - }); - it('Can get database with custom URL', () => { const db = defaultApp.database('http://foo.bar.com'); expect(db).to.be.ok; @@ -66,7 +60,7 @@ describe('Database Tests', () => { it('Can get database with multi-region URL', () => { const db = defaultApp.database('http://foo.euw1.firebasedatabase.app'); expect(db).to.be.ok; - expect(db.repo_.repoInfo_.namespace).to.equal('foo'); + expect(db._delegate._repo.repoInfo_.namespace).to.equal('foo'); expect(db.ref().toString()).to.equal( 'https://foo.euw1.firebasedatabase.app/' ); @@ -75,7 +69,7 @@ describe('Database Tests', () => { it('Can get database with upper case URL', () => { const db = defaultApp.database('http://fOO.EUW1.firebaseDATABASE.app'); expect(db).to.be.ok; - expect(db.repo_.repoInfo_.namespace).to.equal('foo'); + expect(db._delegate._repo.repoInfo_.namespace).to.equal('foo'); expect(db.ref().toString()).to.equal( 'https://foo.euw1.firebasedatabase.app/' ); @@ -102,7 +96,7 @@ describe('Database Tests', () => { it('Can get database with a upper case localhost URL and ns', () => { const db = defaultApp.database('http://LOCALHOST?ns=foo'); expect(db).to.be.ok; - expect(db.repo_.repoInfo_.namespace).to.equal('foo'); + expect(db._delegate._repo.repoInfo_.namespace).to.equal('foo'); expect(db.ref().toString()).to.equal('https://localhost/'); }); @@ -123,14 +117,14 @@ describe('Database Tests', () => { it('Can read ns query param', () => { const db = defaultApp.database('http://localhost:80/?ns=foo&unused=true'); expect(db).to.be.ok; - expect(db.repo_.repoInfo_.namespace).to.equal('foo'); + expect(db._delegate._repo.repoInfo_.namespace).to.equal('foo'); expect(db.ref().toString()).to.equal('http://localhost:80/'); }); it('Reads ns query param even when subdomain is set', () => { const db = defaultApp.database('http://bar.firebaseio.com?ns=foo'); expect(db).to.be.ok; - expect(db.repo_.repoInfo_.namespace).to.equal('foo'); + expect(db._delegate._repo.repoInfo_.namespace).to.equal('foo'); expect(db.ref().toString()).to.equal('https://bar.firebaseio.com/'); }); @@ -138,8 +132,8 @@ describe('Database Tests', () => { process.env['FIREBASE_DATABASE_EMULATOR_HOST'] = 'localhost:9000'; const db = defaultApp.database('https://bar.firebaseio.com'); expect(db).to.be.ok; - expect(db.repo_.repoInfo_.namespace).to.equal('bar'); - expect(db.repo_.repoInfo_.host).to.equal('localhost:9000'); + expect(db._delegate._repo.repoInfo_.namespace).to.equal('bar'); + expect(db._delegate._repo.repoInfo_.host).to.equal('localhost:9000'); delete process.env['FIREBASE_DATABASE_EMULATOR_HOST']; }); @@ -154,10 +148,10 @@ describe('Database Tests', () => { process.env['FIREBASE_DATABASE_EMULATOR_HOST'] = 'localhost:9000'; const db1 = defaultApp.database('http://foo1.bar.com'); const db2 = defaultApp.database('http://foo2.bar.com'); - expect(db1.repo_.repoInfo_.toURLString()).to.equal( + expect(db1._delegate._repo.repoInfo_.toURLString()).to.equal( 'http://localhost:9000/?ns=foo1' ); - expect(db2.repo_.repoInfo_.toURLString()).to.equal( + expect(db2._delegate._repo.repoInfo_.toURLString()).to.equal( 'http://localhost:9000/?ns=foo2' ); delete process.env['FIREBASE_DATABASE_EMULATOR_HOST']; From 2199e251170f4d5af16f9b4769a75d304abfc7a1 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Thu, 1 Apr 2021 14:44:43 -0600 Subject: [PATCH 4/5] Implement Query Compat and query@exp --- packages/database/src/api/Reference.ts | 559 +++++------------- packages/database/src/api/internal.ts | 11 +- packages/database/src/exp/Reference.ts | 2 +- packages/database/src/exp/Reference_impl.ts | 595 ++++++++++++++++++-- packages/database/test/query.test.ts | 16 +- packages/firestore/src/lite/query.ts | 4 +- 6 files changed, 697 insertions(+), 490 deletions(-) diff --git a/packages/database/src/api/Reference.ts b/packages/database/src/api/Reference.ts index b0c194f5255..a7f0bee7eb5 100644 --- a/packages/database/src/api/Reference.ts +++ b/packages/database/src/api/Reference.ts @@ -16,7 +16,6 @@ */ import { - assert, Compat, Deferred, errorPrefix, @@ -26,53 +25,35 @@ import { } from '@firebase/util'; import { - Repo, - repoGetValue, repoServerTime, repoSetWithPriority, repoStartTransaction, repoUpdate } from '../core/Repo'; -import { KEY_INDEX } from '../core/snap/indexes/KeyIndex'; -import { PathIndex } from '../core/snap/indexes/PathIndex'; import { PRIORITY_INDEX } from '../core/snap/indexes/PriorityIndex'; -import { VALUE_INDEX } from '../core/snap/indexes/ValueIndex'; import { Node } from '../core/snap/Node'; import { nextPushId } from '../core/util/NextPushId'; import { Path, pathChild, - pathEquals, pathGetBack, pathGetFront, pathIsEmpty, - pathParent, - pathToUrlEncodedString + pathParent } from '../core/util/Path'; -import { MAX_NAME, MIN_NAME, warn } from '../core/util/util'; +import { warn } from '../core/util/util'; import { - isValidPriority, validateBoolean, validateEventType, validateFirebaseDataArg, validateFirebaseMergeDataArg, - validateKey, validatePathString, validatePriority, validateRootPathString, validateWritablePath } from '../core/util/validation'; import { UserCallback } from '../core/view/EventRegistration'; -import { - QueryParams, - queryParamsEndAt, - queryParamsEndBefore, - queryParamsLimitToFirst, - queryParamsLimitToLast, - queryParamsOrderBy, - queryParamsStartAfter, - queryParamsStartAt -} from '../core/view/QueryParams'; +import { QueryParams } from '../core/view/QueryParams'; import { DataSnapshot as ExpDataSnapshot, off, @@ -83,7 +64,20 @@ import { onValue, QueryImpl, ReferenceImpl, - EventType + EventType, + limitToFirst, + query, + limitToLast, + orderByChild, + orderByKey, + orderByValue, + orderByPriority, + startAt, + startAfter, + endAt, + endBefore, + equalTo, + get } from '../exp/Reference_impl'; import { Database } from './Database'; @@ -240,122 +234,7 @@ export interface SnapshotCallback { * Since every Firebase reference is a query, Firebase inherits from this object. */ export class Query implements Compat { - readonly _delegate: QueryImpl; - readonly repo: Repo; - - constructor( - public database: Database, - public path: Path, - private queryParams_: QueryParams, - private orderByCalled_: boolean - ) { - this.repo = database._delegate._repo; - this._delegate = new QueryImpl( - this.repo, - path, - queryParams_, - orderByCalled_ - ); - } - - /** - * Validates start/end values for queries. - */ - private static validateQueryEndpoints_(params: QueryParams) { - let startNode = null; - let endNode = null; - if (params.hasStart()) { - startNode = params.getIndexStartValue(); - } - if (params.hasEnd()) { - endNode = params.getIndexEndValue(); - } - - if (params.getIndex() === KEY_INDEX) { - const tooManyArgsError = - 'Query: When ordering by key, you may only pass one argument to ' + - 'startAt(), endAt(), or equalTo().'; - const wrongArgTypeError = - 'Query: When ordering by key, the argument passed to startAt(), startAfter(), ' + - 'endAt(), endBefore(), or equalTo() must be a string.'; - if (params.hasStart()) { - const startName = params.getIndexStartName(); - if (startName !== MIN_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) { - throw new Error(tooManyArgsError); - } else if (typeof endNode !== 'string') { - throw new Error(wrongArgTypeError); - } - } - } else if (params.getIndex() === PRIORITY_INDEX) { - if ( - (startNode != null && !isValidPriority(startNode)) || - (endNode != null && !isValidPriority(endNode)) - ) { - throw new Error( - 'Query: When ordering by priority, the first argument passed to startAt(), ' + - 'startAfter() endAt(), endBefore(), or equalTo() must be a valid priority value ' + - '(null, a number, or a string).' - ); - } - } else { - assert( - params.getIndex() instanceof PathIndex || - params.getIndex() === VALUE_INDEX, - 'unknown index type.' - ); - if ( - (startNode != null && typeof startNode === 'object') || - (endNode != null && typeof endNode === 'object') - ) { - throw new Error( - 'Query: First argument passed to startAt(), startAfter(), endAt(), endBefore(), or ' + - 'equalTo() cannot be an object.' - ); - } - } - } - - /** - * Validates that limit* has been called with the correct combination of parameters - */ - private static validateLimit_(params: QueryParams) { - if ( - params.hasStart() && - params.hasEnd() && - params.hasLimit() && - !params.hasAnchoredLimit() - ) { - throw new Error( - "Query: Can't combine startAt(), startAfter(), endAt(), endBefore(), and limit(). Use " + - 'limitToFirst() or limitToLast() instead.' - ); - } - } - - /** - * Validates that no other order by call has been made - */ - private validateNoPreviousOrderByCall_(fnName: string) { - if (this.orderByCalled_ === true) { - throw new Error(fnName + ": You can't combine multiple orderBy calls."); - } - } - - getRef(): Reference { - validateArgCount('Query.ref', 0, 0, arguments.length); - // This is a slight hack. We cannot goog.require('fb.api.Firebase'), since Firebase requires fb.api.Query. - // However, we will always export 'Firebase' to the global namespace, so it's guaranteed to exist by the time this - // method gets called. - return new Reference(this.database, this.path); - } + constructor(readonly database: Database, readonly _delegate: QueryImpl) {} on( eventType: string, @@ -430,21 +309,9 @@ export class Query implements Compat { * Get the server-value for this query, or return a cached value if not connected. */ get(): Promise { - return repoGetValue(this.database._delegate._repo, this._delegate).then( - node => { - return new DataSnapshot( - this.database, - new ExpDataSnapshot( - node, - new ReferenceImpl( - this.getRef().database._delegate._repo, - this.getRef().path - ), - this._delegate._queryParams.getIndex() - ) - ); - } - ); + return get(this._delegate).then(expSnapshot => { + return new DataSnapshot(this.database, expSnapshot); + }); } /** @@ -457,51 +324,64 @@ export class Query implements Compat { context?: object | null ): Promise { validateArgCount('Query.once', 1, 4, arguments.length); - validateEventType('Query.once', 1, eventType, false); validateCallback('Query.once', 2, userCallback, true); const ret = Query.getCancelAndContextArgs_( - 'Query.once', + 'Query.on', failureCallbackOrContext, context ); - - // TODO: Implement this more efficiently (in particular, use 'get' wire protocol for 'value' event) - // TODO: consider actually wiring the callbacks into the promise. We cannot do this without a breaking change - // because the API currently expects callbacks will be called synchronously if the data is cached, but this is - // against the Promise specification. - let firstCall = true; const deferred = new Deferred(); - - // A dummy error handler in case a user wasn't expecting promises - deferred.promise.catch(() => {}); - - const onceCallback = (snapshot: DataSnapshot) => { - // NOTE: Even though we unsubscribe, we may get called multiple times if a single action (e.g. set() with JSON) - // triggers multiple events (e.g. child_added or child_changed). - if (firstCall) { - firstCall = false; - this.off(eventType, onceCallback); - - if (userCallback) { - userCallback.bind(ret.context)(snapshot); - } - deferred.resolve(snapshot); + const valueCallback: UserCallback = (expSnapshot, previousChildName?) => { + const result = new DataSnapshot(this.database, expSnapshot); + if (userCallback) { + userCallback.call(ret.context, result, previousChildName); } + deferred.resolve(result); + }; + valueCallback.userCallback = userCallback; + valueCallback.context = ret.context; + const cancelCallback = (error: Error) => { + if (ret.cancel) { + ret.cancel.call(ret.context, error); + } + deferred.reject(error); }; - this.on( - eventType, - onceCallback, - /*cancel=*/ err => { - this.off(eventType, onceCallback); + switch (eventType) { + case 'value': + onValue(this._delegate, valueCallback, cancelCallback, { + onlyOnce: true + }); + break; + case 'child_added': + onChildAdded(this._delegate, valueCallback, cancelCallback, { + onlyOnce: true + }); + break; + case 'child_removed': + onChildRemoved(this._delegate, valueCallback, cancelCallback, { + onlyOnce: true + }); + break; + case 'child_changed': + onChildChanged(this._delegate, valueCallback, cancelCallback, { + onlyOnce: true + }); + break; + case 'child_moved': + onChildMoved(this._delegate, valueCallback, cancelCallback, { + onlyOnce: true + }); + break; + default: + throw new Error( + errorPrefix('Query.once', 1, false) + + 'must be a valid event type = "value", "child_added", "child_removed", ' + + '"child_changed", or "child_moved".' + ); + } - if (ret.cancel) { - ret.cancel.bind(ret.context)(err); - } - deferred.reject(err); - } - ); return deferred.promise; } @@ -510,28 +390,7 @@ export class Query implements Compat { */ limitToFirst(limit: number): Query { validateArgCount('Query.limitToFirst', 1, 1, arguments.length); - if ( - typeof limit !== 'number' || - Math.floor(limit) !== limit || - limit <= 0 - ) { - throw new Error( - 'Query.limitToFirst: First argument must be a positive integer.' - ); - } - if (this.queryParams_.hasLimit()) { - throw new Error( - 'Query.limitToFirst: Limit was already set (by another call to limit, ' + - 'limitToFirst, or limitToLast).' - ); - } - - return new Query( - this.database, - this.path, - queryParamsLimitToFirst(this.queryParams_, limit), - this.orderByCalled_ - ); + return new Query(this.database, query(this._delegate, limitToFirst(limit))); } /** @@ -539,28 +398,7 @@ export class Query implements Compat { */ limitToLast(limit: number): Query { validateArgCount('Query.limitToLast', 1, 1, arguments.length); - if ( - typeof limit !== 'number' || - Math.floor(limit) !== limit || - limit <= 0 - ) { - throw new Error( - 'Query.limitToLast: First argument must be a positive integer.' - ); - } - if (this.queryParams_.hasLimit()) { - throw new Error( - 'Query.limitToLast: Limit was already set (by another call to limit, ' + - 'limitToFirst, or limitToLast).' - ); - } - - return new Query( - this.database, - this.path, - queryParamsLimitToLast(this.queryParams_, limit), - this.orderByCalled_ - ); + return new Query(this.database, query(this._delegate, limitToLast(limit))); } /** @@ -568,37 +406,7 @@ export class Query implements Compat { */ orderByChild(path: string): Query { validateArgCount('Query.orderByChild', 1, 1, arguments.length); - if (path === '$key') { - throw new Error( - 'Query.orderByChild: "$key" is invalid. Use Query.orderByKey() instead.' - ); - } else if (path === '$priority') { - throw new Error( - 'Query.orderByChild: "$priority" is invalid. Use Query.orderByPriority() instead.' - ); - } else if (path === '$value') { - throw new Error( - 'Query.orderByChild: "$value" is invalid. Use Query.orderByValue() instead.' - ); - } - validatePathString('Query.orderByChild', 1, path, false); - this.validateNoPreviousOrderByCall_('Query.orderByChild'); - const parsedPath = new Path(path); - if (pathIsEmpty(parsedPath)) { - throw new Error( - 'Query.orderByChild: cannot pass in empty path. Use Query.orderByValue() instead.' - ); - } - const index = new PathIndex(parsedPath); - const newParams = queryParamsOrderBy(this.queryParams_, index); - Query.validateQueryEndpoints_(newParams); - - return new Query( - this.database, - this.path, - newParams, - /*orderByCalled=*/ true - ); + return new Query(this.database, query(this._delegate, orderByChild(path))); } /** @@ -606,15 +414,7 @@ export class Query implements Compat { */ orderByKey(): Query { validateArgCount('Query.orderByKey', 0, 0, arguments.length); - this.validateNoPreviousOrderByCall_('Query.orderByKey'); - const newParams = queryParamsOrderBy(this.queryParams_, KEY_INDEX); - Query.validateQueryEndpoints_(newParams); - return new Query( - this.database, - this.path, - newParams, - /*orderByCalled=*/ true - ); + return new Query(this.database, query(this._delegate, orderByKey())); } /** @@ -622,15 +422,7 @@ export class Query implements Compat { */ orderByPriority(): Query { validateArgCount('Query.orderByPriority', 0, 0, arguments.length); - this.validateNoPreviousOrderByCall_('Query.orderByPriority'); - const newParams = queryParamsOrderBy(this.queryParams_, PRIORITY_INDEX); - Query.validateQueryEndpoints_(newParams); - return new Query( - this.database, - this.path, - newParams, - /*orderByCalled=*/ true - ); + return new Query(this.database, query(this._delegate, orderByPriority())); } /** @@ -638,15 +430,7 @@ export class Query implements Compat { */ orderByValue(): Query { validateArgCount('Query.orderByValue', 0, 0, arguments.length); - this.validateNoPreviousOrderByCall_('Query.orderByValue'); - const newParams = queryParamsOrderBy(this.queryParams_, VALUE_INDEX); - Query.validateQueryEndpoints_(newParams); - return new Query( - this.database, - this.path, - newParams, - /*orderByCalled=*/ true - ); + return new Query(this.database, query(this._delegate, orderByValue())); } startAt( @@ -654,26 +438,10 @@ export class Query implements Compat { name?: string | null ): Query { validateArgCount('Query.startAt', 0, 2, arguments.length); - validateFirebaseDataArg('Query.startAt', 1, value, this.path, true); - validateKey('Query.startAt', 2, name, true); - - const newParams = queryParamsStartAt(this.queryParams_, value, name); - Query.validateLimit_(newParams); - Query.validateQueryEndpoints_(newParams); - if (this.queryParams_.hasStart()) { - throw new Error( - 'Query.startAt: Starting point was already set (by another call to startAt ' + - 'or equalTo).' - ); - } - - // Calling with no params tells us to start at the beginning. - if (value === undefined) { - value = null; - name = null; - } - - return new Query(this.database, this.path, newParams, this.orderByCalled_); + return new Query( + this.database, + query(this._delegate, startAt(value, name)) + ); } startAfter( @@ -681,20 +449,10 @@ export class Query implements Compat { 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 = queryParamsStartAfter(this.queryParams_, 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.database, this.path, newParams, this.orderByCalled_); + return new Query( + this.database, + query(this._delegate, startAfter(value, name)) + ); } endAt( @@ -702,20 +460,7 @@ export class Query implements Compat { name?: string | null ): Query { validateArgCount('Query.endAt', 0, 2, arguments.length); - validateFirebaseDataArg('Query.endAt', 1, value, this.path, true); - validateKey('Query.endAt', 2, name, true); - - const newParams = queryParamsEndAt(this.queryParams_, value, name); - Query.validateLimit_(newParams); - Query.validateQueryEndpoints_(newParams); - if (this.queryParams_.hasEnd()) { - throw new Error( - 'Query.endAt: Ending point was already set (by another call to endAt, endBefore, or ' + - 'equalTo).' - ); - } - - return new Query(this.database, this.path, newParams, this.orderByCalled_); + return new Query(this.database, query(this._delegate, endAt(value, name))); } endBefore( @@ -723,20 +468,10 @@ export class Query implements Compat { 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 = queryParamsEndBefore(this.queryParams_, 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).' - ); - } - - return new Query(this.database, this.path, newParams, this.orderByCalled_); + return new Query( + this.database, + query(this._delegate, endBefore(value, name)) + ); } /** @@ -745,21 +480,10 @@ export class Query implements Compat { */ equalTo(value: number | string | boolean | null, name?: string) { validateArgCount('Query.equalTo', 1, 2, arguments.length); - validateFirebaseDataArg('Query.equalTo', 1, value, this.path, false); - validateKey('Query.equalTo', 2, name, true); - if (this.queryParams_.hasStart()) { - throw new Error( - 'Query.equalTo: Starting point was already set (by another call to startAt/startAfter or ' + - 'equalTo).' - ); - } - if (this.queryParams_.hasEnd()) { - throw new Error( - 'Query.equalTo: Ending point was already set (by another call to endAt/endBefore or ' + - 'equalTo).' - ); - } - return this.startAt(value, name).endAt(value, name); + return new Query( + this.database, + query(this._delegate, equalTo(value, name)) + ); } /** @@ -767,11 +491,7 @@ export class Query implements Compat { */ toString(): string { validateArgCount('Query.toString', 0, 0, arguments.length); - - return ( - this.database._delegate._repo.toString() + - pathToUrlEncodedString(this.path) - ); + return this._delegate.toString(); } // Do not create public documentation. This is intended to make JSON serialization work but is otherwise unnecessary @@ -779,7 +499,7 @@ export class Query implements Compat { toJSON() { // An optional spacer argument is unnecessary for a string. validateArgCount('Query.toJSON', 0, 1, arguments.length); - return this.toString(); + return this._delegate.toJSON(); } /** @@ -792,14 +512,7 @@ export class Query implements Compat { 'Query.isEqual failed: First argument must be an instance of firebase.database.Query.'; throw new Error(error); } - - const sameRepo = - this.database._delegate._repo === other.database._delegate._repo; - const samePath = pathEquals(this.path, other.path); - const sameQueryIdentifier = - this._delegate._queryIdentifier === other._delegate._queryIdentifier; - - return sameRepo && samePath && sameQueryIdentifier; + return this._delegate.isEqual(other._delegate); } /** @@ -840,7 +553,7 @@ export class Query implements Compat { } get ref(): Reference { - return this.getRef(); + return new Reference(this.database, this._delegate._path); } } @@ -858,17 +571,20 @@ export class Reference extends Query implements Compat { * Externally - this is the firebase.database.Reference type. */ constructor(database: Database, path: Path) { - super(database, path, new QueryParams(), false); + super( + database, + new QueryImpl(database._delegate._repo, path, new QueryParams(), false) + ); } /** @return {?string} */ getKey(): string | null { validateArgCount('Reference.key', 0, 0, arguments.length); - if (pathIsEmpty(this.path)) { + if (pathIsEmpty(this._delegate._path)) { return null; } else { - return pathGetBack(this.path); + return pathGetBack(this._delegate._path); } } @@ -877,21 +593,24 @@ export class Reference extends Query implements Compat { if (typeof pathString === 'number') { pathString = String(pathString); } else if (!(pathString instanceof Path)) { - if (pathGetFront(this.path) === null) { + if (pathGetFront(this._delegate._path) === null) { validateRootPathString('Reference.child', 1, pathString, false); } else { validatePathString('Reference.child', 1, pathString, false); } } - return new Reference(this.database, pathChild(this.path, pathString)); + return new Reference( + this.database, + pathChild(this._delegate._path, pathString) + ); } /** @return {?Reference} */ getParent(): Reference | null { validateArgCount('Reference.parent', 0, 0, arguments.length); - const parentPath = pathParent(this.path); + const parentPath = pathParent(this._delegate._path); return parentPath === null ? null : new Reference(this.database, parentPath); @@ -913,14 +632,20 @@ export class Reference extends Query implements Compat { onComplete?: (a: Error | null) => void ): Promise { validateArgCount('Reference.set', 1, 2, arguments.length); - validateWritablePath('Reference.set', this.path); - validateFirebaseDataArg('Reference.set', 1, newVal, this.path, false); + validateWritablePath('Reference.set', this._delegate._path); + validateFirebaseDataArg( + 'Reference.set', + 1, + newVal, + this._delegate._path, + false + ); validateCallback('Reference.set', 2, onComplete, true); const deferred = new Deferred(); repoSetWithPriority( - this.repo, - this.path, + this._delegate._repo, + this._delegate._path, newVal, /*priority=*/ null, deferred.wrapCallback(onComplete) @@ -933,7 +658,7 @@ export class Reference extends Query implements Compat { onComplete?: (a: Error | null) => void ): Promise { validateArgCount('Reference.update', 1, 2, arguments.length); - validateWritablePath('Reference.update', this.path); + validateWritablePath('Reference.update', this._delegate._path); if (Array.isArray(objectToMerge)) { const newObjectToMerge: { [k: string]: unknown } = {}; @@ -952,14 +677,14 @@ export class Reference extends Query implements Compat { 'Reference.update', 1, objectToMerge, - this.path, + this._delegate._path, false ); validateCallback('Reference.update', 2, onComplete, true); const deferred = new Deferred(); repoUpdate( - this.repo, - this.path, + this._delegate._repo, + this._delegate._path, objectToMerge as { [k: string]: unknown }, deferred.wrapCallback(onComplete) ); @@ -972,12 +697,12 @@ export class Reference extends Query implements Compat { onComplete?: (a: Error | null) => void ): Promise { validateArgCount('Reference.setWithPriority', 2, 3, arguments.length); - validateWritablePath('Reference.setWithPriority', this.path); + validateWritablePath('Reference.setWithPriority', this._delegate._path); validateFirebaseDataArg( 'Reference.setWithPriority', 1, newVal, - this.path, + this._delegate._path, false ); validatePriority('Reference.setWithPriority', 2, newPriority, false); @@ -993,8 +718,8 @@ export class Reference extends Query implements Compat { const deferred = new Deferred(); repoSetWithPriority( - this.repo, - this.path, + this._delegate._repo, + this._delegate._path, newVal, newPriority, deferred.wrapCallback(onComplete) @@ -1004,7 +729,7 @@ export class Reference extends Query implements Compat { remove(onComplete?: (a: Error | null) => void): Promise { validateArgCount('Reference.remove', 0, 1, arguments.length); - validateWritablePath('Reference.remove', this.path); + validateWritablePath('Reference.remove', this._delegate._path); validateCallback('Reference.remove', 1, onComplete, true); return this.set(null, onComplete); @@ -1020,7 +745,7 @@ export class Reference extends Query implements Compat { applyLocally?: boolean ): Promise { validateArgCount('Reference.transaction', 1, 3, arguments.length); - validateWritablePath('Reference.transaction', this.path); + validateWritablePath('Reference.transaction', this._delegate._path); validateCallback('Reference.transaction', 1, transactionUpdate, false); validateCallback('Reference.transaction', 2, onComplete, true); // NOTE: applyLocally is an internal-only option for now. We need to decide if we want to keep it and how @@ -1060,7 +785,7 @@ export class Reference extends Query implements Compat { this.database, new ExpDataSnapshot( node, - new ReferenceImpl(this.database._delegate._repo, this.path), + new ReferenceImpl(this._delegate._repo, this._delegate._path), PRIORITY_INDEX ) ); @@ -1073,15 +798,15 @@ export class Reference extends Query implements Compat { // Add a watch to make sure we get server updates. const valueCallback = function () {}; - const watchRef = new Reference(this.database, this.path); + const watchRef = new Reference(this.database, this._delegate._path); watchRef.on('value', valueCallback); const unwatcher = function () { watchRef.off('value', valueCallback); }; repoStartTransaction( - this.database._delegate._repo, - this.path, + this._delegate._repo, + this._delegate._path, transactionUpdate, promiseComplete, unwatcher, @@ -1096,14 +821,14 @@ export class Reference extends Query implements Compat { onComplete?: (a: Error | null) => void ): Promise { validateArgCount('Reference.setPriority', 1, 2, arguments.length); - validateWritablePath('Reference.setPriority', this.path); + validateWritablePath('Reference.setPriority', this._delegate._path); validatePriority('Reference.setPriority', 1, priority, false); validateCallback('Reference.setPriority', 2, onComplete, true); const deferred = new Deferred(); repoSetWithPriority( - this.database._delegate._repo, - pathChild(this.path, '.priority'), + this._delegate._repo, + pathChild(this._delegate._path, '.priority'), priority, null, deferred.wrapCallback(onComplete) @@ -1113,11 +838,17 @@ export class Reference extends Query implements Compat { push(value?: unknown, onComplete?: (a: Error | null) => void): Reference { validateArgCount('Reference.push', 0, 2, arguments.length); - validateWritablePath('Reference.push', this.path); - validateFirebaseDataArg('Reference.push', 1, value, this.path, true); + validateWritablePath('Reference.push', this._delegate._path); + validateFirebaseDataArg( + 'Reference.push', + 1, + value, + this._delegate._path, + true + ); validateCallback('Reference.push', 2, onComplete, true); - const now = repoServerTime(this.database._delegate._repo); + const now = repoServerTime(this._delegate._repo); const name = nextPushId(now); // push() returns a ThennableReference whose promise is fulfilled with a regular Reference. @@ -1146,8 +877,8 @@ export class Reference extends Query implements Compat { } onDisconnect(): OnDisconnect { - validateWritablePath('Reference.onDisconnect', this.path); - return new OnDisconnect(this.repo, this.path); + validateWritablePath('Reference.onDisconnect', this._delegate._path); + return new OnDisconnect(this._delegate._repo, this._delegate._path); } get key(): string | null { diff --git a/packages/database/src/api/internal.ts b/packages/database/src/api/internal.ts index 4400a0fe23b..f5eacb6963d 100644 --- a/packages/database/src/api/internal.ts +++ b/packages/database/src/api/internal.ts @@ -66,26 +66,27 @@ export const setSecurityDebugCallback = function ( callback: (a: object) => void ) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (ref.repo.persistentConnection_ as any).securityDebugCallback_ = callback; + (ref._delegate._repo + .persistentConnection_ as any).securityDebugCallback_ = callback; }; export const stats = function (ref: Reference, showDelta?: boolean) { - repoStats(ref.repo, showDelta); + repoStats(ref._delegate._repo, showDelta); }; export const statsIncrementCounter = function (ref: Reference, metric: string) { - repoStatsIncrementCounter(ref.repo, metric); + repoStatsIncrementCounter(ref._delegate._repo, metric); }; export const dataUpdateCount = function (ref: Reference): number { - return ref.repo.dataUpdateCount; + return ref._delegate._repo.dataUpdateCount; }; export const interceptServerData = function ( ref: Reference, callback: ((a: string, b: unknown) => void) | null ) { - return repoInterceptServerData(ref.repo, callback); + return repoInterceptServerData(ref._delegate._repo, callback); }; /** diff --git a/packages/database/src/exp/Reference.ts b/packages/database/src/exp/Reference.ts index 0d518a677ab..056660c93ed 100644 --- a/packages/database/src/exp/Reference.ts +++ b/packages/database/src/exp/Reference.ts @@ -22,7 +22,7 @@ import { QueryContext } from '../core/view/EventRegistration'; export interface Query extends QueryContext { readonly ref: Reference; isEqual(other: Query | null): boolean; - toJSON(): object; + toJSON(): string; toString(): string; } diff --git a/packages/database/src/exp/Reference_impl.ts b/packages/database/src/exp/Reference_impl.ts index 2064d74ca6d..7a7b0a68a9e 100644 --- a/packages/database/src/exp/Reference_impl.ts +++ b/packages/database/src/exp/Reference_impl.ts @@ -20,11 +20,15 @@ import { assert, contains, getModularInstance } from '@firebase/util'; import { Repo, repoAddEventCallbackForQuery, + repoGetValue, repoRemoveEventCallbackForQuery } from '../core/Repo'; import { ChildrenNode } from '../core/snap/ChildrenNode'; import { Index } from '../core/snap/indexes/Index'; +import { KEY_INDEX } from '../core/snap/indexes/KeyIndex'; +import { PathIndex } from '../core/snap/indexes/PathIndex'; import { PRIORITY_INDEX } from '../core/snap/indexes/PriorityIndex'; +import { VALUE_INDEX } from '../core/snap/indexes/ValueIndex'; import { Node } from '../core/snap/Node'; import { syncPointSetReferenceConstructor } from '../core/SyncPoint'; import { syncTreeSetReferenceConstructor } from '../core/SyncTree'; @@ -32,22 +36,43 @@ import { parseRepoInfo } from '../core/util/libs/parser'; import { Path, pathChild, + pathEquals, pathGetBack, pathIsEmpty, - pathParent + pathParent, + pathToUrlEncodedString } from '../core/util/Path'; -import { fatal, ObjectToUniqueKey } from '../core/util/util'; -import { validateUrl } from '../core/util/validation'; +import { + fatal, + MAX_NAME, + MIN_NAME, + ObjectToUniqueKey +} from '../core/util/util'; +import { + isValidPriority, + validateFirebaseDataArg, + validateKey, + validatePathString, + validateUrl +} from '../core/util/validation'; import { Change } from '../core/view/Change'; import { CancelEvent, DataEvent, EventType } from '../core/view/Event'; import { CallbackContext, EventRegistration, - QueryContext + QueryContext, + UserCallback } from '../core/view/EventRegistration'; import { QueryParams, - queryParamsGetQueryObject + queryParamsEndAt, + queryParamsEndBefore, + queryParamsGetQueryObject, + queryParamsLimitToFirst, + queryParamsLimitToLast, + queryParamsOrderBy, + queryParamsStartAfter, + queryParamsStartAt } from '../core/view/QueryParams'; import { FirebaseDatabase } from './Database'; @@ -68,7 +93,7 @@ export class QueryImpl implements Query, QueryContext { readonly _repo: Repo, readonly _path: Path, readonly _queryParams: QueryParams, - private readonly _orderByCalled: boolean + readonly _orderByCalled: boolean ) {} get key(): string | null { @@ -97,18 +122,116 @@ export class QueryImpl implements Query, QueryContext { } isEqual(other: QueryImpl | null): boolean { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + other = getModularInstance(other); + if (!(other instanceof QueryImpl)) { + return false; + } + + const sameRepo = this._repo === other._repo; + const samePath = pathEquals(this._path, other._path); + const sameQueryIdentifier = + this._queryIdentifier === other._queryIdentifier; + + return sameRepo && samePath && sameQueryIdentifier; } - toJSON(): object { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + toJSON(): string { + return this.toString(); } toString(): string { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + return this._repo.toString() + pathToUrlEncodedString(this._path); + } +} + +/** + * Validates that no other order by call has been made + */ +function validateNoPreviousOrderByCall(query: QueryImpl, fnName: string) { + if (query._orderByCalled === true) { + throw new Error(fnName + ": You can't combine multiple orderBy calls."); + } +} + +/** + * Validates start/end values for queries. + */ +function validateQueryEndpoints(params: QueryParams) { + let startNode = null; + let endNode = null; + if (params.hasStart()) { + startNode = params.getIndexStartValue(); + } + if (params.hasEnd()) { + endNode = params.getIndexEndValue(); + } + + if (params.getIndex() === KEY_INDEX) { + const tooManyArgsError = + 'Query: When ordering by key, you may only pass one argument to ' + + 'startAt(), endAt(), or equalTo().'; + const wrongArgTypeError = + 'Query: When ordering by key, the argument passed to startAt(), startAfter(), ' + + 'endAt(), endBefore(), or equalTo() must be a string.'; + if (params.hasStart()) { + const startName = params.getIndexStartName(); + if (startName !== MIN_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) { + throw new Error(tooManyArgsError); + } else if (typeof endNode !== 'string') { + throw new Error(wrongArgTypeError); + } + } + } else if (params.getIndex() === PRIORITY_INDEX) { + if ( + (startNode != null && !isValidPriority(startNode)) || + (endNode != null && !isValidPriority(endNode)) + ) { + throw new Error( + 'Query: When ordering by priority, the first argument passed to startAt(), ' + + 'startAfter() endAt(), endBefore(), or equalTo() must be a valid priority value ' + + '(null, a number, or a string).' + ); + } + } else { + assert( + params.getIndex() instanceof PathIndex || + params.getIndex() === VALUE_INDEX, + 'unknown index type.' + ); + if ( + (startNode != null && typeof startNode === 'object') || + (endNode != null && typeof endNode === 'object') + ) { + throw new Error( + 'Query: First argument passed to startAt(), startAfter(), endAt(), endBefore(), or ' + + 'equalTo() cannot be an object.' + ); + } + } +} + +/** + * Validates that limit* has been called with the correct combination of parameters + */ +function validateLimit(params: QueryParams) { + if ( + params.hasStart() && + params.hasEnd() && + params.hasLimit() && + !params.hasAnchoredLimit() + ) { + throw new Error( + "Query: Can't combine startAt(), startAfter(), endAt(), endBefore(), and limit(). Use " + + 'limitToFirst() or limitToLast() instead.' + ); } } @@ -289,8 +412,14 @@ export function update(ref: Reference, values: object): Promise { } export function get(query: Query): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + const queryImpl = getModularInstance(query) as QueryImpl; + return repoGetValue(query._repo, queryImpl).then(node => { + return new DataSnapshot( + node, + new ReferenceImpl(query._repo, query._path), + query._queryParams.getIndex() + ); + }); } /** @@ -488,12 +617,9 @@ export class ChildEventRegistration implements EventRegistration { function addEventListener( query: Query, eventType: EventType, - callback: ( - snapshot: DataSnapshot, - previousChildName: string | null - ) => unknown, - cancelCallbackOrListenOptions: ((error: Error) => unknown) | ListenOptions, - options: ListenOptions + callback: UserCallback, + cancelCallbackOrListenOptions?: ((error: Error) => unknown) | ListenOptions, + options?: ListenOptions ) { let cancelCallback: ((error: Error) => unknown) | undefined; if (typeof cancelCallbackOrListenOptions === 'object') { @@ -504,6 +630,17 @@ function addEventListener( cancelCallback = cancelCallbackOrListenOptions; } + if (options && options.onlyOnce) { + const userCallback = callback; + const onceCallback: UserCallback = (dataSnapshot, previousChildName) => { + userCallback(dataSnapshot, previousChildName); + repoRemoveEventCallbackForQuery(query._repo, query, container); + }; + onceCallback.userCallback = callback.userCallback; + onceCallback.context = callback.context; + callback = onceCallback; + } + const callbackContext = new CallbackContext( callback, cancelCallback || undefined @@ -734,94 +871,432 @@ export function off( repoRemoveEventCallbackForQuery(query._repo, query, container); } -export interface QueryConstraint { - type: - | 'endAt' - | 'endBefore' - | 'startAt' - | 'startAfter' - | 'limitToFirst' - | 'limitToLast' - | 'orderByChild' - | 'orderByKey' - | 'orderByPriority' - | 'orderByValue' - | 'equalTo'; +/** Describes the different query constraints available in this SDK. */ +export type QueryConstraintType = + | 'endAt' + | 'endBefore' + | 'startAt' + | 'startAfter' + | 'limitToFirst' + | 'limitToLast' + | 'orderByChild' + | 'orderByKey' + | 'orderByPriority' + | 'orderByValue' + | 'equalTo'; + +/** + * A `QueryConstraint` is used to narrow the set of documents returned by a + * Database query. `QueryConstraint`s are created by invoking {@link endAt}, + * {@link endBefore}, {@link startAt}, {@link startAfter}, {@link + * limitToFirst}, {@link limitToLast}, {@link orderByChild}, + * {@link orderByChild}, {@link orderByKey} , {@link orderByPriority} , + * {@link orderByValue} or {@link equalTo} and + * can then be passed to {@link query} to create a new query instance that + * also contains this `QueryConstraint`. + */ +export abstract class QueryConstraint { + /** The type of this query constraints */ + abstract readonly type: QueryConstraintType; + + /** + * Takes the provided `Query` and returns a copy of the `Query` with this + * `QueryConstraint` applied. + */ + abstract _apply(query: QueryImpl): QueryImpl; +} + +class QueryEndAtConstraint extends QueryConstraint { + readonly type: 'endAt'; + + constructor( + private readonly _value: number | string | boolean | null, + private readonly _key?: string + ) { + super(); + } + + _apply(query: QueryImpl): QueryImpl { + validateFirebaseDataArg('endAt', 1, this._value, query._path, true); + const newParams = queryParamsEndAt( + query._queryParams, + this._value, + this._key + ); + validateLimit(newParams); + validateQueryEndpoints(newParams); + if (query._queryParams.hasEnd()) { + throw new Error( + 'endAt: Starting point was already set (by another call to endAt, ' + + 'endBefore or equalTo).' + ); + } + return new QueryImpl( + query._repo, + query._path, + newParams, + query._orderByCalled + ); + } } export function endAt( value: number | string | boolean | null, key?: string ): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + validateKey('endAt', 2, key, true); + return new QueryEndAtConstraint(value, key); +} + +class QueryEndBeforeConstraint extends QueryConstraint { + readonly type: 'endBefore'; + + constructor( + private readonly _value: number | string | boolean | null, + private readonly _key?: string + ) { + super(); + } + + _apply(query: QueryImpl): QueryImpl { + validateFirebaseDataArg('endBefore', 1, this._value, query._path, false); + const newParams = queryParamsEndBefore( + query._queryParams, + this._value, + this._key + ); + validateLimit(newParams); + validateQueryEndpoints(newParams); + if (query._queryParams.hasEnd()) { + throw new Error( + 'endBefore: Starting point was already set (by another call to endAt, ' + + 'endBefore or equalTo).' + ); + } + return new QueryImpl( + query._repo, + query._path, + newParams, + query._orderByCalled + ); + } } export function endBefore( value: number | string | boolean | null, key?: string ): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + validateKey('endBefore', 2, key, true); + return new QueryEndBeforeConstraint(value, key); +} + +class QueryStartAtConstraint extends QueryConstraint { + readonly type: 'startAt'; + + constructor( + private readonly _value: number | string | boolean | null, + private readonly _key?: string + ) { + super(); + } + + _apply(query: QueryImpl): QueryImpl { + validateFirebaseDataArg('startAt', 1, this._value, query._path, true); + const newParams = queryParamsStartAt( + query._queryParams, + this._value, + this._key + ); + validateLimit(newParams); + validateQueryEndpoints(newParams); + if (query._queryParams.hasStart()) { + throw new Error( + 'startAt: Starting point was already set (by another call to startAt, ' + + 'startBefore or equalTo).' + ); + } + return new QueryImpl( + query._repo, + query._path, + newParams, + query._orderByCalled + ); + } } export function startAt( - value: number | string | boolean | null, + value: number | string | boolean | null = null, key?: string ): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + validateKey('startAt', 2, key, true); + return new QueryStartAtConstraint(value, key); +} + +class QueryStartAfterConstraint extends QueryConstraint { + readonly type: 'startAfter'; + + constructor( + private readonly _value: number | string | boolean | null, + private readonly _key?: string + ) { + super(); + } + + _apply(query: QueryImpl): QueryImpl { + validateFirebaseDataArg('startAfter', 1, this._value, query._path, false); + const newParams = queryParamsStartAfter( + query._queryParams, + this._value, + this._key + ); + validateLimit(newParams); + validateQueryEndpoints(newParams); + if (query._queryParams.hasStart()) { + throw new Error( + 'startAfter: Starting point was already set (by another call to startAt, ' + + 'startAfter, or equalTo).' + ); + } + return new QueryImpl( + query._repo, + query._path, + newParams, + query._orderByCalled + ); + } } export function startAfter( value: number | string | boolean | null, key?: string ): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + validateKey('startAfter', 2, key, true); + return new QueryStartAfterConstraint(value, key); +} + +class QueryLimitToFirstConstraint extends QueryConstraint { + readonly type: 'limitToFirst'; + + constructor(private readonly _limit: number) { + super(); + } + + _apply(query: QueryImpl): QueryImpl { + if (query._queryParams.hasLimit()) { + throw new Error( + 'limitToFirst: Limit was already set (by another call to limitToFirst ' + + 'or limitToLast).' + ); + } + return new QueryImpl( + query._repo, + query._path, + queryParamsLimitToFirst(query._queryParams, this._limit), + query._orderByCalled + ); + } } export function limitToFirst(limit: number): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + if (typeof limit !== 'number' || Math.floor(limit) !== limit || limit <= 0) { + throw new Error('limitToFirst: First argument must be a positive integer.'); + } + return new QueryLimitToFirstConstraint(limit); +} + +class QueryLimitToLastConstraint extends QueryConstraint { + readonly type: 'limitToLast'; + + constructor(private readonly _limit: number) { + super(); + } + + _apply(query: QueryImpl): QueryImpl { + if (query._queryParams.hasLimit()) { + throw new Error( + 'limitToLast: Limit was already set (by another call to limitToFirst ' + + 'or limitToLast).' + ); + } + return new QueryImpl( + query._repo, + query._path, + queryParamsLimitToLast(query._queryParams, this._limit), + query._orderByCalled + ); + } } export function limitToLast(limit: number): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + if (typeof limit !== 'number' || Math.floor(limit) !== limit || limit <= 0) { + throw new Error('limitToLast: First argument must be a positive integer.'); + } + + return new QueryLimitToLastConstraint(limit); +} + +class QueryOrderByChildConstraint extends QueryConstraint { + readonly type: 'orderByChild'; + + constructor(private readonly _path: string) { + super(); + } + + _apply(query: QueryImpl): QueryImpl { + validateNoPreviousOrderByCall(query, 'orderByChild'); + const parsedPath = new Path(this._path); + if (pathIsEmpty(parsedPath)) { + throw new Error( + 'orderByChild: cannot pass in empty path. Use orderByValue() instead.' + ); + } + const index = new PathIndex(parsedPath); + const newParams = queryParamsOrderBy(query._queryParams, index); + validateQueryEndpoints(newParams); + + return new QueryImpl( + query._repo, + query._path, + newParams, + /*orderByCalled=*/ true + ); + } } export function orderByChild(path: string): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + if (path === '$key') { + throw new Error( + 'orderByChild: "$key" is invalid. Use orderByKey() instead.' + ); + } else if (path === '$priority') { + throw new Error( + 'orderByChild: "$priority" is invalid. Use orderByPriority() instead.' + ); + } else if (path === '$value') { + throw new Error( + 'orderByChild: "$value" is invalid. Use orderByValue() instead.' + ); + } + validatePathString('orderByChild', 1, path, false); + return new QueryOrderByChildConstraint(path); +} + +class QueryOrderByKeyConstraint extends QueryConstraint { + readonly type: 'orderByKey'; + + _apply(query: QueryImpl): QueryImpl { + validateNoPreviousOrderByCall(query, 'orderByKey'); + const newParams = queryParamsOrderBy(query._queryParams, KEY_INDEX); + validateQueryEndpoints(newParams); + return new QueryImpl( + query._repo, + query._path, + newParams, + /*orderByCalled=*/ true + ); + } } export function orderByKey(): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + return new QueryOrderByKeyConstraint(); +} + +class QueryOrderByPriorityConstraint extends QueryConstraint { + readonly type: 'orderByPriority'; + + _apply(query: QueryImpl): QueryImpl { + validateNoPreviousOrderByCall(query, 'orderByPriority'); + const newParams = queryParamsOrderBy(query._queryParams, PRIORITY_INDEX); + validateQueryEndpoints(newParams); + return new QueryImpl( + query._repo, + query._path, + newParams, + /*orderByCalled=*/ true + ); + } } export function orderByPriority(): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + return new QueryOrderByPriorityConstraint(); +} + +class QueryOrderByValueConstraint extends QueryConstraint { + readonly type: 'orderByValue'; + + _apply(query: QueryImpl): QueryImpl { + validateNoPreviousOrderByCall(query, 'orderByValue'); + const newParams = queryParamsOrderBy(query._queryParams, VALUE_INDEX); + validateQueryEndpoints(newParams); + return new QueryImpl( + query._repo, + query._path, + newParams, + /*orderByCalled=*/ true + ); + } } export function orderByValue(): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + return new QueryOrderByValueConstraint(); +} + +class QueryEqualToValueConstraint extends QueryConstraint { + readonly type: 'equalTo'; + + constructor( + private readonly _value: number | string | boolean | null, + private readonly _key?: string + ) { + super(); + } + + _apply(query: QueryImpl): QueryImpl { + validateFirebaseDataArg('equalTo', 1, this._value, query._path, false); + if (query._queryParams.hasStart()) { + throw new Error( + 'equalTo: Starting point was already set (by another call to startAt/startAfter or ' + + 'equalTo).' + ); + } + if (query._queryParams.hasEnd()) { + throw new Error( + 'equalTo: Ending point was already set (by another call to endAt/endBefore or ' + + 'equalTo).' + ); + } + return new QueryEndAtConstraint(this._value, this._key)._apply( + new QueryStartAtConstraint(this._value, this._key)._apply(query) + ); + } } export function equalTo( value: number | string | boolean | null, key?: string ): QueryConstraint { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; + validateKey('equalTo', 2, key, true); + return new QueryEqualToValueConstraint(value, key); } -export function query(query: Query, ...constraints: QueryConstraint[]): Query { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {} as any; +/** + * Creates a new immutable instance of `Query` that is extended to also include + * additional query constraints. + * + * @param query - The Query instance to use as a base for the new constraints. + * @param queryConstraints - The list of `QueryConstraint`s to apply. + * @throws if any of the provided query constraints cannot be combined with the + * existing or new constraints. + */ +export function query( + query: Query, + ...queryConstraints: QueryConstraint[] +): QueryImpl { + let queryImpl = getModularInstance(query) as QueryImpl; + for (const constraint of queryConstraints) { + queryImpl = constraint._apply(queryImpl); + } + return queryImpl; } /** diff --git a/packages/database/test/query.test.ts b/packages/database/test/query.test.ts index 76fd4f7b940..877a0b40bf3 100644 --- a/packages/database/test/query.test.ts +++ b/packages/database/test/query.test.ts @@ -2475,7 +2475,7 @@ describe('Query Tests', () => { }); function dumpListens(node: Query) { - const listens: Map> = (node.repo + const listens: Map> = (node._delegate._repo .persistentConnection_ as any).listens; const nodePath = getPath(node); const listenPaths = []; @@ -3194,7 +3194,7 @@ describe('Query Tests', () => { it('get reads node from cache when not connected', async () => { const node = getRandomNode() as Reference; - const node2 = getFreshRepo(node.path); + const node2 = getFreshRepo(node._delegate._path); try { await node2.set({ foo: 'bar' }); const onSnapshot = await new Promise((resolve, _) => { @@ -3216,7 +3216,7 @@ describe('Query Tests', () => { it('get reads child node from cache when not connected', async () => { const node = getRandomNode() as Reference; - const node2 = getFreshRepo(node.path); + const node2 = getFreshRepo(node._delegate._path); try { await node2.set({ foo: 'bar' }); const onSnapshot = await new Promise((resolve, _) => { @@ -3236,7 +3236,7 @@ describe('Query Tests', () => { it('get reads parent node from cache when not connected', async () => { const node = getRandomNode() as Reference; - const node2 = getFreshRepo(node.path); + const node2 = getFreshRepo(node._delegate._path); try { await node2.set({ foo: 'bar' }); await node2.child('baz').set(1); @@ -3257,7 +3257,7 @@ describe('Query Tests', () => { it('get with pending node writes when not connected', async () => { const node = getRandomNode() as Reference; - const node2 = getFreshRepo(node.path); + const node2 = getFreshRepo(node._delegate._path); try { await node2.set({ foo: 'bar' }); const onSnapshot = await new Promise((resolve, _) => { @@ -3278,7 +3278,7 @@ describe('Query Tests', () => { it('get with pending child writes when not connected', async () => { const node = getRandomNode() as Reference; - const node2 = getFreshRepo(node.path); + const node2 = getFreshRepo(node._delegate._path); try { await node2.set({ foo: 'bar' }); const onSnapshot = await new Promise((resolve, _) => { @@ -3299,7 +3299,7 @@ describe('Query Tests', () => { it('get with pending parent writes when not connected', async () => { const node = getRandomNode() as Reference; - const node2 = getFreshRepo(node.path); + const node2 = getFreshRepo(node._delegate._path); try { await node2.set({ foo: 'bar' }); const onSnapshot = await new Promise((resolve, _) => { @@ -3344,7 +3344,7 @@ describe('Query Tests', () => { it('get does not cache sibling data', async () => { const reader = getRandomNode() as Reference; - const writer = getFreshRepo(reader.path); + const writer = getFreshRepo(reader._delegate._path); await writer.set({ foo: { cached: { data: '1' }, notCached: { data: '2' } } }); diff --git a/packages/firestore/src/lite/query.ts b/packages/firestore/src/lite/query.ts index f162be5ed46..4d9e27dc967 100644 --- a/packages/firestore/src/lite/query.ts +++ b/packages/firestore/src/lite/query.ts @@ -105,10 +105,10 @@ export abstract class QueryConstraint { } /** - * Creates a new immutable instance of `query` that is extended to also include + * Creates a new immutable instance of `Query` that is extended to also include * additional query constraints. * - * @param query - The query instance to use as a base for the new constraints. + * @param query - The Query instance to use as a base for the new constraints. * @param queryConstraints - The list of `QueryConstraint`s to apply. * @throws if any of the provided query constraints cannot be combined with the * existing or new constraints. From 93930a6409d82534c516fc45d5f717bf7878e24d Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Thu, 1 Apr 2021 15:06:34 -0600 Subject: [PATCH 5/5] Lint --- packages/database/src/api/internal.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/database/src/api/internal.ts b/packages/database/src/api/internal.ts index f5eacb6963d..773bf0fd0a2 100644 --- a/packages/database/src/api/internal.ts +++ b/packages/database/src/api/internal.ts @@ -65,9 +65,9 @@ export const setSecurityDebugCallback = function ( ref: Reference, callback: (a: object) => void ) { + const connection = ref._delegate._repo.persistentConnection_; // eslint-disable-next-line @typescript-eslint/no-explicit-any - (ref._delegate._repo - .persistentConnection_ as any).securityDebugCallback_ = callback; + (connection as any).securityDebugCallback_ = callback; }; export const stats = function (ref: Reference, showDelta?: boolean) {