Add an ObservableSet to compliment List and Map (#20)
* Add an ObservableSet implementation
* Add tests, cleanup.
* Cleanup licensing.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3549221..dc5e443 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.20.0
+
+* Add `ObservableSet`, `SetChangeRecord`, and `SetDiffer`
+
## 0.19.0
* Refactor and deprecate `ObservableMap`-specific API
diff --git a/lib/observable.dart b/lib/observable.dart
index 641fa7b..be4fe34 100644
--- a/lib/observable.dart
+++ b/lib/observable.dart
@@ -5,9 +5,15 @@
library observable;
export 'src/change_notifier.dart' show ChangeNotifier, PropertyChangeNotifier;
-export 'src/collections.dart' show ObservableList, ObservableMap;
-export 'src/differs.dart' show Differ, EqualityDiffer, ListDiffer, MapDiffer;
+export 'src/collections.dart' show ObservableList, ObservableMap, ObservableSet;
+export 'src/differs.dart'
+ show Differ, EqualityDiffer, ListDiffer, MapDiffer, SetDiffer;
export 'src/records.dart'
- show ChangeRecord, ListChangeRecord, MapChangeRecord, PropertyChangeRecord;
+ show
+ ChangeRecord,
+ ListChangeRecord,
+ MapChangeRecord,
+ PropertyChangeRecord,
+ SetChangeRecord;
export 'src/observable.dart';
export 'src/to_observable.dart';
diff --git a/lib/src/change_notifier.dart b/lib/src/change_notifier.dart
index 775df10..f5ca601 100644
--- a/lib/src/change_notifier.dart
+++ b/lib/src/change_notifier.dart
@@ -1,3 +1,7 @@
+// Copyright (c) 2016, 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:meta/meta.dart';
diff --git a/lib/src/collections.dart b/lib/src/collections.dart
index 363e925..7441831 100644
--- a/lib/src/collections.dart
+++ b/lib/src/collections.dart
@@ -1,2 +1,7 @@
+// Copyright (c) 2016, 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.
+
export 'collections/observable_list.dart';
export 'collections/observable_map.dart';
+export 'collections/observable_set.dart';
diff --git a/lib/src/collections/observable_list.dart b/lib/src/collections/observable_list.dart
index 1052df2..c8266c7 100644
--- a/lib/src/collections/observable_list.dart
+++ b/lib/src/collections/observable_list.dart
@@ -1,3 +1,7 @@
+// Copyright (c) 2016, 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:collection/collection.dart';
diff --git a/lib/src/collections/observable_map.dart b/lib/src/collections/observable_map.dart
index 50fac80..3c2ed48 100644
--- a/lib/src/collections/observable_map.dart
+++ b/lib/src/collections/observable_map.dart
@@ -1,3 +1,7 @@
+// Copyright (c) 2016, 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 'dart:collection';
@@ -13,7 +17,7 @@
/// ```
/// set grades(Map<String, int> grades) {
/// buildBook(grades);
-/// if (names is ObservableMap<String>, int) {
+/// if (names is ObservableMap<String, int>) {
/// grades.changes.listen(updateBook);
/// }
/// }
@@ -46,7 +50,7 @@
/// Creates a new observable map that contains all entries in [other].
///
- /// It will attempt to use the same backing map type if the other map is
+ /// It will attempt to use the same backing map type if the other map is
/// either a [LinkedHashMap], [SplayTreeMap], or [HashMap]. Otherwise it will
/// fall back to using a [HashMap].
factory ObservableMap.from(Map<K, V> other) {
diff --git a/lib/src/collections/observable_set.dart b/lib/src/collections/observable_set.dart
new file mode 100644
index 0000000..762a782
--- /dev/null
+++ b/lib/src/collections/observable_set.dart
@@ -0,0 +1,118 @@
+// Copyright (c) 2016, 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:collection';
+
+import 'package:collection/collection.dart';
+import 'package:observable/observable.dart';
+
+/// A [Set] that broadcasts [changes] to subscribers for efficient mutations.
+///
+/// When client code expects a read heavy/write light workload, it is often more
+/// efficient to notify _when_ something has changed, instead of constantly
+/// diffing lists to find a single change (like an inserted element). You may
+/// accept an observable set to be notified of mutations:
+/// ```
+/// set emails(Set<String> emails) {
+/// emailUsers(emails);
+/// if (names is ObservableSet<String>) {
+/// emails.changes.listen(updateEmailList);
+/// }
+/// }
+/// ```
+///
+/// *See [SetDiffer] to manually diff two lists instead*
+abstract class ObservableSet<E>
+ implements Observable<SetChangeRecord<E>>, Set<E> {
+ /// Create a new empty observable set.
+ factory ObservableSet() => new _DelegatingObservableSet<E>(new HashSet<E>());
+
+ /// Like [ObservableSet.from], but creates a new empty set.
+ factory ObservableSet.createFromType(Iterable<E> other) {
+ ObservableSet<E> result;
+ if (other is LinkedHashSet) {
+ result = new _DelegatingObservableSet<E>(new LinkedHashSet<E>());
+ } else if (result is SplayTreeSet) {
+ result = new _DelegatingObservableSet<E>(new SplayTreeSet<E>());
+ } else {
+ result = new _DelegatingObservableSet<E>(new HashSet<E>());
+ }
+ return result;
+ }
+
+ /// Create a new observable set using [set] as a backing store.
+ factory ObservableSet.delegate(Set<E> set) = _DelegatingObservableSet<E>;
+
+ /// Creates a new observable set that contains all elements in [other].
+ ///
+ /// It will attempt to use the same backing set type if the other set is
+ /// either a [LinkedHashSet], [SplayTreeSet], or [HashSet]. Otherwise it will
+ /// fall back to using a [HashSet].
+ factory ObservableSet.from(Iterable<E> other) {
+ return new ObservableSet<E>.createFromType(other)..addAll(other);
+ }
+
+ /// Creates a new observable map using a [LinkedHashSet].
+ factory ObservableSet.linked() {
+ return new _DelegatingObservableSet<E>(new LinkedHashSet<E>());
+ }
+
+ /// Creates a new observable map using a [SplayTreeSet].
+ factory ObservableSet.sorted() {
+ return new _DelegatingObservableSet<E>(new SplayTreeSet<E>());
+ }
+}
+
+class _DelegatingObservableSet<E> extends DelegatingSet<E>
+ with ChangeNotifier<SetChangeRecord<E>>
+ implements ObservableSet<E> {
+ _DelegatingObservableSet(Set<E> set) : super(set);
+
+ @override
+ bool add(E value) {
+ if (super.add(value)) {
+ if (hasObservers) {
+ notifyChange(new SetChangeRecord<E>.add(value));
+ }
+ return true;
+ }
+ return false;
+ }
+
+ @override
+ void addAll(Iterable<E> values) {
+ values.forEach(add);
+ }
+
+ @override
+ bool remove(Object value) {
+ if (super.remove(value)) {
+ if (hasObservers) {
+ notifyChange(new SetChangeRecord<E>.remove(value as E));
+ }
+ return true;
+ }
+ return false;
+ }
+
+ @override
+ void removeAll(Iterable<Object> values) {
+ values.toList().forEach(remove);
+ }
+
+ @override
+ void removeWhere(bool test(E value)) {
+ removeAll(super.where(test));
+ }
+
+ @override
+ void retainAll(Iterable<Object> elements) {
+ retainWhere(elements.toSet().contains);
+ }
+
+ @override
+ void retainWhere(bool test(E element)) {
+ removeWhere((e) => !test(e));
+ }
+}
diff --git a/lib/src/differs.dart b/lib/src/differs.dart
index 40f005e..63d5638 100644
--- a/lib/src/differs.dart
+++ b/lib/src/differs.dart
@@ -9,11 +9,11 @@
import 'package:collection/collection.dart';
import 'records.dart';
-
import 'internal.dart';
part 'differs/list_differ.dart';
part 'differs/map_differ.dart';
+part 'differs/set_differ.dart';
/// Generic comparisons between two comparable objects.
abstract class Differ<E> {
diff --git a/lib/src/differs/set_differ.dart b/lib/src/differs/set_differ.dart
new file mode 100644
index 0000000..31d93cb
--- /dev/null
+++ b/lib/src/differs/set_differ.dart
@@ -0,0 +1,31 @@
+// Copyright (c) 2016, 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.
+
+part of observable.src.differs;
+
+/// Determines differences between two maps, returning [SetChangeRecord]s.
+///
+/// While [SetChangeRecord] has more information and can be replayed they carry
+/// a more significant cost to calculate and create and should only be used when
+/// the details in the record will actually be used.
+///
+/// See also [EqualityDiffer] for a simpler comparison.
+class SetDiffer<E> implements Differ<Set<E>> {
+ const SetDiffer();
+
+ @override
+ List<SetChangeRecord<E>> diff(Set<E> oldValue, Set<E> newValue) {
+ if (identical(oldValue, newValue)) {
+ return ChangeRecord.NONE;
+ }
+ final changes = <SetChangeRecord<E>>[];
+ for (final added in newValue.difference(oldValue)) {
+ changes.add(new SetChangeRecord<E>.add(added));
+ }
+ for (final removed in oldValue.difference(newValue)) {
+ changes.add(new SetChangeRecord<E>.remove(removed));
+ }
+ return changes;
+ }
+}
diff --git a/lib/src/records.dart b/lib/src/records.dart
index 87d7d4f..40a8d4e 100644
--- a/lib/src/records.dart
+++ b/lib/src/records.dart
@@ -12,6 +12,7 @@
part 'records/list_change_record.dart';
part 'records/map_change_record.dart';
part 'records/property_change_record.dart';
+part 'records/set_change_record.dart';
/// Result of a change to an observed object.
class ChangeRecord {
diff --git a/lib/src/records/map_change_record.dart b/lib/src/records/map_change_record.dart
index cd37fc9..dc252d2 100644
--- a/lib/src/records/map_change_record.dart
+++ b/lib/src/records/map_change_record.dart
@@ -77,6 +77,6 @@
@override
String toString() {
final kind = isInsert ? 'insert' : isRemove ? 'remove' : 'set';
- return '#<MapChangeRecord $kind $key from $oldValue to $newValue';
+ return '#<MapChangeRecord $kind $key from $oldValue to $newValue>';
}
}
diff --git a/lib/src/records/set_change_record.dart b/lib/src/records/set_change_record.dart
new file mode 100644
index 0000000..2846e31
--- /dev/null
+++ b/lib/src/records/set_change_record.dart
@@ -0,0 +1,41 @@
+// Copyright (c) 2016, 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.
+
+part of observable.src.records;
+
+/// A [ChangeRecord] that denotes adding or removing values from a [Set].
+class SetChangeRecord<E> implements ChangeRecord {
+ /// Whether this is a removal operation.
+ final bool isRemove;
+
+ /// Element added or removed in the operation.
+ final E element;
+
+ const SetChangeRecord.add(this.element) : isRemove = false;
+ const SetChangeRecord.remove(this.element) : isRemove = true;
+
+ /// Whether this is an add operation.
+ bool get isAdd => !isRemove;
+
+ /// Apply the change operation to [set].
+ void apply(Set<E> set) {
+ if (isRemove) {
+ set.remove(element);
+ } else {
+ set.add(element);
+ }
+ }
+
+ @override
+ bool operator ==(Object o) =>
+ o is SetChangeRecord<E> && element == o.element && isRemove == o.isRemove;
+
+ @override
+ int get hashCode => hash2(element, isRemove);
+
+ @override
+ String toString() {
+ return '#<SetChangeRecord ${isRemove ? 'remove' : 'add'} $element>';
+ }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 8065fa9..84d9382 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
name: observable
-version: 0.19.0
+version: 0.20.0
author: Dart Team <misc@dartlang.org>
description: Support for marking objects as observable
homepage: https://github.com/dart-lang/observable
diff --git a/test/collections/observable_list_test.dart b/test/collections/observable_list_test.dart
index b8e50d0..2544677 100644
--- a/test/collections/observable_list_test.dart
+++ b/test/collections/observable_list_test.dart
@@ -1,3 +1,7 @@
+// Copyright (c) 2016, 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:observable/observable.dart';
diff --git a/test/collections/observable_set_test.dart b/test/collections/observable_set_test.dart
new file mode 100644
index 0000000..852fd17
--- /dev/null
+++ b/test/collections/observable_set_test.dart
@@ -0,0 +1,163 @@
+// Copyright (c) 2016, 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:observable/observable.dart';
+import 'package:test/test.dart';
+
+main() {
+ group('$ObservableSet', () {
+ group('set api', _runSetTests);
+ _runObservableSetTests();
+ });
+}
+
+_runSetTests() {
+ // TODO(matanl): Can we run the Set-API tests from the SDK?
+ // Any methods actually implemented by ObservableSet are below, otherwise I am
+ // relying on the test suite for DelegatingSet.
+ test('add', () {
+ final set = new ObservableSet<String>();
+ expect(set.add('item'), isTrue);
+ expect(set, ['item']);
+ expect(set.add('item'), isFalse);
+ expect(set, ['item']);
+ });
+
+ test('addAll', () {
+ final set = new ObservableSet<String>.linked();
+ set.addAll(['1', '2', '3']);
+ expect(set, ['1', '2', '3']);
+ set.addAll(['3', '4']);
+ expect(set, ['1', '2', '3', '4']);
+ });
+
+ test('remove', () {
+ final set = new ObservableSet<String>();
+ expect(set.remove('item'), isFalse);
+ expect(set, isEmpty);
+ set.add('item');
+ expect(set, isNotEmpty);
+ expect(set.remove('item'), isTrue);
+ expect(set, isEmpty);
+ });
+
+ test('removeAll', () {
+ final set = new ObservableSet<String>.from(['1', '2', '3']);
+ set.removeAll(['1', '3']);
+ expect(set, ['2']);
+ });
+
+ test('removeWhere', () {
+ final set = new ObservableSet<String>.from(['1', '2', '3']);
+ set.removeWhere((s) => s != '2');
+ expect(set, ['2']);
+ });
+
+ test('retainAll', () {
+ final set = new ObservableSet<String>.from(['1', '2', '3']);
+ set.retainAll(['2']);
+ expect(set, ['2']);
+ });
+
+ test('retainWhere', () {
+ final set = new ObservableSet<String>.from(['1', '2', '3']);
+ set.retainWhere((s) => s == '2');
+ expect(set, ['2']);
+ });
+}
+
+_runObservableSetTests() {
+ group('observable changes', () {
+ Completer<List<SetChangeRecord>> completer;
+ Set<String> previousState;
+
+ ObservableSet<String> set;
+ StreamSubscription sub;
+
+ Future next() {
+ completer = new Completer<List<SetChangeRecord>>.sync();
+ return completer.future;
+ }
+
+ Future<Null> expectChanges(List<SetChangeRecord> changes) {
+ // Applying these change records in order should make the new list.
+ for (final change in changes) {
+ change.apply(previousState);
+ }
+
+ expect(set, previousState);
+
+ // If these fail, it might be safe to update if optimized/changed.
+ return next().then((actualChanges) {
+ for (final change in changes) {
+ expect(actualChanges, contains(change));
+ }
+ });
+ }
+
+ setUp(() {
+ set = new ObservableSet.from(['a', 'b', 'c']);
+ previousState = set.toSet();
+ sub = set.changes.listen((c) {
+ if (completer?.isCompleted == false) {
+ completer.complete(c);
+ }
+ previousState = set.toSet();
+ });
+ });
+
+ tearDown(() => sub.cancel());
+
+ test('add', () async {
+ set.add('value');
+ await expectChanges([
+ new SetChangeRecord.add('value'),
+ ]);
+ });
+
+ test('addAll', () async {
+ set.addAll(['1', '2']);
+ await expectChanges([
+ new SetChangeRecord.add('1'),
+ new SetChangeRecord.add('2'),
+ ]);
+ });
+
+ test('remove', () async {
+ set.remove('a');
+ await expectChanges([
+ new SetChangeRecord.remove('a'),
+ ]);
+ });
+
+ Future expectOnlyItem() {
+ return expectChanges([
+ new SetChangeRecord.remove('a'),
+ new SetChangeRecord.remove('c'),
+ ]);
+ }
+
+ test('removeAll', () async {
+ set.removeAll(['a', 'c']);
+ await expectOnlyItem();
+ });
+
+ test('removeWhere', () async {
+ set.removeWhere((s) => s != 'b');
+ await expectOnlyItem();
+ });
+
+ test('retainAll', () async {
+ set.retainAll(['b']);
+ await expectOnlyItem();
+ });
+
+ test('retainWhere', () async {
+ set.retainWhere((s) => s == 'b');
+ await expectOnlyItem();
+ });
+ });
+}
diff --git a/test/map_differ_test.dart b/test/differs/map_differ_test.dart
similarity index 91%
rename from test/map_differ_test.dart
rename to test/differs/map_differ_test.dart
index b6a11c8..b6ebb53 100644
--- a/test/map_differ_test.dart
+++ b/test/differs/map_differ_test.dart
@@ -1,3 +1,7 @@
+// Copyright (c) 2016, 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 'package:observable/observable.dart';
import 'package:test/test.dart';
@@ -71,7 +75,7 @@
});
group('$MapChangeRecord', () {
- test('should reply an insertion', () {
+ test('should replay an insertion', () {
final map1 = {
'key-a': 'value-a',
'key-b': 'value-b',
diff --git a/test/differs/set_differ_test.dart b/test/differs/set_differ_test.dart
new file mode 100644
index 0000000..b6fbf04
--- /dev/null
+++ b/test/differs/set_differ_test.dart
@@ -0,0 +1,69 @@
+// Copyright (c) 2016, 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 'package:observable/observable.dart';
+import 'package:test/test.dart';
+
+main() {
+ group('$SetDiffer', () {
+ final diff = const SetDiffer<String>().diff;
+
+ test('should emit no changes for identical maps', () {
+ final set = new Set<String>.from(
+ new Iterable.generate(10, (i) => '$i'),
+ );
+ expect(diff(set, set), ChangeRecord.NONE);
+ });
+
+ test('should emit no changes for maps with identical content', () {
+ final set1 = new Set<String>.from(
+ new Iterable.generate(10, (i) => '$i'),
+ );
+ final set2 = new Set<String>.from(
+ new Iterable.generate(10, (i) => '$i'),
+ );
+ expect(diff(set1, set2), ChangeRecord.NONE);
+ });
+
+ test('should detect insertions', () {
+ expect(
+ diff(
+ new Set<String>.from(['a']),
+ new Set<String>.from(['a', 'b']),
+ ),
+ [
+ new SetChangeRecord.add('b'),
+ ],
+ );
+ });
+
+ test('should detect removals', () {
+ expect(
+ diff(
+ new Set<String>.from(['a', 'b']),
+ new Set<String>.from(['a']),
+ ),
+ [
+ new SetChangeRecord.remove('b'),
+ ],
+ );
+ });
+ });
+
+ group('$SetChangeRecord', () {
+ test('should reply an insertion', () {
+ final set1 = new Set<String>.from(['a', 'b']);
+ final set2 = new Set<String>.from(['a', 'b', 'c']);
+ new SetChangeRecord.add('c').apply(set1);
+ expect(set1, set2);
+ });
+
+ test('should replay a removal', () {
+ final set1 = new Set<String>.from(['a', 'b', 'c']);
+ final set2 = new Set<String>.from(['a', 'b']);
+ new SetChangeRecord.remove('c').apply(set1);
+ expect(set1, set2);
+ });
+ });
+}
diff --git a/test/observable_map_test.dart b/test/observable_map_test.dart
index 5e369d5..795328b 100644
--- a/test/observable_map_test.dart
+++ b/test/observable_map_test.dart
@@ -305,5 +305,3 @@
_insertKey(key, newValue) => new MapChangeRecord.insert(key, newValue);
_removeKey(key, oldValue) => new MapChangeRecord.remove(key, oldValue);
-
-_propChange(map, prop) => new PropertyChangeRecord(map, prop, null, null);