blob: 6384f6ee4735851af401ca70b290b348f9a9b7d0 [file] [log] [blame]
// Copyright (c) 2013, 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.
library observe.src.path_observer;
import 'dart:async';
@MirrorsUsed(metaTargets: const [Reflectable, ObservableProperty],
override: 'observe.src.path_observer')
import 'dart:mirrors';
import 'package:logging/logging.dart' show Logger, Level;
import 'package:observe/observe.dart';
import 'package:observe/src/observable.dart' show objectType;
// This code is inspired by ChangeSummary:
// https://github.com/rafaelw/ChangeSummary/blob/master/change_summary.js
// ...which underlies MDV. Since we don't need the functionality of
// ChangeSummary, we just implement what we need for data bindings.
// This allows our implementation to be much simpler.
/**
* A data-bound path starting from a view-model or model object, for example
* `foo.bar.baz`.
*
* When the [values] stream is being listened to, this will observe changes to
* the object and any intermediate object along the path, and send [values]
* accordingly. When all listeners are unregistered it will stop observing
* the objects.
*
* This class is used to implement [Node.bind] and similar functionality.
*/
// TODO(jmesserly): consider specialized subclasses for:
// * empty path
// * "value"
// * single token in path, e.g. "foo"
class PathObserver extends ChangeNotifier {
/** The path string. */
final String path;
/** True if the path is valid, otherwise false. */
final bool _isValid;
final List<Object> _segments;
List<Object> _values;
List<StreamSubscription> _subs;
final Function _computeValue;
/**
* Observes [path] on [object] for changes. This returns an object that can be
* used to get the changes and get/set the value at this path.
*
* You can optionally use [computeValue] to apply a function to the result of
* evaluating the path. The function should be pure, as PathObserver will not
* know to observe any of its dependencies. If you need to observe mutliple
* values, use [CompoundPathObserver] instead.
*
* See [PathObserver.bindSync] and [PathObserver.value].
*/
PathObserver(Object object, String path, {computeValue(newValue)})
: path = path,
_computeValue = computeValue,
_isValid = _isPathValid(path),
_segments = <Object>[] {
if (_isValid) {
for (var segment in path.trim().split('.')) {
if (segment == '') continue;
var index = int.parse(segment, radix: 10, onError: (_) => null);
_segments.add(index != null ? index : new Symbol(segment));
}
}
// Initialize arrays.
// Note that the path itself can't change after it is initially
// constructed, even though the objects along the path can change.
_values = new List<Object>(_segments.length + 1);
// If we have an empty path, we need to apply the transformation function
// to the value. The "value" property should always show the transformed
// value.
if (_segments.isEmpty && computeValue != null) {
object = computeValue(object);
}
_values[0] = object;
_subs = new List<StreamSubscription>(_segments.length);
}
/** The object being observed. If the path is empty this will be [value]. */
get object => _values[0];
/** Gets the last reported value at this path. */
@reflectable get value {
if (!_isValid) return null;
if (!hasObservers) _updateValues();
return _values.last;
}
/** Sets the value at this path. */
@reflectable void set value(Object newValue) {
int len = _segments.length;
// TODO(jmesserly): throw if property cannot be set?
// MDV seems tolerant of these errors.
if (len == 0) return;
if (!hasObservers) _updateValues(end: len - 1);
if (_setObjectProperty(_values[len - 1], _segments[len - 1], newValue)) {
// Technically, this would get updated asynchronously via a change record.
// However, it is nice if calling the getter will yield the same value
// that was just set. So we use this opportunity to update our cache.
_values[len] = newValue;
}
}
/**
* Invokes the [callback] immediately with the current [value], and every time
* the value changes. This is useful for bindings, which want to be up-to-date
* immediately and stay bound to the value of the path.
*/
StreamSubscription bindSync(void callback(value)) {
var result = changes.listen((records) { callback(value); });
callback(value);
return result;
}
void observed() {
super.observed();
_updateValues();
_observePath();
}
void unobserved() {
for (int i = 0; i < _subs.length; i++) {
if (_subs[i] != null) {
_subs[i].cancel();
_subs[i] = null;
}
}
super.unobserved();
}
// TODO(jmesserly): should we be caching these values if not observing?
void _updateValues({int end}) {
if (end == null) end = _segments.length;
int last = _segments.length - 1;
for (int i = 0; i < end; i++) {
var newValue = _getObjectProperty(_values[i], _segments[i]);
if (i == last && _computeValue != null) {
newValue = _computeValue(newValue);
}
_values[i + 1] = newValue;
}
}
void _updateObservedValues({int start: 0}) {
var oldValue, newValue;
for (int i = start, last = _segments.length - 1; i <= last; i++) {
oldValue = _values[i + 1];
newValue = _getObjectProperty(_values[i], _segments[i]);
if (i == last && _computeValue != null) {
newValue = _computeValue(newValue);
}
if (identical(oldValue, newValue)) {
_observePath(start, i);
return;
}
_values[i + 1] = newValue;
}
_observePath(start);
notifyPropertyChange(#value, oldValue, newValue);
}
void _observePath([int start = 0, int end]) {
if (end == null) end = _segments.length;
for (int i = start; i < end; i++) {
if (_subs[i] != null) _subs[i].cancel();
_observeIndex(i);
}
}
void _observeIndex(int i) {
final object = _values[i];
if (object is Observable) {
// TODO(jmesserly): rather than allocating a new closure for each
// property, we could try and have one for the entire path. However we'd
// need to do a linear scan to find the index as soon as we got a change.
// Also we need to fix ListChangeRecord and MapChangeRecord to contain
// the target. Not sure if it's worth it.
_subs[i] = object.changes.listen((List<ChangeRecord> records) {
for (var record in records) {
if (_changeRecordMatches(record, _segments[i])) {
_updateObservedValues(start: i);
return;
}
}
});
}
}
}
bool _changeRecordMatches(record, key) {
if (record is ListChangeRecord) {
return key is int && (record as ListChangeRecord).indexChanged(key);
}
if (record is PropertyChangeRecord) {
return (record as PropertyChangeRecord).name == key;
}
if (record is MapChangeRecord) {
if (key is Symbol) key = MirrorSystem.getName(key);
return (record as MapChangeRecord).key == key;
}
return false;
}
_getObjectProperty(object, property) {
if (object == null) return null;
if (property is int) {
if (object is List && property >= 0 && property < object.length) {
return object[property];
}
} else if (property is Symbol) {
var mirror = reflect(object);
final type = mirror.type;
try {
if (_maybeHasGetter(type, property)) {
return mirror.getField(property).reflectee;
}
// Support indexer if available, e.g. Maps or polymer_expressions Scope.
// This is the default syntax used by polymer/nodebind and
// polymer/observe-js PathObserver.
if (_hasMethod(type, const Symbol('[]'))) {
return object[MirrorSystem.getName(property)];
}
} on NoSuchMethodError catch (e) {
// Rethrow, unless the type implements noSuchMethod, in which case we
// interpret the exception as a signal that the method was not found.
if (!_hasMethod(type, #noSuchMethod)) rethrow;
}
}
if (_logger.isLoggable(Level.FINER)) {
_logger.log("can't get $property in $object");
}
return null;
}
bool _setObjectProperty(object, property, value) {
if (object == null) return false;
if (property is int) {
if (object is List && property >= 0 && property < object.length) {
object[property] = value;
return true;
}
} else if (property is Symbol) {
var mirror = reflect(object);
final type = mirror.type;
try {
if (_maybeHasSetter(type, property)) {
mirror.setField(property, value);
return true;
}
// Support indexer if available, e.g. Maps or polymer_expressions Scope.
if (_hasMethod(type, const Symbol('[]='))) {
object[MirrorSystem.getName(property)] = value;
return true;
}
} on NoSuchMethodError catch (e) {
if (!_hasMethod(type, #noSuchMethod)) rethrow;
}
}
if (_logger.isLoggable(Level.FINER)) {
_logger.log("can't set $property in $object");
}
return false;
}
bool _maybeHasGetter(ClassMirror type, Symbol name) {
while (type != objectType) {
final members = type.members;
if (members.containsKey(name)) return true;
if (members.containsKey(#noSuchMethod)) return true;
type = _safeSuperclass(type);
}
return false;
}
// TODO(jmesserly): workaround for:
// https://code.google.com/p/dart/issues/detail?id=10029
Symbol _setterName(Symbol getter) =>
new Symbol('${MirrorSystem.getName(getter)}=');
bool _maybeHasSetter(ClassMirror type, Symbol name) {
var setterName = _setterName(name);
while (type != objectType) {
final members = type.members;
if (members[name] is VariableMirror) return true;
if (members.containsKey(setterName)) return true;
if (members.containsKey(#noSuchMethod)) return true;
type = _safeSuperclass(type);
}
return false;
}
/**
* True if the type has a method, other than on Object.
* Doesn't consider noSuchMethod, unless [name] is `#noSuchMethod`.
*/
bool _hasMethod(ClassMirror type, Symbol name) {
while (type != objectType) {
final member = type.members[name];
if (member is MethodMirror && member.isRegularMethod) return true;
type = _safeSuperclass(type);
}
return false;
}
ClassMirror _safeSuperclass(ClassMirror type) {
try {
return type.superclass;
} on UnsupportedError catch (e) {
// TODO(jmesserly): dart2js throws this error when the type is not
// reflectable.
return objectType;
}
}
// From: https://github.com/rafaelw/ChangeSummary/blob/master/change_summary.js
final _pathRegExp = () {
const identStart = '[\$_a-zA-Z]';
const identPart = '[\$_a-zA-Z0-9]';
const ident = '$identStart+$identPart*';
const elementIndex = '(?:[0-9]|[1-9]+[0-9]+)';
const identOrElementIndex = '(?:$ident|$elementIndex)';
const path = '(?:$identOrElementIndex)(?:\\.$identOrElementIndex)*';
return new RegExp('^$path\$');
}();
final _spacesRegExp = new RegExp(r'\s');
bool _isPathValid(String s) {
s = s.replaceAll(_spacesRegExp, '');
if (s == '') return true;
if (s[0] == '.') return false;
return _pathRegExp.hasMatch(s);
}
final _logger = new Logger('observe.PathObserver');