CombinedMapView.keys fix (#110)

Fixes https://github.com/dart-lang/collection/issues/109

Adds a custom iterable/iterator that can filter out duplicates and use that for the `CombineMapView.keys` getter. 

Updates tests to contain duplicates in maps, and ensure the keys/values from the earlier maps are the ones that are returned.

Updates the changelog and docs to no longer claim `O(maps)` for the length getter. This now requires iteration of all items and is `O(total map entries)`.

Prepare to publish as 1.14.12
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f806f93..b5db991 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,12 @@
+## 1.14.12
+
+* Fix `CombinedMapView.keys`, `CombinedMapView.length`,
+  `CombinedMapView.forEach`, and `CombinedMapView.values` to work as specified
+  and not repeat duplicate items from the maps.
+  * As a result of this fix the `length` getter now must iterate all maps in
+    order to remove duplicates and return an accurate length, so it is no
+    longer `O(maps)`.
+
 ## 1.14.11
 
 * Set max SDK version to `<3.0.0`.
diff --git a/lib/src/combined_wrappers/combined_map.dart b/lib/src/combined_wrappers/combined_map.dart
index aa86b5d..386a6de 100644
--- a/lib/src/combined_wrappers/combined_map.dart
+++ b/lib/src/combined_wrappers/combined_map.dart
@@ -13,14 +13,16 @@
 /// accessing individual map instances. In the occasion where a key occurs in
 /// multiple maps the first value is returned.
 ///
-/// The resulting map has an index operator (`[]`) and `length` property that
-/// are both `O(maps)`, rather than `O(1)`, and the map is unmodifiable - but
-/// underlying changes to these maps are still accessible from the resulting
-/// map.
+/// The resulting map has an index operator (`[]`) that is `O(maps)`, rather
+/// than `O(1)`, and the map is unmodifiable, but underlying changes to these
+/// maps are still accessible from the resulting map.
+///
+/// The `length` getter is `O(M)` where M is the total number of entries in
+/// all maps, since it has to remove duplicate entries.
 class CombinedMapView<K, V> extends UnmodifiableMapBase<K, V> {
   final Iterable<Map<K, V>> _maps;
 
-  /// Create a new combined view into multiple maps.
+  /// Create a new combined view of multiple maps.
   ///
   /// The iterable is accessed lazily so it should be collection type like
   /// [List] or [Set] rather than a lazy iterable produced by `map()` et al.
@@ -39,8 +41,11 @@
 
   /// The keys of [this].
   ///
-  /// The returned iterable has efficient `length` and `contains` operations,
-  /// based on [length] and [containsKey] of the individual maps.
+  /// The returned iterable has efficient `contains` operations, assuming the
+  /// iterables returned by the wrapped maps have efficient `contains` operations
+  /// for their `keys` iterables.
+  ///
+  /// The `length` must do deduplication and thus is not optimized.
   ///
   /// The order of iteration is defined by the individual `Map` implementations,
   /// but must be consistent between changes to the maps.
@@ -48,5 +53,45 @@
   /// Unlike most [Map] implementations, modifying an individual map while
   /// iterating the keys will _sometimes_ throw. This behavior may change in
   /// the future.
-  Iterable<K> get keys => CombinedIterableView<K>(_maps.map((m) => m.keys));
+  Iterable<K> get keys => _DeduplicatingIterableView(
+      CombinedIterableView(_maps.map((m) => m.keys)));
+}
+
+/// A view of an iterable that skips any duplicate entries.
+class _DeduplicatingIterableView<T> extends IterableBase<T> {
+  final Iterable<T> _iterable;
+
+  const _DeduplicatingIterableView(this._iterable);
+
+  Iterator<T> get iterator => _DeduplicatingIterator(_iterable.iterator);
+
+  // Special cased contains/isEmpty since many iterables have an efficient
+  // implementation instead of running through the entire iterator.
+  //
+  // Note: We do not do this for `length` because we have to remove the
+  // duplicates.
+
+  bool contains(Object element) => _iterable.contains(element);
+
+  bool get isEmpty => _iterable.isEmpty;
+}
+
+/// An iterator that wraps another iterator and skips duplicate values.
+class _DeduplicatingIterator<T> implements Iterator<T> {
+  final Iterator<T> _iterator;
+
+  final _emitted = HashSet<T>();
+
+  _DeduplicatingIterator(this._iterator);
+
+  T get current => _iterator.current;
+
+  bool moveNext() {
+    while (_iterator.moveNext()) {
+      if (_emitted.add(current)) {
+        return true;
+      }
+    }
+    return false;
+  }
 }
diff --git a/pubspec.yaml b/pubspec.yaml
index d8775b2..95759b8 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: collection
-version: 1.14.12-dev
+version: 1.14.12
 
 description: Collections and utilities functions and classes related to collections.
 author: Dart Team <misc@dartlang.org>
diff --git a/test/combined_wrapper/map_test.dart b/test/combined_wrapper/map_test.dart
index 605c0be..ad6ccc2 100644
--- a/test/combined_wrapper/map_test.dart
+++ b/test/combined_wrapper/map_test.dart
@@ -2,6 +2,8 @@
 // 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:test/test.dart';
 
@@ -11,13 +13,23 @@
   var map1 = const {1: 1, 2: 2, 3: 3};
   var map2 = const {4: 4, 5: 5, 6: 6};
   var map3 = const {7: 7, 8: 8, 9: 9};
-  var concat = <int, int>{}..addAll(map1)..addAll(map2)..addAll(map3);
+  var map4 = const {1: -1, 2: -2, 3: -3};
+  var concat = SplayTreeMap<int, int>()
+    // The duplicates map appears first here but last in the CombinedMapView
+    // which has the opposite semantics of `concat`. Keys/values should be
+    // returned from the first map that contains them.
+    ..addAll(map4)
+    ..addAll(map1)
+    ..addAll(map2)
+    ..addAll(map3);
 
   // In every way possible this should test the same as an UnmodifiableMapView.
   common.testReadMap(
-      concat, CombinedMapView([map1, map2, map3]), 'CombinedMapView');
+      concat, CombinedMapView([map1, map2, map3, map4]), 'CombinedMapView');
 
-  common.testReadMap(concat, CombinedMapView([map1, {}, map2, {}, map3, {}]),
+  common.testReadMap(
+      concat,
+      CombinedMapView([map1, {}, map2, {}, map3, {}, map4, {}]),
       'CombinedMapView (some empty)');
 
   test('should function as an empty map when no maps are passed', () {
@@ -50,4 +62,10 @@
     backing1.addAll(map1);
     expect(combined, map1);
   });
+
+  test('re-iterating keys produces same result', () {
+    var combined = CombinedMapView([map1, map2, map3, map4]);
+    var keys = combined.keys;
+    expect(keys.toList(), keys.toList());
+  });
 }