Skip to content

Commit

Permalink
feat(NODE-5957): add BSON indexing API (#654)
Browse files Browse the repository at this point in the history
  • Loading branch information
nbbeeken authored Mar 7, 2024
1 parent b64e912 commit 2ac17ec
Show file tree
Hide file tree
Showing 9 changed files with 557 additions and 1 deletion.
2 changes: 2 additions & 0 deletions rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const tsConfig = {
importHelpers: false,
noEmitHelpers: false,
noEmitOnError: true,
// preserveConstEnums: false is the default, but we explicitly set it here to ensure we do not mistakenly generate objects where we expect literals
preserveConstEnums: false,
// Generate separate source maps files with sourceContent included
sourceMap: true,
inlineSourceMap: false,
Expand Down
1 change: 1 addition & 0 deletions src/bson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export { BSONValue } from './bson_value';
export { BSONError, BSONVersionError, BSONRuntimeError } from './error';
export { BSONType } from './constants';
export { EJSON } from './extended_json';
export { onDemand } from './parser/on_demand/index';

/** @public */
export interface Document {
Expand Down
22 changes: 22 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,25 @@ export class BSONRuntimeError extends BSONError {
super(message);
}
}

/**
* @public
* @category Error
*
* @experimental
*
* An error generated when BSON bytes are invalid.
* Reports the offset the parser was able to reach before encountering the error.
*/
export class BSONOffsetError extends BSONError {
public get name(): 'BSONOffsetError' {
return 'BSONOffsetError';
}

public offset: number;

constructor(message: string, offset: number) {
super(`${message}. offset: ${offset}`);
this.offset = offset;
}
}
28 changes: 28 additions & 0 deletions src/parser/on_demand/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { type BSONError, BSONOffsetError } from '../../error';
import { type BSONElement, parseToElements } from './parse_to_elements';
/**
* @experimental
* @public
*
* A new set of BSON APIs that are currently experimental and not intended for production use.
*/
export type OnDemand = {
BSONOffsetError: {
new (message: string, offset: number): BSONOffsetError;
isBSONError(value: unknown): value is BSONError;
};
parseToElements: (this: void, bytes: Uint8Array, startOffset?: number) => Iterable<BSONElement>;
};

/**
* @experimental
* @public
*/
const onDemand: OnDemand = Object.create(null);

onDemand.parseToElements = parseToElements;
onDemand.BSONOffsetError = BSONOffsetError;

Object.freeze(onDemand);

export { onDemand };
174 changes: 174 additions & 0 deletions src/parser/on_demand/parse_to_elements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
import { BSONOffsetError } from '../../error';

/**
* @internal
*
* @remarks
* - This enum is const so the code we produce will inline the numbers
* - `minKey` is set to 255 so unsigned comparisons succeed
* - Modify with caution, double check the bundle contains literals
*/
const enum t {
double = 1,
string = 2,
object = 3,
array = 4,
binData = 5,
undefined = 6,
objectId = 7,
bool = 8,
date = 9,
null = 10,
regex = 11,
dbPointer = 12,
javascript = 13,
symbol = 14,
javascriptWithScope = 15,
int = 16,
timestamp = 17,
long = 18,
decimal = 19,
minKey = 255,
maxKey = 127
}

/**
* @public
* @experimental
*/
export type BSONElement = [
type: number,
nameOffset: number,
nameLength: number,
offset: number,
length: number
];

/** Parses a int32 little-endian at offset, throws if it is negative */
function getSize(source: Uint8Array, offset: number): number {
if (source[offset + 3] > 127) {
throw new BSONOffsetError('BSON size cannot be negative', offset);
}
return (
source[offset] |
(source[offset + 1] << 8) |
(source[offset + 2] << 16) |
(source[offset + 3] << 24)
);
}

/**
* Searches for null terminator of a BSON element's value (Never the document null terminator)
* **Does not** bounds check since this should **ONLY** be used within parseToElements which has asserted that `bytes` ends with a `0x00`.
* So this will at most iterate to the document's terminator and error if that is the offset reached.
*/
function findNull(bytes: Uint8Array, offset: number): number {
let nullTerminatorOffset = offset;

for (; bytes[nullTerminatorOffset] !== 0x00; nullTerminatorOffset++);

if (nullTerminatorOffset === bytes.length - 1) {
// We reached the null terminator of the document, not a value's
throw new BSONOffsetError('Null terminator not found', offset);
}

return nullTerminatorOffset;
}

/**
* @public
* @experimental
*/
export function parseToElements(bytes: Uint8Array, startOffset = 0): Iterable<BSONElement> {
if (bytes.length < 5) {
throw new BSONOffsetError(
`Input must be at least 5 bytes, got ${bytes.length} bytes`,
startOffset
);
}

const documentSize = getSize(bytes, startOffset);

if (documentSize > bytes.length - startOffset) {
throw new BSONOffsetError(
`Parsed documentSize (${documentSize} bytes) does not match input length (${bytes.length} bytes)`,
startOffset
);
}

if (bytes[startOffset + documentSize - 1] !== 0x00) {
throw new BSONOffsetError('BSON documents must end in 0x00', startOffset + documentSize);
}

const elements: BSONElement[] = [];
let offset = startOffset + 4;

while (offset <= documentSize + startOffset) {
const type = bytes[offset];
offset += 1;

if (type === 0) {
if (offset - startOffset !== documentSize) {
throw new BSONOffsetError(`Invalid 0x00 type byte`, offset);
}
break;
}

const nameOffset = offset;
const nameLength = findNull(bytes, offset) - nameOffset;
offset += nameLength + 1;

let length: number;

if (type === t.double || type === t.long || type === t.date || type === t.timestamp) {
length = 8;
} else if (type === t.int) {
length = 4;
} else if (type === t.objectId) {
length = 12;
} else if (type === t.decimal) {
length = 16;
} else if (type === t.bool) {
length = 1;
} else if (type === t.null || type === t.undefined || type === t.maxKey || type === t.minKey) {
length = 0;
}
// Needs a size calculation
else if (type === t.regex) {
length = findNull(bytes, findNull(bytes, offset) + 1) + 1 - offset;
} else if (type === t.object || type === t.array || type === t.javascriptWithScope) {
length = getSize(bytes, offset);
} else if (
type === t.string ||
type === t.binData ||
type === t.dbPointer ||
type === t.javascript ||
type === t.symbol
) {
length = getSize(bytes, offset) + 4;
if (type === t.binData) {
// binary subtype
length += 1;
}
if (type === t.dbPointer) {
// dbPointer's objectId
length += 12;
}
} else {
throw new BSONOffsetError(
`Invalid 0x${type.toString(16).padStart(2, '0')} type byte`,
offset
);
}

if (length > documentSize) {
throw new BSONOffsetError('value reports length larger than document', offset);
}

elements.push([type, nameOffset, nameLength, offset, length]);
offset += length;
}

return elements;
}
28 changes: 27 additions & 1 deletion test/node/error.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { expect } from 'chai';
import { loadESModuleBSON } from '../load_bson';

import { __isWeb__, BSONError, BSONVersionError, BSONRuntimeError } from '../register-bson';
import {
__isWeb__,
BSONError,
BSONVersionError,
BSONRuntimeError,
onDemand
} from '../register-bson';

const instanceOfChecksWork = !__isWeb__;

Expand Down Expand Up @@ -102,4 +108,24 @@ describe('BSONError', function () {
expect(new BSONRuntimeError('Woops!')).to.have.property('name', 'BSONRuntimeError');
});
});

describe('class BSONOffsetError', () => {
it('is a BSONError instance', function () {
expect(BSONError.isBSONError(new onDemand.BSONOffsetError('Oopsie', 3))).to.be.true;
});

it('has a name property equal to "BSONOffsetError"', function () {
expect(new onDemand.BSONOffsetError('Woops!', 3)).to.have.property('name', 'BSONOffsetError');
});

it('sets the offset property', function () {
expect(new onDemand.BSONOffsetError('Woops!', 3)).to.have.property('offset', 3);
});

it('includes the offset in the message', function () {
expect(new onDemand.BSONOffsetError('Woops!', 3))
.to.have.property('message')
.that.matches(/offset: 3/i);
});
});
});
1 change: 1 addition & 0 deletions test/node/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const EXPECTED_EXPORTS = [
'DBRef',
'Binary',
'ObjectId',
'onDemand',
'UUID',
'Long',
'Timestamp',
Expand Down
Loading

0 comments on commit 2ac17ec

Please sign in to comment.