Merge branch 'firestore-transactions' of https://github.com/invertase/react-native-firebase into firestore-transactions

This commit is contained in:
Salakar 2018-02-23 09:43:51 +00:00
commit f5836d0325
8 changed files with 399 additions and 23 deletions

View File

@ -1,7 +1,9 @@
package io.invertase.firebase.firestore; package io.invertase.firebase.firestore;
import android.os.AsyncTask;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.util.Log; import android.util.Log;
import android.util.SparseArray;
import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.Promise;
@ -12,8 +14,11 @@ import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.WritableMap;
import com.google.android.gms.tasks.OnCompleteListener; import com.google.android.gms.tasks.OnCompleteListener;
import com.google.android.gms.tasks.OnFailureListener;
import com.google.android.gms.tasks.OnSuccessListener;
import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.Task;
import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseApp;
import com.google.firebase.firestore.Transaction;
import com.google.firebase.firestore.DocumentReference; import com.google.firebase.firestore.DocumentReference;
import com.google.firebase.firestore.FieldValue; import com.google.firebase.firestore.FieldValue;
import com.google.firebase.firestore.FirebaseFirestore; import com.google.firebase.firestore.FirebaseFirestore;
@ -26,11 +31,12 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import io.invertase.firebase.ErrorUtils; import io.invertase.firebase.ErrorUtils;
import io.invertase.firebase.Utils;
public class RNFirebaseFirestore extends ReactContextBaseJavaModule { public class RNFirebaseFirestore extends ReactContextBaseJavaModule {
private static final String TAG = "RNFirebaseFirestore"; private static final String TAG = "RNFirebaseFirestore";
// private SparseArray<RNFirebaseTransactionHandler> transactionHandlers = new SparseArray<>(); private SparseArray<RNFirebaseFirestoreTransactionHandler> transactionHandlers = new SparseArray<>();
RNFirebaseFirestore(ReactApplicationContext reactContext) { RNFirebaseFirestore(ReactApplicationContext reactContext) {
super(reactContext); super(reactContext);
@ -92,7 +98,7 @@ public class RNFirebaseFirestore extends ReactContextBaseJavaModule {
break; break;
case "SET": case "SET":
Map<String, Object> options = (Map) write.get("options"); Map<String, Object> options = (Map) write.get("options");
if (options != null && options.containsKey("merge") && (boolean)options.get("merge")) { if (options != null && options.containsKey("merge") && (boolean) options.get("merge")) {
batch = batch.set(ref, data, SetOptions.merge()); batch = batch.set(ref, data, SetOptions.merge());
} else { } else {
batch = batch.set(ref, data); batch = batch.set(ref, data);
@ -113,7 +119,7 @@ public class RNFirebaseFirestore extends ReactContextBaseJavaModule {
promise.resolve(null); promise.resolve(null);
} else { } else {
Log.e(TAG, "documentBatch:onComplete:failure", task.getException()); Log.e(TAG, "documentBatch:onComplete:failure", task.getException());
RNFirebaseFirestore.promiseRejectException(promise, (FirebaseFirestoreException)task.getException()); RNFirebaseFirestore.promiseRejectException(promise, (FirebaseFirestoreException) task.getException());
} }
} }
}); });
@ -160,6 +166,199 @@ public class RNFirebaseFirestore extends ReactContextBaseJavaModule {
ref.update(data, promise); ref.update(data, promise);
} }
/**
* Try clean up previous transactions on reload
*
*/
@Override
public void onCatalystInstanceDestroy() {
for (int i = 0, size = transactionHandlers.size(); i < size; i++) {
RNFirebaseFirestoreTransactionHandler transactionHandler = transactionHandlers.get(i);
if (transactionHandler != null) {
transactionHandler.abort();
}
}
transactionHandlers.clear();
}
/*
* Transaction Methods
*/
/**
* Calls the internal Firestore Transaction classes instance .get(ref) method and resolves with
* the DocumentSnapshot.
*
* @param appName
* @param transactionId
* @param path
* @param promise
*/
@ReactMethod
public void transactionGetDocument(String appName, int transactionId, String path, final Promise promise) {
RNFirebaseFirestoreTransactionHandler handler = transactionHandlers.get(transactionId);
if (handler == null) {
promise.reject(
"internal-error",
"An internal error occurred whilst attempting to find a native transaction by id."
);
} else {
DocumentReference ref = getDocumentForAppPath(appName, path).getRef();
handler.getDocument(ref, promise);
}
}
/**
* Aborts any pending signals and deletes the transaction handler.
*
* @param appName
* @param transactionId
*/
@ReactMethod
public void transactionDispose(String appName, int transactionId) {
RNFirebaseFirestoreTransactionHandler handler = transactionHandlers.get(transactionId);
if (handler != null) {
handler.abort();
transactionHandlers.delete(transactionId);
}
}
/**
* Signals to transactionHandler that the command buffer is ready.
*
* @param appName
* @param transactionId
* @param commandBuffer
*/
@ReactMethod
public void transactionApplyBuffer(String appName, int transactionId, ReadableArray commandBuffer) {
RNFirebaseFirestoreTransactionHandler handler = transactionHandlers.get(transactionId);
if (handler != null) {
handler.signalBufferReceived(commandBuffer);
}
}
/**
* Begin a new transaction via AsyncTask 's
*
* @param appName
* @param transactionId
*/
@ReactMethod
public void transactionBegin(final String appName, int transactionId) {
final RNFirebaseFirestoreTransactionHandler transactionHandler = new RNFirebaseFirestoreTransactionHandler(appName, transactionId);
transactionHandlers.put(transactionId, transactionHandler);
AsyncTask.execute(new Runnable() {
@Override
public void run() {
getFirestoreForApp(appName)
.runTransaction(new Transaction.Function<Void>() {
@Override
public Void apply(@NonNull Transaction transaction) throws FirebaseFirestoreException {
transactionHandler.setFirestoreTransaction(transaction);
// emit the update cycle to JS land using an async task
// otherwise it gets blocked by the pending lock await
AsyncTask.execute(new Runnable() {
@Override
public void run() {
WritableMap eventMap = transactionHandler.createEventMap(null, "update");
Utils.sendEvent(getReactApplicationContext(), "firestore_transaction_event", eventMap);
}
});
// wait for a signal to be received from JS land code
transactionHandler.await();
// exit early if aborted - has to throw an exception otherwise will just keep trying ...
if (transactionHandler.aborted) {
throw new FirebaseFirestoreException("abort", FirebaseFirestoreException.Code.ABORTED);
}
// exit early if timeout from bridge - has to throw an exception otherwise will just keep trying ...
if (transactionHandler.timeout) {
throw new FirebaseFirestoreException("timeout", FirebaseFirestoreException.Code.DEADLINE_EXCEEDED);
}
// process any buffered commands from JS land
ReadableArray buffer = transactionHandler.getCommandBuffer();
// exit early if no commands
if (buffer == null) {
return null;
}
for (int i = 0, size = buffer.size(); i < size; i++) {
ReadableMap data;
ReadableMap command = buffer.getMap(i);
String path = command.getString("path");
String type = command.getString("type");
RNFirebaseFirestoreDocumentReference documentReference = getDocumentForAppPath(appName, path);
switch (type) {
case "set":
data = command.getMap("data");
ReadableMap options = command.getMap("options");
Map<String, Object> setData = FirestoreSerialize.parseReadableMap(RNFirebaseFirestore.getFirestoreForApp(appName), data);
if (options != null && options.hasKey("merge") && options.getBoolean("merge")) {
transaction.set(documentReference.getRef(), setData, SetOptions.merge());
} else {
transaction.set(documentReference.getRef(), setData);
}
break;
case "update":
data = command.getMap("data");
Map<String, Object> updateData = FirestoreSerialize.parseReadableMap(RNFirebaseFirestore.getFirestoreForApp(appName), data);
transaction.update(documentReference.getRef(), updateData);
break;
case "delete":
transaction.delete(documentReference.getRef());
break;
default:
throw new IllegalArgumentException("Unknown command type at index " + i + ".");
}
}
return null;
}
})
.addOnSuccessListener(new OnSuccessListener<Void>() {
@Override
public void onSuccess(Void aVoid) {
if (!transactionHandler.aborted) {
Log.d(TAG, "Transaction onSuccess!");
WritableMap eventMap = transactionHandler.createEventMap(null, "complete");
Utils.sendEvent(getReactApplicationContext(), "firestore_transaction_event", eventMap);
}
}
})
.addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(@NonNull Exception e) {
if (!transactionHandler.aborted) {
Log.w(TAG, "Transaction onFailure.", e);
WritableMap eventMap = transactionHandler.createEventMap((FirebaseFirestoreException) e, "error");
Utils.sendEvent(getReactApplicationContext(), "firestore_transaction_event", eventMap);
}
}
});
}
});
}
/* /*
* INTERNALS/UTILS * INTERNALS/UTILS
*/ */
@ -197,7 +396,7 @@ public class RNFirebaseFirestore extends ReactContextBaseJavaModule {
* @param filters * @param filters
* @param orders * @param orders
* @param options * @param options
* @param path @return * @param path @return
*/ */
private RNFirebaseFirestoreCollectionReference getCollectionForAppPath(String appName, String path, private RNFirebaseFirestoreCollectionReference getCollectionForAppPath(String appName, String path,
ReadableArray filters, ReadableArray filters,

View File

@ -145,6 +145,10 @@ public class RNFirebaseFirestoreDocumentReference {
* INTERNALS/UTILS * INTERNALS/UTILS
*/ */
public DocumentReference getRef() {
return ref;
}
boolean hasListeners() { boolean hasListeners() {
return !documentSnapshotListeners.isEmpty(); return !documentSnapshotListeners.isEmpty();
} }

View File

@ -0,0 +1,166 @@
package io.invertase.firebase.firestore;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.WritableMap;
import com.google.firebase.firestore.DocumentReference;
import com.google.firebase.firestore.DocumentSnapshot;
import com.google.firebase.firestore.FirebaseFirestoreException;
import com.google.firebase.firestore.Transaction;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import javax.annotation.Nullable;
class RNFirebaseFirestoreTransactionHandler {
private String appName;
private long timeoutAt;
private int transactionId;
private final ReentrantLock lock;
private final Condition condition;
private ReadableArray commandBuffer;
private Transaction firestoreTransaction;
boolean aborted = false;
boolean timeout = false;
RNFirebaseFirestoreTransactionHandler(String app, int id) {
appName = app;
transactionId = id;
updateInternalTimeout();
lock = new ReentrantLock();
condition = lock.newCondition();
}
/*
* -------------
* PACKAGE API
* -------------
*/
/**
* Abort the currently in progress transaction if any.
*/
void abort() {
aborted = true;
safeUnlock();
}
/**
* Keep a ref to the Transaction instance.
*
* @param firestoreTransaction
*/
void setFirestoreTransaction(Transaction firestoreTransaction) {
this.firestoreTransaction = firestoreTransaction;
}
/**
* Signal that the transaction buffer has been received and needs to be processed.
*
* @param buffer
*/
void signalBufferReceived(ReadableArray buffer) {
lock.lock();
try {
commandBuffer = buffer;
condition.signalAll();
} finally {
safeUnlock();
}
}
/**
* Wait for signalBufferReceived to signal condition
*
* @throws InterruptedException
*/
void await() {
lock.lock();
updateInternalTimeout();
try {
while (!aborted && !timeout && !condition.await(10, TimeUnit.MILLISECONDS)) {
if (System.currentTimeMillis() > timeoutAt) timeout = true;
}
} catch (InterruptedException ie) {
// should never be interrupted
} finally {
safeUnlock();
}
}
/**
* Get the current pending command buffer.
*
* @return
*/
ReadableArray getCommandBuffer() {
return commandBuffer;
}
/**
* Get and resolve a DocumentSnapshot from transaction.get(ref);
*
* @param ref
* @param promise
*/
void getDocument(DocumentReference ref, Promise promise) {
updateInternalTimeout();
try {
DocumentSnapshot documentSnapshot = firestoreTransaction.get(ref);
WritableMap writableMap = FirestoreSerialize.snapshotToWritableMap(documentSnapshot);
promise.resolve(writableMap);
} catch (FirebaseFirestoreException firestoreException) {
WritableMap jsError = RNFirebaseFirestore.getJSError(firestoreException);
promise.reject(jsError.getString("code"), jsError.getString("message"));
}
}
/**
* Event map for `firestore_transaction_event` events.
*
* @param error
* @param type
* @return
*/
WritableMap createEventMap(@Nullable FirebaseFirestoreException error, String type) {
WritableMap eventMap = Arguments.createMap();
eventMap.putInt("id", transactionId);
eventMap.putString("appName", appName);
if (error != null) {
eventMap.putString("type", "error");
eventMap.putMap("error", RNFirebaseFirestore.getJSError(error));
} else {
eventMap.putString("type", type);
}
return eventMap;
}
/*
* -------------
* INTERNAL API
* -------------
*/
private void safeUnlock() {
if (lock.isLocked()) {
lock.unlock();
}
}
private void updateInternalTimeout() {
timeoutAt = System.currentTimeMillis() + 15000;
}
}

View File

@ -21,14 +21,13 @@ const generateTransactionId = (): number => transactionId++;
*/ */
export default class TransactionHandler { export default class TransactionHandler {
_database: Database; _database: Database;
_transactionListener: Function;
_transactions: { [number]: Object }; _transactions: { [number]: Object };
constructor(database: Database) { constructor(database: Database) {
this._transactions = {}; this._transactions = {};
this._database = database; this._database = database;
this._transactionListener = SharedEventEmitter.addListener( SharedEventEmitter.addListener(
getAppEventName(this._database, 'database_transaction_event'), getAppEventName(this._database, 'database_transaction_event'),
this._handleTransactionEvent.bind(this) this._handleTransactionEvent.bind(this)
); );

View File

@ -8,7 +8,7 @@ import { buildNativeMap } from './utils/serialize';
import type Firestore from './'; import type Firestore from './';
import type { TransactionMeta } from './TransactionHandler'; import type { TransactionMeta } from './TransactionHandler';
import type DocumentReference from './DocumentReference'; import type DocumentReference from './DocumentReference';
import type DocumentSnapshot from './DocumentSnapshot'; import DocumentSnapshot from './DocumentSnapshot';
import { isObject, isString } from '../../utils'; import { isObject, isString } from '../../utils';
import FieldPath from './FieldPath'; import FieldPath from './FieldPath';
import { getNativeModule } from '../../utils/native'; import { getNativeModule } from '../../utils/native';
@ -24,6 +24,9 @@ type SetOptions = {
merge: boolean, merge: boolean,
}; };
// TODO docs state all get requests must be made FIRST before any modifications
// TODO so need to validate that
/** /**
* @class Transaction * @class Transaction
*/ */
@ -72,10 +75,9 @@ export default class Transaction {
*/ */
get(documentRef: DocumentReference): Promise<DocumentSnapshot> { get(documentRef: DocumentReference): Promise<DocumentSnapshot> {
// todo validate doc ref // todo validate doc ref
return getNativeModule(this._firestore).transactionGetDocument( return getNativeModule(this._firestore)
this._meta.id, .transactionGetDocument(this._meta.id, documentRef.path)
documentRef.path .then(result => new DocumentSnapshot(this._firestore, result));
);
} }
/** /**
@ -102,7 +104,7 @@ export default class Transaction {
type: 'set', type: 'set',
path: documentRef.path, path: documentRef.path,
data: buildNativeMap(data), data: buildNativeMap(data),
options, options: options || {},
}); });
return this; return this;

View File

@ -37,13 +37,12 @@ type TransactionEvent = {
*/ */
export default class TransactionHandler { export default class TransactionHandler {
_firestore: Firestore; _firestore: Firestore;
_transactionListener: Function;
_pending: { [number]: TransactionMeta }; _pending: { [number]: TransactionMeta };
constructor(firestore: Firestore) { constructor(firestore: Firestore) {
this._pending = {}; this._pending = {};
this._firestore = firestore; this._firestore = firestore;
this._transactionListener = SharedEventEmitter.addListener( SharedEventEmitter.addListener(
getAppEventName(this._firestore, 'firestore_transaction_event'), getAppEventName(this._firestore, 'firestore_transaction_event'),
this._handleTransactionEvent.bind(this) this._handleTransactionEvent.bind(this)
); );
@ -68,7 +67,10 @@ export default class TransactionHandler {
reject: null, reject: null,
resolve: null, resolve: null,
updateFunction, updateFunction,
stack: new Error().stack.slice(1), stack: new Error().stack
.split('\n')
.slice(4)
.join('\n'),
}; };
meta.transaction = new Transaction(this._firestore, meta); meta.transaction = new Transaction(this._firestore, meta);
@ -92,14 +94,11 @@ export default class TransactionHandler {
* Destroys a local instance of a transaction meta * Destroys a local instance of a transaction meta
* *
* @param id * @param id
* @param pendingAbort Notify native that there's still an transaction in
* progress that needs aborting - this is to handle a JS side
* exception
* @private * @private
*/ */
_remove(id, pendingAbort = false) { _remove(id) {
// todo confirm pending arg no longer needed // todo confirm pending arg no longer needed
getNativeModule(this._firestore).transactionDispose(id, pendingAbort); getNativeModule(this._firestore).transactionDispose(id);
// TODO may need delaying to next event loop // TODO may need delaying to next event loop
delete this._pending[id]; delete this._pending[id];
} }
@ -169,11 +168,15 @@ export default class TransactionHandler {
pendingResult = await possiblePromise; pendingResult = await possiblePromise;
} }
} catch (exception) { } catch (exception) {
updateFailed = true; // in case the user rejects with nothing // exception can still be falsey if user `Promise.reject();` 's with no args
// so we track the exception with a updateFailed boolean to ensure no fall-through
updateFailed = true;
finalError = exception; finalError = exception;
} }
// reject the final promise and remove from native // reject the final promise and remove from native
// update is failed when either the users updateFunction
// throws an error or rejects a promise
if (updateFailed) { if (updateFailed) {
return reject(finalError); return reject(finalError);
} }
@ -184,7 +187,7 @@ export default class TransactionHandler {
transaction._pendingResult = pendingResult; transaction._pendingResult = pendingResult;
// send the buffered update/set/delete commands for native to process // send the buffered update/set/delete commands for native to process
return getNativeModule(this._firestore).transactionProcessUpdateResponse( return getNativeModule(this._firestore).transactionApplyBuffer(
id, id,
transaction._commandBuffer transaction._commandBuffer
); );

View File

@ -154,6 +154,7 @@ export default class Firestore extends ModuleBase {
) )
); );
} }
enableNetwork(): void { enableNetwork(): void {
throw new Error( throw new Error(
INTERNALS.STRINGS.ERROR_UNSUPPORTED_MODULE_METHOD( INTERNALS.STRINGS.ERROR_UNSUPPORTED_MODULE_METHOD(
@ -162,6 +163,7 @@ export default class Firestore extends ModuleBase {
) )
); );
} }
disableNetwork(): void { disableNetwork(): void {
throw new Error( throw new Error(
INTERNALS.STRINGS.ERROR_UNSUPPORTED_MODULE_METHOD( INTERNALS.STRINGS.ERROR_UNSUPPORTED_MODULE_METHOD(
@ -180,6 +182,7 @@ export default class Firestore extends ModuleBase {
enablePersistence(): Promise<void> { enablePersistence(): Promise<void> {
throw new Error('Persistence is enabled by default on the Firestore SDKs'); throw new Error('Persistence is enabled by default on the Firestore SDKs');
} }
settings(): void { settings(): void {
throw new Error('firebase.firestore().settings() coming soon'); throw new Error('firebase.firestore().settings() coming soon');
} }

View File

@ -51,7 +51,7 @@ export const buildNativeArray = (array: Object[]): FirestoreTypeMap[] => {
export const buildTypeMap = (value: any): FirestoreTypeMap | null => { export const buildTypeMap = (value: any): FirestoreTypeMap | null => {
const type = typeOf(value); const type = typeOf(value);
if (value === null || value === undefined) { if (value === null || value === undefined || Number.isNaN(value)) {
return { return {
type: 'null', type: 'null',
value: null, value: null,