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: Parse ObjectID scalar to Mongo ObjectID #2008

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion src/mocks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ObjectID } from './scalars/library/bson/index.js';

const BigIntMock = () => BigInt(Number.MAX_SAFE_INTEGER);
const ByteMock = () => new Uint8Array([1988, 1981, 1965, 1963, 1959, 1955]);
const DateMock = () => '2007-12-03';
Expand All @@ -21,7 +23,7 @@ export const NonNegativeInt = () => 123;
export const NonPositiveFloat = () => -123.45;
export const NonPositiveInt = () => -123;
export const PhoneNumber = () => '+17895551234';
export const ObjectID = () => '5e5677d71bdc2ae76344968c';
export const ObjectIDMock = () => new ObjectID('5e5677d71bdc2ae76344968c');
export const PositiveFloat = () => 123.45;
export const PositiveInt = () => 123;
export const PostalCode = () => '60031';
Expand Down Expand Up @@ -125,4 +127,5 @@ export {
BigIntMock as BigInt,
ByteMock as Byte,
Duration as ISO8601Duration,
ObjectIDMock as ObjectID,
};
14 changes: 8 additions & 6 deletions src/scalars/ObjectID.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { GraphQLScalarType, Kind, ValueNode } from 'graphql';
import { createGraphQLError } from '../error.js';
import { ObjectID } from './library/bson/index.js';

const MONGODB_OBJECTID_REGEX = /*#__PURE__*/ /^[A-Fa-f0-9]{24}$/;

Expand All @@ -9,20 +10,21 @@ export const GraphQLObjectID = /*#__PURE__*/ new GraphQLScalarType({
description:
'A field whose value conforms with the standard mongodb object ID as described here: https://docs.mongodb.com/manual/reference/method/ObjectId/#ObjectId. Example: 5e5677d71bdc2ae76344968c',

serialize(value: string) {
if (!MONGODB_OBJECTID_REGEX.test(value)) {
throw createGraphQLError(`Value is not a valid mongodb object id of form: ${value}`);
serialize(value: ObjectID | string) {
const valueToString = value.toString();
if (!MONGODB_OBJECTID_REGEX.test(valueToString)) {
throw createGraphQLError(`Value is not a valid mongodb object id of form: ${valueToString}`);
}

return value;
return valueToString;
},

parseValue(value: string) {
if (!MONGODB_OBJECTID_REGEX.test(value)) {
throw createGraphQLError(`Value is not a valid mongodb object id of form: ${value}`);
}

return value;
return new ObjectID(value);
},

parseLiteral(ast: ValueNode) {
Expand All @@ -41,7 +43,7 @@ export const GraphQLObjectID = /*#__PURE__*/ new GraphQLScalarType({
});
}

return ast.value;
return new ObjectID(ast.value);
},
extensions: {
codegenScalarType: 'string',
Expand Down
323 changes: 323 additions & 0 deletions src/scalars/library/bson/ObjectId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
import { BSONValue } from './bsonValue.js';
import { BSONError } from './error.js';
import { isUint8Array } from './parser/utils.js';
import { BSONDataView, ByteUtils } from './utils/byteUtills.js';

const HEX_REGEX = /^[0-9a-fA-F]+$/;

// Unique sequence for the current process (initialized on first use)
let PROCESS_UNIQUE: Uint8Array | null = null;

/** @public */
export interface ObjectIdLike {
id: string | Uint8Array;
__id?: string;
toHexString(): string;
}

/** @public */
export interface ObjectIdExtended {
$oid: string;
}

const kId = Symbol('id');

/**
* A class representation of the BSON ObjectId type.
* @public
* @category BSONType
*/
export class ObjectId extends BSONValue {
get _bsontype(): 'ObjectId' {
return 'ObjectId';
}

/** @internal */
private static index = Math.floor(Math.random() * 0xffffff);

static cacheHexString: boolean;

/** ObjectId Bytes @internal */
private [kId]!: Uint8Array;
/** ObjectId hexString cache @internal */
private __id?: string;

/**
* Create an ObjectId type
*
* @param inputId - Can be a 24 character hex string, 12 byte binary Buffer, or a number.
*/
constructor(inputId?: string | number | ObjectId | ObjectIdLike | Uint8Array) {
super();
// workingId is set based on type of input and whether valid id exists for the input
let workingId;
if (typeof inputId === 'object' && inputId && 'id' in inputId) {
if (typeof inputId.id !== 'string' && !ArrayBuffer.isView(inputId.id)) {
throw new BSONError('Argument passed in must have an id that is of type string or Buffer');
}
if ('toHexString' in inputId && typeof inputId.toHexString === 'function') {
workingId = ByteUtils.fromHex(inputId.toHexString());
} else {
workingId = inputId.id;
}
} else {
workingId = inputId;
}

// the following cases use workingId to construct an ObjectId
if (workingId == null || typeof workingId === 'number') {
// The most common use case (blank id, new objectId instance)
// Generate a new id
this[kId] = ObjectId.generate(typeof workingId === 'number' ? workingId : undefined);
} else if (ArrayBuffer.isView(workingId) && workingId.byteLength === 12) {
// If intstanceof matches we can escape calling ensure buffer in Node.js environments
this[kId] = ByteUtils.toLocalBufferType(workingId);
} else if (typeof workingId === 'string') {
if (workingId.length === 12) {
// TODO(NODE-4361): Remove string of length 12 support
const bytes = ByteUtils.fromUTF8(workingId);
if (bytes.byteLength === 12) {
this[kId] = bytes;
} else {
throw new BSONError('Argument passed in must be a string of 12 bytes');
}
} else if (workingId.length === 24 && HEX_REGEX.test(workingId)) {
this[kId] = ByteUtils.fromHex(workingId);
} else {
throw new BSONError(
'Argument passed in must be a string of 12 bytes or a string of 24 hex characters or an integer',
);
}
} else {
throw new BSONError('Argument passed in does not match the accepted types');
}
// If we are caching the hex string
if (ObjectId.cacheHexString) {
this.__id = ByteUtils.toHex(this.id);
}
}

/**
* The ObjectId bytes
* @readonly
*/
get id(): Uint8Array {
return this[kId];
}

set id(value: Uint8Array) {
this[kId] = value;
if (ObjectId.cacheHexString) {
this.__id = ByteUtils.toHex(value);
}
}

/** Returns the ObjectId id as a 24 character hex string representation */
toHexString(): string {
if (ObjectId.cacheHexString && this.__id) {
return this.__id;
}

const hexString = ByteUtils.toHex(this.id);

if (ObjectId.cacheHexString && !this.__id) {
this.__id = hexString;
}

return hexString;
}

/**
* Update the ObjectId index
* @internal
*/
private static getInc(): number {
return (ObjectId.index = (ObjectId.index + 1) % 0xffffff);
}

/**
* Generate a 12 byte id buffer used in ObjectId's
*
* @param time - pass in a second based timestamp.
*/
static generate(time?: number): Uint8Array {
if (typeof time !== 'number') {
time = Math.floor(Date.now() / 1000);
}

const inc = ObjectId.getInc();
const buffer = ByteUtils.allocate(12);

// 4-byte timestamp
BSONDataView.fromUint8Array(buffer).setUint32(0, time, false);

// set PROCESS_UNIQUE if yet not initialized
if (PROCESS_UNIQUE === null) {
PROCESS_UNIQUE = ByteUtils.randomBytes(5);
}

// 5-byte process unique
buffer[4] = PROCESS_UNIQUE[0];
buffer[5] = PROCESS_UNIQUE[1];
buffer[6] = PROCESS_UNIQUE[2];
buffer[7] = PROCESS_UNIQUE[3];
buffer[8] = PROCESS_UNIQUE[4];

// 3-byte counter
buffer[11] = inc & 0xff;
buffer[10] = (inc >> 8) & 0xff;
buffer[9] = (inc >> 16) & 0xff;

return buffer;
}

/**
* Converts the id into a 24 character hex string for printing, unless encoding is provided.
* @param encoding - hex or base64
*/
toString(encoding?: 'hex' | 'base64'): string {
// Is the id a buffer then use the buffer toString method to return the format
if (encoding === 'base64') return ByteUtils.toBase64(this.id);
if (encoding === 'hex') return this.toHexString();
return this.toHexString();
}

/** Converts to its JSON the 24 character hex string representation. */
toJSON(): string {
return this.toHexString();
}

/**
* Compares the equality of this ObjectId with `otherID`.
*
* @param otherId - ObjectId instance to compare against.
*/
equals(otherId: string | ObjectId | ObjectIdLike): boolean {
if (otherId === undefined || otherId === null) {
return false;
}

if (otherId instanceof ObjectId) {
return this[kId][11] === otherId[kId][11] && ByteUtils.equals(this[kId], otherId[kId]);
}

if (
typeof otherId === 'string' &&
ObjectId.isValid(otherId) &&
otherId.length === 12 &&
isUint8Array(this.id)
) {
return ByteUtils.equals(this.id, ByteUtils.fromISO88591(otherId));
}

if (typeof otherId === 'string' && ObjectId.isValid(otherId) && otherId.length === 24) {
return otherId.toLowerCase() === this.toHexString();
}

if (typeof otherId === 'string' && ObjectId.isValid(otherId) && otherId.length === 12) {
return ByteUtils.equals(ByteUtils.fromUTF8(otherId), this.id);
}

if (
typeof otherId === 'object' &&
'toHexString' in otherId &&
typeof otherId.toHexString === 'function'
) {
const otherIdString = otherId.toHexString();
const thisIdString = this.toHexString().toLowerCase();
return typeof otherIdString === 'string' && otherIdString.toLowerCase() === thisIdString;
}

return false;
}

/** Returns the generation date (accurate up to the second) that this ID was generated. */
getTimestamp(): Date {
const timestamp = new Date();
const time = BSONDataView.fromUint8Array(this.id).getUint32(0, false);
timestamp.setTime(Math.floor(time) * 1000);
return timestamp;
}

/** @internal */
static createPk(): ObjectId {
return new ObjectId();
}

/**
* Creates an ObjectId from a second based number, with the rest of the ObjectId zeroed out. Used for comparisons or sorting the ObjectId.
*
* @param time - an integer number representing a number of seconds.
*/
static createFromTime(time: number): ObjectId {
const buffer = ByteUtils.fromNumberArray([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
// Encode time into first 4 bytes
BSONDataView.fromUint8Array(buffer).setUint32(0, time, false);
// Return the new objectId
return new ObjectId(buffer);
}

/**
* Creates an ObjectId from a hex string representation of an ObjectId.
*
* @param hexString - create a ObjectId from a passed in 24 character hexstring.
*/
static createFromHexString(hexString: string): ObjectId {
if (hexString?.length !== 24) {
throw new BSONError('hex string must be 24 characters');
}

return new ObjectId(ByteUtils.fromHex(hexString));
}

/** Creates an ObjectId instance from a base64 string */
static createFromBase64(base64: string): ObjectId {
if (base64?.length !== 16) {
throw new BSONError('base64 string must be 16 characters');
}

return new ObjectId(ByteUtils.fromBase64(base64));
}

/**
* Checks if a value is a valid bson ObjectId
*
* @param id - ObjectId instance to validate.
*/
static isValid(id: string | number | ObjectId | ObjectIdLike | Uint8Array): boolean {
if (id == null) return false;

try {
// eslint-disable-next-line no-new
new ObjectId(id);
return true;
} catch {
return false;
}
}

/** @internal */
toExtendedJSON(): ObjectIdExtended {
if (this.toHexString) return { $oid: this.toHexString() };
return { $oid: this.toString('hex') };
}

/** @internal */
static fromExtendedJSON(doc: ObjectIdExtended): ObjectId {
return new ObjectId(doc.$oid);
}

/**
* Converts to a string representation of this Id.
*
* @returns return the 24 character hex string representation.
* @internal
*/
[Symbol.for('nodejs.util.inspect.custom')](): string {
return this.inspect();
}

inspect(): string {
return `new ObjectId("${this.toHexString()}")`;
}
}