meteor-ejson
Version:
Extended JSON as seen in Meteor and DDP
446 lines (416 loc) • 11.5 kB
JavaScript
var EJSON = exports; // Global!
var customTypes = {};
var _ = require('underscore');
// Base 64 encoding
var BASE_64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var BASE_64_VALS = {};
for (var i = 0; i < BASE_64_CHARS.length; i++) {
BASE_64_VALS[BASE_64_CHARS.charAt(i)] = i;
};
EJSON._base64Encode = function (array) {
var answer = [];
var a = null;
var b = null;
var c = null;
var d = null;
for (var i = 0; i < array.length; i++) {
switch (i % 3) {
case 0:
a = (array[i] >> 2) & 0x3F;
b = (array[i] & 0x03) << 4;
break;
case 1:
b = b | (array[i] >> 4) & 0xF;
c = (array[i] & 0xF) << 2;
break;
case 2:
c = c | (array[i] >> 6) & 0x03;
d = array[i] & 0x3F;
answer.push(getChar(a));
answer.push(getChar(b));
answer.push(getChar(c));
answer.push(getChar(d));
a = null;
b = null;
c = null;
d = null;
break;
}
}
if (a != null) {
answer.push(getChar(a));
answer.push(getChar(b));
if (c == null)
answer.push('=');
else
answer.push(getChar(c));
if (d == null)
answer.push('=');
}
return answer.join("");
};
var getChar = function (val) {
return BASE_64_CHARS.charAt(val);
};
var getVal = function (ch) {
if (ch === '=') {
return -1;
}
return BASE_64_VALS[ch];
};
EJSON.newBinary = function (len) {
if (typeof Uint8Array === 'undefined' || typeof ArrayBuffer === 'undefined') {
var ret = [];
for (var i = 0; i < len; i++) {
ret.push(0);
}
ret.$Uint8ArrayPolyfill = true;
return ret;
}
return new Uint8Array(new ArrayBuffer(len));
};
EJSON._base64Decode = function (str) {
var len = Math.floor((str.length*3)/4);
if (str.charAt(str.length - 1) == '=') {
len--;
if (str.charAt(str.length - 2) == '=')
len--;
}
var arr = EJSON.newBinary(len);
var one = null;
var two = null;
var three = null;
var j = 0;
for (var i = 0; i < str.length; i++) {
var c = str.charAt(i);
var v = getVal(c);
switch (i % 4) {
case 0:
if (v < 0)
throw new Error('invalid base64 string');
one = v << 2;
break;
case 1:
if (v < 0)
throw new Error('invalid base64 string');
one = one | (v >> 4);
arr[j++] = one;
two = (v & 0x0F) << 4;
break;
case 2:
if (v > 0) {
two = two | (v >> 2);
arr[j++] = two;
three = (v & 0x03) << 6;
}
break;
case 3:
if (v > 0) {
arr[j++] = three | v;
}
break;
}
}
return arr;
};
// Add a custom type, using a method of your choice to get to and
// from a basic JSON-able representation. The factory argument
// is a function of JSON-able --> your object
// The type you add must have:
// - A clone() method, so that Meteor can deep-copy it when necessary.
// - A equals() method, so that Meteor can compare it
// - A toJSONValue() method, so that Meteor can serialize it
// - a typeName() method, to show how to look it up in our type table.
// It is okay if these methods are monkey-patched on.
EJSON.addType = function (name, factory) {
if (_.has(customTypes, name))
throw new Error("Type " + name + " already present");
customTypes[name] = factory;
};
var builtinConverters = [
{ // Date
matchJSONValue: function (obj) {
return _.has(obj, '$date') && _.size(obj) === 1;
},
matchObject: function (obj) {
return obj instanceof Date;
},
toJSONValue: function (obj) {
return {$date: obj.getTime()};
},
fromJSONValue: function (obj) {
return new Date(obj.$date);
}
},
{ // Binary
matchJSONValue: function (obj) {
return _.has(obj, '$binary') && _.size(obj) === 1;
},
matchObject: function (obj) {
return typeof Uint8Array !== 'undefined' && obj instanceof Uint8Array
|| (obj && _.has(obj, '$Uint8ArrayPolyfill'));
},
toJSONValue: function (obj) {
return {$binary: EJSON._base64Encode(obj)};
},
fromJSONValue: function (obj) {
return EJSON._base64Decode(obj.$binary);
}
},
{ // Escaping one level
matchJSONValue: function (obj) {
return _.has(obj, '$escape') && _.size(obj) === 1;
},
matchObject: function (obj) {
if (_.isEmpty(obj) || _.size(obj) > 2) {
return false;
}
return _.any(builtinConverters, function (converter) {
return converter.matchJSONValue(obj);
});
},
toJSONValue: function (obj) {
var newObj = {};
_.each(obj, function (value, key) {
newObj[key] = EJSON.toJSONValue(value);
});
return {$escape: newObj};
},
fromJSONValue: function (obj) {
var newObj = {};
_.each(obj.$escape, function (value, key) {
newObj[key] = EJSON.fromJSONValue(value);
});
return newObj;
}
},
{ // Custom
matchJSONValue: function (obj) {
return _.has(obj, '$type') && _.has(obj, '$value') && _.size(obj) === 2;
},
matchObject: function (obj) {
return EJSON._isCustomType(obj);
},
toJSONValue: function (obj) {
return {$type: obj.typeName(), $value: obj.toJSONValue()};
},
fromJSONValue: function (obj) {
var typeName = obj.$type;
var converter = customTypes[typeName];
return converter(obj.$value);
}
}
];
EJSON._isCustomType = function (obj) {
return obj &&
typeof obj.toJSONValue === 'function' &&
typeof obj.typeName === 'function' &&
_.has(customTypes, obj.typeName());
};
//for both arrays and objects, in-place modification.
var adjustTypesToJSONValue =
EJSON._adjustTypesToJSONValue = function (obj) {
if (obj === null)
return null;
var maybeChanged = toJSONValueHelper(obj);
if (maybeChanged !== undefined)
return maybeChanged;
_.each(obj, function (value, key) {
if (typeof value !== 'object' && value !== undefined)
return; // continue
var changed = toJSONValueHelper(value);
if (changed) {
obj[key] = changed;
return; // on to the next key
}
// if we get here, value is an object but not adjustable
// at this level. recurse.
adjustTypesToJSONValue(value);
});
return obj;
};
// Either return the JSON-compatible version of the argument, or undefined (if
// the item isn't itself replaceable, but maybe some fields in it are)
var toJSONValueHelper = function (item) {
for (var i = 0; i < builtinConverters.length; i++) {
var converter = builtinConverters[i];
if (converter.matchObject(item)) {
return converter.toJSONValue(item);
}
}
return undefined;
};
EJSON.toJSONValue = function (item) {
var changed = toJSONValueHelper(item);
if (changed !== undefined)
return changed;
if (typeof item === 'object') {
item = EJSON.clone(item);
adjustTypesToJSONValue(item);
}
return item;
};
//for both arrays and objects. Tries its best to just
// use the object you hand it, but may return something
// different if the object you hand it itself needs changing.
var adjustTypesFromJSONValue =
EJSON._adjustTypesFromJSONValue = function (obj) {
if (obj === null)
return null;
var maybeChanged = fromJSONValueHelper(obj);
if (maybeChanged !== obj)
return maybeChanged;
_.each(obj, function (value, key) {
if (typeof value === 'object') {
var changed = fromJSONValueHelper(value);
if (value !== changed) {
obj[key] = changed;
return;
}
// if we get here, value is an object but not adjustable
// at this level. recurse.
adjustTypesFromJSONValue(value);
}
});
return obj;
};
// Either return the argument changed to have the non-json
// rep of itself (the Object version) or the argument itself.
// DOES NOT RECURSE. For actually getting the fully-changed value, use
// EJSON.fromJSONValue
var fromJSONValueHelper = function (value) {
if (typeof value === 'object' && value !== null) {
if (_.size(value) <= 2
&& _.all(value, function (v, k) {
return typeof k === 'string' && k.substr(0, 1) === '$';
})) {
for (var i = 0; i < builtinConverters.length; i++) {
var converter = builtinConverters[i];
if (converter.matchJSONValue(value)) {
return converter.fromJSONValue(value);
}
}
}
}
return value;
};
EJSON.fromJSONValue = function (item) {
var changed = fromJSONValueHelper(item);
if (changed === item && typeof item === 'object') {
item = EJSON.clone(item);
adjustTypesFromJSONValue(item);
return item;
} else {
return changed;
}
};
EJSON.stringify = function (item) {
return JSON.stringify(EJSON.toJSONValue(item));
};
EJSON.parse = function (item) {
return EJSON.fromJSONValue(JSON.parse(item));
};
EJSON.isBinary = function (obj) {
return (typeof Uint8Array !== 'undefined' && obj instanceof Uint8Array) ||
(obj && obj.$Uint8ArrayPolyfill);
};
EJSON.equals = function (a, b, options) {
var i;
var keyOrderSensitive = !!(options && options.keyOrderSensitive);
if (a === b)
return true;
if (!a || !b) // if either one is falsy, they'd have to be === to be equal
return false;
if (!(typeof a === 'object' && typeof b === 'object'))
return false;
if (a instanceof Date && b instanceof Date)
return a.valueOf() === b.valueOf();
if (EJSON.isBinary(a) && EJSON.isBinary(b)) {
if (a.length !== b.length)
return false;
for (i = 0; i < a.length; i++) {
if (a[i] !== b[i])
return false;
}
return true;
}
if (typeof (a.equals) === 'function')
return a.equals(b, options);
if (a instanceof Array) {
if (!(b instanceof Array))
return false;
if (a.length !== b.length)
return false;
for (i = 0; i < a.length; i++) {
if (!EJSON.equals(a[i], b[i], options))
return false;
}
return true;
}
// fall back to structural equality of objects
var ret;
if (keyOrderSensitive) {
var bKeys = [];
_.each(b, function (val, x) {
bKeys.push(x);
});
i = 0;
ret = _.all(a, function (val, x) {
if (i >= bKeys.length) {
return false;
}
if (x !== bKeys[i]) {
return false;
}
if (!EJSON.equals(val, b[bKeys[i]], options)) {
return false;
}
i++;
return true;
});
return ret && i === bKeys.length;
} else {
i = 0;
ret = _.all(a, function (val, key) {
if (!_.has(b, key)) {
return false;
}
if (!EJSON.equals(val, b[key], options)) {
return false;
}
i++;
return true;
});
return ret && _.size(b) === i;
}
};
EJSON.clone = function (v) {
var ret;
if (typeof v !== "object")
return v;
if (v === null)
return null; // null has typeof "object"
if (v instanceof Date)
return new Date(v.getTime());
if (EJSON.isBinary(v)) {
ret = EJSON.newBinary(v.length);
for (i = 0; i < v.length; i++) {
ret[i] = v[i];
}
return ret;
}
// Clone arrays (and turn 'arguments' into an array).
if (_.isArray(v) || _.isArguments(v)) {
return _.map(v, EJSON.clone);
}
// handle general user-defined typed Objects if they have a clone method
if (typeof v.clone === 'function') {
return v.clone();
}
// handle other objects
ret = {};
_.each(v, function (value, key) {
ret[key] = EJSON.clone(value);
});
return ret;
};