Skip to content

Commit

Permalink
feat(firestore): named query and data bundle APIs (#6199)
Browse files Browse the repository at this point in the history
  • Loading branch information
kmsbernard authored Apr 27, 2022
1 parent c5975df commit 96591e0
Show file tree
Hide file tree
Showing 17 changed files with 748 additions and 93 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/scripts/firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions .spellcheck.dict.txt
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,4 @@ firebase-admin
SSV
CP-User
Intellisense
CDN
24 changes: 24 additions & 0 deletions docs/firestore/usage/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
42 changes: 42 additions & 0 deletions packages/firestore/__tests__/firestore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -104,6 +106,11 @@ Task<Void> settings(String appName, Map<String, Object> settings) {
});
}

LoadBundleTask loadBundle(String appName, String bundle) {
byte[] bundleData = bundle.getBytes(StandardCharsets.UTF_8);
return getFirestoreForApp(appName).loadBundle(bundleData);
}

Task<Void> clearPersistence(String appName) {
return getFirestoreForApp(appName).clearPersistence();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<QuerySnapshot> 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
Expand All @@ -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,
Expand All @@ -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<QuerySnapshot> 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 -> {
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
}
Loading

1 comment on commit 96591e0

@vercel
Copy link

@vercel vercel bot commented on 96591e0 Apr 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.