Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(firestore): data bundles API #6199

Merged
merged 40 commits into from
Apr 27, 2022
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
302d491
add test cases
kmsbernard Apr 14, 2022
3258320
add loadBundle interface
kmsbernard Apr 14, 2022
71480b4
install isomorphic-fetch
kmsbernard Apr 14, 2022
6021688
enhance tests
kmsbernard Apr 14, 2022
bfb19dc
implement loadBundle in android side
kmsbernard Apr 14, 2022
5e96851
update comments
kmsbernard Apr 14, 2022
82bf24c
implement loadBundle in iOS
kmsbernard Apr 14, 2022
3ab16d0
use specific charsets to get byte array
kmsbernard Apr 15, 2022
b4e9320
implement basic nameQuery
kmsbernard Apr 15, 2022
a6b115e
enhance implemantation
kmsbernard Apr 15, 2022
a64ef17
extract getSource method
kmsbernard Apr 15, 2022
58489dd
add test case for query modification
kmsbernard Apr 15, 2022
413d7ee
add type def
kmsbernard Apr 15, 2022
3ac68ca
implement basic nameQuery in iOS
kmsbernard Apr 15, 2022
9e39128
enhance test when omitting cache
kmsbernard Apr 15, 2022
00cadf0
implement basic namedQueryOnSnapshot
kmsbernard Apr 15, 2022
80637e6
abstract internal methods
kmsbernard Apr 15, 2022
f93994a
add javascript test cases
kmsbernard Apr 15, 2022
0ad081d
add documentation
kmsbernard Apr 15, 2022
00d0009
remove exclusive test
kmsbernard Apr 15, 2022
cfeaefa
fix typo
kmsbernard Apr 16, 2022
adc90e7
improve tests
kmsbernard Apr 16, 2022
6525cd3
minor document update
kmsbernard Apr 16, 2022
d56b8ee
better error handling on android
kmsbernard Apr 16, 2022
df8c7ac
better error handling on iOS
kmsbernard Apr 16, 2022
7f965b3
alter error handling on android
kmsbernard Apr 16, 2022
1bdcb2d
add LoadBundleTaskProgress interface
kmsbernard Apr 17, 2022
5b7cdb2
add test for LoadBundleTaskProgress
kmsbernard Apr 17, 2022
05fc5e9
implement LoadBundleTaskProgress
kmsbernard Apr 17, 2022
e98c398
enhance tests
kmsbernard Apr 17, 2022
3f5776b
apply linter
kmsbernard Apr 17, 2022
ba8d550
apply linter
kmsbernard Apr 17, 2022
f9483d9
tidy task progress serialization
kmsbernard Apr 17, 2022
cf6efe6
add comments on loadBundle test
kmsbernard Apr 20, 2022
2dfc6cf
add CDN to spellcheck.dict
kmsbernard Apr 20, 2022
be8dd67
alter bundle requests with static resource
kmsbernard Apr 20, 2022
c3fefab
lint: result of `yarn lint:android` for java formatting
mikehardy Apr 26, 2022
ae42e5a
Update docs/firestore/usage/index.md
mikehardy Apr 26, 2022
fa2e865
chore: httpGet no longer needed, bundle available statically
mikehardy Apr 26, 2022
bdb2a80
test(firestore, bundles): centralize before logic / stabilize across …
mikehardy Apr 26, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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 documentation on [Data bundles](https://firebase.google.com/docs/firestore/bundles)
mikehardy marked this conversation as resolved.
Show resolved Hide resolved
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) {
mikehardy marked this conversation as resolved.
Show resolved Hide resolved
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,10 +24,12 @@
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.util.Map;
import java.util.Objects;
import java.nio.charset.StandardCharsets;
mikehardy marked this conversation as resolved.
Show resolved Hide resolved

public class UniversalFirebaseFirestoreModule extends UniversalFirebaseModule {

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,15 @@
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 +49,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 +161,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