UNPKG

hadron-type-checker

Version:
725 lines (654 loc) • 18.9 kB
import { isPlainObject, isArray, isString, isNumber, keys, without, toNumber, toString, } from 'lodash'; import { ObjectId, MinKey, MaxKey, Long, Double, Int32, Decimal128, Binary, BSONRegExp, Code, BSONSymbol, Timestamp, UUID, bsonType, type BSONTypeTag, } from 'bson'; export { bsonType, type BSONTypeTag }; export function getBsonType(value: any): BSONTypeTag | undefined { return value?.[bsonType]; } export type TypeCastMap = { Array: unknown[]; Binary: Binary; Boolean: boolean; Code: Code; Date: Date; Decimal128: Decimal128; Double: Double; Int32: Int32; Int64: Long; MaxKey: MaxKey; MinKey: MinKey; Null: null; Object: Record<string, unknown>; ObjectId: ObjectId; BSONRegExp: BSONRegExp; String: string; BSONSymbol: BSONSymbol; Timestamp: Timestamp; Undefined: undefined; UUID: Binary; LegacyJavaUUID: Binary; LegacyCSharpUUID: Binary; LegacyPythonUUID: Binary; }; export type TypeCastTypes = keyof TypeCastMap; /** * The object string. */ const OBJECT = 'Object'; /** * The array type string. */ const ARRAY = 'Array'; /** * True constant. */ const TRUE = 'true'; /** * False constant. */ const FALSE = 'false'; /** * Long constant. */ const LONG = 'Long'; const INT_32 = 'Int32'; const INT_64 = 'Int64'; const DOUBLE = 'Double'; const DECIMAL_128 = 'Decimal128'; const OBJECT_TYPE = '[object Object]'; const EMPTY = ''; /** * The match regex. */ const MATCH = /\[object (\w+)\]/; /** * The max int 32 value. */ const BSON_INT32_MAX = 0x7fffffff; /** * The min int 32 value. */ const BSON_INT32_MIN = -0x80000000; const BSON_INT64_MAX = Math.pow(2, 63) - 1; const BSON_INT64_MIN = -BSON_INT64_MAX; /** * The number regex. */ const NUMBER_REGEX = /^-?\d+$/; /** * All bson types that are numbers. */ const NUMBER_TYPES = ['Long', 'Int32', 'Double', 'Decimal128'] as const; const toDate = (object: unknown): Date => { return new Date(object as string | number | Date); }; const toMinKey = (): MinKey => { return new MinKey(); }; const toMaxKey = (): MaxKey => { return new MaxKey(); }; const toUndefined = (): undefined => { return undefined; }; const toNull = (): null => { return null; }; const toBoolean = (object: unknown): boolean => { if (isString(object)) { if (object.toLowerCase() === TRUE) { return true; } else if (object.toLowerCase() === FALSE) { return false; } throw new Error(`'${object}' is not a valid boolean string`); } if (object) { return true; } return false; }; const toObject = (object: unknown): Record<string, unknown> => { if (isPlainObject(object)) { return object as Record<string, unknown>; } return {}; }; const toArray = (object: unknown): unknown[] => { if (isArray(object)) { return object; } if (isPlainObject(object)) { return []; } return [object]; }; const toInt32 = (object: unknown): Int32 => { if (object === '-' || object === '') { throw new Error(`Value '${object}' is not a valid Int32 value`); } const number = toNumber(object); if (number >= BSON_INT32_MIN && number <= BSON_INT32_MAX) { return new Int32(number); } throw new Error(`Value ${number} is outside the valid Int32 range`); }; const toInt64 = (object: unknown): Long => { if (object === '-' || object === '') { throw new Error(`Value '${object}' is not a valid Int64 value`); } const number = toNumber(object); if (number >= BSON_INT64_MIN && number <= BSON_INT64_MAX) { // when casting from int32 object(this will have object.value) or literal // (it will a typeof number) we can safely create object fromNumber, as it // will not be greater than JS's max value if ((object as { value?: number })?.value || typeof object === 'number') { return Long.fromNumber(number); } else if ( typeof object === 'object' && object !== null && 'toString' in object ) { // to make sure we are still displaying Very Large numbers properly, convert // the current 'object' to a string return Long.fromString((object as { toString(): string }).toString()); } return Long.fromString(toString(object)); } throw new Error(`Value ${toString(object)} is outside the valid Int64 range`); }; const toDouble = (object: unknown): Double => { if (object === '-' || object === '') { throw new Error(`Value '${object}' is not a valid Double value`); } if (isString(object) && object.endsWith('.')) { throw new Error('Please enter at least one digit after the decimal'); } const number = toNumber(object); return new Double(number); }; type BSONObject = { toString(): string; valueOf(): number | string; }; const toDecimal128 = (object: unknown): Decimal128 => { /* If converting a BSON Object, extract the value before converting to a string. */ if ( (NUMBER_TYPES as readonly (BSONTypeTag | undefined)[]).includes( getBsonType(object) ) ) { const bsonObj = object as BSONObject; object = getBsonType(object) === LONG ? bsonObj.toString() : bsonObj.valueOf(); } return Decimal128.fromString(toString(object)); }; const toObjectID = (object: unknown): ObjectId => { if (!isString(object) || object === '') { return new ObjectId(); } return ObjectId.createFromHexString(object); }; const toBinary = (object: unknown): Binary => { const buffer = ArrayBuffer.isView(object) ? Buffer.from(object as Uint8Array) : Buffer.from(toString(object), 'utf8'); return new Binary(buffer, Binary.SUBTYPE_DEFAULT); }; export const UUID_TYPES = [ 'UUID', 'LegacyJavaUUID', 'LegacyCSharpUUID', 'LegacyPythonUUID', ] satisfies TypeCastTypes[]; export type UUIDType = (typeof UUID_TYPES)[number]; export function isUUIDType(type: string): type is UUIDType { return (UUID_TYPES as readonly string[]).includes(type); } /** * UUID regex pattern for validation (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). */ export const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** * Validates a UUID string format. */ const validateUUIDString = (uuidString: string): void => { if (!UUID_REGEX.test(uuidString)) { throw new Error( `'${uuidString}' is not a valid UUID string (expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)` ); } }; /** * Converts a UUID string (with hyphens) to a hex string (without hyphens). */ const uuidStringToHex = (uuidString: string): string => { return uuidString.replace(/-/g, ''); }; /** * Converts a hex string (without hyphens) to UUID format with hyphens. */ export const uuidHexToString = (hex: string): string => { return ( hex.substring(0, 8) + '-' + hex.substring(8, 12) + '-' + hex.substring(12, 16) + '-' + hex.substring(16, 20) + '-' + hex.substring(20, 32) ); }; /** * Reverses byte order for Java legacy UUID format (both MSB and LSB). * Takes a 32-char hex string and returns a 32-char hex string with reversed byte order. * * This function is an involution (self-inverse), meaning applying it twice returns * the original value. It can be used both to: * - Convert from Java legacy binary format to standard UUID hex (for display) * - Convert from standard UUID hex to Java legacy binary format (for storage) */ export const reverseJavaUUIDBytes = (hex: string): string => { let msb = hex.substring(0, 16); let lsb = hex.substring(16, 32); msb = msb.substring(14, 16) + msb.substring(12, 14) + msb.substring(10, 12) + msb.substring(8, 10) + msb.substring(6, 8) + msb.substring(4, 6) + msb.substring(2, 4) + msb.substring(0, 2); lsb = lsb.substring(14, 16) + lsb.substring(12, 14) + lsb.substring(10, 12) + lsb.substring(8, 10) + lsb.substring(6, 8) + lsb.substring(4, 6) + lsb.substring(2, 4) + lsb.substring(0, 2); return msb + lsb; }; /** * Reverses byte order for C# legacy UUID format (first 3 groups only). * Takes a 32-char hex string and returns a 32-char hex string with reversed byte order. * * This function is an involution (self-inverse), meaning applying it twice returns * the original value. It can be used both to: * - Convert from C# legacy binary format to standard UUID hex (for display) * - Convert from standard UUID hex to C# legacy binary format (for storage) */ export const reverseCSharpUUIDBytes = (hex: string): string => { const a = hex.substring(6, 8) + hex.substring(4, 6) + hex.substring(2, 4) + hex.substring(0, 2); const b = hex.substring(10, 12) + hex.substring(8, 10); const c = hex.substring(14, 16) + hex.substring(12, 14); const d = hex.substring(16, 32); return a + b + c + d; }; /** * Generates a random UUID string in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. */ const generateRandomUUID = (): string => { return new UUID().toString(); }; /** * Converts a Binary UUID to a standard UUID string, accounting for its encoding. * For subtype 4 (standard UUID), returns the hex directly as UUID format. * For subtype 3 (legacy UUID), we need to know the original encoding to reverse the bytes. * If sourceEncoding is not provided, assumes Python encoding (no reversal). */ const binaryToUUIDStringWithEncoding = ( binary: Binary, sourceEncoding?: 'Java' | 'CSharp' | 'Python' ): string => { const hex = binary.toString('hex'); // For standard UUID (subtype 4), no byte reversal needed if (binary.sub_type === Binary.SUBTYPE_UUID) { return uuidHexToString(hex); } // For legacy UUID (subtype 3), reverse bytes based on source encoding switch (sourceEncoding) { case 'Java': // Reverse Java encoding to get standard UUID return uuidHexToString(reverseJavaUUIDBytes(hex)); case 'CSharp': // Reverse C# encoding to get standard UUID return uuidHexToString(reverseCSharpUUIDBytes(hex)); case 'Python': default: // Python uses standard byte order, no reversal needed return uuidHexToString(hex); } }; /** * Gets the UUID string from an object, handling Binary inputs specially. * For Binary inputs, extracts and converts to UUID string format. * For string inputs, returns as-is after trimming. */ const getUUIDStringFromObject = ( object: unknown, sourceEncoding?: 'Java' | 'CSharp' | 'Python' ): string => { if (object instanceof Binary) { if ( object.sub_type === Binary.SUBTYPE_UUID || object.sub_type === Binary.SUBTYPE_UUID_OLD ) { return binaryToUUIDStringWithEncoding(object, sourceEncoding); } } return toString(object).trim(); }; /** * Mapping from UUID type names to encoding names. */ const UUID_TYPE_TO_ENCODING: Record< string, 'Java' | 'CSharp' | 'Python' | undefined > = Object.assign(Object.create(null), { UUID: undefined, LegacyJavaUUID: 'Java', LegacyCSharpUUID: 'CSharp', LegacyPythonUUID: 'Python', }); /** * Converts a Binary UUID from one encoding to another. * This is used when changing between UUID types in the document editor. * * @param binary - The source Binary UUID * @param sourceType - The source UUID type (e.g., 'LegacyCSharpUUID') * @param targetType - The target UUID type (e.g., 'LegacyJavaUUID') * @returns A new Binary with the same UUID value but different encoding */ export const convertBinaryUUID = ( binary: Binary, sourceType: string, targetType: string ): Binary => { // Get the source encoding to decode the binary const sourceEncoding = UUID_TYPE_TO_ENCODING[sourceType]; // Convert binary to standard UUID string using source encoding const uuidString = binaryToUUIDStringWithEncoding(binary, sourceEncoding); // Convert UUID string to binary using target encoding const hex = uuidStringToHex(uuidString); switch (targetType) { case 'UUID': return Binary.createFromHexString(hex, Binary.SUBTYPE_UUID); case 'LegacyJavaUUID': return Binary.createFromHexString( reverseJavaUUIDBytes(hex), Binary.SUBTYPE_UUID_OLD ); case 'LegacyCSharpUUID': return Binary.createFromHexString( reverseCSharpUUIDBytes(hex), Binary.SUBTYPE_UUID_OLD ); case 'LegacyPythonUUID': return Binary.createFromHexString(hex, Binary.SUBTYPE_UUID_OLD); default: throw new Error(`Unknown UUID type: ${targetType}`); } }; /** * Converts to UUID (Binary subtype 4). * If the input is empty, generates a random UUID. * If the input is a Binary, extracts the UUID from it. */ const toUUID = (object: unknown): Binary => { const uuidString = getUUIDStringFromObject(object); if (!uuidString) { return new UUID().toBinary(); } validateUUIDString(uuidString); const hex = uuidStringToHex(uuidString); return Binary.createFromHexString(hex, Binary.SUBTYPE_UUID); }; /** * Converts to Legacy Java UUID (Binary subtype 3). * Java legacy format reverses byte order for both MSB and LSB. * If the input is empty, generates a random UUID. * If the input is a Binary, extracts the UUID from it. */ const toLegacyJavaUUID = (object: unknown): Binary => { let uuidString = getUUIDStringFromObject(object, 'Java'); if (!uuidString) { uuidString = generateRandomUUID(); } else { validateUUIDString(uuidString); } const hex = uuidStringToHex(uuidString); const reversedHex = reverseJavaUUIDBytes(hex); return Binary.createFromHexString(reversedHex, Binary.SUBTYPE_UUID_OLD); }; /** * Converts to Legacy C# UUID (Binary subtype 3). * C# legacy format reverses byte order for first 3 groups only. * If the input is empty, generates a random UUID. * If the input is a Binary, extracts the UUID from it. */ const toLegacyCSharpUUID = (object: unknown): Binary => { let uuidString = getUUIDStringFromObject(object, 'CSharp'); if (!uuidString) { uuidString = generateRandomUUID(); } else { validateUUIDString(uuidString); } const hex = uuidStringToHex(uuidString); const reversedHex = reverseCSharpUUIDBytes(hex); return Binary.createFromHexString(reversedHex, Binary.SUBTYPE_UUID_OLD); }; /** * Converts to Legacy Python UUID (Binary subtype 3). * Python legacy format uses direct byte order (no reversal). * If the input is empty, generates a random UUID. * If the input is a Binary, extracts the UUID from it. */ const toLegacyPythonUUID = (object: unknown): Binary => { let uuidString = getUUIDStringFromObject(object, 'Python'); if (!uuidString) { uuidString = generateRandomUUID(); } else { validateUUIDString(uuidString); } const hex = uuidStringToHex(uuidString); return Binary.createFromHexString(hex, Binary.SUBTYPE_UUID_OLD); }; const toRegex = (object: unknown): BSONRegExp => { return new BSONRegExp(toString(object)); }; const toCode = (object: unknown): Code => { return new Code(toString(object)); }; const toSymbol = (object: unknown): BSONSymbol => { return new BSONSymbol(toString(object)); }; const toTimestamp = (object: unknown): Timestamp => { const number = toNumber(object); return Timestamp.fromNumber(number); }; /** * The functions to cast to a type. */ const CASTERS: { [K in TypeCastTypes]: (object: unknown) => TypeCastMap[K]; } = { Array: toArray, Binary: toBinary, Boolean: toBoolean, Code: toCode, Date: toDate, Decimal128: toDecimal128, Double: toDouble, Int32: toInt32, Int64: toInt64, MaxKey: toMaxKey, MinKey: toMinKey, Null: toNull, Object: toObject, ObjectId: toObjectID, BSONRegExp: toRegex, String: toString, BSONSymbol: toSymbol, Timestamp: toTimestamp, Undefined: toUndefined, UUID: toUUID, LegacyJavaUUID: toLegacyJavaUUID, LegacyCSharpUUID: toLegacyCSharpUUID, LegacyPythonUUID: toLegacyPythonUUID, }; /** * An array of all bson types. */ const TYPES = keys(CASTERS); /** * Checks if a string is an int32. */ class Int32Check { test(string: string): boolean { if (NUMBER_REGEX.test(string)) { const value = toNumber(string); return value >= BSON_INT32_MIN && value <= BSON_INT32_MAX; } return false; } } /** * Checks if a string is an int64. */ class Int64Check { test(string: string): boolean { if (NUMBER_REGEX.test(string)) { return Number.isSafeInteger(toNumber(string)); } return false; } } const INT32_CHECK = new Int32Check(); const INT64_CHECK = new Int64Check(); /** * Gets the BSON type for a JS number. */ const numberToBsonType = (number: number): TypeCastTypes => { const string = toString(number); if (INT32_CHECK.test(string)) { return INT_32; } else if (INT64_CHECK.test(string)) { return INT_64; } return DOUBLE; }; /** * Checks the types of objects and returns them as readable strings. */ class TypeChecker { /** * Cast the provided object to the desired type. */ cast<O = unknown, T extends string = string>( object: O, type: T ): T extends TypeCastTypes ? TypeCastMap[T] : O { const caster = CASTERS[type as keyof typeof CASTERS]; let result: unknown = object; if (caster) { result = caster(object); } return ( result === OBJECT_TYPE && result !== object ? EMPTY : result ) as T extends TypeCastTypes ? TypeCastMap[T] : O; } /** * Get the type for the object. * @param legacyUUIDEncoding - Optional encoding for legacy UUID (subtype 3). * If provided and the object is a Binary with subtype 3, returns the specific legacy UUID type. * Valid values: 'LegacyJavaUUID', 'LegacyCSharpUUID', 'LegacyPythonUUID' */ type( object: unknown, legacyUUIDEncoding?: | 'LegacyJavaUUID' | 'LegacyCSharpUUID' | 'LegacyPythonUUID' ): TypeCastTypes { const bsonType = getBsonType(object); if (bsonType) { if (bsonType === LONG) { return INT_64; } // Handle Binary UUID subtypes if (bsonType === 'Binary') { const binary = object as Binary; if (binary.sub_type === Binary.SUBTYPE_UUID) { return 'UUID'; } if ( binary.sub_type === Binary.SUBTYPE_UUID_OLD && binary.buffer.length === 16 && legacyUUIDEncoding ) { return legacyUUIDEncoding; } } return bsonType as TypeCastTypes; } if (isNumber(object)) { return numberToBsonType(object); } if (isPlainObject(object)) { return OBJECT; } if (isArray(object)) { return ARRAY; } return Object.prototype.toString .call(object) .replace(MATCH, '$1') as TypeCastTypes; } /** * Get a list of types the object can be cast to. */ castableTypes(highPrecisionSupport = false): TypeCastTypes[] { if (highPrecisionSupport === true) { return TYPES as TypeCastTypes[]; } return without(TYPES, DECIMAL_128) as TypeCastTypes[]; } } export default new TypeChecker();