Allow arbitrary JSON objects to be stored in Session (but not compared with equals). Test Session. Fixes #215.

This commit is contained in:
David Glasser
2012-10-06 14:17:48 -07:00
parent 86cdbe4d71
commit 9251edbf8d
5 changed files with 273 additions and 81 deletions

View File

@@ -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)`.
<h2 id="templates_api"><span>Templates</span></h2>
A template that you declare as `<{{! }}template name="foo"> ... </{{!

View File

@@ -726,7 +726,7 @@ Template.api.set = {
type: "String",
descr: "The key to set, eg, `selectedItem`"},
{name: "value",
type: "String, Number, Boolean, null, or undefined",
type: "JSON-able object or undefined",
descr: "The new value for `key`"}
]
};
@@ -735,7 +735,7 @@ Template.api.get = {
id: "session_get",
name: "Session.get(key)",
locus: "Client",
descr: ["Get the value of a session variable. If inside a [`Meteor.deps`](#meteor_deps) context, invalidate the context the next time the value of the variable is changed by [`Session.set`](#session_set)."],
descr: ["Get the value of a session variable. If inside a [`Meteor.deps`](#meteor_deps) context, invalidate the context the next time the value of the variable is changed by [`Session.set`](#session_set). This returns a clone of the session value, so if it's an object or an array, mutating the returned value has no effect on the value stored in the session."],
args: [
{name: "key",
type: "String",

View File

@@ -20,3 +20,9 @@ Package.on_use(function (api, where) {
api.add_files('session.js', where);
});
Package.on_test(function (api) {
api.use('tinytest');
api.use('session', 'client');
api.add_files('session_tests.js', 'client');
});

View File

@@ -1,93 +1,117 @@
// XXX could use some tests
(function () {
Session = _.extend({}, {
keys: {}, // key -> 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;
}
})();
}
}());

View File

@@ -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);
});
}());