Add transaction support. (#199)
Add transaction support to Firebase Realtime Database
diff --git a/packages/firebase_database/CHANGELOG.md b/packages/firebase_database/CHANGELOG.md
index 52188db..2927188 100644
--- a/packages/firebase_database/CHANGELOG.md
+++ b/packages/firebase_database/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.1.1
+
+* Add RTDB transaction support.
+
## 0.1.0+1
* Aligned author name with rest of repo.
diff --git a/packages/firebase_database/android/src/main/java/io/flutter/plugins/firebase/database/FirebaseDatabasePlugin.java b/packages/firebase_database/android/src/main/java/io/flutter/plugins/firebase/database/FirebaseDatabasePlugin.java
index ed9e561..1ac3c05 100644
--- a/packages/firebase_database/android/src/main/java/io/flutter/plugins/firebase/database/FirebaseDatabasePlugin.java
+++ b/packages/firebase_database/android/src/main/java/io/flutter/plugins/firebase/database/FirebaseDatabasePlugin.java
@@ -4,14 +4,20 @@
package io.flutter.plugins.firebase.database;
+import android.util.Log;
import android.util.SparseArray;
+import com.google.android.gms.tasks.Task;
+import com.google.android.gms.tasks.TaskCompletionSource;
+import com.google.android.gms.tasks.Tasks;
import com.google.firebase.database.ChildEventListener;
import com.google.firebase.database.DataSnapshot;
import com.google.firebase.database.DatabaseError;
import com.google.firebase.database.DatabaseException;
import com.google.firebase.database.DatabaseReference;
import com.google.firebase.database.FirebaseDatabase;
+import com.google.firebase.database.MutableData;
import com.google.firebase.database.Query;
+import com.google.firebase.database.Transaction;
import com.google.firebase.database.ValueEventListener;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
@@ -20,10 +26,15 @@
import io.flutter.plugin.common.PluginRegistry;
import java.util.HashMap;
import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
/** FirebaseDatabasePlugin */
public class FirebaseDatabasePlugin implements MethodCallHandler {
+ private static final String TAG = "FirebaseDatabasePlugin";
+
private final MethodChannel channel;
private static final String EVENT_TYPE_CHILD_ADDED = "_EventType.childAdded";
private static final String EVENT_TYPE_CHILD_REMOVED = "_EventType.childRemoved";
@@ -197,7 +208,7 @@
}
@Override
- public void onMethodCall(MethodCall call, final Result result) {
+ public void onMethodCall(final MethodCall call, final Result result) {
switch (call.method) {
case "FirebaseDatabase#goOnline":
{
@@ -278,6 +289,108 @@
break;
}
+ case "DatabaseReference#runTransaction":
+ {
+ final Map<String, Object> arguments = call.arguments();
+ final DatabaseReference reference = getReference(arguments);
+
+ // Initiate native transaction.
+ reference.runTransaction(
+ new Transaction.Handler() {
+ @Override
+ public Transaction.Result doTransaction(MutableData mutableData) {
+ // Tasks are used to allow native execution of doTransaction to wait while Snapshot is
+ // processed by logic on the Dart side.
+ final TaskCompletionSource<Map<String, Object>> updateMutableDataTCS =
+ new TaskCompletionSource<>();
+ final Task<Map<String, Object>> updateMutableDataTCSTask =
+ updateMutableDataTCS.getTask();
+
+ Map<String, Object> doTransactionMap = new HashMap<>();
+ doTransactionMap.put("transactionKey", arguments.get("transactionKey"));
+
+ final Map<String, Object> snapshotMap = new HashMap<>();
+ snapshotMap.put("key", mutableData.getKey());
+ snapshotMap.put("value", mutableData.getValue());
+ doTransactionMap.put("snapshot", snapshotMap);
+
+ // Return snapshot to Dart side for update.
+ channel.invokeMethod(
+ "DoTransaction",
+ doTransactionMap,
+ new MethodChannel.Result() {
+ @Override
+ @SuppressWarnings("unchecked")
+ public void success(Object result) {
+ updateMutableDataTCS.setResult((Map<String, Object>) result);
+ }
+
+ @Override
+ public void error(
+ String errorCode, String errorMessage, Object errorDetails) {
+ String exceptionMessage =
+ "Error code: "
+ + errorCode
+ + "\nError message: "
+ + errorMessage
+ + "\nError details: "
+ + errorDetails;
+ updateMutableDataTCS.setException(new Exception(exceptionMessage));
+ }
+
+ @Override
+ public void notImplemented() {
+ updateMutableDataTCS.setException(
+ new Exception("DoTransaction not implemented on Dart side."));
+ }
+ });
+
+ try {
+ // Wait for updated snapshot from the Dart side.
+ Map<String, Object> updatedSnapshotMap =
+ Tasks.await(
+ updateMutableDataTCSTask,
+ (int) arguments.get("transactionTimeout"),
+ TimeUnit.MILLISECONDS);
+ // Set value of MutableData to value returned from the Dart side.
+ mutableData.setValue(updatedSnapshotMap.get("value"));
+ } catch (ExecutionException | InterruptedException | TimeoutException e) {
+ Log.e(TAG, "Unable to commit Snapshot update. Transaction failed.", e);
+ if (e instanceof TimeoutException) {
+ Log.e(TAG, "Transaction at " + reference.toString() + " timed out.");
+ }
+ return Transaction.abort();
+ }
+ return Transaction.success(mutableData);
+ }
+
+ @Override
+ public void onComplete(
+ DatabaseError databaseError, boolean committed, DataSnapshot dataSnapshot) {
+ Map<String, Object> completionMap = new HashMap<>();
+ completionMap.put("transactionKey", arguments.get("transactionKey"));
+ if (databaseError != null) {
+ Map<String, Object> errorMap = new HashMap<>();
+ errorMap.put("code", databaseError.getCode());
+ errorMap.put("message", databaseError.getMessage());
+ errorMap.put("details", databaseError.getDetails());
+ completionMap.put("error", errorMap);
+ }
+ completionMap.put("committed", committed);
+ if (dataSnapshot != null) {
+ Map<String, Object> snapshotMap = new HashMap<>();
+ snapshotMap.put("key", dataSnapshot.getKey());
+ snapshotMap.put("value", dataSnapshot.getValue());
+ completionMap.put("snapshot", snapshotMap);
+ }
+
+ // Invoke transaction completion on the Dart side.
+ result.success(completionMap);
+ }
+ });
+ break;
+ }
+
case "Query#keepSynced":
{
Map<String, Object> arguments = call.arguments();
diff --git a/packages/firebase_database/example/lib/main.dart b/packages/firebase_database/example/lib/main.dart
index afce2a2..3ba755c 100755
--- a/packages/firebase_database/example/lib/main.dart
+++ b/packages/firebase_database/example/lib/main.dart
@@ -67,15 +67,23 @@
Future<Null> _increment() async {
await FirebaseAuth.instance.signInAnonymously();
- // TODO(jackson): This illustrates a case where transactions are needed
- final DataSnapshot snapshot = await _counterRef.once();
- setState(() {
- _counter = (snapshot.value ?? 0) + 1;
+ // Increment counter in transaction.
+ final TransactionResult transactionResult =
+ await _counterRef.runTransaction((MutableData mutableData) async {
+ mutableData.value = (mutableData.value ?? 0) + 1;
+ return mutableData;
});
- _counterRef.set(_counter);
- _messagesRef
- .push()
- .set(<String, String>{_kTestKey: '$_kTestValue $_counter'});
+
+ if (transactionResult.committed) {
+ _messagesRef.push().set(<String, String>{
+ _kTestKey: '$_kTestValue ${transactionResult.dataSnapshot.value}'
+ });
+ } else {
+ print('Transaction not committed.');
+ if (transactionResult.error != null) {
+ print(transactionResult.error.message);
+ }
+ }
}
@override
diff --git a/packages/firebase_database/ios/Classes/FirebaseDatabasePlugin.h b/packages/firebase_database/ios/Classes/FirebaseDatabasePlugin.h
index deff3a4..3a360f9 100644
--- a/packages/firebase_database/ios/Classes/FirebaseDatabasePlugin.h
+++ b/packages/firebase_database/ios/Classes/FirebaseDatabasePlugin.h
@@ -5,4 +5,7 @@
#import <Flutter/Flutter.h>
@interface FirebaseDatabasePlugin : NSObject<FlutterPlugin>
+
+@property(nonatomic) NSMutableDictionary *updatedSnapshots;
+
@end
diff --git a/packages/firebase_database/ios/Classes/FirebaseDatabasePlugin.m b/packages/firebase_database/ios/Classes/FirebaseDatabasePlugin.m
index 65a5a23..89966bc 100644
--- a/packages/firebase_database/ios/Classes/FirebaseDatabasePlugin.m
+++ b/packages/firebase_database/ios/Classes/FirebaseDatabasePlugin.m
@@ -6,6 +6,9 @@
#import <Firebase/Firebase.h>
+@interface FirebaseDatabasePlugin ()
+@end
+
@interface NSError (FlutterError)
@property(readonly, nonatomic) FlutterError *flutterError;
@end
@@ -136,6 +139,7 @@
if (![FIRApp defaultApp]) {
[FIRApp configure];
}
+ self.updatedSnapshots = [NSMutableDictionary new];
}
return self;
}
@@ -190,6 +194,69 @@
} else if ([@"DatabaseReference#setPriority" isEqualToString:call.method]) {
[getReference(call.arguments) setPriority:call.arguments[@"priority"]
withCompletionBlock:defaultCompletionBlock];
+ } else if ([@"DatabaseReference#runTransaction" isEqualToString:call.method]) {
+ [getReference(call.arguments) runTransactionBlock:^FIRTransactionResult *_Nonnull(
+ FIRMutableData *_Nonnull currentData) {
+ // Create semaphore to allow native side to wait while snapshot
+ // updates occur on the Dart side.
+ dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
+
+ NSObject *snapshot =
+ @{@"key" : currentData.key ?: [NSNull null], @"value" : currentData.value};
+
+ __block bool shouldAbort = false;
+
+ [self.channel invokeMethod:@"DoTransaction"
+ arguments:@{
+ @"transactionKey" : call.arguments[@"transactionKey"],
+ @"snapshot" : snapshot
+ }
+ result:^(id _Nullable result) {
+ if ([result isKindOfClass:[FlutterError class]]) {
+ FlutterError *flutterError = ((FlutterError *)result);
+ NSLog(@"Error code: %@", flutterError.code);
+ NSLog(@"Error message: %@", flutterError.message);
+ NSLog(@"Error details: %@", flutterError.details);
+ shouldAbort = true;
+ } else if ([result isEqual:FlutterMethodNotImplemented]) {
+ NSLog(@"DoTransaction not implemented on the Dart side.");
+ shouldAbort = true;
+ } else {
+ [self.updatedSnapshots setObject:result
+ forKey:call.arguments[@"transactionKey"]];
+ }
+ dispatch_semaphore_signal(semaphore);
+ }];
+
+ // Wait while Dart side updates the snapshot. Incoming transactionTimeout is in milliseconds
+ // so converting to nanoseconds for use with dispatch_semaphore_wait.
+ long result = dispatch_semaphore_wait(
+ semaphore, dispatch_time(DISPATCH_TIME_NOW,
+ [call.arguments[@"transactionTimeout"] integerValue] * 1000000));
+
+ if (result == 0 && !shouldAbort) {
+ // Set FIRMutableData value to value returned from the Dart side.
+ currentData.value =
+ [self.updatedSnapshots objectForKey:call.arguments[@"transactionKey"]][@"value"];
+ } else {
+ if (result != 0) {
+ NSLog(@"Transaction at %@ timed out.", [getReference(call.arguments) URL]);
+ }
+ return [FIRTransactionResult abort];
+ }
+
+ return [FIRTransactionResult successWithValue:currentData];
+ }
+ andCompletionBlock:^(NSError *_Nullable error, BOOL committed,
+ FIRDataSnapshot *_Nullable snapshot) {
+ // Invoke transaction completion on the Dart side.
+ result(@{
+ @"transactionKey" : call.arguments[@"transactionKey"],
+ @"error" : error.flutterError ?: [NSNull null],
+ @"committed" : [NSNumber numberWithBool:committed],
+ @"snapshot" : @{@"key" : snapshot.key ?: [NSNull null], @"value" : snapshot.value}
+ });
+ }];
} else if ([@"Query#observe" isEqualToString:call.method]) {
FIRDataEventType eventType = parseEventType(call.arguments[@"eventType"]);
__block FIRDatabaseHandle handle = [getQuery(call.arguments)
diff --git a/packages/firebase_database/lib/src/database_reference.dart b/packages/firebase_database/lib/src/database_reference.dart
index a668b07..ff584cc 100644
--- a/packages/firebase_database/lib/src/database_reference.dart
+++ b/packages/firebase_database/lib/src/database_reference.dart
@@ -125,6 +125,45 @@
///
/// remove() is equivalent to calling set(null)
Future<Null> remove() => set(null);
+
+ /// Performs an optimistic-concurrency transactional update to the data at
+ /// this Firebase Database location.
+ Future<TransactionResult> runTransaction(
+ TransactionHandler transactionHandler,
+ {Duration timeout: const Duration(seconds: 5)}) async {
+ assert(timeout.inMilliseconds > 0,
+ 'Transaction timeout must be more than 0 milliseconds.');
+
+ final Completer<TransactionResult> completer =
+ new Completer<TransactionResult>();
+
+ final int transactionKey = FirebaseDatabase._transactions.isEmpty
+ ? 0
+ : FirebaseDatabase._transactions.keys.last + 1;
+
+ FirebaseDatabase._transactions[transactionKey] = transactionHandler;
+
+ _database._channel
+ .invokeMethod('DatabaseReference#runTransaction', <String, dynamic>{
+ 'path': path,
+ 'transactionKey': transactionKey,
+ 'transactionTimeout': timeout.inMilliseconds
+ }).then((Map<String, dynamic> result) {
+ final DatabaseError databaseError =
+ result['error'] != null ? new DatabaseError._(result['error']) : null;
+ final bool committed = result['committed'];
+ final DataSnapshot dataSnapshot = result['snapshot'] != null
+ ? new DataSnapshot._(result['snapshot'])
+ : null;
+
+ FirebaseDatabase._transactions.remove(transactionKey);
+
+ completer.complete(
+ new TransactionResult._(databaseError, committed, dataSnapshot));
+ });
+
+ return completer.future;
+ }
}
class ServerValue {
@@ -132,3 +171,12 @@
'.sv': 'timestamp'
};
}
+
+typedef Future<MutableData> TransactionHandler(MutableData mutableData);
+
+class TransactionResult {
+ const TransactionResult._(this.error, this.committed, this.dataSnapshot);
+ final DatabaseError error;
+ final bool committed;
+ final DataSnapshot dataSnapshot;
+}
diff --git a/packages/firebase_database/lib/src/event.dart b/packages/firebase_database/lib/src/event.dart
index f3b902a..4be4c7c 100644
--- a/packages/firebase_database/lib/src/event.dart
+++ b/packages/firebase_database/lib/src/event.dart
@@ -25,7 +25,7 @@
/// A DataSnapshot contains data from a Firebase Database location.
/// Any time you read Firebase data, you receive the data as a DataSnapshot.
class DataSnapshot {
- Map<String, dynamic> _data;
+ final Map<String, dynamic> _data;
DataSnapshot._(this._data);
/// The key of the location that generated this DataSnapshot.
@@ -34,3 +34,33 @@
/// Returns the contents of this data snapshot as native types.
dynamic get value => _data['value'];
}
+
+class MutableData {
+ final Map<String, dynamic> _data;
+ @visibleForTesting
+ MutableData.private(this._data);
+
+ /// The key of the location that generated this MutableData.
+ String get key => _data['key'];
+
+ /// Returns the mutable contents of this MutableData as native types.
+ dynamic get value => _data['value'];
+ set value(dynamic newValue) => _data['value'] = newValue;
+}
+
+/// A DatabaseError contains code, message and details of a Firebase Database
+/// Error that results from a transaction operation at a Firebase Database
+/// location.
+class DatabaseError {
+ Map<String, dynamic> _data;
+ DatabaseError._(this._data);
+
+ /// One of the defined status codes, depending on the error.
+ int get code => _data['code'];
+
+ /// A human-readable description of the error.
+ String get message => _data['message'];
+
+ /// Human-readable details on the error and additional information.
+ String get details => _data['details'];
+}
diff --git a/packages/firebase_database/lib/src/firebase_database.dart b/packages/firebase_database/lib/src/firebase_database.dart
index 678f0dd..4ab151e 100644
--- a/packages/firebase_database/lib/src/firebase_database.dart
+++ b/packages/firebase_database/lib/src/firebase_database.dart
@@ -15,11 +15,24 @@
static final Map<int, StreamController<Event>> _observers =
<int, StreamController<Event>>{};
+ static final Map<int, TransactionHandler> _transactions =
+ <int, TransactionHandler>{};
+
FirebaseDatabase._() {
- _channel.setMethodCallHandler((MethodCall call) {
+ _channel.setMethodCallHandler((MethodCall call) async {
if (call.method == 'Event') {
final Event event = new Event._(call.arguments);
_observers[call.arguments['handle']].add(event);
+ } else if (call.method == 'DoTransaction') {
+ final MutableData mutableData =
+ new MutableData.private(call.arguments['snapshot']);
+ final MutableData updated =
+ await _transactions[call.arguments['transactionKey']](mutableData);
+ return <String, dynamic>{'value': updated.value};
+ } else {
+ throw new MissingPluginException(
+ '${call.method} method not implemented on the Dart side.',
+ );
}
});
}
diff --git a/packages/firebase_database/pubspec.yaml b/packages/firebase_database/pubspec.yaml
index 6d8aac1..f6e61a0 100755
--- a/packages/firebase_database/pubspec.yaml
+++ b/packages/firebase_database/pubspec.yaml
@@ -3,7 +3,7 @@
description: Firebase Database plugin for Flutter.
author: Flutter Team <flutter-dev@googlegroups.com>
homepage: https://github.com/flutter/plugins/tree/master/packages/firebase_database
-version: 0.1.0+1
+version: 0.1.1
flutter:
plugin:
diff --git a/packages/firebase_database/test/firebase_database_test.dart b/packages/firebase_database/test/firebase_database_test.dart
index 27a329d..24a87e7 100755
--- a/packages/firebase_database/test/firebase_database_test.dart
+++ b/packages/firebase_database/test/firebase_database_test.dart
@@ -28,6 +28,41 @@
return true;
case 'FirebaseDatabase#setPersistenceCacheSizeBytes':
return true;
+ case 'DatabaseReference#runTransaction':
+ Map<String, dynamic> updatedValue;
+ Future<Null> simulateEvent(
+ int transactionKey, final MutableData mutableData) async {
+ await BinaryMessages.handlePlatformMessage(
+ channel.name,
+ channel.codec.encodeMethodCall(
+ new MethodCall('DoTransaction', <String, dynamic>{
+ 'transactionKey': transactionKey,
+ 'snapshot': <String, dynamic>{
+ 'key': mutableData.key,
+ 'value': mutableData.value,
+ },
+ })),
+ (_) {
+ updatedValue = channel.codec.decodeEnvelope(_)['value'];
+ },
+ );
+ }
+
+ await simulateEvent(
+ 0,
+ new MutableData.private(<String, dynamic>{
+ 'key': 'fakeKey',
+ 'value': <String, dynamic>{'fakeKey': 'fakeValue'},
+ }));
+
+ return <String, dynamic>{
+ 'error': null,
+ 'committed': true,
+ 'snapshot': <String, dynamic>{
+ 'key': 'fakeKey',
+ 'value': updatedValue,
+ }
+ };
default:
return null;
}
@@ -142,6 +177,40 @@
]),
);
});
+
+ test('runTransaction', () async {
+ final TransactionResult transactionResult = await database
+ .reference()
+ .child('foo')
+ .runTransaction((MutableData mutableData) {
+ return new Future<MutableData>(() {
+ mutableData.value['fakeKey'] =
+ 'updated ' + mutableData.value['fakeKey'];
+ return mutableData;
+ });
+ });
+ expect(
+ log,
+ equals(<MethodCall>[
+ new MethodCall(
+ 'DatabaseReference#runTransaction', <String, dynamic>{
+ 'path': 'foo',
+ 'transactionKey': 0,
+ 'transactionTimeout': 5000
+ }),
+ ]),
+ );
+ expect(transactionResult.committed, equals(true));
+ expect(transactionResult.dataSnapshot.value,
+ equals(<String, dynamic>{'fakeKey': 'updated fakeValue'}));
+ expect(
+ database.reference().child('foo').runTransaction(
+ (MutableData mutableData) {},
+ timeout: const Duration(milliseconds: 0),
+ ),
+ throwsA(const isInstanceOf<AssertionError>()),
+ );
+ });
});
group('$Query', () {