Skip to content

Commit 96591e0

Browse files
authored
feat(firestore): named query and data bundle APIs (#6199)
1 parent c5975df commit 96591e0

File tree

17 files changed

+748
-93
lines changed

17 files changed

+748
-93
lines changed

.github/workflows/scripts/firestore.rules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ service cloud.firestore {
44
match /{document=**} {
55
allow read, write: if false;
66
}
7+
match /firestore-bundle-tests/{document=**} {
8+
allow read, write: if true;
9+
}
710
match /firestore/{document=**} {
811
allow read, write: if true;
912
}

.spellcheck.dict.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,4 @@ firebase-admin
191191
SSV
192192
CP-User
193193
Intellisense
194+
CDN

docs/firestore/usage/index.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,3 +592,27 @@ async function bootstrap() {
592592
});
593593
}
594594
```
595+
596+
## Data bundles
597+
598+
Cloud Firestore data bundles are static data files built by you from Cloud Firestore document and query snapshots,
599+
and published by you on a CDN, hosting service or other solution. Once a bundle is loaded, a client app can query documents
600+
from the local cache or the backend.
601+
602+
To load and query data bundles, use the `loadBundle` and `namedQuery` methods:
603+
604+
```js
605+
import firestore from '@react-native-firebase/firestore';
606+
607+
// load the bundle contents
608+
const response = await fetch('https://api.example.com/bundles/latest-stories');
609+
const bundle = await response.text();
610+
await firestore().loadBundle(bundle);
611+
612+
// query the results from the cache
613+
// note: omitting "source: cache" will query the Firestore backend
614+
const query = firestore().namedQuery('latest-stories-query');
615+
const snapshot = await query.get({ source: 'cache' });
616+
```
617+
618+
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

packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,14 @@ public void setAutoRetrievedSmsCodeForPhoneNumber(
245245

246246
/**
247247
* Disable app verification for the running of tests
248+
*
248249
* @param appName
249250
* @param disabled
250251
* @param promise
251252
*/
252253
@ReactMethod
253254
public void setAppVerificationDisabledForTesting(
254-
String appName, boolean disabled, Promise promise) {
255+
String appName, boolean disabled, Promise promise) {
255256
Log.d(TAG, "setAppVerificationDisabledForTesting");
256257
FirebaseApp firebaseApp = FirebaseApp.getInstance(appName);
257258
FirebaseAuth firebaseAuth = FirebaseAuth.getInstance(firebaseApp);

packages/firestore/__tests__/firestore.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,4 +329,46 @@ describe('Storage', function () {
329329
});
330330
});
331331
});
332+
333+
describe('loadBundle()', function () {
334+
it('throws if bundle is not a string', async function () {
335+
try {
336+
// @ts-ignore the type is incorrect *on purpose* to test type checking in javascript
337+
firebase.firestore().loadBundle(123);
338+
return Promise.reject(new Error('Did not throw an Error.'));
339+
} catch (e: any) {
340+
return expect(e.message).toContain("'bundle' must be a string value");
341+
}
342+
});
343+
344+
it('throws if bundle is empty string', async function () {
345+
try {
346+
firebase.firestore().loadBundle('');
347+
return Promise.reject(new Error('Did not throw an Error.'));
348+
} catch (e: any) {
349+
return expect(e.message).toContain("'bundle' must be a non-empty string");
350+
}
351+
});
352+
});
353+
354+
describe('namedQuery()', function () {
355+
it('throws if queryName is not a string', async function () {
356+
try {
357+
// @ts-ignore the type is incorrect *on purpose* to test type checking in javascript
358+
firebase.firestore().namedQuery(123);
359+
return Promise.reject(new Error('Did not throw an Error.'));
360+
} catch (e: any) {
361+
return expect(e.message).toContain("'queryName' must be a string value");
362+
}
363+
});
364+
365+
it('throws if queryName is empty string', async function () {
366+
try {
367+
firebase.firestore().namedQuery('');
368+
return Promise.reject(new Error('Did not throw an Error.'));
369+
} catch (e: any) {
370+
return expect(e.message).toContain("'queryName' must be a non-empty string");
371+
}
372+
});
373+
});
332374
});

packages/firestore/android/src/main/java/io/invertase/firebase/firestore/UniversalFirebaseFirestoreModule.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@
2424
import com.google.android.gms.tasks.Task;
2525
import com.google.android.gms.tasks.Tasks;
2626
import com.google.firebase.firestore.FirebaseFirestore;
27+
import com.google.firebase.firestore.LoadBundleTask;
2728
import io.invertase.firebase.common.UniversalFirebaseModule;
2829
import io.invertase.firebase.common.UniversalFirebasePreferences;
30+
import java.nio.charset.StandardCharsets;
2931
import java.util.Map;
3032
import java.util.Objects;
3133

@@ -104,6 +106,11 @@ Task<Void> settings(String appName, Map<String, Object> settings) {
104106
});
105107
}
106108

109+
LoadBundleTask loadBundle(String appName, String bundle) {
110+
byte[] bundleData = bundle.getBytes(StandardCharsets.UTF_8);
111+
return getFirestoreForApp(appName).loadBundle(bundleData);
112+
}
113+
107114
Task<Void> clearPersistence(String appName) {
108115
return getFirestoreForApp(appName).clearPersistence();
109116
}

packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java

Lines changed: 123 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,41 @@ public void onCatalystInstanceDestroy() {
5050
collectionSnapshotListeners.clear();
5151
}
5252

53+
@ReactMethod
54+
public void namedQueryOnSnapshot(
55+
String appName,
56+
String queryName,
57+
String type,
58+
ReadableArray filters,
59+
ReadableArray orders,
60+
ReadableMap options,
61+
int listenerId,
62+
ReadableMap listenerOptions) {
63+
if (collectionSnapshotListeners.get(listenerId) != null) {
64+
return;
65+
}
66+
67+
FirebaseFirestore firebaseFirestore = getFirestoreForApp(appName);
68+
firebaseFirestore
69+
.getNamedQuery(queryName)
70+
.addOnCompleteListener(
71+
task -> {
72+
if (task.isSuccessful()) {
73+
Query query = task.getResult();
74+
if (query == null) {
75+
sendOnSnapshotError(appName, listenerId, new NullPointerException());
76+
} else {
77+
ReactNativeFirebaseFirestoreQuery firestoreQuery =
78+
new ReactNativeFirebaseFirestoreQuery(
79+
appName, query, filters, orders, options);
80+
handleQueryOnSnapshot(firestoreQuery, appName, listenerId, listenerOptions);
81+
}
82+
} else {
83+
sendOnSnapshotError(appName, listenerId, task.getException());
84+
}
85+
});
86+
}
87+
5388
@ReactMethod
5489
public void collectionOnSnapshot(
5590
String appName,
@@ -69,34 +104,7 @@ public void collectionOnSnapshot(
69104
new ReactNativeFirebaseFirestoreQuery(
70105
appName, getQueryForFirestore(firebaseFirestore, path, type), filters, orders, options);
71106

72-
MetadataChanges metadataChanges;
73-
74-
if (listenerOptions != null
75-
&& listenerOptions.hasKey("includeMetadataChanges")
76-
&& listenerOptions.getBoolean("includeMetadataChanges")) {
77-
metadataChanges = MetadataChanges.INCLUDE;
78-
} else {
79-
metadataChanges = MetadataChanges.EXCLUDE;
80-
}
81-
82-
final EventListener<QuerySnapshot> listener =
83-
(querySnapshot, exception) -> {
84-
if (exception != null) {
85-
ListenerRegistration listenerRegistration = collectionSnapshotListeners.get(listenerId);
86-
if (listenerRegistration != null) {
87-
listenerRegistration.remove();
88-
collectionSnapshotListeners.remove(listenerId);
89-
}
90-
sendOnSnapshotError(appName, listenerId, exception);
91-
} else {
92-
sendOnSnapshotEvent(appName, listenerId, querySnapshot, metadataChanges);
93-
}
94-
};
95-
96-
ListenerRegistration listenerRegistration =
97-
firestoreQuery.query.addSnapshotListener(metadataChanges, listener);
98-
99-
collectionSnapshotListeners.put(listenerId, listenerRegistration);
107+
handleQueryOnSnapshot(firestoreQuery, appName, listenerId, listenerOptions);
100108
}
101109

102110
@ReactMethod
@@ -109,6 +117,37 @@ public void collectionOffSnapshot(String appName, int listenerId) {
109117
}
110118
}
111119

120+
@ReactMethod
121+
public void namedQueryGet(
122+
String appName,
123+
String queryName,
124+
String type,
125+
ReadableArray filters,
126+
ReadableArray orders,
127+
ReadableMap options,
128+
ReadableMap getOptions,
129+
Promise promise) {
130+
FirebaseFirestore firebaseFirestore = getFirestoreForApp(appName);
131+
firebaseFirestore
132+
.getNamedQuery(queryName)
133+
.addOnCompleteListener(
134+
task -> {
135+
if (task.isSuccessful()) {
136+
Query query = task.getResult();
137+
if (query == null) {
138+
rejectPromiseFirestoreException(promise, new NullPointerException());
139+
} else {
140+
ReactNativeFirebaseFirestoreQuery firestoreQuery =
141+
new ReactNativeFirebaseFirestoreQuery(
142+
appName, query, filters, orders, options);
143+
handleQueryGet(firestoreQuery, getSource(getOptions), promise);
144+
}
145+
} else {
146+
rejectPromiseFirestoreException(promise, task.getException());
147+
}
148+
});
149+
}
150+
112151
@ReactMethod
113152
public void collectionGet(
114153
String appName,
@@ -120,26 +159,50 @@ public void collectionGet(
120159
ReadableMap getOptions,
121160
Promise promise) {
122161
FirebaseFirestore firebaseFirestore = getFirestoreForApp(appName);
123-
ReactNativeFirebaseFirestoreQuery query =
162+
ReactNativeFirebaseFirestoreQuery firestoreQuery =
124163
new ReactNativeFirebaseFirestoreQuery(
125164
appName, getQueryForFirestore(firebaseFirestore, path, type), filters, orders, options);
165+
handleQueryGet(firestoreQuery, getSource(getOptions), promise);
166+
}
126167

127-
Source source;
168+
private void handleQueryOnSnapshot(
169+
ReactNativeFirebaseFirestoreQuery firestoreQuery,
170+
String appName,
171+
int listenerId,
172+
ReadableMap listenerOptions) {
173+
MetadataChanges metadataChanges;
128174

129-
if (getOptions != null && getOptions.hasKey("source")) {
130-
String optionsSource = getOptions.getString("source");
131-
if ("server".equals(optionsSource)) {
132-
source = Source.SERVER;
133-
} else if ("cache".equals(optionsSource)) {
134-
source = Source.CACHE;
135-
} else {
136-
source = Source.DEFAULT;
137-
}
175+
if (listenerOptions != null
176+
&& listenerOptions.hasKey("includeMetadataChanges")
177+
&& listenerOptions.getBoolean("includeMetadataChanges")) {
178+
metadataChanges = MetadataChanges.INCLUDE;
138179
} else {
139-
source = Source.DEFAULT;
180+
metadataChanges = MetadataChanges.EXCLUDE;
140181
}
141182

142-
query
183+
final EventListener<QuerySnapshot> listener =
184+
(querySnapshot, exception) -> {
185+
if (exception != null) {
186+
ListenerRegistration listenerRegistration = collectionSnapshotListeners.get(listenerId);
187+
if (listenerRegistration != null) {
188+
listenerRegistration.remove();
189+
collectionSnapshotListeners.remove(listenerId);
190+
}
191+
sendOnSnapshotError(appName, listenerId, exception);
192+
} else {
193+
sendOnSnapshotEvent(appName, listenerId, querySnapshot, metadataChanges);
194+
}
195+
};
196+
197+
ListenerRegistration listenerRegistration =
198+
firestoreQuery.query.addSnapshotListener(metadataChanges, listener);
199+
200+
collectionSnapshotListeners.put(listenerId, listenerRegistration);
201+
}
202+
203+
private void handleQueryGet(
204+
ReactNativeFirebaseFirestoreQuery firestoreQuery, Source source, Promise promise) {
205+
firestoreQuery
143206
.get(getExecutor(), source)
144207
.addOnCompleteListener(
145208
task -> {
@@ -202,4 +265,23 @@ private void sendOnSnapshotError(String appName, int listenerId, Exception excep
202265
new ReactNativeFirebaseFirestoreEvent(
203266
ReactNativeFirebaseFirestoreEvent.COLLECTION_EVENT_SYNC, body, appName, listenerId));
204267
}
268+
269+
private Source getSource(ReadableMap getOptions) {
270+
Source source;
271+
272+
if (getOptions != null && getOptions.hasKey("source")) {
273+
String optionsSource = getOptions.getString("source");
274+
if ("server".equals(optionsSource)) {
275+
source = Source.SERVER;
276+
} else if ("cache".equals(optionsSource)) {
277+
source = Source.CACHE;
278+
} else {
279+
source = Source.DEFAULT;
280+
}
281+
} else {
282+
source = Source.DEFAULT;
283+
}
284+
285+
return source;
286+
}
205287
}

packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreModule.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@
2020
import static io.invertase.firebase.common.RCTConvertFirebase.toHashMap;
2121
import static io.invertase.firebase.firestore.ReactNativeFirebaseFirestoreCommon.rejectPromiseFirestoreException;
2222

23+
import com.facebook.react.bridge.Arguments;
2324
import com.facebook.react.bridge.Promise;
2425
import com.facebook.react.bridge.ReactApplicationContext;
2526
import com.facebook.react.bridge.ReactMethod;
2627
import com.facebook.react.bridge.ReadableMap;
28+
import com.facebook.react.bridge.WritableMap;
2729
import com.google.firebase.firestore.FirebaseFirestore;
30+
import com.google.firebase.firestore.LoadBundleTaskProgress;
2831
import io.invertase.firebase.common.ReactNativeFirebaseModule;
2932

3033
public class ReactNativeFirebaseFirestoreModule extends ReactNativeFirebaseModule {
@@ -45,6 +48,21 @@ public void setLogLevel(String logLevel) {
4548
}
4649
}
4750

51+
@ReactMethod
52+
public void loadBundle(String appName, String bundle, Promise promise) {
53+
module
54+
.loadBundle(appName, bundle)
55+
.addOnCompleteListener(
56+
task -> {
57+
if (task.isSuccessful()) {
58+
LoadBundleTaskProgress progress = task.getResult();
59+
promise.resolve(taskProgressToWritableMap(progress));
60+
} else {
61+
rejectPromiseFirestoreException(promise, task.getException());
62+
}
63+
});
64+
}
65+
4866
@ReactMethod
4967
public void clearPersistence(String appName, Promise promise) {
5068
module
@@ -142,4 +160,28 @@ public void terminate(String appName, Promise promise) {
142160
}
143161
});
144162
}
163+
164+
private WritableMap taskProgressToWritableMap(LoadBundleTaskProgress progress) {
165+
WritableMap writableMap = Arguments.createMap();
166+
writableMap.putDouble("bytesLoaded", progress.getBytesLoaded());
167+
writableMap.putInt("documentsLoaded", progress.getDocumentsLoaded());
168+
writableMap.putDouble("totalBytes", progress.getTotalBytes());
169+
writableMap.putInt("totalDocuments", progress.getTotalDocuments());
170+
171+
LoadBundleTaskProgress.TaskState taskState = progress.getTaskState();
172+
String convertedState = "Running";
173+
switch (taskState) {
174+
case RUNNING:
175+
convertedState = "Running";
176+
break;
177+
case SUCCESS:
178+
convertedState = "Success";
179+
break;
180+
case ERROR:
181+
convertedState = "Error";
182+
break;
183+
}
184+
writableMap.putString("taskState", convertedState);
185+
return writableMap;
186+
}
145187
}

0 commit comments

Comments
 (0)