UNPKG

mongodb

Version:
354 lines (315 loc) 11.4 kB
import { Binary, type BSONElement, BSONError, BSONType, deserialize, type DeserializeOptions, getBigInt64LE, getFloat64LE, getInt32LE, ObjectId, parseToElementsToArray, Timestamp, toUTF8 } from '../../../bson'; // eslint-disable-next-line no-restricted-syntax const enum BSONElementOffset { type = 0, nameOffset = 1, nameLength = 2, offset = 3, length = 4 } /** @internal */ export type JSTypeOf = { [BSONType.null]: null; [BSONType.undefined]: null; [BSONType.double]: number; [BSONType.int]: number; [BSONType.long]: bigint; [BSONType.timestamp]: Timestamp; [BSONType.binData]: Binary; [BSONType.bool]: boolean; [BSONType.objectId]: ObjectId; [BSONType.string]: string; [BSONType.date]: Date; [BSONType.object]: OnDemandDocument; [BSONType.array]: OnDemandDocument; }; /** @internal */ type CachedBSONElement = { element: BSONElement; value: any | undefined }; /** * @internal * * Options for `OnDemandDocument.toObject()`. Validation is required to ensure * that callers provide utf8 validation options. */ export type OnDemandDocumentDeserializeOptions = Omit<DeserializeOptions, 'validation'> & Required<Pick<DeserializeOptions, 'validation'>>; /** @internal */ export class OnDemandDocument { /** * Maps JS strings to elements and jsValues for speeding up subsequent lookups. * - If `false` then name does not exist in the BSON document * - If `CachedBSONElement` instance name exists * - If `cache[name].value == null` jsValue has not yet been parsed * - Null/Undefined values do not get cached because they are zero-length values. */ private readonly cache: Record<string, CachedBSONElement | false | undefined> = Object.create(null); /** Caches the index of elements that have been named */ private readonly indexFound: Record<number, boolean> = Object.create(null); /** All bson elements in this document */ private readonly elements: ReadonlyArray<BSONElement>; constructor( /** BSON bytes, this document begins at offset */ protected readonly bson: Uint8Array, /** The start of the document */ private readonly offset = 0, /** If this is an embedded document, indicates if this was a BSON array */ public readonly isArray = false, /** If elements was already calculated */ elements?: BSONElement[] ) { this.elements = elements ?? parseToElementsToArray(this.bson, offset); } /** Only supports basic latin strings */ private isElementName(name: string, element: BSONElement): boolean { const nameLength = element[BSONElementOffset.nameLength]; const nameOffset = element[BSONElementOffset.nameOffset]; if (name.length !== nameLength) return false; const nameEnd = nameOffset + nameLength; for ( let byteIndex = nameOffset, charIndex = 0; charIndex < name.length && byteIndex < nameEnd; charIndex++, byteIndex++ ) { if (this.bson[byteIndex] !== name.charCodeAt(charIndex)) return false; } return true; } /** * Seeks into the elements array for an element matching the given name. * * @remarks * Caching: * - Caches the existence of a property making subsequent look ups for non-existent properties return immediately * - Caches names mapped to elements to avoid reiterating the array and comparing the name again * - Caches the index at which an element has been found to prevent rechecking against elements already determined to belong to another name * * @param name - a basic latin string name of a BSON element * @returns */ private getElement(name: string | number): CachedBSONElement | null { const cachedElement = this.cache[name]; if (cachedElement === false) return null; if (cachedElement != null) { return cachedElement; } if (typeof name === 'number') { if (this.isArray) { if (name < this.elements.length) { const element = this.elements[name]; const cachedElement = { element, value: undefined }; this.cache[name] = cachedElement; this.indexFound[name] = true; return cachedElement; } else { return null; } } else { return null; } } for (let index = 0; index < this.elements.length; index++) { const element = this.elements[index]; // skip this element if it has already been associated with a name if (!(index in this.indexFound) && this.isElementName(name, element)) { const cachedElement = { element, value: undefined }; this.cache[name] = cachedElement; this.indexFound[index] = true; return cachedElement; } } this.cache[name] = false; return null; } /** * Translates BSON bytes into a javascript value. Checking `as` against the BSON element's type * this methods returns the small subset of BSON types that the driver needs to function. * * @remarks * - BSONType.null and BSONType.undefined always return null * - If the type requested does not match this returns null * * @param element - The element to revive to a javascript value * @param as - A type byte expected to be returned */ private toJSValue<T extends keyof JSTypeOf>(element: BSONElement, as: T): JSTypeOf[T]; private toJSValue(element: BSONElement, as: keyof JSTypeOf): any { const type = element[BSONElementOffset.type]; const offset = element[BSONElementOffset.offset]; const length = element[BSONElementOffset.length]; if (as !== type) { return null; } switch (as) { case BSONType.null: case BSONType.undefined: return null; case BSONType.double: return getFloat64LE(this.bson, offset); case BSONType.int: return getInt32LE(this.bson, offset); case BSONType.long: return getBigInt64LE(this.bson, offset); case BSONType.bool: return Boolean(this.bson[offset]); case BSONType.objectId: return new ObjectId(this.bson.subarray(offset, offset + 12)); case BSONType.timestamp: return new Timestamp(getBigInt64LE(this.bson, offset)); case BSONType.string: return toUTF8(this.bson, offset + 4, offset + length - 1, false); case BSONType.binData: { const totalBinarySize = getInt32LE(this.bson, offset); const subType = this.bson[offset + 4]; if (subType === 2) { const subType2BinarySize = getInt32LE(this.bson, offset + 1 + 4); if (subType2BinarySize < 0) throw new BSONError('Negative binary type element size found for subtype 0x02'); if (subType2BinarySize > totalBinarySize - 4) throw new BSONError('Binary type with subtype 0x02 contains too long binary size'); if (subType2BinarySize < totalBinarySize - 4) throw new BSONError('Binary type with subtype 0x02 contains too short binary size'); return new Binary( this.bson.subarray(offset + 1 + 4 + 4, offset + 1 + 4 + 4 + subType2BinarySize), 2 ); } return new Binary( this.bson.subarray(offset + 1 + 4, offset + 1 + 4 + totalBinarySize), subType ); } case BSONType.date: // Pretend this is correct. return new Date(Number(getBigInt64LE(this.bson, offset))); case BSONType.object: return new OnDemandDocument(this.bson, offset); case BSONType.array: return new OnDemandDocument(this.bson, offset, true); default: throw new BSONError(`Unsupported BSON type: ${as}`); } } /** * Returns the number of elements in this BSON document */ public size() { return this.elements.length; } /** * Checks for the existence of an element by name. * * @remarks * Uses `getElement` with the expectation that will populate caches such that a `has` call * followed by a `getElement` call will not repeat the cost paid by the first look up. * * @param name - element name */ public has(name: string): boolean { const cachedElement = this.cache[name]; if (cachedElement === false) return false; if (cachedElement != null) return true; return this.getElement(name) != null; } /** * Turns BSON element with `name` into a javascript value. * * @typeParam T - must be one of the supported BSON types determined by `JSTypeOf` this will determine the return type of this function. * @param name - the element name * @param as - the bson type expected * @param required - whether or not the element is expected to exist, if true this function will throw if it is not present */ public get<const T extends keyof JSTypeOf>( name: string | number, as: T, required?: boolean ): JSTypeOf[T] | null; /** `required` will make `get` throw if name does not exist or is null/undefined */ public get<const T extends keyof JSTypeOf>( name: string | number, as: T, required: true ): JSTypeOf[T]; public get<const T extends keyof JSTypeOf>( name: string | number, as: T, required?: boolean ): JSTypeOf[T] | null { const element = this.getElement(name); if (element == null) { if (required === true) { throw new BSONError(`BSON element "${name}" is missing`); } else { return null; } } if (element.value == null) { const value = this.toJSValue(element.element, as); if (value == null) { if (required === true) { throw new BSONError(`BSON element "${name}" is missing`); } else { return null; } } // It is important to never store null element.value = value; } return element.value; } /** * Supports returning int, double, long, and bool as javascript numbers * * @remarks * **NOTE:** * - Use this _only_ when you believe the potential precision loss of an int64 is acceptable * - This method does not cache the result as Longs or booleans would be stored incorrectly * * @param name - element name * @param required - throws if name does not exist */ public getNumber<const Req extends boolean = false>( name: string, required?: Req ): Req extends true ? number : number | null; public getNumber(name: string, required: boolean): number | null { const maybeBool = this.get(name, BSONType.bool); const bool = maybeBool == null ? null : maybeBool ? 1 : 0; const maybeLong = this.get(name, BSONType.long); const long = maybeLong == null ? null : Number(maybeLong); const result = bool ?? long ?? this.get(name, BSONType.int) ?? this.get(name, BSONType.double); if (required === true && result == null) { throw new BSONError(`BSON element "${name}" is missing`); } return result; } /** * Deserialize this object, DOES NOT cache result so avoid multiple invocations * @param options - BSON deserialization options */ public toObject(options?: OnDemandDocumentDeserializeOptions): Record<string, any> { return deserialize(this.bson, { ...options, index: this.offset, allowObjectSmallerThanBufferSize: true }); } /** Returns this document's bytes only */ toBytes() { const size = getInt32LE(this.bson, this.offset); return this.bson.subarray(this.offset, this.offset + size); } }