From 9251edbf8d2a8e7cffdafe17f59f2469feaddb93 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Sat, 6 Oct 2012 14:17:48 -0700 Subject: [PATCH] Allow arbitrary JSON objects to be stored in Session (but not compared with equals). Test Session. Fixes #215. --- docs/client/api.html | 6 +- docs/client/api.js | 4 +- packages/session/package.js | 6 + packages/session/session.js | 180 +++++++++++++++++------------- packages/session/session_tests.js | 158 ++++++++++++++++++++++++++ 5 files changed, 273 insertions(+), 81 deletions(-) create mode 100644 packages/session/session_tests.js diff --git a/docs/client/api.html b/docs/client/api.html index 22dae6cc5d..3aaefe2aec 100644 --- a/docs/client/api.html +++ b/docs/client/api.html @@ -858,7 +858,7 @@ Example: {{> api_box equals}} -These two expressions do the same thing: +If value is a scalar, then these two expressions do the same thing: (1) Session.get("key") === value (2) Session.equals("key", value) @@ -905,6 +905,10 @@ Example: // If Session.get had been used instead of Session.equals, then // when the selection changed, all the items would be re-rendered. +For object and array session values, you cannot use `Session.equals`; instead, +you need to use the `underscore` package and write +`_.isEqual(Session.get(key), value)`. +

Templates

A template that you declare as `<{{! }}template name="foo"> ... value - keyDeps: {}, // key -> _ContextSet - keyValueDeps: {}, // key -> value -> _ContextSet + // XXX come up with a serialization method which canonicalizes object key + // order, which would allow us to use objects as values for equals. + var stringify = function (value) { + if (value === undefined) + return 'undefined'; + return JSON.stringify(value); + }; + var parse = function (serialized) { + if (serialized === undefined || serialized === 'undefined') + return undefined; + return JSON.parse(serialized); + }; - set: function (key, value) { - var self = this; + Session = _.extend({}, { + keys: {}, // key -> value + keyDeps: {}, // key -> _ContextSet + keyValueDeps: {}, // key -> value -> _ContextSet - if (typeof value !== 'string' && - typeof value !== 'number' && - typeof value !== 'boolean' && - value !== null && value !== undefined) - throw new Error("Session.set: value can't be an object"); + set: function (key, value) { + var self = this; - var oldValue = self.keys[key]; - if (value === oldValue) - return; - self.keys[key] = value; + value = stringify(value); - var invalidateAll = function (cset) { - cset && cset.invalidateAll(); - }; + var oldSerializedValue = 'undefined'; + if (_.has(self.keys, key)) oldSerializedValue = self.keys[key]; + if (value === oldSerializedValue) + return; + self.keys[key] = value; - invalidateAll(self.keyDeps[key]); - if (self.keyValueDeps[key]) { - invalidateAll(self.keyValueDeps[key][oldValue]); - invalidateAll(self.keyValueDeps[key][value]); - } - }, + var invalidateAll = function (cset) { + cset && cset.invalidateAll(); + }; - get: function (key) { - var self = this; - self._ensureKey(key); - self.keyDeps[key].addCurrentContext(); - return self.keys[key]; - }, + invalidateAll(self.keyDeps[key]); + if (self.keyValueDeps[key]) { + invalidateAll(self.keyValueDeps[key][oldSerializedValue]); + invalidateAll(self.keyValueDeps[key][value]); + } + }, - equals: function (key, value) { - var self = this; - var context = Meteor.deps.Context.current; - - if (typeof value !== 'string' && - typeof value !== 'number' && - typeof value !== 'boolean' && - typeof value !== 'undefined' && - value !== null) - throw new Error("Session.equals: value can't be an object"); - - if (context) { + get: function (key) { + var self = this; self._ensureKey(key); + self.keyDeps[key].addCurrentContext(); + return parse(self.keys[key]); + }, - if (!(value in self.keyValueDeps[key])) - self.keyValueDeps[key][value] = new Meteor.deps._ContextSet; + equals: function (key, value) { + var self = this; + var context = Meteor.deps.Context.current; - var isNew = self.keyValueDeps[key][value].add(context); - if (isNew) { - context.onInvalidate(function () { - // clean up [key][value] if it's now empty, so we don't use - // O(n) memory for n = values seen ever - if (self.keyValueDeps[key][value].isEmpty()) - delete self.keyValueDeps[key][value]; - }); + // We don't allow objects (or arrays that might include objects) for + // .equals, because JSON.stringify doesn't canonicalize object key + // order. (We can make equals have the right return value by parsing the + // current value and using _.isEqual, but we won't have a canonical + // element of keyValueDeps[key] to store the context.) You can still use + // "_.isEqual(Session.get(key), value)". + // + // XXX we could allow arrays as long as we recursively check that there + // are no objects + if (typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' && + typeof value !== 'undefined' && + value !== null) + throw new Error("Session.equals: value must be scalar"); + var serializedValue = stringify(value); + + if (context) { + self._ensureKey(key); + + if (! _.has(self.keyValueDeps[key], serializedValue)) + self.keyValueDeps[key][serializedValue] = new Meteor.deps._ContextSet; + + var isNew = self.keyValueDeps[key][serializedValue].add(context); + if (isNew) { + context.onInvalidate(function () { + // clean up [key][serializedValue] if it's now empty, so we don't + // use O(n) memory for n = values seen ever + if (self.keyValueDeps[key][serializedValue].isEmpty()) + delete self.keyValueDeps[key][serializedValue]; + }); + } + } + + var oldValue = undefined; + if (_.has(self.keys, key)) oldValue = parse(self.keys[key]); + return oldValue === value; + }, + + _ensureKey: function (key) { + var self = this; + if (!(key in self.keyDeps)) { + self.keyDeps[key] = new Meteor.deps._ContextSet; + self.keyValueDeps[key] = {}; } } - - return self.keys[key] === value; - }, - - _ensureKey: function (key) { - var self = this; - if (!(key in self.keyDeps)) { - self.keyDeps[key] = new Meteor.deps._ContextSet; - self.keyValueDeps[key] = {}; - } - } -}); - - -if (Meteor._reload) { - Meteor._reload.onMigrate('session', function () { - // XXX sanitize and make sure it's JSONible? - return [true, {keys: Session.keys}]; }); - (function () { - var migrationData = Meteor._reload.migrationData('session'); - if (migrationData && migrationData.keys) { - Session.keys = migrationData.keys; - } - })(); -} + + if (Meteor._reload) { + Meteor._reload.onMigrate('session', function () { + // XXX sanitize and make sure it's JSONible? + return [true, {keys: Session.keys}]; + }); + + (function () { + var migrationData = Meteor._reload.migrationData('session'); + if (migrationData && migrationData.keys) { + Session.keys = migrationData.keys; + } + })(); + } + +}()); diff --git a/packages/session/session_tests.js b/packages/session/session_tests.js new file mode 100644 index 0000000000..0a750b4086 --- /dev/null +++ b/packages/session/session_tests.js @@ -0,0 +1,158 @@ +(function () { + + Tinytest.add('session - get/set/equals types', function (test) { + test.equal(Session.get('u'), undefined); + test.isTrue(Session.equals('u', undefined)); + test.isFalse(Session.equals('u', null)); + test.isFalse(Session.equals('u', 0)); + test.isFalse(Session.equals('u', '')); + + Session.set('u', undefined); + test.equal(Session.get('u'), undefined); + test.isTrue(Session.equals('u', undefined)); + test.isFalse(Session.equals('u', null)); + test.isFalse(Session.equals('u', 0)); + test.isFalse(Session.equals('u', '')); + test.isFalse(Session.equals('u', 'undefined')); + test.isFalse(Session.equals('u', 'null')); + + Session.set('n', null); + test.equal(Session.get('n'), null); + test.isFalse(Session.equals('n', undefined)); + test.isTrue(Session.equals('n', null)); + test.isFalse(Session.equals('n', 0)); + test.isFalse(Session.equals('n', '')); + test.isFalse(Session.equals('n', 'undefined')); + test.isFalse(Session.equals('n', 'null')); + + Session.set('t', true); + test.equal(Session.get('t'), true); + test.isTrue(Session.equals('t', true)); + test.isFalse(Session.equals('t', false)); + test.isFalse(Session.equals('t', 1)); + test.isFalse(Session.equals('t', 'true')); + + Session.set('f', false); + test.equal(Session.get('f'), false); + test.isFalse(Session.equals('f', true)); + test.isTrue(Session.equals('f', false)); + test.isFalse(Session.equals('f', 1)); + test.isFalse(Session.equals('f', 'false')); + + Session.set('num', 0); + test.equal(Session.get('num'), 0); + test.isTrue(Session.equals('num', 0)); + test.isFalse(Session.equals('num', false)); + test.isFalse(Session.equals('num', '0')); + test.isFalse(Session.equals('num', 1)); + + Session.set('str', 'true'); + test.equal(Session.get('str'), 'true'); + test.isTrue(Session.equals('str', 'true')); + test.isFalse(Session.equals('str', true)); + + Session.set('arr', [1, 2, {a: 1, b: [5, 6]}]); + test.equal(Session.get('arr'), [1, 2, {b: [5, 6], a: 1}]); + test.isFalse(Session.equals('arr', 1)); + test.isFalse(Session.equals('arr', '[1,2,{"a":1,"b":[5,6]}]')); + test.throws(function () { + Session.equals('arr', [1, 2, {a: 1, b: [5, 6]}]); + }); + + Session.set('obj', {a: 1, b: [5, 6]}); + test.equal(Session.get('obj'), {b: [5, 6], a: 1}); + test.isFalse(Session.equals('obj', 1)); + test.isFalse(Session.equals('obj', '{"a":1,"b":[5,6]}')); + test.throws(function() { Session.equals('obj', {a: 1, b: [5, 6]}); }); + }); + + Tinytest.add('session - objects are cloned', function (test) { + Session.set('frozen-array', [1, 2, 3]); + Session.get('frozen-array')[1] = 42; + test.equal(Session.get('frozen-array'), [1, 2, 3]); + + Session.set('frozen-object', {a: 1, b: 2}); + Session.get('frozen-object').a = 43; + test.equal(Session.get('frozen-object'), {a: 1, b: 2}); + }); + + Tinytest.add('session - context invalidation for get', function (test) { + var xGetExecutions = 0; + Meteor._autorun(function () { + ++xGetExecutions; + Session.get('x'); + }); + test.equal(xGetExecutions, 1); + Session.set('x', 1); + // Invalidation shouldn't happen until flush time. + test.equal(xGetExecutions, 1); + Meteor.flush(); + test.equal(xGetExecutions, 2); + // Setting to the same value doesn't re-run. + Session.set('x', 1); + Meteor.flush(); + test.equal(xGetExecutions, 2); + Session.set('x', '1'); + Meteor.flush(); + test.equal(xGetExecutions, 3); + }); + + Tinytest.add('session - context invalidation for equals', function (test) { + var xEqualsExecutions = 0; + Meteor._autorun(function () { + ++xEqualsExecutions; + Session.equals('x', 5); + }); + test.equal(xEqualsExecutions, 1); + Session.set('x', 1); + Meteor.flush(); + // Changing undefined -> 1 shouldn't affect equals(5). + test.equal(xEqualsExecutions, 1); + Session.set('x', 5); + // Invalidation shouldn't happen until flush time. + test.equal(xEqualsExecutions, 1); + Meteor.flush(); + test.equal(xEqualsExecutions, 2); + Session.set('x', 5); + Meteor.flush(); + // Setting to the same value doesn't re-run. + test.equal(xEqualsExecutions, 2); + Session.set('x', '5'); + test.equal(xEqualsExecutions, 2); + Meteor.flush(); + test.equal(xEqualsExecutions, 3); + Session.set('x', 5); + test.equal(xEqualsExecutions, 3); + Meteor.flush(); + test.equal(xEqualsExecutions, 4); + }); + + Tinytest.add( + 'session - context invalidation for equals with undefined', + function (test) { + // Make sure the special casing for equals undefined works. + var yEqualsExecutions = 0; + Meteor._autorun(function () { + ++yEqualsExecutions; + Session.equals('y', undefined); + }); + test.equal(yEqualsExecutions, 1); + Session.set('y', undefined); + Meteor.flush(); + test.equal(yEqualsExecutions, 1); + Session.set('y', 5); + test.equal(yEqualsExecutions, 1); + Meteor.flush(); + test.equal(yEqualsExecutions, 2); + Session.set('y', 3); + Meteor.flush(); + test.equal(yEqualsExecutions, 2); + Session.set('y', 'undefined'); + Meteor.flush(); + test.equal(yEqualsExecutions, 2); + Session.set('y', undefined); + test.equal(yEqualsExecutions, 2); + Meteor.flush(); + test.equal(yEqualsExecutions, 3); + }); +}());