Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(NODE-5957): add BSON indexing API #654

Merged
merged 6 commits into from
Mar 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved

/**
* @public
* @experimental
*/
export type BSONElement = [
baileympearson marked this conversation as resolved.
Show resolved Hide resolved
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) {
baileympearson marked this conversation as resolved.
Show resolved Hide resolved
// 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> {
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
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);
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
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