From 96591e0dac957383c503e94fbf7bf0379d5569f2 Mon Sep 17 00:00:00 2001 From: Minsik Kim Date: Wed, 27 Apr 2022 10:14:17 +0900 Subject: [PATCH] feat(firestore): named query and data bundle APIs (#6199) --- .github/workflows/scripts/firestore.rules | 3 + .spellcheck.dict.txt | 1 + docs/firestore/usage/index.md | 24 +++ .../auth/ReactNativeFirebaseAuthModule.java | 3 +- .../firestore/__tests__/firestore.test.ts | 42 +++++ .../UniversalFirebaseFirestoreModule.java | 7 + ...tiveFirebaseFirestoreCollectionModule.java | 164 ++++++++++++----- .../ReactNativeFirebaseFirestoreModule.java | 42 +++++ .../firestore/e2e/Bundle/loadBundle.e2e.js | 48 +++++ .../firestore/e2e/Bundle/namedQuery.e2e.js | 97 ++++++++++ packages/firestore/e2e/helpers.js | 54 ++++++ .../RNFBFirestoreCollectionModule.m | 168 ++++++++++++++---- .../ios/RNFBFirestore/RNFBFirestoreModule.m | 40 +++++ packages/firestore/lib/FirestoreQuery.js | 58 ++++-- packages/firestore/lib/index.d.ts | 61 +++++++ packages/firestore/lib/index.js | 25 +++ tests/app.js | 4 + 17 files changed, 748 insertions(+), 93 deletions(-) create mode 100644 packages/firestore/e2e/Bundle/loadBundle.e2e.js create mode 100644 packages/firestore/e2e/Bundle/namedQuery.e2e.js diff --git a/.github/workflows/scripts/firestore.rules b/.github/workflows/scripts/firestore.rules index b132c648f5..84924b7e66 100644 --- a/.github/workflows/scripts/firestore.rules +++ b/.github/workflows/scripts/firestore.rules @@ -4,6 +4,9 @@ service cloud.firestore { match /{document=**} { allow read, write: if false; } + match /firestore-bundle-tests/{document=**} { + allow read, write: if true; + } match /firestore/{document=**} { allow read, write: if true; } diff --git a/.spellcheck.dict.txt b/.spellcheck.dict.txt index 138a4dcd7a..44ab6974b6 100644 --- a/.spellcheck.dict.txt +++ b/.spellcheck.dict.txt @@ -191,3 +191,4 @@ firebase-admin SSV CP-User Intellisense +CDN diff --git a/docs/firestore/usage/index.md b/docs/firestore/usage/index.md index ff0e5d34d3..71dc861f56 100644 --- a/docs/firestore/usage/index.md +++ b/docs/firestore/usage/index.md @@ -592,3 +592,27 @@ async function bootstrap() { }); } ``` + +## Data bundles + +Cloud Firestore data bundles are static data files built by you from Cloud Firestore document and query snapshots, +and published by you on a CDN, hosting service or other solution. Once a bundle is loaded, a client app can query documents +from the local cache or the backend. + +To load and query data bundles, use the `loadBundle` and `namedQuery` methods: + +```js +import firestore from '@react-native-firebase/firestore'; + +// load the bundle contents +const response = await fetch('https://api.example.com/bundles/latest-stories'); +const bundle = await response.text(); +await firestore().loadBundle(bundle); + +// query the results from the cache +// note: omitting "source: cache" will query the Firestore backend +const query = firestore().namedQuery('latest-stories-query'); +const snapshot = await query.get({ source: 'cache' }); +``` + +You can build data bundles with the Admin SDK. For more information about building and serving data bundles, see Firebase Firestore main documentation on [Data bundles](https://firebase.google.com/docs/firestore/bundles) as well as their "[Bundle Solutions](https://firebase.google.com/docs/firestore/solutions/serve-bundles)" page diff --git a/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java b/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java index c88af12ed2..acfb6983ed 100644 --- a/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java +++ b/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java @@ -245,13 +245,14 @@ public void setAutoRetrievedSmsCodeForPhoneNumber( /** * Disable app verification for the running of tests + * * @param appName * @param disabled * @param promise */ @ReactMethod public void setAppVerificationDisabledForTesting( - String appName, boolean disabled, Promise promise) { + String appName, boolean disabled, Promise promise) { Log.d(TAG, "setAppVerificationDisabledForTesting"); FirebaseApp firebaseApp = FirebaseApp.getInstance(appName); FirebaseAuth firebaseAuth = FirebaseAuth.getInstance(firebaseApp); diff --git a/packages/firestore/__tests__/firestore.test.ts b/packages/firestore/__tests__/firestore.test.ts index d0ccee469b..81697fc351 100644 --- a/packages/firestore/__tests__/firestore.test.ts +++ b/packages/firestore/__tests__/firestore.test.ts @@ -329,4 +329,46 @@ describe('Storage', function () { }); }); }); + + describe('loadBundle()', function () { + it('throws if bundle is not a string', async function () { + try { + // @ts-ignore the type is incorrect *on purpose* to test type checking in javascript + firebase.firestore().loadBundle(123); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (e: any) { + return expect(e.message).toContain("'bundle' must be a string value"); + } + }); + + it('throws if bundle is empty string', async function () { + try { + firebase.firestore().loadBundle(''); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (e: any) { + return expect(e.message).toContain("'bundle' must be a non-empty string"); + } + }); + }); + + describe('namedQuery()', function () { + it('throws if queryName is not a string', async function () { + try { + // @ts-ignore the type is incorrect *on purpose* to test type checking in javascript + firebase.firestore().namedQuery(123); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (e: any) { + return expect(e.message).toContain("'queryName' must be a string value"); + } + }); + + it('throws if queryName is empty string', async function () { + try { + firebase.firestore().namedQuery(''); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (e: any) { + return expect(e.message).toContain("'queryName' must be a non-empty string"); + } + }); + }); }); diff --git a/packages/firestore/android/src/main/java/io/invertase/firebase/firestore/UniversalFirebaseFirestoreModule.java b/packages/firestore/android/src/main/java/io/invertase/firebase/firestore/UniversalFirebaseFirestoreModule.java index efb57b5898..241cee142d 100644 --- a/packages/firestore/android/src/main/java/io/invertase/firebase/firestore/UniversalFirebaseFirestoreModule.java +++ b/packages/firestore/android/src/main/java/io/invertase/firebase/firestore/UniversalFirebaseFirestoreModule.java @@ -24,8 +24,10 @@ import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.Tasks; import com.google.firebase.firestore.FirebaseFirestore; +import com.google.firebase.firestore.LoadBundleTask; import io.invertase.firebase.common.UniversalFirebaseModule; import io.invertase.firebase.common.UniversalFirebasePreferences; +import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Objects; @@ -104,6 +106,11 @@ Task settings(String appName, Map settings) { }); } + LoadBundleTask loadBundle(String appName, String bundle) { + byte[] bundleData = bundle.getBytes(StandardCharsets.UTF_8); + return getFirestoreForApp(appName).loadBundle(bundleData); + } + Task clearPersistence(String appName) { return getFirestoreForApp(appName).clearPersistence(); } diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java index d0a2d1ff6c..042401b7a6 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java @@ -50,6 +50,41 @@ public void onCatalystInstanceDestroy() { collectionSnapshotListeners.clear(); } + @ReactMethod + public void namedQueryOnSnapshot( + String appName, + String queryName, + String type, + ReadableArray filters, + ReadableArray orders, + ReadableMap options, + int listenerId, + ReadableMap listenerOptions) { + if (collectionSnapshotListeners.get(listenerId) != null) { + return; + } + + FirebaseFirestore firebaseFirestore = getFirestoreForApp(appName); + firebaseFirestore + .getNamedQuery(queryName) + .addOnCompleteListener( + task -> { + if (task.isSuccessful()) { + Query query = task.getResult(); + if (query == null) { + sendOnSnapshotError(appName, listenerId, new NullPointerException()); + } else { + ReactNativeFirebaseFirestoreQuery firestoreQuery = + new ReactNativeFirebaseFirestoreQuery( + appName, query, filters, orders, options); + handleQueryOnSnapshot(firestoreQuery, appName, listenerId, listenerOptions); + } + } else { + sendOnSnapshotError(appName, listenerId, task.getException()); + } + }); + } + @ReactMethod public void collectionOnSnapshot( String appName, @@ -69,34 +104,7 @@ public void collectionOnSnapshot( new ReactNativeFirebaseFirestoreQuery( appName, getQueryForFirestore(firebaseFirestore, path, type), filters, orders, options); - MetadataChanges metadataChanges; - - if (listenerOptions != null - && listenerOptions.hasKey("includeMetadataChanges") - && listenerOptions.getBoolean("includeMetadataChanges")) { - metadataChanges = MetadataChanges.INCLUDE; - } else { - metadataChanges = MetadataChanges.EXCLUDE; - } - - final EventListener listener = - (querySnapshot, exception) -> { - if (exception != null) { - ListenerRegistration listenerRegistration = collectionSnapshotListeners.get(listenerId); - if (listenerRegistration != null) { - listenerRegistration.remove(); - collectionSnapshotListeners.remove(listenerId); - } - sendOnSnapshotError(appName, listenerId, exception); - } else { - sendOnSnapshotEvent(appName, listenerId, querySnapshot, metadataChanges); - } - }; - - ListenerRegistration listenerRegistration = - firestoreQuery.query.addSnapshotListener(metadataChanges, listener); - - collectionSnapshotListeners.put(listenerId, listenerRegistration); + handleQueryOnSnapshot(firestoreQuery, appName, listenerId, listenerOptions); } @ReactMethod @@ -109,6 +117,37 @@ public void collectionOffSnapshot(String appName, int listenerId) { } } + @ReactMethod + public void namedQueryGet( + String appName, + String queryName, + String type, + ReadableArray filters, + ReadableArray orders, + ReadableMap options, + ReadableMap getOptions, + Promise promise) { + FirebaseFirestore firebaseFirestore = getFirestoreForApp(appName); + firebaseFirestore + .getNamedQuery(queryName) + .addOnCompleteListener( + task -> { + if (task.isSuccessful()) { + Query query = task.getResult(); + if (query == null) { + rejectPromiseFirestoreException(promise, new NullPointerException()); + } else { + ReactNativeFirebaseFirestoreQuery firestoreQuery = + new ReactNativeFirebaseFirestoreQuery( + appName, query, filters, orders, options); + handleQueryGet(firestoreQuery, getSource(getOptions), promise); + } + } else { + rejectPromiseFirestoreException(promise, task.getException()); + } + }); + } + @ReactMethod public void collectionGet( String appName, @@ -120,26 +159,50 @@ public void collectionGet( ReadableMap getOptions, Promise promise) { FirebaseFirestore firebaseFirestore = getFirestoreForApp(appName); - ReactNativeFirebaseFirestoreQuery query = + ReactNativeFirebaseFirestoreQuery firestoreQuery = new ReactNativeFirebaseFirestoreQuery( appName, getQueryForFirestore(firebaseFirestore, path, type), filters, orders, options); + handleQueryGet(firestoreQuery, getSource(getOptions), promise); + } - Source source; + private void handleQueryOnSnapshot( + ReactNativeFirebaseFirestoreQuery firestoreQuery, + String appName, + int listenerId, + ReadableMap listenerOptions) { + MetadataChanges metadataChanges; - if (getOptions != null && getOptions.hasKey("source")) { - String optionsSource = getOptions.getString("source"); - if ("server".equals(optionsSource)) { - source = Source.SERVER; - } else if ("cache".equals(optionsSource)) { - source = Source.CACHE; - } else { - source = Source.DEFAULT; - } + if (listenerOptions != null + && listenerOptions.hasKey("includeMetadataChanges") + && listenerOptions.getBoolean("includeMetadataChanges")) { + metadataChanges = MetadataChanges.INCLUDE; } else { - source = Source.DEFAULT; + metadataChanges = MetadataChanges.EXCLUDE; } - query + final EventListener listener = + (querySnapshot, exception) -> { + if (exception != null) { + ListenerRegistration listenerRegistration = collectionSnapshotListeners.get(listenerId); + if (listenerRegistration != null) { + listenerRegistration.remove(); + collectionSnapshotListeners.remove(listenerId); + } + sendOnSnapshotError(appName, listenerId, exception); + } else { + sendOnSnapshotEvent(appName, listenerId, querySnapshot, metadataChanges); + } + }; + + ListenerRegistration listenerRegistration = + firestoreQuery.query.addSnapshotListener(metadataChanges, listener); + + collectionSnapshotListeners.put(listenerId, listenerRegistration); + } + + private void handleQueryGet( + ReactNativeFirebaseFirestoreQuery firestoreQuery, Source source, Promise promise) { + firestoreQuery .get(getExecutor(), source) .addOnCompleteListener( task -> { @@ -202,4 +265,23 @@ private void sendOnSnapshotError(String appName, int listenerId, Exception excep new ReactNativeFirebaseFirestoreEvent( ReactNativeFirebaseFirestoreEvent.COLLECTION_EVENT_SYNC, body, appName, listenerId)); } + + private Source getSource(ReadableMap getOptions) { + Source source; + + if (getOptions != null && getOptions.hasKey("source")) { + String optionsSource = getOptions.getString("source"); + if ("server".equals(optionsSource)) { + source = Source.SERVER; + } else if ("cache".equals(optionsSource)) { + source = Source.CACHE; + } else { + source = Source.DEFAULT; + } + } else { + source = Source.DEFAULT; + } + + return source; + } } diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreModule.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreModule.java index 936df2eb9c..323188ee47 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreModule.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreModule.java @@ -20,11 +20,14 @@ import static io.invertase.firebase.common.RCTConvertFirebase.toHashMap; import static io.invertase.firebase.firestore.ReactNativeFirebaseFirestoreCommon.rejectPromiseFirestoreException; +import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableMap; import com.google.firebase.firestore.FirebaseFirestore; +import com.google.firebase.firestore.LoadBundleTaskProgress; import io.invertase.firebase.common.ReactNativeFirebaseModule; public class ReactNativeFirebaseFirestoreModule extends ReactNativeFirebaseModule { @@ -45,6 +48,21 @@ public void setLogLevel(String logLevel) { } } + @ReactMethod + public void loadBundle(String appName, String bundle, Promise promise) { + module + .loadBundle(appName, bundle) + .addOnCompleteListener( + task -> { + if (task.isSuccessful()) { + LoadBundleTaskProgress progress = task.getResult(); + promise.resolve(taskProgressToWritableMap(progress)); + } else { + rejectPromiseFirestoreException(promise, task.getException()); + } + }); + } + @ReactMethod public void clearPersistence(String appName, Promise promise) { module @@ -142,4 +160,28 @@ public void terminate(String appName, Promise promise) { } }); } + + private WritableMap taskProgressToWritableMap(LoadBundleTaskProgress progress) { + WritableMap writableMap = Arguments.createMap(); + writableMap.putDouble("bytesLoaded", progress.getBytesLoaded()); + writableMap.putInt("documentsLoaded", progress.getDocumentsLoaded()); + writableMap.putDouble("totalBytes", progress.getTotalBytes()); + writableMap.putInt("totalDocuments", progress.getTotalDocuments()); + + LoadBundleTaskProgress.TaskState taskState = progress.getTaskState(); + String convertedState = "Running"; + switch (taskState) { + case RUNNING: + convertedState = "Running"; + break; + case SUCCESS: + convertedState = "Success"; + break; + case ERROR: + convertedState = "Error"; + break; + } + writableMap.putString("taskState", convertedState); + return writableMap; + } } diff --git a/packages/firestore/e2e/Bundle/loadBundle.e2e.js b/packages/firestore/e2e/Bundle/loadBundle.e2e.js new file mode 100644 index 0000000000..81f7290026 --- /dev/null +++ b/packages/firestore/e2e/Bundle/loadBundle.e2e.js @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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. + * + */ +const { wipe, getBundle, BUNDLE_COLLECTION } = require('../helpers'); + +describe('firestore().loadBundle()', function () { + before(async function () { + return await wipe(); + }); + + it('loads the bundle contents', async function () { + const bundle = getBundle(); + const progress = await firebase.firestore().loadBundle(bundle); + const query = firebase.firestore().collection(BUNDLE_COLLECTION); + const snapshot = await query.get({ source: 'cache' }); + + progress.taskState.should.eql('Success'); + progress.documentsLoaded.should.eql(6); + snapshot.size.should.eql(6); + }); + + it('throws if invalid bundle', async function () { + try { + await firebase.firestore().loadBundle('not-a-bundle'); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + /* + * Due to inconsistent error throws between Android and iOS Firebase SDK, + * it is not able to test a specific error message. + * Android SDK throws 'invalid-arguments', while iOS SDK throws 'unknown' + */ + return Promise.resolve(); + } + }); +}); diff --git a/packages/firestore/e2e/Bundle/namedQuery.e2e.js b/packages/firestore/e2e/Bundle/namedQuery.e2e.js new file mode 100644 index 0000000000..fbddadb5ea --- /dev/null +++ b/packages/firestore/e2e/Bundle/namedQuery.e2e.js @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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. + * + */ +const { wipe, getBundle, BUNDLE_COLLECTION, BUNDLE_QUERY_NAME } = require('../helpers'); + +describe('firestore().namedQuery()', function () { + beforeEach(async function () { + await wipe(); + return await firebase.firestore().loadBundle(getBundle()); + }); + + it('returns bundled QuerySnapshot', async function () { + const query = firebase.firestore().namedQuery(BUNDLE_QUERY_NAME); + const snapshot = await query.get({ source: 'cache' }); + + snapshot.constructor.name.should.eql('FirestoreQuerySnapshot'); + snapshot.docs.forEach(doc => { + doc.data().number.should.equalOneOf(1, 2, 3); + doc.metadata.fromCache.should.eql(true); + }); + }); + + it('limits the number of documents in bundled QuerySnapshot', async function () { + const query = firebase.firestore().namedQuery(BUNDLE_QUERY_NAME); + const snapshot = await query.limit(1).get({ source: 'cache' }); + + snapshot.size.should.equal(1); + snapshot.docs[0].metadata.fromCache.should.eql(true); + }); + + it('returns QuerySnapshot from firestore backend when omitting "source: cache"', async function () { + const docRef = firebase.firestore().collection(BUNDLE_COLLECTION).doc(); + await docRef.set({ number: 4 }); + + const query = firebase.firestore().namedQuery(BUNDLE_QUERY_NAME); + const snapshot = await query.get(); + + snapshot.size.should.equal(1); + snapshot.docs[0].data().number.should.eql(4); + snapshot.docs[0].metadata.fromCache.should.eql(false); + }); + + it('calls onNext with QuerySnapshot from firestore backend', async function () { + const docRef = firebase.firestore().collection(BUNDLE_COLLECTION).doc(); + await docRef.set({ number: 5 }); + + const onNext = sinon.spy(); + const onError = sinon.spy(); + const unsub = firebase.firestore().namedQuery(BUNDLE_QUERY_NAME).onSnapshot(onNext, onError); + + await Utils.spyToBeCalledOnceAsync(onNext); + + onNext.should.be.calledOnce(); + onError.should.be.callCount(0); + // FIXME not stable on tests::test-reuse + // 5 on first run, 4 on reuse + // onNext.args[0][0].docs[0].data().number.should.eql(4); + unsub(); + }); + + it('throws if invalid query name', async function () { + const query = firebase.firestore().namedQuery('invalid-query'); + try { + await query.get({ source: 'cache' }); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('unknown'); + return Promise.resolve(); + } + }); + + it('calls onError if invalid query name', async function () { + const onNext = sinon.spy(); + const onError = sinon.spy(); + const unsub = firebase.firestore().namedQuery('invalid-query').onSnapshot(onNext, onError); + + await Utils.spyToBeCalledOnceAsync(onError); + + onNext.should.be.callCount(0); + onError.should.be.calledOnce(); + onError.args[0][0].message.should.containEql('unknown'); + unsub(); + }); +}); diff --git a/packages/firestore/e2e/helpers.js b/packages/firestore/e2e/helpers.js index 0b328c69cd..178b0a59e0 100644 --- a/packages/firestore/e2e/helpers.js +++ b/packages/firestore/e2e/helpers.js @@ -52,3 +52,57 @@ exports.wipe = async function wipe(debug = false) { throw e; } }; + +exports.BUNDLE_QUERY_NAME = 'named-bundle-test'; +exports.BUNDLE_COLLECTION = 'firestore-bundle-tests'; + +exports.getBundle = function getBundle() { + // Original source: http://api.rnfirebase.io/firestore/bundle + const content = ` + 151{"metadata":{"id":"firestore-bundle-tests","createTime":{"seconds":"1621252690","n + anos":143267000},"version":1,"totalDocuments":6,"totalBytes":"3500"}}265{"namedQuery" + :{"name":"named-bundle-test","bundledQuery":{"parent":"projects/react-native-firebase + -testing/databases/(default)/documents","structuredQuery":{"from":[{"collectionId":"f + irestore-bundle-tests"}]}},"readTime":{"seconds":"1621252690","nanos":143267000}}}244 + {"documentMetadata":{"name":"projects/react-native-firebase-testing/databases/(defaul + t)/documents/firestore-bundle-tests/8I4LbBaQBgOQpbiw3BA6","readTime":{"seconds":"16 + 21252690","nanos":143267000},"exists":true,"queries":["named-bundle-test"]}}287{"do + cument":{"name":"projects/react-native-firebase-testing/databases/(default)/documen + ts/firestore-bundle-tests/8I4LbBaQBgOQpbiw3BA6","fields":{"number":{"integerValue": + "1"}},"createTime":{"seconds":"1621252690","nanos":90568000},"updateTime":{"seconds + ":"1621252690","nanos":90568000}}}244{"documentMetadata":{"name":"projects/react-na + tive-firebase-testing/databases/(default)/documents/firestore-bundle-tests/IGQgUS2i + OfxbiEjPwU79","readTime":{"seconds":"1621252690","nanos":143267000},"exists":true," + queries":["named-bundle-test"]}}289{"document":{"name":"projects/react-native-fireb + ase-testing/databases/(default)/documents/firestore-bundle-tests/IGQgUS2iOfxbiEjPwU + 79","fields":{"number":{"integerValue":"2"}},"createTime":{"seconds":"1621252690"," + nanos":103781000},"updateTime":{"seconds":"1621252690","nanos":103781000}}}244{"doc + umentMetadata":{"name":"projects/react-native-firebase-testing/databases/(default)/ + documents/firestore-bundle-tests/IfIlAnixFCuxKJfjgU8R","readTime":{"seconds":"16212 + 52690","nanos":143267000},"exists":true,"queries":["named-bundle-test"]}}289{"docum + ent":{"name":"projects/react-native-firebase-testing/databases/(default)/documents/ + firestore-bundle-tests/IfIlAnixFCuxKJfjgU8R","fields":{"number":{"integerValue":"3" + }},"createTime":{"seconds":"1621252690","nanos":107789000},"updateTime":{"seconds":"1 + 621252690","nanos":107789000}}}244{"documentMetadata":{"name":"projects/react-native- + firebase-testing/databases/(default)/documents/firestore-bundle-tests/2QXGhtR75xCpN3k + p1Uup","readTime":{"seconds":"1621252690","nanos":143267000},"exists":true,"queries": + ["named-bundle-test"]}}289{"document":{"name":"projects/react-native-firebase-testing + /databases/(default)/documents/firestore-bundle-tests/2QXGhtR75xCpN3kp1Uup","fields": + {"number":{"integerValue":"2"}},"createTime":{"seconds":"1621250323","nanos":16629300 + 0},"updateTime":{"seconds":"1621250323","nanos":166293000}}}244{"documentMetadata":{" + name":"projects/react-native-firebase-testing/databases/(default)/documents/firestore + -bundle-tests/gwuvIm5uXGRk1jZGdsvP","readTime":{"seconds":"1621252690","nanos":143267 + 000},"exists":true,"queries":["named-bundle-test"]}}289{"document":{"name":"projects/ + react-native-firebase-testing/databases/(default)/documents/firestore-bundle-tests/gw + uvIm5uXGRk1jZGdsvP","fields":{"number":{"integerValue":"1"}},"createTime":{"seconds": + "1621250323","nanos":125981000},"updateTime":{"seconds":"1621250323","nanos":12598100 + 0}}}244{"documentMetadata":{"name":"projects/react-native-firebase-testing/databases/ + (default)/documents/firestore-bundle-tests/yDtWms0n3845bs5CAyEu","readTime":{"seconds + ":"1621252690","nanos":143267000},"exists":true,"queries":["named-bundle-test"]}}289{ + "document":{"name":"projects/react-native-firebase-testing/databases/(default)/docu + ments/firestore-bundle-tests/yDtWms0n3845bs5CAyEu","fields":{"number":{"integerValu + e":"3"}},"createTime":{"seconds":"1621250323","nanos":170451000},"updateTime":{"sec + onds":"1621250323","nanos":170451000}}} + `; + return content.replace(/(\r\n|\n|\r|\s)/gm, ''); +}; diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m index 1b8e7cb2dc..82538ad04d 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m @@ -61,6 +61,42 @@ - (void)invalidate { #pragma mark - #pragma mark Firebase Firestore Methods +RCT_EXPORT_METHOD(namedQueryOnSnapshot + : (FIRApp *)firebaseApp + : (NSString *)name + : (NSString *)type + : (NSArray *)filters + : (NSArray *)orders + : (NSDictionary *)options + : (nonnull NSNumber *)listenerId + : (NSDictionary *)listenerOptions) { + if (collectionSnapshotListeners[listenerId]) { + return; + } + + FIRFirestore *firestore = [RNFBFirestoreCommon getFirestoreForApp:firebaseApp]; + [[FIRFirestore firestore] getQueryNamed:name + completion:^(FIRQuery *query) { + if (query == nil) { + [self sendSnapshotError:firebaseApp + listenerId:listenerId + error:nil]; + return; + } + + RNFBFirestoreQuery *firestoreQuery = + [[RNFBFirestoreQuery alloc] initWithModifiers:firestore + query:query + filters:filters + orders:orders + options:options]; + [self handleQueryOnSnapshot:firebaseApp + firestoreQuery:firestoreQuery + listenerId:listenerId + listenerOptions:listenerOptions]; + }]; +} + RCT_EXPORT_METHOD(collectionOnSnapshot : (FIRApp *)firebaseApp : (NSString *)path @@ -82,33 +118,10 @@ - (void)invalidate { filters:filters orders:orders options:options]; - - BOOL includeMetadataChanges = NO; - if (listenerOptions[KEY_INCLUDE_METADATA_CHANGES] != nil) { - includeMetadataChanges = [listenerOptions[KEY_INCLUDE_METADATA_CHANGES] boolValue]; - } - - __weak RNFBFirestoreCollectionModule *weakSelf = self; - id listenerBlock = ^(FIRQuerySnapshot *snapshot, NSError *error) { - if (error) { - id listener = collectionSnapshotListeners[listenerId]; - if (listener) { - [listener remove]; - [collectionSnapshotListeners removeObjectForKey:listenerId]; - } - [weakSelf sendSnapshotError:firebaseApp listenerId:listenerId error:error]; - } else { - [weakSelf sendSnapshotEvent:firebaseApp - listenerId:listenerId - snapshot:snapshot - includeMetadataChanges:includeMetadataChanges]; - } - }; - - id listener = [[firestoreQuery instance] - addSnapshotListenerWithIncludeMetadataChanges:includeMetadataChanges - listener:listenerBlock]; - collectionSnapshotListeners[listenerId] = listener; + [self handleQueryOnSnapshot:firebaseApp + firestoreQuery:firestoreQuery + listenerId:listenerId + listenerOptions:listenerOptions]; } RCT_EXPORT_METHOD(collectionOffSnapshot : (FIRApp *)firebaseApp : (nonnull NSNumber *)listenerId) { @@ -119,6 +132,39 @@ - (void)invalidate { } } +RCT_EXPORT_METHOD(namedQueryGet + : (FIRApp *)firebaseApp + : (NSString *)name + : (NSString *)type + : (NSArray *)filters + : (NSArray *)orders + : (NSDictionary *)options + : (NSDictionary *)getOptions + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + FIRFirestore *firestore = [RNFBFirestoreCommon getFirestoreForApp:firebaseApp]; + [[FIRFirestore firestore] + getQueryNamed:name + completion:^(FIRQuery *query) { + if (query == nil) { + return [RNFBFirestoreCommon promiseRejectFirestoreException:reject error:nil]; + } + + RNFBFirestoreQuery *firestoreQuery = + [[RNFBFirestoreQuery alloc] initWithModifiers:firestore + query:query + filters:filters + orders:orders + options:options]; + FIRFirestoreSource source = [self getSource:getOptions]; + [self handleQueryGet:firebaseApp + firestoreQuery:firestoreQuery + source:source + resolve:resolve + reject:reject]; + }]; +} + RCT_EXPORT_METHOD(collectionGet : (FIRApp *)firebaseApp : (NSString *)path @@ -137,21 +183,51 @@ - (void)invalidate { filters:filters orders:orders options:options]; + FIRFirestoreSource source = [self getSource:getOptions]; + [self handleQueryGet:firebaseApp + firestoreQuery:firestoreQuery + source:source + resolve:resolve + reject:reject]; +} - FIRFirestoreSource source; +- (void)handleQueryOnSnapshot:(FIRApp *)firebaseApp + firestoreQuery:(RNFBFirestoreQuery *)firestoreQuery + listenerId:(nonnull NSNumber *)listenerId + listenerOptions:(NSDictionary *)listenerOptions { + BOOL includeMetadataChanges = NO; + if (listenerOptions[KEY_INCLUDE_METADATA_CHANGES] != nil) { + includeMetadataChanges = [listenerOptions[KEY_INCLUDE_METADATA_CHANGES] boolValue]; + } - if (getOptions[@"source"]) { - if ([getOptions[@"source"] isEqualToString:@"server"]) { - source = FIRFirestoreSourceServer; - } else if ([getOptions[@"source"] isEqualToString:@"cache"]) { - source = FIRFirestoreSourceCache; + __weak RNFBFirestoreCollectionModule *weakSelf = self; + id listenerBlock = ^(FIRQuerySnapshot *snapshot, NSError *error) { + if (error) { + id listener = collectionSnapshotListeners[listenerId]; + if (listener) { + [listener remove]; + [collectionSnapshotListeners removeObjectForKey:listenerId]; + } + [weakSelf sendSnapshotError:firebaseApp listenerId:listenerId error:error]; } else { - source = FIRFirestoreSourceDefault; + [weakSelf sendSnapshotEvent:firebaseApp + listenerId:listenerId + snapshot:snapshot + includeMetadataChanges:includeMetadataChanges]; } - } else { - source = FIRFirestoreSourceDefault; - } + }; + id listener = [[firestoreQuery instance] + addSnapshotListenerWithIncludeMetadataChanges:includeMetadataChanges + listener:listenerBlock]; + collectionSnapshotListeners[listenerId] = listener; +} + +- (void)handleQueryGet:(FIRApp *)firebaseApp + firestoreQuery:(RNFBFirestoreQuery *)firestoreQuery + source:(FIRFirestoreSource)source + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { [[firestoreQuery instance] getDocumentsWithSource:source completion:^(FIRQuerySnapshot *snapshot, NSError *error) { @@ -209,4 +285,22 @@ - (void)sendSnapshotError:(FIRApp *)firApp }]; } +- (FIRFirestoreSource)getSource:(NSDictionary *)getOptions { + FIRFirestoreSource source; + + if (getOptions[@"source"]) { + if ([getOptions[@"source"] isEqualToString:@"server"]) { + source = FIRFirestoreSourceServer; + } else if ([getOptions[@"source"] isEqualToString:@"cache"]) { + source = FIRFirestoreSourceCache; + } else { + source = FIRFirestoreSourceDefault; + } + } else { + source = FIRFirestoreSourceDefault; + } + + return source; +} + @end diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m index dc23aca6d8..a5326c60da 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreModule.m @@ -107,6 +107,23 @@ + (BOOL)requiresMainQueueSetup { resolve([NSNull null]); } +RCT_EXPORT_METHOD(loadBundle + : (FIRApp *)firebaseApp + : (nonnull NSString *)bundle + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + NSData *bundleData = [bundle dataUsingEncoding:NSUTF8StringEncoding]; + [[RNFBFirestoreCommon getFirestoreForApp:firebaseApp] + loadBundle:bundleData + completion:^(FIRLoadBundleTaskProgress *progress, NSError *error) { + if (error) { + [RNFBFirestoreCommon promiseRejectFirestoreException:reject error:error]; + } else { + resolve([self taskProgressToDictionary:progress]); + } + }]; +} + RCT_EXPORT_METHOD(clearPersistence : (FIRApp *)firebaseApp : (RCTPromiseResolveBlock)resolve @@ -164,4 +181,27 @@ + (BOOL)requiresMainQueueSetup { }]; } +- (NSMutableDictionary *)taskProgressToDictionary:(FIRLoadBundleTaskProgress *)progress { + NSMutableDictionary *progressMap = [[NSMutableDictionary alloc] init]; + progressMap[@"bytesLoaded"] = @(progress.bytesLoaded); + progressMap[@"documentsLoaded"] = @(progress.documentsLoaded); + progressMap[@"totalBytes"] = @(progress.totalBytes); + progressMap[@"totalDocuments"] = @(progress.totalDocuments); + + NSString *state; + switch (progress.state) { + case FIRLoadBundleTaskStateError: + state = @"Error"; + break; + case FIRLoadBundleTaskStateSuccess: + state = @"Success"; + break; + case FIRLoadBundleTaskStateInProgress: + state = @"Running"; + break; + } + progressMap[@"taskState"] = state; + return progressMap; +} + @end diff --git a/packages/firestore/lib/FirestoreQuery.js b/packages/firestore/lib/FirestoreQuery.js index 8226ec13dd..ff501f2246 100644 --- a/packages/firestore/lib/FirestoreQuery.js +++ b/packages/firestore/lib/FirestoreQuery.js @@ -31,10 +31,11 @@ import { parseSnapshotArgs } from './utils'; let _id = 0; export default class FirestoreQuery { - constructor(firestore, collectionPath, modifiers) { + constructor(firestore, collectionPath, modifiers, queryName) { this._firestore = firestore; this._collectionPath = collectionPath; this._modifiers = modifiers; + this._queryName = queryName; } get firestore() { @@ -134,6 +135,7 @@ export default class FirestoreQuery { this._firestore, this._collectionPath, this._handleQueryCursor('endAt', docOrField, fields), + this._queryName, ); } @@ -142,6 +144,7 @@ export default class FirestoreQuery { this._firestore, this._collectionPath, this._handleQueryCursor('endBefore', docOrField, fields), + this._queryName, ); } @@ -164,6 +167,19 @@ export default class FirestoreQuery { ); } + if (!isUndefined(this._queryName)) { + return this._firestore.native + .namedQueryGet( + this._queryName, + this._modifiers.type, + this._modifiers.filters, + this._modifiers.orders, + this._modifiers.options, + options, + ) + .then(data => new FirestoreQuerySnapshot(this._firestore, this, data)); + } + this._modifiers.validatelimitToLast(); return this._firestore.native @@ -219,7 +235,7 @@ export default class FirestoreQuery { const modifiers = this._modifiers._copy().limit(limit); - return new FirestoreQuery(this._firestore, this._collectionPath, modifiers); + return new FirestoreQuery(this._firestore, this._collectionPath, modifiers, this._queryName); } limitToLast(limitToLast) { @@ -231,7 +247,7 @@ export default class FirestoreQuery { const modifiers = this._modifiers._copy().limitToLast(limitToLast); - return new FirestoreQuery(this._firestore, this._collectionPath, modifiers); + return new FirestoreQuery(this._firestore, this._collectionPath, modifiers, this._queryName); } onSnapshot(...args) { @@ -285,15 +301,27 @@ export default class FirestoreQuery { this._firestore.native.collectionOffSnapshot(listenerId); }; - this._firestore.native.collectionOnSnapshot( - this._collectionPath.relativeName, - this._modifiers.type, - this._modifiers.filters, - this._modifiers.orders, - this._modifiers.options, - listenerId, - snapshotListenOptions, - ); + if (!isUndefined(this._queryName)) { + this._firestore.native.namedQueryOnSnapshot( + this._queryName, + this._modifiers.type, + this._modifiers.filters, + this._modifiers.orders, + this._modifiers.options, + listenerId, + snapshotListenOptions, + ); + } else { + this._firestore.native.collectionOnSnapshot( + this._collectionPath.relativeName, + this._modifiers.type, + this._modifiers.filters, + this._modifiers.orders, + this._modifiers.options, + listenerId, + snapshotListenOptions, + ); + } return unsubscribe; } @@ -343,7 +371,7 @@ export default class FirestoreQuery { throw new Error(`firebase.firestore().collection().orderBy() ${e.message}`); } - return new FirestoreQuery(this._firestore, this._collectionPath, modifiers); + return new FirestoreQuery(this._firestore, this._collectionPath, modifiers, this._queryName); } startAfter(docOrField, ...fields) { @@ -351,6 +379,7 @@ export default class FirestoreQuery { this._firestore, this._collectionPath, this._handleQueryCursor('startAfter', docOrField, fields), + this._queryName, ); } @@ -359,6 +388,7 @@ export default class FirestoreQuery { this._firestore, this._collectionPath, this._handleQueryCursor('startAt', docOrField, fields), + this._queryName, ); } @@ -421,6 +451,6 @@ export default class FirestoreQuery { throw new Error(`firebase.firestore().collection().where() ${e.message}`); } - return new FirestoreQuery(this._firestore, this._collectionPath, modifiers); + return new FirestoreQuery(this._firestore, this._collectionPath, modifiers, this._queryName); } } diff --git a/packages/firestore/lib/index.d.ts b/packages/firestore/lib/index.d.ts index 6b274cdbb7..8a824a62bf 100644 --- a/packages/firestore/lib/index.d.ts +++ b/packages/firestore/lib/index.d.ts @@ -1833,6 +1833,44 @@ export namespace FirebaseFirestoreTypes { ): WriteBatch; } + /** + * Represents the state of bundle loading tasks. + * + * Both 'Error' and 'Success' are sinking state: task will abort or complete and there will be no more + * updates after they are reported. + */ + export type TaskState = 'Error' | 'Running' | 'Success'; + + /** + * Represents a progress update or a final state from loading bundles. + */ + export interface LoadBundleTaskProgress { + /** + * How many bytes have been loaded. + */ + bytesLoaded: number; + + /** + * How many documents have been loaded. + */ + documentsLoaded: number; + + /** + * Current task state. + */ + taskState: TaskState; + + /** + * How many bytes are in the bundle being loaded. + */ + totalBytes: number; + + /** + * How many documents are in the bundle being loaded. + */ + totalDocuments: number; + } + /** * `firebase.firestore.X` */ @@ -2028,6 +2066,29 @@ export namespace FirebaseFirestoreTypes { * @param settings A `Settings` object. */ settings(settings: Settings): Promise; + /** + * Loads a Firestore bundle into the local cache. + * + * #### Example + * + * ```js + * const resp = await fetch('/createBundle'); + * const bundleString = await resp.text(); + * await firestore().loadBundle(bundleString); + * ``` + */ + loadBundle(bundle: string): Promise; + /** + * Reads a Firestore Query from local cache, identified by the given name. + * + * #### Example + * + * ```js + * const query = firestore().namedQuery('latest-stories-query'); + * const storiesSnap = await query.get({ source: 'cache' }); + * ``` + */ + namedQuery(name: string): Query; /** * Aimed primarily at clearing up any data cached from running tests. Needs to be executed before any database calls * are made. diff --git a/packages/firestore/lib/index.js b/packages/firestore/lib/index.js index aded37bb10..7b7e8167ff 100644 --- a/packages/firestore/lib/index.js +++ b/packages/firestore/lib/index.js @@ -84,6 +84,30 @@ class FirebaseFirestoreModule extends FirebaseModule { return new FirestoreWriteBatch(this); } + loadBundle(bundle) { + if (!isString(bundle)) { + throw new Error("firebase.firestore().loadBundle(*) 'bundle' must be a string value."); + } + + if (bundle === '') { + throw new Error("firebase.firestore().loadBundle(*) 'bundle' must be a non-empty string."); + } + + return this.native.loadBundle(bundle); + } + + namedQuery(queryName) { + if (!isString(queryName)) { + throw new Error("firebase.firestore().namedQuery(*) 'queryName' must be a string value."); + } + + if (queryName === '') { + throw new Error("firebase.firestore().namedQuery(*) 'queryName' must be a non-empty string."); + } + + return new FirestoreQuery(this, this._referencePath, new FirestoreQueryModifiers(), queryName); + } + async clearPersistence() { await this.native.clearPersistence(); } @@ -164,6 +188,7 @@ class FirebaseFirestoreModule extends FirebaseModule { this, this._referencePath.child(collectionId), new FirestoreQueryModifiers().asCollectionGroupQuery(), + undefined, ); } diff --git a/tests/app.js b/tests/app.js index 1121dbf8bb..190d89ed66 100644 --- a/tests/app.js +++ b/tests/app.js @@ -53,6 +53,10 @@ firebase.firestore().useEmulator('localhost', 8080); firebase.storage().useEmulator('localhost', 9199); firebase.functions().useFunctionsEmulator('http://localhost:5001'); +// Firestore caches docuuments locally (a great feature!) and that confounds tests +// as data from previous runs pollutes following runs until re-install the app. Clear it. +firebase.firestore().clearPersistence(); + function Root() { return (