diff --git a/packages/firebase/index.d.ts b/packages/firebase/index.d.ts index 7f04594d4d2..91fbf87149a 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -1112,6 +1112,37 @@ declare namespace firebase.firestore { readonly merge?: boolean; } + /** + * An options object that configures the behavior of `get()` calls on + * `DocumentReference` and `Query`. By providing a `GetOptions` object, these + * methods can be configured to fetch results only from the server, only from + * the local cache or attempt to fetch results from the server and fall back to + * the cache (which is the default). + */ + export interface GetOptions { + /** + * Describes whether we should get from server or cache. + * + * Setting to 'default' (or not setting at all), causes Firestore to try to + * retrieve an up-to-date (server-retrieved) snapshot, but fall back to + * returning cached data if the server can't be reached. + * + * Setting to 'server' causes Firestore to avoid the cache, generating an + * error if the server cannot be reached. Note that the cache will still be + * updated if the server request succeeds. Also note that latency-compensation + * still takes effect, so any pending write operations will be visible in the + * returned data (merged into the server-provided data). + * + * Setting to 'cache' causes Firestore to immediately return a value from the + * cache, ignoring the server completely (implying that the returned value + * may be stale with respect to the value on the server.) If there is no data + * in the cache to satisfy the `get()` call, `DocumentReference.get()` will + * return an error and `QuerySnapshot.get()` will return an empty + * `QuerySnapshot` with no documents. + */ + readonly source?: 'default' | 'server' | 'cache'; + } + /** * A `DocumentReference` refers to a document location in a Firestore database * and can be used to write, read, or listen to the location. The document at @@ -1213,14 +1244,16 @@ declare namespace firebase.firestore { /** * Reads the document referred to by this `DocumentReference`. * - * Note: get() attempts to provide up-to-date data when possible by waiting - * for data from the server, but it may return cached data or fail if you - * are offline and the server cannot be reached. + * Note: By default, get() attempts to provide up-to-date data when possible + * by waiting for data from the server, but it may return cached data or fail + * if you are offline and the server cannot be reached. This behavior can be + * altered via the `GetOptions` parameter. * + * @param options An object to configure the get behavior. * @return A Promise resolved with a DocumentSnapshot containing the * current document contents. */ - get(): Promise; + get(options?: GetOptions): Promise; /** * Attaches a listener for DocumentSnapshot events. You may either pass @@ -1598,9 +1631,15 @@ declare namespace firebase.firestore { /** * Executes the query and returns the results as a QuerySnapshot. * + * Note: By default, get() attempts to provide up-to-date data when possible + * by waiting for data from the server, but it may return cached data or fail + * if you are offline and the server cannot be reached. This behavior can be + * altered via the `GetOptions` parameter. + * + * @param options An object to configure the get behavior. * @return A Promise that will be resolved with the results of the Query. */ - get(): Promise; + get(options?: GetOptions): Promise; /** * Attaches a listener for QuerySnapshot events. You may either pass diff --git a/packages/firestore-types/index.d.ts b/packages/firestore-types/index.d.ts index b47ed1be81f..5620a56ccdd 100644 --- a/packages/firestore-types/index.d.ts +++ b/packages/firestore-types/index.d.ts @@ -494,6 +494,37 @@ export interface SetOptions { readonly merge?: boolean; } +/** + * An options object that configures the behavior of `get()` calls on + * `DocumentReference` and `Query`. By providing a `GetOptions` object, these + * methods can be configured to fetch results only from the server, only from + * the local cache or attempt to fetch results from the server and fall back to + * the cache (which is the default). + */ +export interface GetOptions { + /** + * Describes whether we should get from server or cache. + * + * Setting to 'default' (or not setting at all), causes Firestore to try to + * retrieve an up-to-date (server-retrieved) snapshot, but fall back to + * returning cached data if the server can't be reached. + * + * Setting to 'server' causes Firestore to avoid the cache, generating an + * error if the server cannot be reached. Note that the cache will still be + * updated if the server request succeeds. Also note that latency-compensation + * still takes effect, so any pending write operations will be visible in the + * returned data (merged into the server-provided data). + * + * Setting to 'cache' causes Firestore to immediately return a value from the + * cache, ignoring the server completely (implying that the returned value + * may be stale with respect to the value on the server.) If there is no data + * in the cache to satisfy the `get()` call, `DocumentReference.get()` will + * return an error and `QuerySnapshot.get()` will return an empty + * `QuerySnapshot` with no documents. + */ + readonly source?: 'default' | 'server' | 'cache'; +} + /** * A `DocumentReference` refers to a document location in a Firestore database * and can be used to write, read, or listen to the location. The document at @@ -595,14 +626,16 @@ export class DocumentReference { /** * Reads the document referred to by this `DocumentReference`. * - * Note: get() attempts to provide up-to-date data when possible by waiting - * for data from the server, but it may return cached data or fail if you - * are offline and the server cannot be reached. + * Note: By default, get() attempts to provide up-to-date data when possible + * by waiting for data from the server, but it may return cached data or fail + * if you are offline and the server cannot be reached. This behavior can be + * altered via the `GetOptions` parameter. * + * @param options An object to configure the get behavior. * @return A Promise resolved with a DocumentSnapshot containing the * current document contents. */ - get(): Promise; + get(options?: GetOptions): Promise; /** * Attaches a listener for DocumentSnapshot events. You may either pass @@ -976,9 +1009,15 @@ export class Query { /** * Executes the query and returns the results as a QuerySnapshot. * + * Note: By default, get() attempts to provide up-to-date data when possible + * by waiting for data from the server, but it may return cached data or fail + * if you are offline and the server cannot be reached. This behavior can be + * altered via the `GetOptions` parameter. + * + * @param options An object to configure the get behavior. * @return A Promise that will be resolved with the results of the Query. */ - get(): Promise; + get(options?: GetOptions): Promise; /** * Attaches a listener for QuerySnapshot events. You may either pass diff --git a/packages/firestore/CHANGELOG.md b/packages/firestore/CHANGELOG.md index a7d8d618d34..22176674d24 100644 --- a/packages/firestore/CHANGELOG.md +++ b/packages/firestore/CHANGELOG.md @@ -10,6 +10,10 @@ `FirestoreSettings` to `true`. Note that the current behavior (`DocumentSnapshot`s returning JS Date objects) will be removed in a future release. `Timestamp` supports higher precision than JS Date. +- [feature] Added ability to control whether DocumentReference.get() and + Query.get() should fetch from server only, (by passing { source: 'server' }), + cache only (by passing { source: 'cache' }), or attempt server and fall back + to the cache (which was the only option previously, and is now the default). # 0.3.6 - [fixed] Fixed a regression in the Firebase JS release 4.11.0 that could diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index f4a79939bd6..2add2d58a5f 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -1000,43 +1000,91 @@ export class DocumentReference implements firestore.DocumentReference { }; } - get(): Promise { - validateExactNumberOfArgs('DocumentReference.get', arguments, 0); + get(options?: firestore.GetOptions): Promise { + validateOptionNames('DocumentReference.get', options, ['source']); + if (options) { + validateNamedOptionalPropertyEquals( + 'DocumentReference.get', + 'options', + 'source', + options.source, + ['default', 'server', 'cache'] + ); + } return new Promise( (resolve: Resolver, reject: Rejecter) => { - const unlisten = this.onSnapshotInternal( - { - includeQueryMetadataChanges: true, - includeDocumentMetadataChanges: true, - waitForSyncWhenOnline: true - }, - { - next: (snap: firestore.DocumentSnapshot) => { - // Remove query first before passing event to user to avoid - // user actions affecting the now stale query. - unlisten(); - - if (!snap.exists && snap.metadata.fromCache) { - // TODO(dimond): If we're online and the document doesn't - // exist then we resolve with a doc.exists set to false. If - // we're offline however, we reject the Promise in this - // case. Two options: 1) Cache the negative response from - // the server so we can deliver that even when you're - // offline 2) Actually reject the Promise in the online case - // if the document doesn't exist. - reject( - new FirestoreError( - Code.ABORTED, - 'Failed to get document because the client is ' + 'offline.' - ) - ); - } else { - resolve(snap); - } - }, - error: reject + if (options && options.source === 'cache') { + this.firestore + .ensureClientConfigured() + .getDocumentFromLocalCache(this._key) + .then((doc: Document) => { + resolve( + new DocumentSnapshot( + this.firestore, + this._key, + doc, + /*fromCache=*/ true + ) + ); + }, reject); + } else { + this.getViaSnapshotListener(resolve, reject, options); + } + } + ); + } + + private getViaSnapshotListener( + resolve: Resolver, + reject: Rejecter, + options?: firestore.GetOptions + ): void { + const unlisten = this.onSnapshotInternal( + { + includeQueryMetadataChanges: true, + includeDocumentMetadataChanges: true, + waitForSyncWhenOnline: true + }, + { + next: (snap: firestore.DocumentSnapshot) => { + // Remove query first before passing event to user to avoid + // user actions affecting the now stale query. + unlisten(); + + if (!snap.exists && snap.metadata.fromCache) { + // TODO(dimond): If we're online and the document doesn't + // exist then we resolve with a doc.exists set to false. If + // we're offline however, we reject the Promise in this + // case. Two options: 1) Cache the negative response from + // the server so we can deliver that even when you're + // offline 2) Actually reject the Promise in the online case + // if the document doesn't exist. + reject( + new FirestoreError( + Code.UNAVAILABLE, + 'Failed to get document because the client is ' + 'offline.' + ) + ); + } else if ( + snap.exists && + snap.metadata.fromCache && + options && + options.source === 'server' + ) { + reject( + new FirestoreError( + Code.UNAVAILABLE, + 'Failed to get document from server. (However, this ' + + 'document does exist in the local cache. Run again ' + + 'without setting source to "server" to ' + + 'retrieve the cached document.)' + ) + ); + } else { + resolve(snap); } - ); + }, + error: reject } ); } @@ -1619,27 +1667,60 @@ export class Query implements firestore.Query { }; } - get(): Promise { - validateExactNumberOfArgs('Query.get', arguments, 0); + get(options?: firestore.GetOptions): Promise { + validateBetweenNumberOfArgs('Query.get', arguments, 0, 1); return new Promise( (resolve: Resolver, reject: Rejecter) => { - const unlisten = this.onSnapshotInternal( - { - includeDocumentMetadataChanges: false, - includeQueryMetadataChanges: true, - waitForSyncWhenOnline: true - }, - { - next: (result: firestore.QuerySnapshot) => { - // Remove query first before passing event to user to avoid - // user actions affecting the now stale query. - unlisten(); - - resolve(result); - }, - error: reject + if (options && options.source === 'cache') { + this.firestore + .ensureClientConfigured() + .getDocumentsFromLocalCache(this._query) + .then((viewSnap: ViewSnapshot) => { + resolve(new QuerySnapshot(this.firestore, this._query, viewSnap)); + }, reject); + } else { + this.getViaSnapshotListener(resolve, reject, options); + } + } + ); + } + + private getViaSnapshotListener( + resolve: Resolver, + reject: Rejecter, + options?: firestore.GetOptions + ): void { + const unlisten = this.onSnapshotInternal( + { + includeDocumentMetadataChanges: false, + includeQueryMetadataChanges: true, + waitForSyncWhenOnline: true + }, + { + next: (result: firestore.QuerySnapshot) => { + // Remove query first before passing event to user to avoid + // user actions affecting the now stale query. + unlisten(); + + if ( + result.metadata.fromCache && + options && + options.source === 'server' + ) { + reject( + new FirestoreError( + Code.UNAVAILABLE, + 'Failed to get documents from server. (However, these ' + + 'documents may exist in the local cache. Run again ' + + 'without setting source to "server" to ' + + 'retrieve the cached documents.)' + ) + ); + } else { + resolve(result); } - ); + }, + error: reject } ); } diff --git a/packages/firestore/src/core/firestore_client.ts b/packages/firestore/src/core/firestore_client.ts index 7829d07eabb..640b200b904 100644 --- a/packages/firestore/src/core/firestore_client.ts +++ b/packages/firestore/src/core/firestore_client.ts @@ -23,6 +23,7 @@ import { QueryListener } from './event_manager'; import { SyncEngine } from './sync_engine'; +import { View, ViewDocumentChanges } from './view'; import { EagerGarbageCollector } from '../local/eager_garbage_collector'; import { GarbageCollector } from '../local/garbage_collector'; import { IndexedDbPersistence } from '../local/indexeddb_persistence'; @@ -30,6 +31,13 @@ import { LocalStore } from '../local/local_store'; import { MemoryPersistence } from '../local/memory_persistence'; import { NoOpGarbageCollector } from '../local/no_op_garbage_collector'; import { Persistence } from '../local/persistence'; +import { + DocumentKeySet, + documentKeySet, + DocumentMap +} from '../model/collections'; +import { Document, MaybeDocument } from '../model/document'; +import { DocumentKey } from '../model/document_key'; import { Mutation } from '../model/mutation'; import { Platform } from '../platform/platform'; import { Datastore } from '../remote/datastore'; @@ -357,6 +365,42 @@ export class FirestoreClient { }); } + getDocumentFromLocalCache(docKey: DocumentKey): Promise { + return this.asyncQueue + .enqueue(() => { + return this.localStore.readDocument(docKey); + }) + .then((maybeDoc: MaybeDocument | null) => { + if (maybeDoc instanceof Document) { + return maybeDoc; + } else { + throw new FirestoreError( + Code.UNAVAILABLE, + 'Failed to get document from cache. (However, this document may ' + + "exist on the server. Run again without setting 'source' in " + + 'the GetOptions to attempt to retrieve the document from the ' + + 'server.)' + ); + } + }); + } + + getDocumentsFromLocalCache(query: Query): Promise { + return this.asyncQueue + .enqueue(() => { + return this.localStore.executeQuery(query); + }) + .then((docs: DocumentMap) => { + const remoteKeys: DocumentKeySet = documentKeySet(); + + const view = new View(query, remoteKeys); + const viewDocChanges: ViewDocumentChanges = view.computeDocChanges( + docs + ); + return view.applyChanges(viewDocChanges).snapshot; + }); + } + write(mutations: Mutation[]): Promise { const deferred = new Deferred(); this.asyncQueue.enqueue(() => this.syncEngine.write(mutations, deferred)); diff --git a/packages/firestore/test/integration/api/database.test.ts b/packages/firestore/test/integration/api/database.test.ts index 02e786d0abb..3543a9e5c89 100644 --- a/packages/firestore/test/integration/api/database.test.ts +++ b/packages/firestore/test/integration/api/database.test.ts @@ -479,7 +479,7 @@ apiDescribe('Database', persistence => { expect(err.message).to.exist; } ) - .then(queryForRejection.get) + .then(() => queryForRejection.get()) .then( () => { throw new Error('Promise resolved even though error was expected.'); diff --git a/packages/firestore/test/integration/api/get_options.test.ts b/packages/firestore/test/integration/api/get_options.test.ts new file mode 100644 index 00000000000..7836882548d --- /dev/null +++ b/packages/firestore/test/integration/api/get_options.test.ts @@ -0,0 +1,558 @@ +/** + * Copyright 2018 Google Inc. + * + * 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 { expect } from 'chai'; +import { + apiDescribe, + toDataMap, + withTestDocAndInitialData, + withTestCollection +} from '../util/helpers'; + +apiDescribe('GetOptions', persistence => { + it('get document while online with default get options', () => { + const initialData = { key: 'value' }; + return withTestDocAndInitialData(persistence, initialData, docRef => { + return docRef.get().then(doc => { + expect(doc.exists).to.be.true; + expect(doc.metadata.fromCache).to.be.false; + expect(doc.metadata.hasPendingWrites).to.be.false; + expect(doc.data()).to.deep.equal(initialData); + }); + }); + }); + + it('get collection while online with default get options', () => { + const initialDocs = { + doc1: { key1: 'value1' }, + doc2: { key2: 'value2' }, + doc3: { key3: 'value3' } + }; + return withTestCollection(persistence, initialDocs, colRef => { + return colRef.get().then(qrySnap => { + expect(qrySnap.metadata.fromCache).to.be.false; + expect(qrySnap.metadata.hasPendingWrites).to.be.false; + expect(qrySnap.docChanges.length).to.equal(3); + expect(toDataMap(qrySnap)).to.deep.equal(initialDocs); + }); + }); + }); + + it('get document while offline with default get options', () => { + const initialData = { key: 'value' }; + return withTestDocAndInitialData(persistence, initialData, docRef => { + // Register a snapshot to force the data to stay in the cache and not be + // garbage collected. + docRef.onSnapshot(() => {}); + return docRef + .get() + .then(ignored => docRef.firestore.disableNetwork()) + .then(() => docRef.get()) + .then(doc => { + expect(doc.exists).to.be.true; + expect(doc.metadata.fromCache).to.be.true; + expect(doc.metadata.hasPendingWrites).to.be.false; + expect(doc.data()).to.deep.equal(initialData); + }); + }); + }); + + it('get collection while offline with default get options', () => { + const initialDocs = { + doc1: { key1: 'value1' }, + doc2: { key2: 'value2' }, + doc3: { key3: 'value3' } + }; + return withTestCollection(persistence, initialDocs, colRef => { + // Register a snapshot to force the data to stay in the cache and not be + // garbage collected. + colRef.onSnapshot(() => {}); + return colRef + .get() + .then(ignored => colRef.firestore.disableNetwork()) + .then(() => { + // NB: since we're offline, the returned promises won't complete + colRef.doc('doc2').set({ key2b: 'value2b' }, { merge: true }); + colRef.doc('doc3').set({ key3b: 'value3b' }); + colRef.doc('doc4').set({ key4: 'value4' }); + return colRef.get(); + }) + .then(qrySnap => { + expect(qrySnap.metadata.fromCache).to.be.true; + expect(qrySnap.metadata.hasPendingWrites).to.be.true; + const docsData = toDataMap(qrySnap); + expect(qrySnap.docChanges.length).to.equal(4); + expect(docsData).to.deep.equal({ + doc1: { key1: 'value1' }, + doc2: { key2: 'value2', key2b: 'value2b' }, + doc3: { key3b: 'value3b' }, + doc4: { key4: 'value4' } + }); + }); + }); + }); + + it('get document while online with source=cache', () => { + const initialData = { key: 'value' }; + return withTestDocAndInitialData(persistence, initialData, docRef => { + // Register a snapshot to force the data to stay in the cache and not be + // garbage collected. + docRef.onSnapshot(() => {}); + return docRef + .get() + .then(ignored => docRef.get({ source: 'cache' })) + .then(doc => { + expect(doc.exists).to.be.true; + expect(doc.metadata.fromCache).to.be.true; + expect(doc.metadata.hasPendingWrites).to.be.false; + expect(doc.data()).to.deep.equal(initialData); + }); + }); + }); + + it('get collection while online with source=cache', () => { + const initialDocs = { + doc1: { key1: 'value1' }, + doc2: { key2: 'value2' }, + doc3: { key3: 'value3' } + }; + return withTestCollection(persistence, initialDocs, colRef => { + // Register a snapshot to force the data to stay in the cache and not be + // garbage collected. + colRef.onSnapshot(() => {}); + return colRef + .get() + .then(ignored => colRef.get({ source: 'cache' })) + .then(qrySnap => { + expect(qrySnap.metadata.fromCache).to.be.true; + expect(qrySnap.metadata.hasPendingWrites).to.be.false; + expect(qrySnap.docChanges.length).to.equal(3); + expect(toDataMap(qrySnap)).to.deep.equal(initialDocs); + }); + }); + }); + + it('get document while offline with source=cache', () => { + const initialData = { key: 'value' }; + + return withTestDocAndInitialData(persistence, initialData, docRef => { + // Register a snapshot to force the data to stay in the cache and not be + // garbage collected. + docRef.onSnapshot(() => {}); + return docRef + .get() + .then(ignored => docRef.firestore.disableNetwork()) + .then(() => docRef.get({ source: 'cache' })) + .then(doc => { + expect(doc.exists).to.be.true; + expect(doc.metadata.fromCache).to.be.true; + expect(doc.metadata.hasPendingWrites).to.be.false; + expect(doc.data()).to.deep.equal(initialData); + }); + }); + }); + + it('get collection while offline with source=cache', () => { + const initialDocs = { + doc1: { key1: 'value1' }, + doc2: { key2: 'value2' }, + doc3: { key3: 'value3' } + }; + return withTestCollection(persistence, initialDocs, colRef => { + // Register a snapshot to force the data to stay in the cache and not be + // garbage collected. + colRef.onSnapshot(() => {}); + return colRef + .get() + .then(ignored => colRef.firestore.disableNetwork()) + .then(() => { + // NB: since we're offline, the returned promises won't complete + colRef.doc('doc2').set({ key2b: 'value2b' }, { merge: true }); + colRef.doc('doc3').set({ key3b: 'value3b' }); + colRef.doc('doc4').set({ key4: 'value4' }); + return colRef.get({ source: 'cache' }); + }) + .then(qrySnap => { + expect(qrySnap.metadata.fromCache).to.be.true; + expect(qrySnap.metadata.hasPendingWrites).to.be.true; + const docsData = toDataMap(qrySnap); + expect(qrySnap.docChanges.length).to.equal(4); + expect(docsData).to.deep.equal({ + doc1: { key1: 'value1' }, + doc2: { key2: 'value2', key2b: 'value2b' }, + doc3: { key3b: 'value3b' }, + doc4: { key4: 'value4' } + }); + }); + }); + }); + + it('get document while online with source=server', () => { + const initialData = { key: 'value' }; + return withTestDocAndInitialData(persistence, initialData, docRef => { + return docRef.get({ source: 'server' }).then(doc => { + expect(doc.exists).to.be.true; + expect(doc.metadata.fromCache).to.be.false; + expect(doc.metadata.hasPendingWrites).to.be.false; + expect(doc.data()).to.deep.equal(initialData); + }); + }); + }); + + it('get collection while online with source=server', () => { + const initialDocs = { + doc1: { key1: 'value1' }, + doc2: { key2: 'value2' }, + doc3: { key3: 'value3' } + }; + return withTestCollection(persistence, initialDocs, colRef => { + return colRef.get({ source: 'server' }).then(qrySnap => { + expect(qrySnap.metadata.fromCache).to.be.false; + expect(qrySnap.metadata.hasPendingWrites).to.be.false; + expect(qrySnap.docChanges.length).to.equal(3); + expect(toDataMap(qrySnap)).to.deep.equal(initialDocs); + }); + }); + }); + + it('get document while offline with source=server', () => { + const initialData = { key: 'value' }; + return withTestDocAndInitialData(persistence, initialData, docRef => { + return docRef + .get({ source: 'server' }) + .then(ignored => {}) + .then(() => docRef.firestore.disableNetwork()) + .then(() => docRef.get({ source: 'server' })) + .then( + doc => { + expect.fail(); + }, + expected => {} + ); + }); + }); + + it('get collection while offline with source=server', () => { + const initialDocs = { + doc1: { key1: 'value1' }, + doc2: { key2: 'value2' }, + doc3: { key3: 'value3' } + }; + return withTestCollection(persistence, initialDocs, colRef => { + // force local cache of these + return ( + colRef + .get() + // now go offine. Note that if persistence is disabled, this will cause + // the initialDocs to be garbage collected. + .then(ignored => colRef.firestore.disableNetwork()) + .then(() => colRef.get({ source: 'server' })) + .then( + qrySnap => { + expect.fail(); + }, + expected => {} + ) + ); + }); + }); + + it('get document while offline with different get options', () => { + const initialData = { key: 'value' }; + + return withTestDocAndInitialData(persistence, initialData, docRef => { + // Register a snapshot to force the data to stay in the cache and not be + // garbage collected. + docRef.onSnapshot(() => {}); + return docRef + .get() + .then(ignored => docRef.firestore.disableNetwork()) + .then(() => { + // Create an initial listener for this query (to attempt to disrupt the + // gets below) and wait for the listener to deliver its initial + // snapshot before continuing. + return new Promise((resolve, reject) => { + docRef.onSnapshot( + docSnap => { + resolve(); + }, + error => { + reject(); + } + ); + }); + }) + .then(() => docRef.get({ source: 'cache' })) + .then(doc => { + expect(doc.exists).to.be.true; + expect(doc.metadata.fromCache).to.be.true; + expect(doc.metadata.hasPendingWrites).to.be.false; + expect(doc.data()).to.deep.equal(initialData); + return Promise.resolve(); + }) + .then(() => docRef.get()) + .then(doc => { + expect(doc.exists).to.be.true; + expect(doc.metadata.fromCache).to.be.true; + expect(doc.metadata.hasPendingWrites).to.be.false; + expect(doc.data()).to.deep.equal(initialData); + return Promise.resolve(); + }) + .then(() => docRef.get({ source: 'server' })) + .then( + doc => { + expect.fail(); + }, + expected => {} + ); + }); + }); + + it('get collection while offline with different get options', () => { + const initialDocs = { + doc1: { key1: 'value1' }, + doc2: { key2: 'value2' }, + doc3: { key3: 'value3' } + }; + return withTestCollection(persistence, initialDocs, colRef => { + // Register a snapshot to force the data to stay in the cache and not be + // garbage collected. + colRef.onSnapshot(() => {}); + return ( + colRef + .get() + // now go offine. Note that if persistence is disabled, this will cause + // the initialDocs to be garbage collected. + .then(ignored => colRef.firestore.disableNetwork()) + .then(() => { + // NB: since we're offline, the returned promises won't complete + colRef.doc('doc2').set({ key2b: 'value2b' }, { merge: true }); + colRef.doc('doc3').set({ key3b: 'value3b' }); + colRef.doc('doc4').set({ key4: 'value4' }); + + // Create an initial listener for this query (to attempt to disrupt the + // gets below) and wait for the listener to deliver its initial + // snapshot before continuing. + return new Promise((resolve, reject) => { + colRef.onSnapshot( + qrySnap => { + resolve(); + }, + error => { + reject(); + } + ); + }); + }) + .then(() => colRef.get({ source: 'cache' })) + .then(qrySnap => { + expect(qrySnap.metadata.fromCache).to.be.true; + expect(qrySnap.metadata.hasPendingWrites).to.be.true; + const docsData = toDataMap(qrySnap); + expect(qrySnap.docChanges.length).to.equal(4); + expect(docsData).to.deep.equal({ + doc1: { key1: 'value1' }, + doc2: { key2: 'value2', key2b: 'value2b' }, + doc3: { key3b: 'value3b' }, + doc4: { key4: 'value4' } + }); + }) + .then(() => colRef.get()) + .then(qrySnap => { + expect(qrySnap.metadata.fromCache).to.be.true; + expect(qrySnap.metadata.hasPendingWrites).to.be.true; + const docsData = toDataMap(qrySnap); + expect(qrySnap.docChanges.length).to.equal(4); + expect(docsData).to.deep.equal({ + doc1: { key1: 'value1' }, + doc2: { key2: 'value2', key2b: 'value2b' }, + doc3: { key3b: 'value3b' }, + doc4: { key4: 'value4' } + }); + }) + .then(() => colRef.get({ source: 'server' })) + .then( + qrySnap => { + expect.fail(); + }, + expected => {} + ) + ); + }); + }); + + it('get non existing doc while online with default get options', () => { + return withTestDocAndInitialData(persistence, null, docRef => { + return docRef.get().then(doc => { + expect(doc.exists).to.be.false; + expect(doc.metadata.fromCache).to.be.false; + expect(doc.metadata.hasPendingWrites).to.be.false; + }); + }); + }); + + it('get non existing collection while online with default get options', () => { + return withTestCollection(persistence, {}, colRef => { + return colRef.get().then(qrySnap => { + //expect(qrySnap.count).to.equal(0); + expect(qrySnap.empty).to.be.true; + expect(qrySnap.docChanges.length).to.equal(0); + expect(qrySnap.metadata.fromCache).to.be.false; + expect(qrySnap.metadata.hasPendingWrites).to.be.false; + }); + }); + }); + + it('get non existing doc while offline with default get options', () => { + return withTestDocAndInitialData(persistence, null, docRef => { + return docRef.firestore + .disableNetwork() + .then(() => docRef.get()) + .then( + doc => { + expect.fail(); + }, + expected => {} + ); + }); + }); + + it('get non existing collection while offline with default get options', () => { + return withTestCollection(persistence, {}, colRef => { + return colRef.firestore + .disableNetwork() + .then(() => colRef.get()) + .then(qrySnap => { + expect(qrySnap.empty).to.be.true; + expect(qrySnap.docChanges.length).to.equal(0); + expect(qrySnap.metadata.fromCache).to.be.true; + expect(qrySnap.metadata.hasPendingWrites).to.be.false; + }); + }); + }); + + it('get non existing doc while online with source=cache', () => { + return withTestDocAndInitialData(persistence, null, docRef => { + // attempt to get doc. Currently, this is expected to fail. In the + // future, we might consider adding support for negative cache hits so + // that we know certain documents *don't* exist. + return docRef.get({ source: 'cache' }).then( + doc => { + expect.fail(); + }, + expected => {} + ); + }); + }); + + it('get non existing collection while online with source=cache', () => { + return withTestCollection(persistence, {}, colRef => { + return colRef.get({ source: 'cache' }).then(qrySnap => { + expect(qrySnap.empty).to.be.true; + expect(qrySnap.docChanges.length).to.equal(0); + expect(qrySnap.metadata.fromCache).to.be.true; + expect(qrySnap.metadata.hasPendingWrites).to.be.false; + }); + }); + }); + + it('get non existing doc while offline with source=cache', () => { + return withTestDocAndInitialData(persistence, null, docRef => { + return ( + docRef.firestore + .disableNetwork() + // attempt to get doc. Currently, this is expected to fail. In the + // future, we might consider adding support for negative cache hits so + // that we know certain documents *don't* exist. + .then(() => docRef.get({ source: 'cache' })) + .then( + doc => { + expect.fail(); + }, + expected => {} + ) + ); + }); + }); + + it('get non existing collection while offline with source=cache', () => { + return withTestCollection(persistence, {}, colRef => { + return colRef.firestore + .disableNetwork() + .then(() => colRef.get({ source: 'cache' })) + .then(qrySnap => { + expect(qrySnap.empty).to.be.true; + expect(qrySnap.docChanges.length).to.equal(0); + expect(qrySnap.metadata.fromCache).to.be.true; + expect(qrySnap.metadata.hasPendingWrites).to.be.false; + }); + }); + }); + + it('get non existing doc while online with source=server', () => { + return withTestDocAndInitialData(persistence, null, docRef => { + return docRef.get({ source: 'server' }).then(doc => { + expect(doc.exists).to.be.false; + expect(doc.metadata.fromCache).to.be.false; + expect(doc.metadata.hasPendingWrites).to.be.false; + }); + }); + }); + + it('get non existing collection while online with source=server', () => { + return withTestCollection(persistence, {}, colRef => { + return colRef.get({ source: 'server' }).then(qrySnap => { + expect(qrySnap.empty).to.be.true; + expect(qrySnap.docChanges.length).to.equal(0); + expect(qrySnap.metadata.fromCache).to.be.false; + expect(qrySnap.metadata.hasPendingWrites).to.be.false; + }); + }); + }); + + it('get non existing doc while offline with source=server', () => { + return withTestDocAndInitialData(persistence, null, docRef => { + return ( + docRef.firestore + .disableNetwork() + // attempt to get doc. Currently, this is expected to fail. In the + // future, we might consider adding support for negative cache hits so + // that we know certain documents *don't* exist. + .then(() => docRef.get({ source: 'server' })) + .then( + doc => { + expect.fail(); + }, + expected => {} + ) + ); + }); + }); + + it('get non existing collection while offline with source=server', () => { + return withTestCollection(persistence, {}, colRef => { + return colRef.firestore + .disableNetwork() + .then(() => colRef.get({ source: 'server' })) + .then( + qrySnap => { + expect.fail(); + }, + expected => {} + ); + }); + }); +}); diff --git a/packages/firestore/test/integration/util/helpers.ts b/packages/firestore/test/integration/util/helpers.ts index 7ff819c1549..604131c5b38 100644 --- a/packages/firestore/test/integration/util/helpers.ts +++ b/packages/firestore/test/integration/util/helpers.ts @@ -87,6 +87,16 @@ export function toDataArray( return docSet.docs.map(d => d.data()); } +export function toDataMap( + docSet: firestore.QuerySnapshot +): { [field: string]: firestore.DocumentData } { + const docsData = {}; + docSet.forEach(doc => { + docsData[doc.id] = doc.data(); + }); + return docsData; +} + /** Converts a DocumentSet to an array with the id of each document */ export function toIds(docSet: firestore.QuerySnapshot): string[] { return docSet.docs.map(d => d.id); @@ -197,6 +207,28 @@ export function withTestDoc( }); } +// TODO(rsgowman): Modify withTestDoc to take in (an optional) initialData and +// fix existing usages of it. Then delete this function. This makes withTestDoc +// more analogous to withTestCollection and eliminates the pattern of +// `withTestDoc(..., docRef => { docRef.set(initialData) ...});` that otherwise is +// quite common. +export function withTestDocAndInitialData( + persistence: boolean, + initialData: firestore.DocumentData | null, + fn: (doc: firestore.DocumentReference) => Promise +): Promise { + return withTestDb(persistence, db => { + const docRef: firestore.DocumentReference = db + .collection('test-collection') + .doc(); + if (initialData) { + return docRef.set(initialData).then(() => fn(docRef)); + } else { + return fn(docRef); + } + }); +} + export function withTestCollection( persistence: boolean, docs: { [key: string]: firestore.DocumentData },