Skip to content

Commit d6b71ab

Browse files
committed
feat(ext-json): add extended JSON support to the bson library
1 parent 10e5f00 commit d6b71ab

File tree

1 file changed

+297
-0
lines changed

1 file changed

+297
-0
lines changed

lib/extended_json.js

+297
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
'use strict';
2+
3+
// const Buffer = require('buffer').Buffer;
4+
// const Map = require('./map');
5+
const Long = require('./long');
6+
const Double = require('./double');
7+
const Timestamp = require('./timestamp');
8+
const ObjectId = require('./objectid');
9+
const BSONRegExp = require('./regexp');
10+
const Symbol = require('./symbol');
11+
const Int32 = require('./int_32');
12+
const Code = require('./code');
13+
const Decimal128 = require('./decimal128');
14+
const MinKey = require('./min_key');
15+
const MaxKey = require('./max_key');
16+
const DBRef = require('./db_ref');
17+
const Binary = require('./binary');
18+
19+
// all the types where we don't need to do any special processing and can just pass the EJSON
20+
//straight to type.fromExtendedJSON
21+
const keysToCodecs = {
22+
$oid: ObjectId,
23+
$binary: Binary,
24+
$symbol: Symbol,
25+
$numberInt: Int32,
26+
$numberDecimal: Decimal128,
27+
$numberDouble: Double,
28+
$numberLong: Long,
29+
$minKey: MinKey,
30+
$maxKey: MaxKey,
31+
$regularExpression: BSONRegExp,
32+
$timestamp: Timestamp
33+
};
34+
35+
// const BSONTypes = Object.keys(BSON);
36+
37+
function deserializeValue(self, key, value, options) {
38+
if (typeof value === 'number') {
39+
// if it's an integer, should interpret as smallest BSON integer
40+
// that can represent it exactly. (if out of range, interpret as double.)
41+
if (Math.floor(value) === value) {
42+
let int32Range = value >= BSON_INT32_MIN && value <= BSON_INT32_MAX,
43+
int64Range = value >= BSON_INT64_MIN && value <= BSON_INT64_MAX;
44+
45+
if (int32Range) return options.strict ? new Int32(value) : value;
46+
if (int64Range) return options.strict ? new Long.fromNumber(value) : value;
47+
}
48+
// If the number is a non-integer or out of integer range, should interpret as BSON Double.
49+
return new Double(value);
50+
}
51+
52+
// from here on out we're looking for bson types, so bail if its not an object
53+
if (value == null || typeof value !== 'object') return value;
54+
55+
// upgrade deprecated undefined to null
56+
if (value.$undefined) return null;
57+
58+
const keys = Object.keys(value).filter(k => k.startsWith('$') && value[k] != null);
59+
for (let i = 0; i < keys.length; i++) {
60+
let c = keysToCodecs[keys[i]];
61+
if (c) return c.fromExtendedJSON(value, options);
62+
}
63+
64+
if (value.$date != null) {
65+
const d = value.$date;
66+
const date = new Date();
67+
68+
if (typeof d === 'string') date.setTime(Date.parse(d));
69+
else if (d instanceof Long) date.setTime(d.toNumber());
70+
else if (typeof d === 'number' && options.relaxed) date.setTime(d);
71+
return date;
72+
}
73+
74+
if (value.$code != null) {
75+
let copy = Object.assign({}, value);
76+
if (value.$scope) {
77+
copy.$scope = deserializeValue(self, null, value.$scope);
78+
}
79+
80+
return Code.fromExtendedJSON(value);
81+
}
82+
83+
if (value.$ref != null || value.$dbPointer != null) {
84+
let v = value.$ref ? value : value.$dbPointer;
85+
86+
// we run into this in a "degenerate EJSON" case (with $id and $ref order flipped)
87+
// because of the order JSON.parse goes through the document
88+
if (v instanceof DBRef) return v;
89+
90+
const dollarKeys = Object.keys(v).filter(k => k.startsWith('$'));
91+
let valid = true;
92+
dollarKeys.forEach(k => {
93+
if (['$ref', '$id', '$db'].indexOf(k) === -1) valid = false;
94+
});
95+
96+
// only make DBRef if $ keys are all valid
97+
if (valid) return DBRef.fromExtendedJSON(v);
98+
}
99+
100+
return value;
101+
}
102+
103+
/**
104+
* Parse an Extended JSON string, constructing the JavaScript value or object described by that
105+
* string.
106+
*
107+
* @param {string} text
108+
* @param {object} [options] Optional settings
109+
* @param {boolean} [options.relaxed=true] Attempt to return native JS types where possible, rather than BSON types (if true)
110+
* @return {object}
111+
*
112+
* @example
113+
* const EJSON = require('mongodb-extjson');
114+
* const text = '{ "int32": { "$numberInt": "10" } }';
115+
*
116+
* // prints { int32: { [String: '10'] _bsontype: 'Int32', value: '10' } }
117+
* console.log(EJSON.parse(text, { relaxed: false }));
118+
*
119+
* // prints { int32: 10 }
120+
* console.log(EJSON.parse(text));
121+
*/
122+
function parse(text, options) {
123+
options = Object.assign({}, { relaxed: true }, options);
124+
125+
// relaxed implies not strict
126+
if (typeof options.relaxed === 'boolean') options.strict = !options.relaxed;
127+
if (typeof options.strict === 'boolean') options.relaxed = !options.strict;
128+
129+
return JSON.parse(text, (key, value) => deserializeValue(this, key, value, options));
130+
}
131+
132+
//
133+
// Serializer
134+
//
135+
136+
// MAX INT32 boundaries
137+
const BSON_INT32_MAX = 0x7fffffff,
138+
BSON_INT32_MIN = -0x80000000,
139+
BSON_INT64_MAX = 0x7fffffffffffffff,
140+
BSON_INT64_MIN = -0x8000000000000000;
141+
142+
/**
143+
* Converts a BSON document to an Extended JSON string, optionally replacing values if a replacer
144+
* function is specified or optionally including only the specified properties if a replacer array
145+
* is specified.
146+
*
147+
* @param {object} value The value to convert to extended JSON
148+
* @param {function|array} [replacer] A function that alters the behavior of the stringification process, or an array of String and Number objects that serve as a whitelist for selecting/filtering the properties of the value object to be included in the JSON string. If this value is null or not provided, all properties of the object are included in the resulting JSON string
149+
* @param {string|number} [space] A String or Number object that's used to insert white space into the output JSON string for readability purposes.
150+
* @param {object} [options] Optional settings
151+
* @param {boolean} [options.relaxed=true] Enabled Extended JSON's `relaxed` mode
152+
* @returns {string}
153+
*
154+
* @example
155+
* const EJSON = require('mongodb-extjson');
156+
* const Int32 = require('mongodb').Int32;
157+
* const doc = { int32: new Int32(10) };
158+
*
159+
* // prints '{"int32":{"$numberInt":"10"}}'
160+
* console.log(EJSON.stringify(doc, { relaxed: false }));
161+
*
162+
* // prints '{"int32":10}'
163+
* console.log(EJSON.stringify(doc));
164+
*/
165+
function stringify(value, replacer, space, options) {
166+
if (space != null && typeof space === 'object') (options = space), (space = 0);
167+
if (replacer != null && typeof replacer === 'object')
168+
(options = replacer), (replacer = null), (space = 0);
169+
options = Object.assign({}, { relaxed: true }, options);
170+
171+
const doc = Array.isArray(value)
172+
? serializeArray(value, options)
173+
: serializeDocument(value, options);
174+
175+
return JSON.stringify(doc, replacer, space);
176+
}
177+
178+
/**
179+
* Serializes an object to an Extended JSON string, and reparse it as a JavaScript object.
180+
*
181+
* @param {object} bson The object to serialize
182+
* @param {object} [options] Optional settings passed to the `stringify` function
183+
* @return {object}
184+
*/
185+
function serialize(bson, options) {
186+
options = options || {};
187+
return JSON.parse(stringify(bson, options));
188+
}
189+
190+
/**
191+
* Deserializes an Extended JSON object into a plain JavaScript object with native/BSON types
192+
*
193+
* @param {object} ejson The Extended JSON object to deserialize
194+
* @param {object} [options] Optional settings passed to the parse method
195+
* @return {object}
196+
*/
197+
function deserialize(ejson, options) {
198+
options = options || {};
199+
return parse(JSON.stringify(ejson), options);
200+
}
201+
202+
function serializeArray(array, options) {
203+
return array.map(v => serializeValue(v, options));
204+
}
205+
206+
function getISOString(date) {
207+
const isoStr = date.toISOString();
208+
// we should only show milliseconds in timestamp if they're non-zero
209+
return date.getUTCMilliseconds() !== 0 ? isoStr : isoStr.slice(0, -5) + 'Z';
210+
}
211+
212+
function serializeValue(value, options) {
213+
if (Array.isArray(value)) return serializeArray(value, options);
214+
215+
if (value === undefined) return null;
216+
217+
if (value instanceof Date) {
218+
let dateNum = value.getTime(),
219+
// is it in year range 1970-9999?
220+
inRange = dateNum > -1 && dateNum < 253402318800000;
221+
222+
return options.relaxed && inRange
223+
? { $date: getISOString(value) }
224+
: { $date: { $numberLong: value.getTime().toString() } };
225+
}
226+
227+
if (typeof value === 'number' && !options.relaxed) {
228+
// it's an integer
229+
if (Math.floor(value) === value) {
230+
let int32Range = value >= BSON_INT32_MIN && value <= BSON_INT32_MAX,
231+
int64Range = value >= BSON_INT64_MIN && value <= BSON_INT64_MAX;
232+
233+
// interpret as being of the smallest BSON integer type that can represent the number exactly
234+
if (int32Range) return { $numberInt: value.toString() };
235+
if (int64Range) return { $numberLong: value.toString() };
236+
}
237+
return { $numberDouble: value.toString() };
238+
}
239+
240+
if (value != null && typeof value === 'object') return serializeDocument(value, options);
241+
return value;
242+
}
243+
244+
function serializeDocument(doc, options) {
245+
if (doc == null || typeof doc !== 'object') throw new Error('not an object instance');
246+
247+
// the document itself is a BSON type
248+
if (doc._bsontype && typeof doc.toExtendedJSON === 'function') {
249+
if (doc._bsontype === 'Code' && doc.scope) {
250+
doc.scope = serializeDocument(doc.scope, options);
251+
} else if (doc._bsontype === 'DBRef' && doc.oid) {
252+
doc.oid = serializeDocument(doc.oid, options);
253+
}
254+
255+
return doc.toExtendedJSON(options);
256+
}
257+
258+
// the document is an object with nested BSON types
259+
const _doc = {};
260+
for (let name in doc) {
261+
let val = doc[name];
262+
if (Array.isArray(val)) {
263+
_doc[name] = serializeArray(val, options);
264+
} else if (val != null && typeof val.toExtendedJSON === 'function') {
265+
if (val._bsontype === 'Code' && val.scope) {
266+
val.scope = serializeDocument(val.scope, options);
267+
} else if (val._bsontype === 'DBRef' && val.oid) {
268+
val.oid = serializeDocument(val.oid, options);
269+
}
270+
271+
_doc[name] = val.toExtendedJSON(options);
272+
} else if (val instanceof Date) {
273+
_doc[name] = serializeValue(val, options);
274+
} else if (val != null && typeof val === 'object') {
275+
_doc[name] = serializeDocument(val, options);
276+
}
277+
_doc[name] = serializeValue(val, options);
278+
if (val instanceof RegExp) {
279+
let flags = val.flags;
280+
if (flags === undefined) {
281+
flags = val.toString().match(/[gimuy]*$/)[0];
282+
}
283+
284+
const rx = new BSONRegExp(val.source, flags);
285+
_doc[name] = rx.toExtendedJSON();
286+
}
287+
}
288+
289+
return _doc;
290+
}
291+
292+
module.exports = {
293+
parse,
294+
deserialize,
295+
serialize,
296+
stringify
297+
};

0 commit comments

Comments
 (0)