Add `concurrentAsyncMap` (dart-lang/stream_transform#50)
The `Stream.asyncMap` call is always serial and preserves ordering.
diff --git a/pkgs/stream_transform/CHANGELOG.md b/pkgs/stream_transform/CHANGELOG.md
index 2ad693c..a7c0a7e 100644
--- a/pkgs/stream_transform/CHANGELOG.md
+++ b/pkgs/stream_transform/CHANGELOG.md
@@ -2,6 +2,7 @@
- `asyncWhere` will now forward exceptions thrown by the callback through the
result Stream.
+- Added `concurrentAsyncMap`.
## 0.0.13
diff --git a/pkgs/stream_transform/README.md b/pkgs/stream_transform/README.md
index 5e59fc9..5e54457 100644
--- a/pkgs/stream_transform/README.md
+++ b/pkgs/stream_transform/README.md
@@ -2,8 +2,8 @@
# asyncMapBuffer
-Like `asyncMap` but events are buffered until previous events have been
-processed.
+Like `asyncMap` but events are buffered in a List until previous events have
+been processed rather than being called for each element individually.
# asyncWhere
@@ -24,6 +24,11 @@
Prevents a source stream from emitting too frequently by dropping or collecting
values that occur within a given duration.
+# concurrentAsyncMap
+
+Like `asyncMap` but the convert callback can be called with subsequent values
+before it has finished for previous values.
+
# followedBy
Appends the values of a stream after another stream finishes.
diff --git a/pkgs/stream_transform/lib/src/concurrent_async_map.dart b/pkgs/stream_transform/lib/src/concurrent_async_map.dart
new file mode 100644
index 0000000..791c84c
--- /dev/null
+++ b/pkgs/stream_transform/lib/src/concurrent_async_map.dart
@@ -0,0 +1,44 @@
+// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'from_handlers.dart';
+
+/// Like [Stream.asyncMap] but the [convert] callback may be called for an
+/// element before processing for the previous element is finished.
+///
+/// Events on the result stream will be emitted in the order that [convert]
+/// completed which may not match the order of the original stream.
+///
+/// If the source stream is a broadcast stream the result will be as well. When
+/// used with a broadcast stream behavior also differs from [Stream.asyncMap] in
+/// that the [convert] function is only called once per event, rather than once
+/// per listener per event. The [convert] callback won't be called for events
+/// while a broadcast stream has no listener.
+///
+/// Errors from the source stream are forwarded directly to the result stream.
+/// Errors during the conversion are also forwarded to the result stream.
+///
+/// The result stream will not close until the source stream closes and all
+/// pending conversions have finished.
+StreamTransformer<S, T> concurrentAsyncMap<S, T>(FutureOr<T> convert(S event)) {
+ var valuesWaiting = 0;
+ var sourceDone = false;
+ return fromHandlers(handleData: (element, sink) {
+ valuesWaiting++;
+ () async {
+ try {
+ sink.add(await convert(element));
+ } catch (e, st) {
+ sink.addError(e, st);
+ }
+ valuesWaiting--;
+ if (valuesWaiting <= 0 && sourceDone) sink.close();
+ }();
+ }, handleDone: (sink) {
+ sourceDone = true;
+ if (valuesWaiting <= 0) sink.close();
+ });
+}
diff --git a/pkgs/stream_transform/lib/stream_transform.dart b/pkgs/stream_transform/lib/stream_transform.dart
index 942391b..13b80a4 100644
--- a/pkgs/stream_transform/lib/stream_transform.dart
+++ b/pkgs/stream_transform/lib/stream_transform.dart
@@ -8,6 +8,7 @@
export 'src/buffer.dart';
export 'src/chain_transformers.dart';
export 'src/concat.dart';
+export 'src/concurrent_async_map.dart';
export 'src/debounce.dart';
export 'src/followed_by.dart';
export 'src/map.dart';
diff --git a/pkgs/stream_transform/pubspec.yaml b/pkgs/stream_transform/pubspec.yaml
index 53f7bbf..6d1d537 100644
--- a/pkgs/stream_transform/pubspec.yaml
+++ b/pkgs/stream_transform/pubspec.yaml
@@ -2,7 +2,7 @@
description: A collection of utilities to transform and manipulate streams.
author: Dart Team <misc@dartlang.org>
homepage: https://www.github.com/dart-lang/stream_transform
-version: 0.0.13
+version: 0.0.14
environment:
sdk: ">=2.0.0-dev.20.0 <2.0.0"
diff --git a/pkgs/stream_transform/test/concurrent_async_map_test.dart b/pkgs/stream_transform/test/concurrent_async_map_test.dart
new file mode 100644
index 0000000..9ea11a5
--- /dev/null
+++ b/pkgs/stream_transform/test/concurrent_async_map_test.dart
@@ -0,0 +1,160 @@
+// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:test/test.dart';
+
+import 'package:stream_transform/stream_transform.dart';
+
+void main() {
+ var streamTypes = {
+ 'single subscription': () => new StreamController(),
+ 'broadcast': () => new StreamController.broadcast()
+ };
+ StreamController streamController;
+ List emittedValues;
+ bool valuesCanceled;
+ bool isDone;
+ List errors;
+ Stream transformed;
+ StreamSubscription subscription;
+
+ List<Completer> finishWork;
+ List<dynamic> values;
+
+ Future convert(dynamic value) {
+ values.add(value);
+ var completer = new Completer();
+ finishWork.add(completer);
+ return completer.future;
+ }
+
+ for (var streamType in streamTypes.keys) {
+ group('concurrentAsyncMap for stream type: [$streamType]', () {
+ setUp(() {
+ valuesCanceled = false;
+ streamController = streamTypes[streamType]()
+ ..onCancel = () {
+ valuesCanceled = true;
+ };
+ emittedValues = [];
+ errors = [];
+ isDone = false;
+ finishWork = [];
+ values = [];
+ transformed =
+ streamController.stream.transform(concurrentAsyncMap(convert));
+ subscription = transformed
+ .listen(emittedValues.add, onError: errors.add, onDone: () {
+ isDone = true;
+ });
+ });
+
+ test('does not emit before convert finishes', () async {
+ streamController.add(1);
+ await new Future(() {});
+ expect(emittedValues, isEmpty);
+ expect(values, [1]);
+ finishWork.first.complete(1);
+ await new Future(() {});
+ expect(emittedValues, [1]);
+ });
+
+ test('allows calls to convert before the last one finished', () async {
+ streamController.add(1);
+ streamController.add(2);
+ streamController.add(3);
+ await new Future(() {});
+ expect(values, [1, 2, 3]);
+ });
+
+ test('forwards errors directly without waiting for previous convert',
+ () async {
+ streamController.add(1);
+ await new Future(() {});
+ streamController.addError('error');
+ await new Future(() {});
+ expect(errors, ['error']);
+ });
+
+ test('forwards errors which occur during the convert', () async {
+ streamController.add(1);
+ await new Future(() {});
+ finishWork.first.completeError('error');
+ await new Future(() {});
+ expect(errors, ['error']);
+ });
+
+ test('can continue handling events after an error', () async {
+ streamController.add(1);
+ await new Future(() {});
+ finishWork[0].completeError('error');
+ streamController.add(2);
+ await new Future(() {});
+ expect(values, [1, 2]);
+ finishWork[1].completeError('another');
+ await new Future(() {});
+ expect(errors, ['error', 'another']);
+ });
+
+ test('cancels value subscription when output canceled', () async {
+ expect(valuesCanceled, false);
+ await subscription.cancel();
+ expect(valuesCanceled, true);
+ });
+
+ test('closes when values end if no conversion is pending', () async {
+ expect(isDone, false);
+ await streamController.close();
+ await new Future(() {});
+ expect(isDone, true);
+ });
+
+ if (streamType == 'broadcast') {
+ test('multiple listeners all get values', () async {
+ var otherValues = [];
+ transformed.listen(otherValues.add);
+ streamController.add(1);
+ await new Future(() {});
+ finishWork.first.complete('result');
+ await new Future(() {});
+ expect(emittedValues, ['result']);
+ expect(otherValues, ['result']);
+ });
+
+ test('multiple listeners get done when values end', () async {
+ var otherDone = false;
+ transformed.listen(null, onDone: () => otherDone = true);
+ streamController.add(1);
+ await new Future(() {});
+ await streamController.close();
+ expect(isDone, false);
+ expect(otherDone, false);
+ finishWork.first.complete();
+ await new Future(() {});
+ expect(isDone, true);
+ expect(otherDone, true);
+ });
+
+ test('can cancel and relisten', () async {
+ streamController.add(1);
+ await new Future(() {});
+ finishWork.first.complete('first');
+ await new Future(() {});
+ await subscription.cancel();
+ streamController.add(2);
+ await new Future(() {});
+ subscription = transformed.listen(emittedValues.add);
+ streamController.add(3);
+ await new Future(() {});
+ expect(values, [1, 3]);
+ finishWork[1].complete('second');
+ await new Future(() {});
+ expect(emittedValues, ['first', 'second']);
+ });
+ }
+ });
+ }
+}