diff --git a/src/ng/parse.js b/src/ng/parse.js index 701647c5..aad740e2 100644 --- a/src/ng/parse.js +++ b/src/ng/parse.js @@ -1,6 +1,8 @@ 'use strict'; var $parseMinErr = minErr('$parse'); +var promiseWarningCache = {}; +var promiseWarning; // Sandboxing Angular Expressions // ------------------------------ @@ -99,8 +101,8 @@ var ESCAPE = {"n":"\n", "f":"\f", "r":"\r", "t":"\t", "v":"\v", "'":"'", '"':'"' /** * @constructor */ -var Lexer = function (csp) { - this.csp = csp; +var Lexer = function (options) { + this.options = options; }; Lexer.prototype = { @@ -108,6 +110,7 @@ Lexer.prototype = { lex: function (text) { this.text = text; + this.index = 0; this.ch = undefined; this.lastCh = ':'; // can start regexp @@ -295,12 +298,12 @@ Lexer.prototype = { token.fn = OPERATORS[ident]; token.json = OPERATORS[ident]; } else { - var getter = getterFn(ident, this.csp, this.text); + var getter = getterFn(ident, this.options, this.text); token.fn = extend(function(self, locals) { return (getter(self, locals)); }, { assign: function(self, value) { - return setter(self, ident, value, parser.text); + return setter(self, ident, value, parser.text, parser.options); } }); } @@ -371,10 +374,10 @@ Lexer.prototype = { /** * @constructor */ -var Parser = function (lexer, $filter, csp) { +var Parser = function (lexer, $filter, options) { this.lexer = lexer; this.$filter = $filter; - this.csp = csp; + this.options = options; }; Parser.ZERO = function () { return 0; }; @@ -388,7 +391,7 @@ Parser.prototype = { //TODO(i): strip all the obsolte json stuff from this file this.json = json; - this.tokens = this.lexer.lex(text, this.csp); + this.tokens = this.lexer.lex(text); if (json) { // The extra level of aliasing is here, just in case the lexer misses something, so that @@ -688,13 +691,13 @@ Parser.prototype = { fieldAccess: function(object) { var parser = this; var field = this.expect().text; - var getter = getterFn(field, this.csp, this.text); + var getter = getterFn(field, this.options, this.text); return extend(function(scope, locals, self) { return getter(self || object(scope, locals), locals); }, { assign: function(scope, value, locals) { - return setter(object(scope, locals), field, value, parser.text); + return setter(object(scope, locals), field, value, parser.text, parser.options); } }); }, @@ -712,7 +715,7 @@ Parser.prototype = { if (!o) return undefined; v = ensureSafeObject(o[i], parser.text); - if (v && v.then) { + if (v && v.then && parser.options.unwrapPromises) { p = v; if (!('$$v' in v)) { p.$$v = undefined; @@ -759,7 +762,7 @@ Parser.prototype = { : fnPtr(args[0], args[1], args[2], args[3], args[4]); // Check for promise - if (v && v.then) { + if (v && v.then && parser.options.unwrapPromises) { var p = v; if (!('$$v' in v)) { p.$$v = undefined; @@ -827,7 +830,7 @@ Parser.prototype = { literal: true, constant: allConstant }); - }, + } }; @@ -835,7 +838,10 @@ Parser.prototype = { // Parser helper functions ////////////////////////////////////////////////// -function setter(obj, path, setValue, fullExp) { +function setter(obj, path, setValue, fullExp, options) { + //needed? + options = options || {}; + var element = path.split('.'), key; for (var i = 0; element.length > 1; i++) { key = ensureSafeMemberName(element.shift(), fullExp); @@ -845,7 +851,8 @@ function setter(obj, path, setValue, fullExp) { obj[key] = propertyObj; } obj = propertyObj; - if (obj.then) { + if (obj.then && options.unwrapPromises) { + promiseWarning(fullExp); if (!("$$v" in obj)) { (function(promise) { promise.then(function(val) { promise.$$v = val; }); } @@ -869,76 +876,103 @@ var getterFnCache = {}; * - http://jsperf.com/angularjs-parse-getter/4 * - http://jsperf.com/path-evaluation-simplified/7 */ -function cspSafeGetterFn(key0, key1, key2, key3, key4, fullExp) { +function cspSafeGetterFn(key0, key1, key2, key3, key4, fullExp, options) { ensureSafeMemberName(key0, fullExp); ensureSafeMemberName(key1, fullExp); ensureSafeMemberName(key2, fullExp); ensureSafeMemberName(key3, fullExp); ensureSafeMemberName(key4, fullExp); - return function(scope, locals) { - var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope, - promise; - if (pathVal === null || pathVal === undefined) return pathVal; + return !options.unwrapPromises + ? function cspSafeGetter(scope, locals) { + var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope; - pathVal = pathVal[key0]; - if (pathVal && pathVal.then) { - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - if (!key1 || pathVal === null || pathVal === undefined) return pathVal; + if (pathVal === null || pathVal === undefined) return pathVal; + pathVal = pathVal[key0]; - pathVal = pathVal[key1]; - if (pathVal && pathVal.then) { - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - if (!key2 || pathVal === null || pathVal === undefined) return pathVal; + if (!key1 || pathVal === null || pathVal === undefined) return pathVal; + pathVal = pathVal[key1]; - pathVal = pathVal[key2]; - if (pathVal && pathVal.then) { - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - if (!key3 || pathVal === null || pathVal === undefined) return pathVal; + if (!key2 || pathVal === null || pathVal === undefined) return pathVal; + pathVal = pathVal[key2]; - pathVal = pathVal[key3]; - if (pathVal && pathVal.then) { - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - if (!key4 || pathVal === null || pathVal === undefined) return pathVal; + if (!key3 || pathVal === null || pathVal === undefined) return pathVal; + pathVal = pathVal[key3]; - pathVal = pathVal[key4]; - if (pathVal && pathVal.then) { - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - return pathVal; - }; + if (!key4 || pathVal === null || pathVal === undefined) return pathVal; + pathVal = pathVal[key4]; + + return pathVal; + } + : function cspSafePromiseEnabledGetter(scope, locals) { + var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope, + promise; + + if (pathVal === null || pathVal === undefined) return pathVal; + + pathVal = pathVal[key0]; + if (pathVal && pathVal.then) { + promiseWarning(fullExp); + if (!("$$v" in pathVal)) { + promise = pathVal; + promise.$$v = undefined; + promise.then(function(val) { promise.$$v = val; }); + } + pathVal = pathVal.$$v; + } + if (!key1 || pathVal === null || pathVal === undefined) return pathVal; + + pathVal = pathVal[key1]; + if (pathVal && pathVal.then) { + promiseWarning(fullExp); + if (!("$$v" in pathVal)) { + promise = pathVal; + promise.$$v = undefined; + promise.then(function(val) { promise.$$v = val; }); + } + pathVal = pathVal.$$v; + } + if (!key2 || pathVal === null || pathVal === undefined) return pathVal; + + pathVal = pathVal[key2]; + if (pathVal && pathVal.then) { + promiseWarning(fullExp); + if (!("$$v" in pathVal)) { + promise = pathVal; + promise.$$v = undefined; + promise.then(function(val) { promise.$$v = val; }); + } + pathVal = pathVal.$$v; + } + if (!key3 || pathVal === null || pathVal === undefined) return pathVal; + + pathVal = pathVal[key3]; + if (pathVal && pathVal.then) { + promiseWarning(fullExp); + if (!("$$v" in pathVal)) { + promise = pathVal; + promise.$$v = undefined; + promise.then(function(val) { promise.$$v = val; }); + } + pathVal = pathVal.$$v; + } + if (!key4 || pathVal === null || pathVal === undefined) return pathVal; + + pathVal = pathVal[key4]; + if (pathVal && pathVal.then) { + promiseWarning(fullExp); + if (!("$$v" in pathVal)) { + promise = pathVal; + promise.$$v = undefined; + promise.then(function(val) { promise.$$v = val; }); + } + pathVal = pathVal.$$v; + } + return pathVal; + } } -function getterFn(path, csp, fullExp) { +function getterFn(path, options, fullExp) { // Check whether the cache has this getter already. // We can use hasOwnProperty directly on the cache because we ensure, // see below, that the cache never stores a path called 'hasOwnProperty' @@ -950,14 +984,14 @@ function getterFn(path, csp, fullExp) { pathKeysLength = pathKeys.length, fn; - if (csp) { + if (options.csp) { fn = (pathKeysLength < 6) - ? cspSafeGetterFn(pathKeys[0], pathKeys[1], pathKeys[2], pathKeys[3], pathKeys[4], fullExp) + ? cspSafeGetterFn(pathKeys[0], pathKeys[1], pathKeys[2], pathKeys[3], pathKeys[4], fullExp, options) : function(scope, locals) { var i = 0, val; do { val = cspSafeGetterFn( - pathKeys[i++], pathKeys[i++], pathKeys[i++], pathKeys[i++], pathKeys[i++], fullExp + pathKeys[i++], pathKeys[i++], pathKeys[i++], pathKeys[i++], pathKeys[i++], fullExp, options )(scope, locals); locals = undefined; // clear after first iteration @@ -976,18 +1010,25 @@ function getterFn(path, csp, fullExp) { ? 's' // but if we are first then we check locals first, and if so read it first : '((k&&k.hasOwnProperty("' + key + '"))?k:s)') + '["' + key + '"]' + ';\n' + - 'if (s && s.then) {\n' + - ' if (!("$$v" in s)) {\n' + - ' p=s;\n' + - ' p.$$v = undefined;\n' + - ' p.then(function(v) {p.$$v=v;});\n' + - '}\n' + - ' s=s.$$v\n' + - '}\n'; + (options.unwrapPromises + ? 'if (s && s.then) {\n' + + ' pw("' + fullExp.replace(/\"/g, '\\"') + '");\n' + + ' if (!("$$v" in s)) {\n' + + ' p=s;\n' + + ' p.$$v = undefined;\n' + + ' p.then(function(v) {p.$$v=v;});\n' + + '}\n' + + ' s=s.$$v\n' + + '}\n' + : ''); }); code += 'return s;'; - fn = Function('s', 'k', code); // s=scope, k=locals - fn.toString = function() { return code; }; + + var evaledFnGetter = Function('s', 'k', 'pw', code); // s=scope, k=locals, pw=promiseWarning + evaledFnGetter.toString = function() { return code; }; + fn = function(scope, locals) { + return evaledFnGetter(scope, locals, promiseWarning); + }; } // Only cache the value if it's not going to mess up the cache object @@ -1039,20 +1080,125 @@ function getterFn(path, csp, fullExp) { * set to a function to change its value on the given context. * */ + + +/** + * @ngdoc object + * @name ng.$parseProvider + * @function + * + * @description + * `$parseProvider` can be used for configuring the default behavior of the {@link ng.$parse $parse} service. + */ function $ParseProvider() { var cache = {}; - this.$get = ['$filter', '$sniffer', function($filter, $sniffer) { + + var $parseOptions = { + csp: false, + unwrapPromises: false, + logPromiseWarnings: true + }; + + + /** + * @deprecated Promise unwrapping via $parse is deprecated and will be removed in the future. + * + * @ngdoc method + * @name ng.$parseProvider#unwrapPromises + * @methodOf ng.$parseProvider + * @description + * + * **This feature is deprecated, see deprecation notes below for more info** + * + * If set to true (default is false), $parse will unwrap promises automatically when a promise is found at any part of + * the expression. In other words, if set to true, the expression will always result in a non-promise value. + * + * While the promise is unresolved, it's treated as undefined, but once resolved and fulfilled, the fulfillment value + * is used in place of the promise while evaluating the expression. + * + * **Deprecation notice** + * + * This is a feature that didn't prove to be wildly useful or popular, primarily because of the dichotomy between data + * access in templates (accessed as raw values) and controller code (accessed as promises). + * + * In most code we ended up resolving promises manually in controllers anyway and thus unifying the model access there. + * + * Other downsides of automatic promise unwrapping: + * + * - when building components it's often desirable to receive the raw promises + * - adds complexity and slows down expression evaluation + * - makes expression code pre-generation unattractive due to the amount of code that needs to be generated + * - makes IDE auto-completion and tool support hard + * + * **Warning Logs** + * + * If the unwrapping is enabled, Angular will log a warning about each expression that unwraps a promise (to reduce + * the noise, each expression is logged only once). To disable this logging use + * `$parseProvider.logPromiseWarnings(false)` api. + * + * + * @param {boolean=} value New value. + * @returns {boolean|self} Returns the current setting when used as getter and self if used as setter. + */ + this.unwrapPromises = function(value) { + if (isDefined(value)) { + $parseOptions.unwrapPromises = !!value; + return this; + } else { + return $parseOptions.unwrapPromises; + } + }; + + + /** + * @deprecated Promise unwrapping via $parse is deprecated and will be removed in the future. + * + * @ngdoc method + * @name ng.$parseProvider#logPromiseWarnings + * @methodOf ng.$parseProvider + * @description + * + * Controls whether Angular should log a warning on any encounter of a promise in an expression. + * + * The default is set to `true`. + * + * This setting applies only if `$parseProvider.unwrapPromises` setting is set to true as well. + * + * @param {boolean=} value New value. + * @returns {boolean|self} Returns the current setting when used as getter and self if used as setter. + */ + this.logPromiseWarnings = function(value) { + if (isDefined(value)) { + $parseOptions.logPromiseWarnings = value; + return this; + } else { + return $parseOptions.logPromiseWarnings; + } + }; + + + this.$get = ['$filter', '$sniffer', '$log', function($filter, $sniffer, $log) { + $parseOptions.csp = $sniffer.csp; + + promiseWarning = function promiseWarningFn(fullExp) { + if (!$parseOptions.logPromiseWarnings || promiseWarningCache.hasOwnProperty(fullExp)) return; + promiseWarningCache[fullExp] = true; + $log.warn('[$parse] Promise found in the expression `' + fullExp + '`. ' + + 'Automatic unwrapping of promises in Angular expressions is deprecated.'); + }; + return function(exp) { var parsedExpression; switch (typeof exp) { case 'string': + if (cache.hasOwnProperty(exp)) { return cache[exp]; } - var lexer = new Lexer($sniffer.csp); - var parser = new Parser(lexer, $filter, $sniffer.csp); + var lexer = new Lexer($parseOptions); + var parser = new Parser(lexer, $filter, $parseOptions); parsedExpression = parser.parse(exp, false); if (exp !== 'hasOwnProperty') { diff --git a/test/ng/parseSpec.js b/test/ng/parseSpec.js index 19182332..277178a1 100644 --- a/test/ng/parseSpec.js +++ b/test/ng/parseSpec.js @@ -1,12 +1,20 @@ 'use strict'; describe('parser', function() { + + beforeEach(function() { + // clear caches + getterFnCache = {}; + promiseWarningCache = {}; + }); + + describe('lexer', function() { var lex; beforeEach(function () { lex = function () { - var lexer = new Lexer(); + var lexer = new Lexer({csp: false, unwrapPromises: false}); return lexer.lex.apply(lexer, arguments); }; }); @@ -198,7 +206,6 @@ describe('parser', function() { beforeEach(inject(function ($rootScope, $sniffer) { scope = $rootScope; $sniffer.csp = cspEnabled; - getterFnCache = {}; // clear cache })); @@ -854,263 +861,6 @@ describe('parser', function() { }); - describe('promises', function() { - var deferred, promise, q; - - beforeEach(inject(function($q) { - q = $q; - deferred = q.defer(); - promise = deferred.promise; - })); - - describe('{{promise}}', function() { - it('should evaluated resolved promise and get its value', function() { - deferred.resolve('hello!'); - scope.greeting = promise; - expect(scope.$eval('greeting')).toBe(undefined); - scope.$digest(); - expect(scope.$eval('greeting')).toBe('hello!'); - }); - - - it('should evaluated rejected promise and ignore the rejection reason', function() { - deferred.reject('sorry'); - scope.greeting = promise; - expect(scope.$eval('gretting')).toBe(undefined); - scope.$digest(); - expect(scope.$eval('greeting')).toBe(undefined); - }); - - - it('should evaluate a promise and eventualy get its value', function() { - scope.greeting = promise; - expect(scope.$eval('greeting')).toBe(undefined); - - scope.$digest(); - expect(scope.$eval('greeting')).toBe(undefined); - - deferred.resolve('hello!'); - expect(scope.$eval('greeting')).toBe(undefined); - scope.$digest(); - expect(scope.$eval('greeting')).toBe('hello!'); - }); - - - it('should evaluate a promise and eventualy ignore its rejection', function() { - scope.greeting = promise; - expect(scope.$eval('greeting')).toBe(undefined); - - scope.$digest(); - expect(scope.$eval('greeting')).toBe(undefined); - - deferred.reject('sorry'); - expect(scope.$eval('greeting')).toBe(undefined); - scope.$digest(); - expect(scope.$eval('greeting')).toBe(undefined); - }); - - it('should evaluate a function call returning a promise and eventually get its return value', function() { - scope.greetingFn = function() { return promise; }; - expect(scope.$eval('greetingFn()')).toBe(undefined); - - scope.$digest(); - expect(scope.$eval('greetingFn()')).toBe(undefined); - - deferred.resolve('hello!'); - expect(scope.$eval('greetingFn()')).toBe(undefined); - scope.$digest(); - expect(scope.$eval('greetingFn()')).toBe('hello!'); - }); - - describe('assignment into promises', function() { - // This behavior is analogous to assignments to non-promise values - // that are lazily set on the scope. - it('should evaluate a resolved object promise and set its value', inject(function($parse) { - scope.person = promise; - deferred.resolve({'name': 'Bill Gates'}); - - var getter = $parse('person.name'); - expect(getter(scope)).toBe(undefined); - - scope.$digest(); - expect(getter(scope)).toBe('Bill Gates'); - getter.assign(scope, 'Warren Buffet'); - expect(getter(scope)).toBe('Warren Buffet'); - })); - - - it('should evaluate a resolved primitive type promise and set its value', inject(function($parse) { - scope.greeting = promise; - deferred.resolve('Salut!'); - - var getter = $parse('greeting'); - expect(getter(scope)).toBe(undefined); - - scope.$digest(); - expect(getter(scope)).toBe('Salut!'); - - getter.assign(scope, 'Bonjour'); - expect(getter(scope)).toBe('Bonjour'); - })); - - - it('should evaluate an unresolved promise and set and remember its value', inject(function($parse) { - scope.person = promise; - - var getter = $parse('person.name'); - expect(getter(scope)).toBe(undefined); - - scope.$digest(); - expect(getter(scope)).toBe(undefined); - - getter.assign(scope, 'Bonjour'); - scope.$digest(); - - expect(getter(scope)).toBe('Bonjour'); - - var c1Getter = $parse('person.A.B.C1'); - scope.$digest(); - expect(c1Getter(scope)).toBe(undefined); - c1Getter.assign(scope, 'c1_value'); - scope.$digest(); - expect(c1Getter(scope)).toBe('c1_value'); - - // Set another property on the person.A.B - var c2Getter = $parse('person.A.B.C2'); - scope.$digest(); - expect(c2Getter(scope)).toBe(undefined); - c2Getter.assign(scope, 'c2_value'); - scope.$digest(); - expect(c2Getter(scope)).toBe('c2_value'); - - // c1 should be unchanged. - expect($parse('person.A')(scope)).toEqual( - {B: {C1: 'c1_value', C2: 'c2_value'}}); - })); - - - it('should evaluate a resolved promise and overwrite the previous set value in the absense of the getter', - inject(function($parse) { - scope.person = promise; - var c1Getter = $parse('person.A.B.C1'); - c1Getter.assign(scope, 'c1_value'); - // resolving the promise should update the tree. - deferred.resolve({A: {B: {C1: 'resolved_c1'}}}); - scope.$digest(); - expect(c1Getter(scope)).toEqual('resolved_c1'); - })); - }); - }); - - describe('dereferencing', function() { - it('should evaluate and dereference properties leading to and from a promise', function() { - scope.obj = {greeting: promise}; - expect(scope.$eval('obj.greeting')).toBe(undefined); - expect(scope.$eval('obj.greeting.polite')).toBe(undefined); - - scope.$digest(); - expect(scope.$eval('obj.greeting')).toBe(undefined); - expect(scope.$eval('obj.greeting.polite')).toBe(undefined); - - deferred.resolve({polite: 'Good morning!'}); - scope.$digest(); - expect(scope.$eval('obj.greeting')).toEqual({polite: 'Good morning!'}); - expect(scope.$eval('obj.greeting.polite')).toBe('Good morning!'); - }); - - it('should evaluate and dereference properties leading to and from a promise via bracket ' + - 'notation', function() { - scope.obj = {greeting: promise}; - expect(scope.$eval('obj["greeting"]')).toBe(undefined); - expect(scope.$eval('obj["greeting"]["polite"]')).toBe(undefined); - - scope.$digest(); - expect(scope.$eval('obj["greeting"]')).toBe(undefined); - expect(scope.$eval('obj["greeting"]["polite"]')).toBe(undefined); - - deferred.resolve({polite: 'Good morning!'}); - scope.$digest(); - expect(scope.$eval('obj["greeting"]')).toEqual({polite: 'Good morning!'}); - expect(scope.$eval('obj["greeting"]["polite"]')).toBe('Good morning!'); - }); - - - it('should evaluate and dereference array references leading to and from a promise', - function() { - scope.greetings = [promise]; - expect(scope.$eval('greetings[0]')).toBe(undefined); - expect(scope.$eval('greetings[0][0]')).toBe(undefined); - - scope.$digest(); - expect(scope.$eval('greetings[0]')).toBe(undefined); - expect(scope.$eval('greetings[0][0]')).toBe(undefined); - - deferred.resolve(['Hi!', 'Cau!']); - scope.$digest(); - expect(scope.$eval('greetings[0]')).toEqual(['Hi!', 'Cau!']); - expect(scope.$eval('greetings[0][0]')).toBe('Hi!'); - }); - - - it('should evaluate and dereference promises used as function arguments', function() { - scope.greet = function(name) { return 'Hi ' + name + '!'; }; - scope.name = promise; - expect(scope.$eval('greet(name)')).toBe('Hi undefined!'); - - scope.$digest(); - expect(scope.$eval('greet(name)')).toBe('Hi undefined!'); - - deferred.resolve('Veronica'); - expect(scope.$eval('greet(name)')).toBe('Hi undefined!'); - - scope.$digest(); - expect(scope.$eval('greet(name)')).toBe('Hi Veronica!'); - }); - - - it('should evaluate and dereference promises used as array indexes', function() { - scope.childIndex = promise; - scope.kids = ['Adam', 'Veronica', 'Elisa']; - expect(scope.$eval('kids[childIndex]')).toBe(undefined); - - scope.$digest(); - expect(scope.$eval('kids[childIndex]')).toBe(undefined); - - deferred.resolve(1); - expect(scope.$eval('kids[childIndex]')).toBe(undefined); - - scope.$digest(); - expect(scope.$eval('kids[childIndex]')).toBe('Veronica'); - }); - - - it('should evaluate and dereference promises used as keys in bracket notation', function() { - scope.childKey = promise; - scope.kids = {'a': 'Adam', 'v': 'Veronica', 'e': 'Elisa'}; - - expect(scope.$eval('kids[childKey]')).toBe(undefined); - - scope.$digest(); - expect(scope.$eval('kids[childKey]')).toBe(undefined); - - deferred.resolve('v'); - expect(scope.$eval('kids[childKey]')).toBe(undefined); - - scope.$digest(); - expect(scope.$eval('kids[childKey]')).toBe('Veronica'); - }); - - - it('should not mess with the promise if it was not directly evaluated', function() { - scope.obj = {greeting: promise, username: 'hi'}; - var obj = scope.$eval('obj'); - expect(obj.username).toEqual('hi'); - expect(typeof obj.greeting.then).toBe('function'); - }); - }); - }); - - describe('assignable', function() { it('should expose assignment function', inject(function($parse) { var fn = $parse('a'); @@ -1204,4 +954,381 @@ describe('parser', function() { }); }); }); + + + describe('promises', function() { + + var deferred, promise, q; + + describe('unwrapPromises setting', function () { + + beforeEach(inject(function($rootScope, $q) { + scope = $rootScope; + + $rootScope.$apply(function() { + deferred = $q.defer(); + deferred.resolve('Bobo'); + promise = deferred.promise; + }); + })); + + it('should not unwrap promises by default', inject(function ($parse) { + scope.person = promise; + scope.things = {person: promise}; + scope.getPerson = function () { return promise; }; + + var getter = $parse('person'); + var propGetter = $parse('things.person'); + var fnGetter = $parse('getPerson()'); + + expect(getter(scope)).toBe(promise); + expect(propGetter(scope)).toBe(promise); + expect(fnGetter(scope)).toBe(promise); + })); + }); + + + forEach([true, false], function(cspEnabled) { + + describe('promise logging (csp:' + cspEnabled + ')', function() { + + var $log; + var PROMISE_WARNING_REGEXP = /\[\$parse\] Promise found in the expression `[^`]+`. Automatic unwrapping of promises in Angular expressions is deprecated\./; + + beforeEach(module(function($parseProvider) { + $parseProvider.unwrapPromises(true); + })); + + beforeEach(inject(function($rootScope, $q, _$log_) { + scope = $rootScope; + + $rootScope.$apply(function() { + deferred = $q.defer(); + deferred.resolve('Bobo'); + promise = deferred.promise; + }); + + $log = _$log_; + })); + + it('should log warnings by default', function() { + scope.person = promise; + scope.$eval('person'); + expect($log.warn.logs.pop()).toEqual(['[$parse] Promise found in the expression `person`. ' + + 'Automatic unwrapping of promises in Angular expressions is deprecated.']); + }); + + + it('should log warnings for deep promises', function() { + scope.car = {wheel: {disc: promise}}; + scope.$eval('car.wheel.disc.pad'); + expect($log.warn.logs.pop()).toMatch(PROMISE_WARNING_REGEXP); + }); + + + it('should log warnings for setters', function() { + scope.person = promise; + scope.$eval('person.name = "Bubu"'); + expect($log.warn.logs.pop()).toMatch(PROMISE_WARNING_REGEXP); + }); + + + it('should log only a single warning for each expression', function() { + scope.person1 = promise; + scope.person2 = promise; + + scope.$eval('person1'); + scope.$eval('person1'); + expect($log.warn.logs.pop()).toMatch(/`person1`/); + expect($log.warn.logs).toEqual([]); + + scope.$eval('person1'); + scope.$eval('person2'); + scope.$eval('person1'); + scope.$eval('person2'); + expect($log.warn.logs.pop()).toMatch(/`person2`/); + expect($log.warn.logs).toEqual([]); + }); + + + it('should log warning for complex expressions', function() { + scope.person1 = promise; + scope.person2 = promise; + + scope.$eval('person1 + person2'); + expect($log.warn.logs.pop()).toMatch(/`person1 \+ person2`/); + expect($log.warn.logs).toEqual([]); + }); + }); + }); + + + forEach([true, false], function(cspEnabled) { + + describe('csp ' + cspEnabled, function() { + + beforeEach(module(function($parseProvider) { + $parseProvider.unwrapPromises(true); + $parseProvider.logPromiseWarnings(false); + })); + + + beforeEach(inject(function($rootScope, $sniffer, $q) { + scope = $rootScope; + $sniffer.csp = cspEnabled; + + q = $q; + deferred = q.defer(); + promise = deferred.promise; + })); + + + describe('{{promise}}', function() { + it('should evaluated resolved promise and get its value', function() { + deferred.resolve('hello!'); + scope.greeting = promise; + expect(scope.$eval('greeting')).toBe(undefined); + scope.$digest(); + expect(scope.$eval('greeting')).toBe('hello!'); + }); + + + it('should evaluated rejected promise and ignore the rejection reason', function() { + deferred.reject('sorry'); + scope.greeting = promise; + expect(scope.$eval('greeting')).toBe(undefined); + scope.$digest(); + expect(scope.$eval('greeting')).toBe(undefined); + }); + + + it('should evaluate a promise and eventualy get its value', function() { + scope.greeting = promise; + expect(scope.$eval('greeting')).toBe(undefined); + + scope.$digest(); + expect(scope.$eval('greeting')).toBe(undefined); + + deferred.resolve('hello!'); + expect(scope.$eval('greeting')).toBe(undefined); + scope.$digest(); + expect(scope.$eval('greeting')).toBe('hello!'); + }); + + + it('should evaluate a promise and eventualy ignore its rejection', function() { + scope.greeting = promise; + expect(scope.$eval('greeting')).toBe(undefined); + + scope.$digest(); + expect(scope.$eval('greeting')).toBe(undefined); + + deferred.reject('sorry'); + expect(scope.$eval('greeting')).toBe(undefined); + scope.$digest(); + expect(scope.$eval('greeting')).toBe(undefined); + }); + + it('should evaluate a function call returning a promise and eventually get its return value', function() { + scope.greetingFn = function() { return promise; }; + expect(scope.$eval('greetingFn()')).toBe(undefined); + + scope.$digest(); + expect(scope.$eval('greetingFn()')).toBe(undefined); + + deferred.resolve('hello!'); + expect(scope.$eval('greetingFn()')).toBe(undefined); + scope.$digest(); + expect(scope.$eval('greetingFn()')).toBe('hello!'); + }); + + describe('assignment into promises', function() { + // This behavior is analogous to assignments to non-promise values + // that are lazily set on the scope. + it('should evaluate a resolved object promise and set its value', inject(function($parse) { + scope.person = promise; + deferred.resolve({'name': 'Bill Gates'}); + + var getter = $parse('person.name', { unwrapPromises: true }); + expect(getter(scope)).toBe(undefined); + + scope.$digest(); + expect(getter(scope)).toBe('Bill Gates'); + getter.assign(scope, 'Warren Buffet'); + expect(getter(scope)).toBe('Warren Buffet'); + })); + + + it('should evaluate a resolved primitive type promise and set its value', inject(function($parse) { + scope.greeting = promise; + deferred.resolve('Salut!'); + + var getter = $parse('greeting', { unwrapPromises: true }); + expect(getter(scope)).toBe(undefined); + + scope.$digest(); + expect(getter(scope)).toBe('Salut!'); + + getter.assign(scope, 'Bonjour'); + expect(getter(scope)).toBe('Bonjour'); + })); + + + it('should evaluate an unresolved promise and set and remember its value', inject(function($parse) { + scope.person = promise; + + var getter = $parse('person.name', { unwrapPromises: true }); + expect(getter(scope)).toBe(undefined); + + scope.$digest(); + expect(getter(scope)).toBe(undefined); + + getter.assign(scope, 'Bonjour'); + scope.$digest(); + + expect(getter(scope)).toBe('Bonjour'); + + var c1Getter = $parse('person.A.B.C1', { unwrapPromises: true }); + scope.$digest(); + expect(c1Getter(scope)).toBe(undefined); + c1Getter.assign(scope, 'c1_value'); + scope.$digest(); + expect(c1Getter(scope)).toBe('c1_value'); + + // Set another property on the person.A.B + var c2Getter = $parse('person.A.B.C2', { unwrapPromises: true }); + scope.$digest(); + expect(c2Getter(scope)).toBe(undefined); + c2Getter.assign(scope, 'c2_value'); + scope.$digest(); + expect(c2Getter(scope)).toBe('c2_value'); + + // c1 should be unchanged. + expect($parse('person.A', { unwrapPromises: true })(scope)).toEqual( + {B: {C1: 'c1_value', C2: 'c2_value'}}); + })); + + + it('should evaluate a resolved promise and overwrite the previous set value in the absense of the getter', + inject(function($parse) { + scope.person = promise; + var c1Getter = $parse('person.A.B.C1', { unwrapPromises: true }); + c1Getter.assign(scope, 'c1_value'); + // resolving the promise should update the tree. + deferred.resolve({A: {B: {C1: 'resolved_c1'}}}); + scope.$digest(); + expect(c1Getter(scope)).toEqual('resolved_c1'); + })); + }); + }); + + describe('dereferencing', function() { + it('should evaluate and dereference properties leading to and from a promise', function() { + scope.obj = {greeting: promise}; + expect(scope.$eval('obj.greeting')).toBe(undefined); + expect(scope.$eval('obj.greeting.polite')).toBe(undefined); + + scope.$digest(); + expect(scope.$eval('obj.greeting')).toBe(undefined); + expect(scope.$eval('obj.greeting.polite')).toBe(undefined); + + deferred.resolve({polite: 'Good morning!'}); + scope.$digest(); + expect(scope.$eval('obj.greeting')).toEqual({polite: 'Good morning!'}); + expect(scope.$eval('obj.greeting.polite')).toBe('Good morning!'); + }); + + it('should evaluate and dereference properties leading to and from a promise via bracket ' + + 'notation', function() { + scope.obj = {greeting: promise}; + expect(scope.$eval('obj["greeting"]')).toBe(undefined); + expect(scope.$eval('obj["greeting"]["polite"]')).toBe(undefined); + + scope.$digest(); + expect(scope.$eval('obj["greeting"]')).toBe(undefined); + expect(scope.$eval('obj["greeting"]["polite"]')).toBe(undefined); + + deferred.resolve({polite: 'Good morning!'}); + scope.$digest(); + expect(scope.$eval('obj["greeting"]')).toEqual({polite: 'Good morning!'}); + expect(scope.$eval('obj["greeting"]["polite"]')).toBe('Good morning!'); + }); + + + it('should evaluate and dereference array references leading to and from a promise', + function() { + scope.greetings = [promise]; + expect(scope.$eval('greetings[0]')).toBe(undefined); + expect(scope.$eval('greetings[0][0]')).toBe(undefined); + + scope.$digest(); + expect(scope.$eval('greetings[0]')).toBe(undefined); + expect(scope.$eval('greetings[0][0]')).toBe(undefined); + + deferred.resolve(['Hi!', 'Cau!']); + scope.$digest(); + expect(scope.$eval('greetings[0]')).toEqual(['Hi!', 'Cau!']); + expect(scope.$eval('greetings[0][0]')).toBe('Hi!'); + }); + + + it('should evaluate and dereference promises used as function arguments', function() { + scope.greet = function(name) { return 'Hi ' + name + '!'; }; + scope.name = promise; + expect(scope.$eval('greet(name)')).toBe('Hi undefined!'); + + scope.$digest(); + expect(scope.$eval('greet(name)')).toBe('Hi undefined!'); + + deferred.resolve('Veronica'); + expect(scope.$eval('greet(name)')).toBe('Hi undefined!'); + + scope.$digest(); + expect(scope.$eval('greet(name)')).toBe('Hi Veronica!'); + }); + + + it('should evaluate and dereference promises used as array indexes', function() { + scope.childIndex = promise; + scope.kids = ['Adam', 'Veronica', 'Elisa']; + expect(scope.$eval('kids[childIndex]')).toBe(undefined); + + scope.$digest(); + expect(scope.$eval('kids[childIndex]')).toBe(undefined); + + deferred.resolve(1); + expect(scope.$eval('kids[childIndex]')).toBe(undefined); + + scope.$digest(); + expect(scope.$eval('kids[childIndex]')).toBe('Veronica'); + }); + + + it('should evaluate and dereference promises used as keys in bracket notation', function() { + scope.childKey = promise; + scope.kids = {'a': 'Adam', 'v': 'Veronica', 'e': 'Elisa'}; + + expect(scope.$eval('kids[childKey]')).toBe(undefined); + + scope.$digest(); + expect(scope.$eval('kids[childKey]')).toBe(undefined); + + deferred.resolve('v'); + expect(scope.$eval('kids[childKey]')).toBe(undefined); + + scope.$digest(); + expect(scope.$eval('kids[childKey]')).toBe('Veronica'); + }); + + + it('should not mess with the promise if it was not directly evaluated', function() { + scope.obj = {greeting: promise, username: 'hi'}; + var obj = scope.$eval('obj'); + expect(obj.username).toEqual('hi'); + expect(typeof obj.greeting.then).toBe('function'); + }); + }); + }); + }); + }); });