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 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions src/bson.ts
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
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
@@ -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
@@ -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 === 5) {
// binary subtype
length += 1;
}
if (type === 12) {
// 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
@@ -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
Expand Up @@ -18,6 +18,7 @@ const EXPECTED_EXPORTS = [
'DBRef',
'Binary',
'ObjectId',
'onDemand',
'UUID',
'Long',
'Timestamp',
Expand Down