|
|
import { Binary } from './binary'; |
|
|
import type { Document } from './bson'; |
|
|
import { Code } from './code'; |
|
|
import { |
|
|
BSON_INT32_MAX, |
|
|
BSON_INT32_MIN, |
|
|
BSON_INT64_MAX, |
|
|
BSON_INT64_MIN, |
|
|
BSON_MAJOR_VERSION |
|
|
} from './constants'; |
|
|
import { DBRef, isDBRefLike } from './db_ref'; |
|
|
import { Decimal128 } from './decimal128'; |
|
|
import { Double } from './double'; |
|
|
import { BSONError, BSONRuntimeError, BSONVersionError } from './error'; |
|
|
import { Int32 } from './int_32'; |
|
|
import { Long } from './long'; |
|
|
import { MaxKey } from './max_key'; |
|
|
import { MinKey } from './min_key'; |
|
|
import { ObjectId } from './objectid'; |
|
|
import { isDate, isRegExp, isMap } from './parser/utils'; |
|
|
import { BSONRegExp } from './regexp'; |
|
|
import { BSONSymbol } from './symbol'; |
|
|
import { Timestamp } from './timestamp'; |
|
|
|
|
|
|
|
|
export type EJSONOptions = { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
legacy?: boolean; |
|
|
|
|
|
|
|
|
|
|
|
relaxed?: boolean; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useBigInt64?: boolean; |
|
|
}; |
|
|
|
|
|
|
|
|
type BSONType = |
|
|
| Binary |
|
|
| Code |
|
|
| DBRef |
|
|
| Decimal128 |
|
|
| Double |
|
|
| Int32 |
|
|
| Long |
|
|
| MaxKey |
|
|
| MinKey |
|
|
| ObjectId |
|
|
| BSONRegExp |
|
|
| BSONSymbol |
|
|
| Timestamp; |
|
|
|
|
|
function isBSONType(value: unknown): value is BSONType { |
|
|
return ( |
|
|
value != null && |
|
|
typeof value === 'object' && |
|
|
'_bsontype' in value && |
|
|
typeof value._bsontype === 'string' |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const keysToCodecs = { |
|
|
$oid: ObjectId, |
|
|
$binary: Binary, |
|
|
$uuid: Binary, |
|
|
$symbol: BSONSymbol, |
|
|
$numberInt: Int32, |
|
|
$numberDecimal: Decimal128, |
|
|
$numberDouble: Double, |
|
|
$numberLong: Long, |
|
|
$minKey: MinKey, |
|
|
$maxKey: MaxKey, |
|
|
$regex: BSONRegExp, |
|
|
$regularExpression: BSONRegExp, |
|
|
$timestamp: Timestamp |
|
|
} as const; |
|
|
|
|
|
|
|
|
function deserializeValue(value: any, options: EJSONOptions = {}) { |
|
|
if (typeof value === 'number') { |
|
|
|
|
|
const in32BitRange = value <= BSON_INT32_MAX && value >= BSON_INT32_MIN; |
|
|
const in64BitRange = value <= BSON_INT64_MAX && value >= BSON_INT64_MIN; |
|
|
|
|
|
if (options.relaxed || options.legacy) { |
|
|
return value; |
|
|
} |
|
|
|
|
|
if (Number.isInteger(value) && !Object.is(value, -0)) { |
|
|
|
|
|
if (in32BitRange) { |
|
|
return new Int32(value); |
|
|
} |
|
|
if (in64BitRange) { |
|
|
if (options.useBigInt64) { |
|
|
|
|
|
return BigInt(value); |
|
|
} |
|
|
return Long.fromNumber(value); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
return new Double(value); |
|
|
} |
|
|
|
|
|
|
|
|
if (value == null || typeof value !== 'object') return value; |
|
|
|
|
|
|
|
|
if (value.$undefined) return null; |
|
|
|
|
|
const keys = Object.keys(value).filter( |
|
|
k => k.startsWith('$') && value[k] != null |
|
|
) as (keyof typeof keysToCodecs)[]; |
|
|
for (let i = 0; i < keys.length; i++) { |
|
|
const c = keysToCodecs[keys[i]]; |
|
|
if (c) return c.fromExtendedJSON(value, options); |
|
|
} |
|
|
|
|
|
if (value.$date != null) { |
|
|
const d = value.$date; |
|
|
const date = new Date(); |
|
|
|
|
|
if (options.legacy) { |
|
|
if (typeof d === 'number') date.setTime(d); |
|
|
else if (typeof d === 'string') date.setTime(Date.parse(d)); |
|
|
else if (typeof d === 'bigint') date.setTime(Number(d)); |
|
|
else throw new BSONRuntimeError(`Unrecognized type for EJSON date: ${typeof d}`); |
|
|
} else { |
|
|
if (typeof d === 'string') date.setTime(Date.parse(d)); |
|
|
else if (Long.isLong(d)) date.setTime(d.toNumber()); |
|
|
else if (typeof d === 'number' && options.relaxed) date.setTime(d); |
|
|
else if (typeof d === 'bigint') date.setTime(Number(d)); |
|
|
else throw new BSONRuntimeError(`Unrecognized type for EJSON date: ${typeof d}`); |
|
|
} |
|
|
return date; |
|
|
} |
|
|
|
|
|
if (value.$code != null) { |
|
|
const copy = Object.assign({}, value); |
|
|
if (value.$scope) { |
|
|
copy.$scope = deserializeValue(value.$scope); |
|
|
} |
|
|
|
|
|
return Code.fromExtendedJSON(value); |
|
|
} |
|
|
|
|
|
if (isDBRefLike(value) || value.$dbPointer) { |
|
|
const v = value.$ref ? value : value.$dbPointer; |
|
|
|
|
|
|
|
|
|
|
|
if (v instanceof DBRef) return v; |
|
|
|
|
|
const dollarKeys = Object.keys(v).filter(k => k.startsWith('$')); |
|
|
let valid = true; |
|
|
dollarKeys.forEach(k => { |
|
|
if (['$ref', '$id', '$db'].indexOf(k) === -1) valid = false; |
|
|
}); |
|
|
|
|
|
|
|
|
if (valid) return DBRef.fromExtendedJSON(v); |
|
|
} |
|
|
|
|
|
return value; |
|
|
} |
|
|
|
|
|
type EJSONSerializeOptions = EJSONOptions & { |
|
|
seenObjects: { obj: unknown; propertyName: string }[]; |
|
|
}; |
|
|
|
|
|
|
|
|
function serializeArray(array: any[], options: EJSONSerializeOptions): any[] { |
|
|
return array.map((v: unknown, index: number) => { |
|
|
options.seenObjects.push({ propertyName: `index ${index}`, obj: null }); |
|
|
try { |
|
|
return serializeValue(v, options); |
|
|
} finally { |
|
|
options.seenObjects.pop(); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
function getISOString(date: Date) { |
|
|
const isoStr = date.toISOString(); |
|
|
|
|
|
return date.getUTCMilliseconds() !== 0 ? isoStr : isoStr.slice(0, -5) + 'Z'; |
|
|
} |
|
|
|
|
|
|
|
|
function serializeValue(value: any, options: EJSONSerializeOptions): any { |
|
|
if (value instanceof Map || isMap(value)) { |
|
|
const obj: Record<string, unknown> = Object.create(null); |
|
|
for (const [k, v] of value) { |
|
|
if (typeof k !== 'string') { |
|
|
throw new BSONError('Can only serialize maps with string keys'); |
|
|
} |
|
|
obj[k] = v; |
|
|
} |
|
|
|
|
|
return serializeValue(obj, options); |
|
|
} |
|
|
|
|
|
if ((typeof value === 'object' || typeof value === 'function') && value !== null) { |
|
|
const index = options.seenObjects.findIndex(entry => entry.obj === value); |
|
|
if (index !== -1) { |
|
|
const props = options.seenObjects.map(entry => entry.propertyName); |
|
|
const leadingPart = props |
|
|
.slice(0, index) |
|
|
.map(prop => `${prop} -> `) |
|
|
.join(''); |
|
|
const alreadySeen = props[index]; |
|
|
const circularPart = |
|
|
' -> ' + |
|
|
props |
|
|
.slice(index + 1, props.length - 1) |
|
|
.map(prop => `${prop} -> `) |
|
|
.join(''); |
|
|
const current = props[props.length - 1]; |
|
|
const leadingSpace = ' '.repeat(leadingPart.length + alreadySeen.length / 2); |
|
|
const dashes = '-'.repeat( |
|
|
circularPart.length + (alreadySeen.length + current.length) / 2 - 1 |
|
|
); |
|
|
|
|
|
throw new BSONError( |
|
|
'Converting circular structure to EJSON:\n' + |
|
|
` ${leadingPart}${alreadySeen}${circularPart}${current}\n` + |
|
|
` ${leadingSpace}\\${dashes}/` |
|
|
); |
|
|
} |
|
|
options.seenObjects[options.seenObjects.length - 1].obj = value; |
|
|
} |
|
|
|
|
|
if (Array.isArray(value)) return serializeArray(value, options); |
|
|
|
|
|
if (value === undefined) return null; |
|
|
|
|
|
if (value instanceof Date || isDate(value)) { |
|
|
const dateNum = value.getTime(), |
|
|
|
|
|
inRange = dateNum > -1 && dateNum < 253402318800000; |
|
|
|
|
|
if (options.legacy) { |
|
|
return options.relaxed && inRange |
|
|
? { $date: value.getTime() } |
|
|
: { $date: getISOString(value) }; |
|
|
} |
|
|
return options.relaxed && inRange |
|
|
? { $date: getISOString(value) } |
|
|
: { $date: { $numberLong: value.getTime().toString() } }; |
|
|
} |
|
|
|
|
|
if (typeof value === 'number' && (!options.relaxed || !isFinite(value))) { |
|
|
if (Number.isInteger(value) && !Object.is(value, -0)) { |
|
|
|
|
|
if (value >= BSON_INT32_MIN && value <= BSON_INT32_MAX) { |
|
|
return { $numberInt: value.toString() }; |
|
|
} |
|
|
if (value >= BSON_INT64_MIN && value <= BSON_INT64_MAX) { |
|
|
|
|
|
return { $numberLong: value.toString() }; |
|
|
} |
|
|
} |
|
|
return { $numberDouble: Object.is(value, -0) ? '-0.0' : value.toString() }; |
|
|
} |
|
|
|
|
|
if (typeof value === 'bigint') { |
|
|
|
|
|
if (!options.relaxed) { |
|
|
return { $numberLong: BigInt.asIntN(64, value).toString() }; |
|
|
} |
|
|
return Number(BigInt.asIntN(64, value)); |
|
|
|
|
|
} |
|
|
|
|
|
if (value instanceof RegExp || isRegExp(value)) { |
|
|
let flags = value.flags; |
|
|
if (flags === undefined) { |
|
|
const match = value.toString().match(/[gimuy]*$/); |
|
|
if (match) { |
|
|
flags = match[0]; |
|
|
} |
|
|
} |
|
|
|
|
|
const rx = new BSONRegExp(value.source, flags); |
|
|
return rx.toExtendedJSON(options); |
|
|
} |
|
|
|
|
|
if (value != null && typeof value === 'object') return serializeDocument(value, options); |
|
|
return value; |
|
|
} |
|
|
|
|
|
const BSON_TYPE_MAPPINGS = { |
|
|
Binary: (o: Binary) => new Binary(o.value(), o.sub_type), |
|
|
Code: (o: Code) => new Code(o.code, o.scope), |
|
|
DBRef: (o: DBRef) => new DBRef(o.collection || o.namespace, o.oid, o.db, o.fields), |
|
|
Decimal128: (o: Decimal128) => new Decimal128(o.bytes), |
|
|
Double: (o: Double) => new Double(o.value), |
|
|
Int32: (o: Int32) => new Int32(o.value), |
|
|
Long: ( |
|
|
o: Long & { |
|
|
low_: number; |
|
|
high_: number; |
|
|
unsigned_: boolean | undefined; |
|
|
} |
|
|
) => |
|
|
Long.fromBits( |
|
|
|
|
|
o.low != null ? o.low : o.low_, |
|
|
o.low != null ? o.high : o.high_, |
|
|
o.low != null ? o.unsigned : o.unsigned_ |
|
|
), |
|
|
MaxKey: () => new MaxKey(), |
|
|
MinKey: () => new MinKey(), |
|
|
ObjectId: (o: ObjectId) => new ObjectId(o), |
|
|
BSONRegExp: (o: BSONRegExp) => new BSONRegExp(o.pattern, o.options), |
|
|
BSONSymbol: (o: BSONSymbol) => new BSONSymbol(o.value), |
|
|
Timestamp: (o: Timestamp) => Timestamp.fromBits(o.low, o.high) |
|
|
} as const; |
|
|
|
|
|
|
|
|
function serializeDocument(doc: any, options: EJSONSerializeOptions) { |
|
|
if (doc == null || typeof doc !== 'object') throw new BSONError('not an object instance'); |
|
|
|
|
|
const bsontype: BSONType['_bsontype'] = doc._bsontype; |
|
|
if (typeof bsontype === 'undefined') { |
|
|
|
|
|
const _doc: Document = {}; |
|
|
for (const name of Object.keys(doc)) { |
|
|
options.seenObjects.push({ propertyName: name, obj: null }); |
|
|
try { |
|
|
const value = serializeValue(doc[name], options); |
|
|
if (name === '__proto__') { |
|
|
Object.defineProperty(_doc, name, { |
|
|
value, |
|
|
writable: true, |
|
|
enumerable: true, |
|
|
configurable: true |
|
|
}); |
|
|
} else { |
|
|
_doc[name] = value; |
|
|
} |
|
|
} finally { |
|
|
options.seenObjects.pop(); |
|
|
} |
|
|
} |
|
|
return _doc; |
|
|
} else if ( |
|
|
doc != null && |
|
|
typeof doc === 'object' && |
|
|
typeof doc._bsontype === 'string' && |
|
|
doc[Symbol.for('@@mdb.bson.version')] !== BSON_MAJOR_VERSION |
|
|
) { |
|
|
throw new BSONVersionError(); |
|
|
} else if (isBSONType(doc)) { |
|
|
|
|
|
|
|
|
let outDoc: any = doc; |
|
|
if (typeof outDoc.toExtendedJSON !== 'function') { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const mapper = BSON_TYPE_MAPPINGS[doc._bsontype]; |
|
|
if (!mapper) { |
|
|
throw new BSONError('Unrecognized or invalid _bsontype: ' + doc._bsontype); |
|
|
} |
|
|
outDoc = mapper(outDoc); |
|
|
} |
|
|
|
|
|
|
|
|
if (bsontype === 'Code' && outDoc.scope) { |
|
|
outDoc = new Code(outDoc.code, serializeValue(outDoc.scope, options)); |
|
|
} else if (bsontype === 'DBRef' && outDoc.oid) { |
|
|
outDoc = new DBRef( |
|
|
serializeValue(outDoc.collection, options), |
|
|
serializeValue(outDoc.oid, options), |
|
|
serializeValue(outDoc.db, options), |
|
|
serializeValue(outDoc.fields, options) |
|
|
); |
|
|
} |
|
|
|
|
|
return outDoc.toExtendedJSON(options); |
|
|
} else { |
|
|
throw new BSONError('_bsontype must be a string, but was: ' + typeof bsontype); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function parse(text: string, options?: EJSONOptions): any { |
|
|
const ejsonOptions = { |
|
|
useBigInt64: options?.useBigInt64 ?? false, |
|
|
relaxed: options?.relaxed ?? true, |
|
|
legacy: options?.legacy ?? false |
|
|
}; |
|
|
return JSON.parse(text, (key, value) => { |
|
|
if (key.indexOf('\x00') !== -1) { |
|
|
throw new BSONError( |
|
|
`BSON Document field names cannot contain null bytes, found: ${JSON.stringify(key)}` |
|
|
); |
|
|
} |
|
|
return deserializeValue(value, ejsonOptions); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function stringify( |
|
|
|
|
|
value: any, |
|
|
|
|
|
replacer?: (number | string)[] | ((this: any, key: string, value: any) => any) | EJSONOptions, |
|
|
space?: string | number, |
|
|
options?: EJSONOptions |
|
|
): string { |
|
|
if (space != null && typeof space === 'object') { |
|
|
options = space; |
|
|
space = 0; |
|
|
} |
|
|
if (replacer != null && typeof replacer === 'object' && !Array.isArray(replacer)) { |
|
|
options = replacer; |
|
|
replacer = undefined; |
|
|
space = 0; |
|
|
} |
|
|
const serializeOptions = Object.assign({ relaxed: true, legacy: false }, options, { |
|
|
seenObjects: [{ propertyName: '(root)', obj: null }] |
|
|
}); |
|
|
|
|
|
const doc = serializeValue(value, serializeOptions); |
|
|
return JSON.stringify(doc, replacer as Parameters<JSON['stringify']>[1], space); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function EJSONserialize(value: any, options?: EJSONOptions): Document { |
|
|
options = options || {}; |
|
|
return JSON.parse(stringify(value, options)); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function EJSONdeserialize(ejson: Document, options?: EJSONOptions): any { |
|
|
options = options || {}; |
|
|
return parse(JSON.stringify(ejson), options); |
|
|
} |
|
|
|
|
|
|
|
|
const EJSON: { |
|
|
parse: typeof parse; |
|
|
stringify: typeof stringify; |
|
|
serialize: typeof EJSONserialize; |
|
|
deserialize: typeof EJSONdeserialize; |
|
|
} = Object.create(null); |
|
|
EJSON.parse = parse; |
|
|
EJSON.stringify = stringify; |
|
|
EJSON.serialize = EJSONserialize; |
|
|
EJSON.deserialize = EJSONdeserialize; |
|
|
Object.freeze(EJSON); |
|
|
export { EJSON }; |
|
|
|