Skip to content

Commit 2ac17ec

Browse files
authored
feat(NODE-5957): add BSON indexing API (#654)
1 parent b64e912 commit 2ac17ec

File tree

9 files changed

+557
-1
lines changed

9 files changed

+557
-1
lines changed

rollup.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const tsConfig = {
1717
importHelpers: false,
1818
noEmitHelpers: false,
1919
noEmitOnError: true,
20+
// preserveConstEnums: false is the default, but we explicitly set it here to ensure we do not mistakenly generate objects where we expect literals
21+
preserveConstEnums: false,
2022
// Generate separate source maps files with sourceContent included
2123
sourceMap: true,
2224
inlineSourceMap: false,

src/bson.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export { BSONValue } from './bson_value';
5454
export { BSONError, BSONVersionError, BSONRuntimeError } from './error';
5555
export { BSONType } from './constants';
5656
export { EJSON } from './extended_json';
57+
export { onDemand } from './parser/on_demand/index';
5758

5859
/** @public */
5960
export interface Document {

src/error.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,25 @@ export class BSONRuntimeError extends BSONError {
8181
super(message);
8282
}
8383
}
84+
85+
/**
86+
* @public
87+
* @category Error
88+
*
89+
* @experimental
90+
*
91+
* An error generated when BSON bytes are invalid.
92+
* Reports the offset the parser was able to reach before encountering the error.
93+
*/
94+
export class BSONOffsetError extends BSONError {
95+
public get name(): 'BSONOffsetError' {
96+
return 'BSONOffsetError';
97+
}
98+
99+
public offset: number;
100+
101+
constructor(message: string, offset: number) {
102+
super(`${message}. offset: ${offset}`);
103+
this.offset = offset;
104+
}
105+
}

src/parser/on_demand/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { type BSONError, BSONOffsetError } from '../../error';
2+
import { type BSONElement, parseToElements } from './parse_to_elements';
3+
/**
4+
* @experimental
5+
* @public
6+
*
7+
* A new set of BSON APIs that are currently experimental and not intended for production use.
8+
*/
9+
export type OnDemand = {
10+
BSONOffsetError: {
11+
new (message: string, offset: number): BSONOffsetError;
12+
isBSONError(value: unknown): value is BSONError;
13+
};
14+
parseToElements: (this: void, bytes: Uint8Array, startOffset?: number) => Iterable<BSONElement>;
15+
};
16+
17+
/**
18+
* @experimental
19+
* @public
20+
*/
21+
const onDemand: OnDemand = Object.create(null);
22+
23+
onDemand.parseToElements = parseToElements;
24+
onDemand.BSONOffsetError = BSONOffsetError;
25+
26+
Object.freeze(onDemand);
27+
28+
export { onDemand };
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
2+
import { BSONOffsetError } from '../../error';
3+
4+
/**
5+
* @internal
6+
*
7+
* @remarks
8+
* - This enum is const so the code we produce will inline the numbers
9+
* - `minKey` is set to 255 so unsigned comparisons succeed
10+
* - Modify with caution, double check the bundle contains literals
11+
*/
12+
const enum t {
13+
double = 1,
14+
string = 2,
15+
object = 3,
16+
array = 4,
17+
binData = 5,
18+
undefined = 6,
19+
objectId = 7,
20+
bool = 8,
21+
date = 9,
22+
null = 10,
23+
regex = 11,
24+
dbPointer = 12,
25+
javascript = 13,
26+
symbol = 14,
27+
javascriptWithScope = 15,
28+
int = 16,
29+
timestamp = 17,
30+
long = 18,
31+
decimal = 19,
32+
minKey = 255,
33+
maxKey = 127
34+
}
35+
36+
/**
37+
* @public
38+
* @experimental
39+
*/
40+
export type BSONElement = [
41+
type: number,
42+
nameOffset: number,
43+
nameLength: number,
44+
offset: number,
45+
length: number
46+
];
47+
48+
/** Parses a int32 little-endian at offset, throws if it is negative */
49+
function getSize(source: Uint8Array, offset: number): number {
50+
if (source[offset + 3] > 127) {
51+
throw new BSONOffsetError('BSON size cannot be negative', offset);
52+
}
53+
return (
54+
source[offset] |
55+
(source[offset + 1] << 8) |
56+
(source[offset + 2] << 16) |
57+
(source[offset + 3] << 24)
58+
);
59+
}
60+
61+
/**
62+
* Searches for null terminator of a BSON element's value (Never the document null terminator)
63+
* **Does not** bounds check since this should **ONLY** be used within parseToElements which has asserted that `bytes` ends with a `0x00`.
64+
* So this will at most iterate to the document's terminator and error if that is the offset reached.
65+
*/
66+
function findNull(bytes: Uint8Array, offset: number): number {
67+
let nullTerminatorOffset = offset;
68+
69+
for (; bytes[nullTerminatorOffset] !== 0x00; nullTerminatorOffset++);
70+
71+
if (nullTerminatorOffset === bytes.length - 1) {
72+
// We reached the null terminator of the document, not a value's
73+
throw new BSONOffsetError('Null terminator not found', offset);
74+
}
75+
76+
return nullTerminatorOffset;
77+
}
78+
79+
/**
80+
* @public
81+
* @experimental
82+
*/
83+
export function parseToElements(bytes: Uint8Array, startOffset = 0): Iterable<BSONElement> {
84+
if (bytes.length < 5) {
85+
throw new BSONOffsetError(
86+
`Input must be at least 5 bytes, got ${bytes.length} bytes`,
87+
startOffset
88+
);
89+
}
90+
91+
const documentSize = getSize(bytes, startOffset);
92+
93+
if (documentSize > bytes.length - startOffset) {
94+
throw new BSONOffsetError(
95+
`Parsed documentSize (${documentSize} bytes) does not match input length (${bytes.length} bytes)`,
96+
startOffset
97+
);
98+
}
99+
100+
if (bytes[startOffset + documentSize - 1] !== 0x00) {
101+
throw new BSONOffsetError('BSON documents must end in 0x00', startOffset + documentSize);
102+
}
103+
104+
const elements: BSONElement[] = [];
105+
let offset = startOffset + 4;
106+
107+
while (offset <= documentSize + startOffset) {
108+
const type = bytes[offset];
109+
offset += 1;
110+
111+
if (type === 0) {
112+
if (offset - startOffset !== documentSize) {
113+
throw new BSONOffsetError(`Invalid 0x00 type byte`, offset);
114+
}
115+
break;
116+
}
117+
118+
const nameOffset = offset;
119+
const nameLength = findNull(bytes, offset) - nameOffset;
120+
offset += nameLength + 1;
121+
122+
let length: number;
123+
124+
if (type === t.double || type === t.long || type === t.date || type === t.timestamp) {
125+
length = 8;
126+
} else if (type === t.int) {
127+
length = 4;
128+
} else if (type === t.objectId) {
129+
length = 12;
130+
} else if (type === t.decimal) {
131+
length = 16;
132+
} else if (type === t.bool) {
133+
length = 1;
134+
} else if (type === t.null || type === t.undefined || type === t.maxKey || type === t.minKey) {
135+
length = 0;
136+
}
137+
// Needs a size calculation
138+
else if (type === t.regex) {
139+
length = findNull(bytes, findNull(bytes, offset) + 1) + 1 - offset;
140+
} else if (type === t.object || type === t.array || type === t.javascriptWithScope) {
141+
length = getSize(bytes, offset);
142+
} else if (
143+
type === t.string ||
144+
type === t.binData ||
145+
type === t.dbPointer ||
146+
type === t.javascript ||
147+
type === t.symbol
148+
) {
149+
length = getSize(bytes, offset) + 4;
150+
if (type === t.binData) {
151+
// binary subtype
152+
length += 1;
153+
}
154+
if (type === t.dbPointer) {
155+
// dbPointer's objectId
156+
length += 12;
157+
}
158+
} else {
159+
throw new BSONOffsetError(
160+
`Invalid 0x${type.toString(16).padStart(2, '0')} type byte`,
161+
offset
162+
);
163+
}
164+
165+
if (length > documentSize) {
166+
throw new BSONOffsetError('value reports length larger than document', offset);
167+
}
168+
169+
elements.push([type, nameOffset, nameLength, offset, length]);
170+
offset += length;
171+
}
172+
173+
return elements;
174+
}

test/node/error.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { expect } from 'chai';
22
import { loadESModuleBSON } from '../load_bson';
33

4-
import { __isWeb__, BSONError, BSONVersionError, BSONRuntimeError } from '../register-bson';
4+
import {
5+
__isWeb__,
6+
BSONError,
7+
BSONVersionError,
8+
BSONRuntimeError,
9+
onDemand
10+
} from '../register-bson';
511

612
const instanceOfChecksWork = !__isWeb__;
713

@@ -102,4 +108,24 @@ describe('BSONError', function () {
102108
expect(new BSONRuntimeError('Woops!')).to.have.property('name', 'BSONRuntimeError');
103109
});
104110
});
111+
112+
describe('class BSONOffsetError', () => {
113+
it('is a BSONError instance', function () {
114+
expect(BSONError.isBSONError(new onDemand.BSONOffsetError('Oopsie', 3))).to.be.true;
115+
});
116+
117+
it('has a name property equal to "BSONOffsetError"', function () {
118+
expect(new onDemand.BSONOffsetError('Woops!', 3)).to.have.property('name', 'BSONOffsetError');
119+
});
120+
121+
it('sets the offset property', function () {
122+
expect(new onDemand.BSONOffsetError('Woops!', 3)).to.have.property('offset', 3);
123+
});
124+
125+
it('includes the offset in the message', function () {
126+
expect(new onDemand.BSONOffsetError('Woops!', 3))
127+
.to.have.property('message')
128+
.that.matches(/offset: 3/i);
129+
});
130+
});
105131
});

test/node/exports.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const EXPECTED_EXPORTS = [
1818
'DBRef',
1919
'Binary',
2020
'ObjectId',
21+
'onDemand',
2122
'UUID',
2223
'Long',
2324
'Timestamp',

0 commit comments

Comments
 (0)