Skip to content

Commit cb5903f

Browse files
refactor(NODE-6055): implement OnDemandDocument (#4061)
Co-authored-by: Bailey Pearson <[email protected]>
1 parent 45cc63c commit cb5903f

File tree

6 files changed

+638
-5
lines changed

6 files changed

+638
-5
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
},
2727
"dependencies": {
2828
"@mongodb-js/saslprep": "^1.1.5",
29-
"bson": "^6.5.0",
29+
"bson": "^6.6.0",
3030
"mongodb-connection-string-url": "^3.0.0"
3131
},
3232
"peerDependencies": {

src/bson.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { DeserializeOptions, SerializeOptions } from 'bson';
2+
import { BSON } from 'bson';
23

34
export {
45
Binary,
56
BSON,
7+
BSONError,
68
BSONRegExp,
79
BSONSymbol,
810
BSONType,
@@ -25,6 +27,17 @@ export {
2527
UUID
2628
} from 'bson';
2729

30+
export type BSONElement = BSON.OnDemand['BSONElement'];
31+
32+
export function parseToElementsToArray(bytes: Uint8Array, offset?: number): BSONElement[] {
33+
const res = BSON.onDemand.parseToElements(bytes, offset);
34+
return Array.isArray(res) ? res : [...res];
35+
}
36+
export const getInt32LE = BSON.onDemand.NumberUtils.getInt32LE;
37+
export const getFloat64LE = BSON.onDemand.NumberUtils.getFloat64LE;
38+
export const getBigInt64LE = BSON.onDemand.NumberUtils.getBigInt64LE;
39+
export const toUTF8 = BSON.onDemand.ByteUtils.toUTF8;
40+
2841
/**
2942
* BSON Serialization options.
3043
* @public
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import {
2+
Binary,
3+
BSON,
4+
type BSONElement,
5+
BSONError,
6+
type BSONSerializeOptions,
7+
BSONType,
8+
getBigInt64LE,
9+
getFloat64LE,
10+
getInt32LE,
11+
ObjectId,
12+
parseToElementsToArray,
13+
Timestamp,
14+
toUTF8
15+
} from '../../../bson';
16+
17+
// eslint-disable-next-line no-restricted-syntax
18+
const enum BSONElementOffset {
19+
type = 0,
20+
nameOffset = 1,
21+
nameLength = 2,
22+
offset = 3,
23+
length = 4
24+
}
25+
26+
export type JSTypeOf = {
27+
[BSONType.null]: null;
28+
[BSONType.undefined]: null;
29+
[BSONType.double]: number;
30+
[BSONType.int]: number;
31+
[BSONType.long]: bigint;
32+
[BSONType.timestamp]: Timestamp;
33+
[BSONType.binData]: Binary;
34+
[BSONType.bool]: boolean;
35+
[BSONType.objectId]: ObjectId;
36+
[BSONType.string]: string;
37+
[BSONType.date]: Date;
38+
[BSONType.object]: OnDemandDocument;
39+
[BSONType.array]: OnDemandDocument;
40+
};
41+
42+
/** @internal */
43+
type CachedBSONElement = { element: BSONElement; value: any | undefined };
44+
45+
/** @internal */
46+
export class OnDemandDocument {
47+
/**
48+
* Maps JS strings to elements and jsValues for speeding up subsequent lookups.
49+
* - If `false` then name does not exist in the BSON document
50+
* - If `CachedBSONElement` instance name exists
51+
* - If `cache[name].value == null` jsValue has not yet been parsed
52+
* - Null/Undefined values do not get cached because they are zero-length values.
53+
*/
54+
private readonly cache: Record<string, CachedBSONElement | false | undefined> =
55+
Object.create(null);
56+
/** Caches the index of elements that have been named */
57+
private readonly indexFound: Record<number, boolean> = Object.create(null);
58+
59+
/** All bson elements in this document */
60+
private readonly elements: BSONElement[];
61+
62+
constructor(
63+
/** BSON bytes, this document begins at offset */
64+
protected readonly bson: Uint8Array,
65+
/** The start of the document */
66+
private readonly offset = 0,
67+
/** If this is an embedded document, indicates if this was a BSON array */
68+
public readonly isArray = false
69+
) {
70+
this.elements = parseToElementsToArray(this.bson, offset);
71+
}
72+
73+
/** Only supports basic latin strings */
74+
private isElementName(name: string, element: BSONElement): boolean {
75+
const nameLength = element[BSONElementOffset.nameLength];
76+
const nameOffset = element[BSONElementOffset.nameOffset];
77+
78+
if (name.length !== nameLength) return false;
79+
80+
for (let i = 0; i < name.length; i++) {
81+
if (this.bson[nameOffset + i] !== name.charCodeAt(i)) return false;
82+
}
83+
84+
return true;
85+
}
86+
87+
/**
88+
* Seeks into the elements array for an element matching the given name.
89+
*
90+
* @remarks
91+
* Caching:
92+
* - Caches the existence of a property making subsequent look ups for non-existent properties return immediately
93+
* - Caches names mapped to elements to avoid reiterating the array and comparing the name again
94+
* - Caches the index at which an element has been found to prevent rechecking against elements already determined to belong to another name
95+
*
96+
* @param name - a basic latin string name of a BSON element
97+
* @returns
98+
*/
99+
private getElement(name: string): CachedBSONElement | null {
100+
const cachedElement = this.cache[name];
101+
if (cachedElement === false) return null;
102+
103+
if (cachedElement != null) {
104+
return cachedElement;
105+
}
106+
107+
for (let index = 0; index < this.elements.length; index++) {
108+
const element = this.elements[index];
109+
110+
// skip this element if it has already been associated with a name
111+
if (!this.indexFound[index] && this.isElementName(name, element)) {
112+
const cachedElement = { element, value: undefined };
113+
this.cache[name] = cachedElement;
114+
this.indexFound[index] = true;
115+
return cachedElement;
116+
}
117+
}
118+
119+
this.cache[name] = false;
120+
return null;
121+
}
122+
123+
/**
124+
* Translates BSON bytes into a javascript value. Checking `as` against the BSON element's type
125+
* this methods returns the small subset of BSON types that the driver needs to function.
126+
*
127+
* @remarks
128+
* - BSONType.null and BSONType.undefined always return null
129+
* - If the type requested does not match this returns null
130+
*
131+
* @param element - The element to revive to a javascript value
132+
* @param as - A type byte expected to be returned
133+
*/
134+
private toJSValue<T extends keyof JSTypeOf>(element: BSONElement, as: T): JSTypeOf[T];
135+
private toJSValue(element: BSONElement, as: keyof JSTypeOf): any {
136+
const type = element[BSONElementOffset.type];
137+
const offset = element[BSONElementOffset.offset];
138+
const length = element[BSONElementOffset.length];
139+
140+
if (as !== type) {
141+
return null;
142+
}
143+
144+
switch (as) {
145+
case BSONType.null:
146+
case BSONType.undefined:
147+
return null;
148+
case BSONType.double:
149+
return getFloat64LE(this.bson, offset);
150+
case BSONType.int:
151+
return getInt32LE(this.bson, offset);
152+
case BSONType.long:
153+
return getBigInt64LE(this.bson, offset);
154+
case BSONType.bool:
155+
return Boolean(this.bson[offset]);
156+
case BSONType.objectId:
157+
return new ObjectId(this.bson.subarray(offset, offset + 12));
158+
case BSONType.timestamp:
159+
return new Timestamp(getBigInt64LE(this.bson, offset));
160+
case BSONType.string:
161+
return toUTF8(this.bson, offset + 4, offset + length - 1, false);
162+
case BSONType.binData: {
163+
const totalBinarySize = getInt32LE(this.bson, offset);
164+
const subType = this.bson[offset + 4];
165+
166+
if (subType === 2) {
167+
const subType2BinarySize = getInt32LE(this.bson, offset + 1 + 4);
168+
if (subType2BinarySize < 0)
169+
throw new BSONError('Negative binary type element size found for subtype 0x02');
170+
if (subType2BinarySize > totalBinarySize - 4)
171+
throw new BSONError('Binary type with subtype 0x02 contains too long binary size');
172+
if (subType2BinarySize < totalBinarySize - 4)
173+
throw new BSONError('Binary type with subtype 0x02 contains too short binary size');
174+
return new Binary(
175+
this.bson.subarray(offset + 1 + 4 + 4, offset + 1 + 4 + 4 + subType2BinarySize),
176+
2
177+
);
178+
}
179+
180+
return new Binary(
181+
this.bson.subarray(offset + 1 + 4, offset + 1 + 4 + totalBinarySize),
182+
subType
183+
);
184+
}
185+
case BSONType.date:
186+
// Pretend this is correct.
187+
return new Date(Number(getBigInt64LE(this.bson, offset)));
188+
189+
case BSONType.object:
190+
return new OnDemandDocument(this.bson, offset);
191+
case BSONType.array:
192+
return new OnDemandDocument(this.bson, offset, true);
193+
194+
default:
195+
throw new BSONError(`Unsupported BSON type: ${as}`);
196+
}
197+
}
198+
199+
/**
200+
* Checks for the existence of an element by name.
201+
*
202+
* @remarks
203+
* Uses `getElement` with the expectation that will populate caches such that a `has` call
204+
* followed by a `getElement` call will not repeat the cost paid by the first look up.
205+
*
206+
* @param name - element name
207+
*/
208+
public has(name: string): boolean {
209+
const cachedElement = this.cache[name];
210+
if (cachedElement === false) return false;
211+
if (cachedElement != null) return true;
212+
return this.getElement(name) != null;
213+
}
214+
215+
/**
216+
* Turns BSON element with `name` into a javascript value.
217+
*
218+
* @typeParam T - must be one of the supported BSON types determined by `JSTypeOf` this will determine the return type of this function.
219+
* @param name - the element name
220+
* @param as - the bson type expected
221+
* @param required - whether or not the element is expected to exist, if true this function will throw if it is not present
222+
*/
223+
public get<const T extends keyof JSTypeOf>(
224+
name: string,
225+
as: T,
226+
required?: false | undefined
227+
): JSTypeOf[T] | null;
228+
229+
/** `required` will make `get` throw if name does not exist or is null/undefined */
230+
public get<const T extends keyof JSTypeOf>(name: string, as: T, required: true): JSTypeOf[T];
231+
232+
public get<const T extends keyof JSTypeOf>(
233+
name: string,
234+
as: T,
235+
required?: boolean
236+
): JSTypeOf[T] | null {
237+
const element = this.getElement(name);
238+
if (element == null) {
239+
if (required === true) {
240+
throw new BSONError(`BSON element "${name}" is missing`);
241+
} else {
242+
return null;
243+
}
244+
}
245+
246+
if (element.value == null) {
247+
const value = this.toJSValue(element.element, as);
248+
if (value == null) {
249+
if (required === true) {
250+
throw new BSONError(`BSON element "${name}" is missing`);
251+
} else {
252+
return null;
253+
}
254+
}
255+
// It is important to never store null
256+
element.value = value;
257+
}
258+
259+
return element.value;
260+
}
261+
262+
/**
263+
* Supports returning int, double, long, and bool as javascript numbers
264+
*
265+
* @remarks
266+
* **NOTE:**
267+
* - Use this _only_ when you believe the potential precision loss of an int64 is acceptable
268+
* - This method does not cache the result as Longs or booleans would be stored incorrectly
269+
*
270+
* @param name - element name
271+
* @param required - throws if name does not exist
272+
*/
273+
public getNumber<const Req extends boolean = false>(
274+
name: string,
275+
required?: Req
276+
): Req extends true ? number : number | null;
277+
public getNumber(name: string, required: boolean): number | null {
278+
const maybeBool = this.get(name, BSONType.bool);
279+
const bool = maybeBool == null ? null : maybeBool ? 1 : 0;
280+
281+
const maybeLong = this.get(name, BSONType.long);
282+
const long = maybeLong == null ? null : Number(maybeLong);
283+
284+
const result = bool ?? long ?? this.get(name, BSONType.int) ?? this.get(name, BSONType.double);
285+
286+
if (required === true && result == null) {
287+
throw new BSONError(`BSON element "${name}" is missing`);
288+
}
289+
290+
return result;
291+
}
292+
293+
/**
294+
* Deserialize this object, DOES NOT cache result so avoid multiple invocations
295+
* @param options - BSON deserialization options
296+
*/
297+
public toObject(options?: BSONSerializeOptions): Record<string, any> {
298+
return BSON.deserialize(this.bson, {
299+
...options,
300+
index: this.offset,
301+
allowObjectSmallerThanBufferSize: true
302+
});
303+
}
304+
305+
/**
306+
* Iterates through the elements of a document reviving them using the `as` BSONType.
307+
*
308+
* @param as - The type to revive all elements as
309+
*/
310+
public *valuesAs<const T extends keyof JSTypeOf>(as: T): Generator<JSTypeOf[T]> {
311+
if (!this.isArray) {
312+
throw new BSONError('Unexpected conversion of non-array value to array');
313+
}
314+
let counter = 0;
315+
for (const element of this.elements) {
316+
const value = this.toJSValue<T>(element, as);
317+
this.cache[counter] = { element, value };
318+
yield value;
319+
counter += 1;
320+
}
321+
}
322+
}

test/mongodb.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ export * from '../src/cmap/metrics';
130130
export * from '../src/cmap/stream_description';
131131
export * from '../src/cmap/wire_protocol/compression';
132132
export * from '../src/cmap/wire_protocol/constants';
133+
export * from '../src/cmap/wire_protocol/on_demand/document';
133134
export * from '../src/cmap/wire_protocol/shared';
134135
export * from '../src/collection';
135136
export * from '../src/connection_string';

0 commit comments

Comments
 (0)