diff --git a/packages/compass-components/src/components/bson-value.spec.tsx b/packages/compass-components/src/components/bson-value.spec.tsx index 06bf64564bf..4c6dc11f500 100644 --- a/packages/compass-components/src/components/bson-value.spec.tsx +++ b/packages/compass-components/src/components/bson-value.spec.tsx @@ -192,7 +192,7 @@ describe('BSONValue', function () { ); expect(container.querySelector('.element-value')?.textContent).to.eq( - 'LegacyJavaUUID("efcdab89-6745-2301-efcd-ab8967452301")' + "LegacyJavaUUID('efcdab89-6745-2301-efcd-ab8967452301')" ); }); @@ -204,7 +204,7 @@ describe('BSONValue', function () { ); expect(container.querySelector('.element-value')?.textContent).to.eq( - 'LegacyCSharpUUID("67452301-ab89-efcd-0123-456789abcdef")' + "LegacyCSharpUUID('67452301-ab89-efcd-0123-456789abcdef')" ); }); @@ -216,7 +216,7 @@ describe('BSONValue', function () { ); expect(container.querySelector('.element-value')?.textContent).to.eq( - 'LegacyPythonUUID("01234567-89ab-cdef-0123-456789abcdef")' + "LegacyPythonUUID('01234567-89ab-cdef-0123-456789abcdef')" ); }); diff --git a/packages/compass-components/src/components/bson-value.tsx b/packages/compass-components/src/components/bson-value.tsx index 51f82ce8b28..d8e5c2a13a4 100644 --- a/packages/compass-components/src/components/bson-value.tsx +++ b/packages/compass-components/src/components/bson-value.tsx @@ -1,5 +1,10 @@ import React, { useMemo } from 'react'; import type { TypeCastMap } from 'hadron-type-checker'; +import { + uuidHexToString, + reverseJavaUUIDBytes, + reverseCSharpUUIDBytes, +} from 'hadron-type-checker'; import { Binary } from 'bson'; import type { DBRef } from 'bson'; import { variantColors } from '@leafygreen-ui/code'; @@ -125,69 +130,21 @@ const ObjectIdValue: React.FunctionComponent> = ({ ); }; -const toUUIDWithHyphens = (hex: string): string => { - return ( - hex.substring(0, 8) + - '-' + - hex.substring(8, 12) + - '-' + - hex.substring(12, 16) + - '-' + - hex.substring(16, 20) + - '-' + - hex.substring(20, 32) - ); -}; - const toLegacyJavaUUID = ({ value }: PropsByValueType<'Binary'>) => { - // Get the hex representation from the buffer. - const hex = Buffer.from(value.buffer).toString('hex'); - // Reverse byte order for Java legacy UUID format (reverse all bytes). - let msb = hex.substring(0, 16); - let lsb = hex.substring(16, 32); - // Reverse pairs of hex characters (bytes). - 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); - const uuid = msb + lsb; - return 'LegacyJavaUUID("' + toUUIDWithHyphens(uuid) + '")'; + const hex = value.toString('hex'); + const reversedHex = reverseJavaUUIDBytes(hex); + return "LegacyJavaUUID('" + uuidHexToString(reversedHex) + "')"; }; const toLegacyCSharpUUID = ({ value }: PropsByValueType<'Binary'>) => { - // Get the hex representation from the buffer. - const hex = Buffer.from(value.buffer).toString('hex'); - // Reverse byte order for C# legacy UUID format (first 3 groups only). - 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); - const uuid = a + b + c + d; - return 'LegacyCSharpUUID("' + toUUIDWithHyphens(uuid) + '")'; + const hex = value.toString('hex'); + const reversedHex = reverseCSharpUUIDBytes(hex); + return "LegacyCSharpUUID('" + uuidHexToString(reversedHex) + "')"; }; const toLegacyPythonUUID = ({ value }: PropsByValueType<'Binary'>) => { - // Get the hex representation from the buffer. - const hex = Buffer.from(value.buffer).toString('hex'); - return 'LegacyPythonUUID("' + toUUIDWithHyphens(hex) + '")'; + const hex = value.toString('hex'); + return "LegacyPythonUUID('" + uuidHexToString(hex) + "')"; }; // Binary sub_type 3. @@ -227,6 +184,104 @@ const LegacyUUIDValue: React.FunctionComponent> = ( ); }; +// UUID value component for UUID type (Binary subtype 4) +const UUIDValue: React.FunctionComponent> = ({ + value, +}) => { + const stringifiedValue = useMemo(() => { + // During editing, value might be a string + if (typeof value === 'string') { + return value; + } + if (!value || !value.buffer) { + return String(value); + } + try { + // Try to get the pretty hex version of the UUID + return value.toUUID().toString(); + } catch { + // If uuid is not following the uuid format (e.g., not exactly 16 bytes), + // converting it to UUID will fail. We don't want the UI to fail rendering + // it and instead will just display the "unformatted" hex value. + return value.toString('hex'); + } + }, [value]); + + return ( + + UUID(' + {stringifiedValue} + ') + + ); +}; + +// Legacy Java UUID value component +const LegacyJavaUUIDValue: React.FunctionComponent< + PropsByValueType<'LegacyJavaUUID'> +> = ({ value }) => { + const stringifiedValue = useMemo(() => { + // During editing, value might be a string + if (typeof value === 'string') { + return `LegacyJavaUUID('${value}')`; + } + if (!value || !value.buffer) { + return String(value); + } + return toLegacyJavaUUID({ value }); + }, [value]); + + return ( + + {stringifiedValue} + + ); +}; + +// Legacy C# UUID value component +const LegacyCSharpUUIDValue: React.FunctionComponent< + PropsByValueType<'LegacyCSharpUUID'> +> = ({ value }) => { + const stringifiedValue = useMemo(() => { + // During editing, value might be a string + if (typeof value === 'string') { + return `LegacyCSharpUUID('${value}')`; + } + if (!value || !value.buffer) { + return String(value); + } + return toLegacyCSharpUUID({ value }); + }, [value]); + + return ( + + {stringifiedValue} + + ); +}; + +// Legacy Python UUID value component +const LegacyPythonUUIDValue: React.FunctionComponent< + PropsByValueType<'LegacyPythonUUID'> +> = ({ value }) => { + const stringifiedValue = useMemo(() => { + // During editing, value might be a string + if (typeof value === 'string') { + return `LegacyPythonUUID('${value}')`; + } + if (!value || !value.buffer) { + return String(value); + } + return toLegacyPythonUUID({ value }); + }, [value]); + + return ( + + {stringifiedValue} + + ); +}; + const BinaryValue: React.FunctionComponent> = ({ value, }) => { @@ -486,6 +541,18 @@ const BSONValue: React.FunctionComponent = (props) => { return ; } return ; + case 'UUID': + return ; + case 'LegacyJavaUUID': + return ; + case 'LegacyCSharpUUID': + return ( + + ); + case 'LegacyPythonUUID': + return ( + + ); case 'Int32': case 'Double': return ; diff --git a/packages/compass-components/src/components/document-list/element-editors.tsx b/packages/compass-components/src/components/document-list/element-editors.tsx index 7deab9b541b..91ca6f9aba3 100644 --- a/packages/compass-components/src/components/document-list/element-editors.tsx +++ b/packages/compass-components/src/components/document-list/element-editors.tsx @@ -11,6 +11,13 @@ import { documentTypography } from './typography'; import { Icon, Tooltip } from '../leafygreen'; import { useDarkMode } from '../../hooks/use-theme'; +const UUID_TYPES = [ + 'UUID', + 'LegacyJavaUUID', + 'LegacyCSharpUUID', + 'LegacyPythonUUID', +] as const; + const maxWidth = css({ maxWidth: '100%', overflowX: 'hidden', @@ -162,6 +169,30 @@ const editorTextarea = css({ color: 'inherit', }); +// UUID editor container that shows: UUID(" ") +const uuidEditorContainer = css({ + display: 'inline-flex', + alignItems: 'center', + maxWidth: '100%', +}); + +const uuidEditorInput = css({ + display: 'inline-block', + whiteSpace: 'nowrap', + verticalAlign: 'top', + color: 'inherit', +}); + +const uuidEditorLabel = css({ + userSelect: 'none', + whiteSpace: 'nowrap', +}); + +type UUIDType = (typeof UUID_TYPES)[number]; +export function isUUIDType(type: string): type is UUIDType { + return (UUID_TYPES as readonly string[]).includes(type); +} + export const ValueEditor: React.FunctionComponent<{ editing?: boolean; onEditStart(): void; @@ -220,6 +251,11 @@ export const ValueEditor: React.FunctionComponent<{ return { width: `${Math.max(val.length, 1)}ch` }; }, [val, type]); + const uuidInputStyle = useMemo(() => { + // UUID format is 36 characters (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) + return { width: `${Math.max(val.length, 36)}ch` }; + }, [val]); + return ( <> {editing ? ( @@ -268,6 +304,36 @@ export const ValueEditor: React.FunctionComponent<{ {...(mergedProps as React.HTMLProps)} > + ) : isUUIDType(type) ? ( +
+ {type}(' + { + onChange(evt.currentTarget.value); + }} + // See ./element.tsx + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={autoFocus} + className={cx( + editorReset, + editorOutline, + uuidEditorInput, + !valid && editorInvalid, + !valid && + (darkMode + ? editorInvalidDarkMode + : editorInvalidLightMode) + )} + spellCheck="false" + style={uuidInputStyle} + {...(mergedProps as React.HTMLProps)} + > + ') +
) : ( { - const Editor = getEditorByType(el.currentType); + // Set the displayType on the element so editors can read it. + // This ensures that Binary UUIDs get the UUIDEditor even when el.currentType is 'Binary' + el.displayType = displayType; + const Editor = getEditorByType(displayType); return new Editor(el); }, - // The list of deps is exhaustive, but we want `currentType` to be an + // The list of deps is exhaustive, but we want `displayType` to be an // explicit dependency of the memo to make sure that even if the `el` - // instance is the same, but `currentType` changed, we create a new editor + // instance is the same, but `displayType` changed, we create a new editor // instance // eslint-disable-next-line react-hooks/exhaustive-deps - [el, el.currentType] + [el, displayType] ); } +/** + * Gets the display type for an element, considering legacy UUID encoding preference. + * For Binary subtype 3 (legacy UUID), returns the appropriate legacy UUID type based on context. + * For Binary subtype 4 (UUID), returns 'UUID'. + * For all other types, returns the element's currentType. + */ +function getDisplayType( + el: HadronElementType, + legacyUUIDEncoding: string +): HadronElementType['type'] { + // If the element already has a specific UUID type, use it + if (isUUIDType(el.currentType)) { + return el.currentType; + } + + // Check if this is a Binary that should be displayed as a UUID type + // Using _bsontype check instead of instanceof for cross-realm compatibility + // and future bson@7.x compatibility + if ( + el.currentType === 'Binary' && + el.currentValue && + typeof el.currentValue === 'object' && + '_bsontype' in el.currentValue && + el.currentValue._bsontype === 'Binary' + ) { + const binary = el.currentValue 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 as HadronElementType['type']; + } + } + + return el.currentType; +} + function useHadronElement(el: HadronElementType) { const forceUpdate = useForceUpdate(); - const editor = useElementEditor(el); + const legacyUUIDEncoding = useLegacyUUIDDisplayContext(); + const displayType = getDisplayType(el, legacyUUIDEncoding); + const editor = useElementEditor(el, displayType); // NB: Duplicate key state is kept local to the component and not derived on // every change so that only the changed key is highlighed as duplicate const [isDuplicateKey, setIsDuplicateKey] = useState(() => { @@ -163,7 +222,7 @@ function useHadronElement(el: HadronElementType) { completeEdit: editor.complete.bind(editor), }, type: { - value: el.currentType, + value: displayType, change(newVal: HadronElementType['type']) { el.changeType(newVal); }, diff --git a/packages/compass-components/src/components/document-list/index.ts b/packages/compass-components/src/components/document-list/index.ts index 38b67e68498..937d6387f66 100644 --- a/packages/compass-components/src/components/document-list/index.ts +++ b/packages/compass-components/src/components/document-list/index.ts @@ -2,3 +2,7 @@ export { default as DocumentActionsGroup } from './document-actions-group'; export { default as VisibleFieldsToggle } from './visible-field-toggle'; export { default as Document } from './document'; export { default as DocumentEditActionsFooter } from './document-edit-actions-footer'; +export { + LegacyUUIDDisplayContext, + type LegacyUUIDDisplay, +} from './legacy-uuid-format-context'; diff --git a/packages/compass-crud/src/components/document-list.tsx b/packages/compass-crud/src/components/document-list.tsx index 370b81ca7ef..0c7ca4a3c4b 100644 --- a/packages/compass-crud/src/components/document-list.tsx +++ b/packages/compass-crud/src/components/document-list.tsx @@ -383,7 +383,13 @@ const DocumentList: React.FunctionComponent = (props) => { readOnly: preferencesReadOnly, readWrite: preferencesReadWrite, enableImportExport: isImportExportEnabled, - } = usePreferences(['readOnly', 'readWrite', 'enableImportExport']); + legacyUUIDDisplayEncoding, + } = usePreferences([ + 'readOnly', + 'readWrite', + 'enableImportExport', + 'legacyUUIDDisplayEncoding', + ]); const isEditable = !preferencesReadOnly && @@ -508,6 +514,7 @@ const DocumentList: React.FunctionComponent = (props) => { scrollTriggerRef={scrollTriggerRef} columnWidths={columnWidths} onColumnWidthChange={onColumnWidthChange} + legacyUUIDDisplayEncoding={legacyUUIDDisplayEncoding} /> ); } @@ -530,6 +537,7 @@ const DocumentList: React.FunctionComponent = (props) => { currentViewInitialScrollTop, columnWidths, onColumnWidthChange, + legacyUUIDDisplayEncoding, ] ); diff --git a/packages/compass-crud/src/components/table-view/cell-editor.tsx b/packages/compass-crud/src/components/table-view/cell-editor.tsx index 8af27bee2a9..60ad1a357e8 100644 --- a/packages/compass-crud/src/components/table-view/cell-editor.tsx +++ b/packages/compass-crud/src/components/table-view/cell-editor.tsx @@ -6,6 +6,7 @@ import { ElementEditor as initEditors, getDefaultValueForType, } from 'hadron-document'; +import { Binary } from 'bson'; import TypesDropdown from './types-dropdown'; import AddFieldButton from './add-field-button'; import { @@ -59,6 +60,52 @@ export interface DocumentTableRowNode extends RowNode { }; } +/** + * Gets the display type for an element, considering legacy UUID encoding preference. + * For Binary subtype 3 (legacy UUID), returns the appropriate legacy UUID type based on encoding. + * For Binary subtype 4 (UUID), returns 'UUID'. + * For all other types, returns the element's currentType. + */ +function getDisplayType( + element: Element, + legacyUUIDEncoding?: string +): TypeCastTypes { + // If the element already has a specific UUID type, use it + if ( + element.currentType === 'UUID' || + element.currentType === 'LegacyJavaUUID' || + element.currentType === 'LegacyCSharpUUID' || + element.currentType === 'LegacyPythonUUID' + ) { + return element.currentType; + } + + // Check if this is a Binary that should be displayed as a UUID type + // Using _bsontype check instead of instanceof for cross-realm compatibility + // and future bson@7.x compatibility + if ( + element.currentType === 'Binary' && + element.currentValue && + typeof element.currentValue === 'object' && + '_bsontype' in element.currentValue && + element.currentValue._bsontype === 'Binary' + ) { + const binary = element.currentValue 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 as TypeCastTypes; + } + } + + return element.currentType; +} + export type CellEditorProps = Omit & { value: Element; node: DocumentTableRowNode; @@ -75,6 +122,7 @@ export type CellEditorProps = Omit & { drillDown: CrudActions['drillDown']; tz: string; darkMode?: boolean; + legacyUUIDDisplayEncoding?: string; }; type CellEditorState = { @@ -146,6 +194,12 @@ class CellEditor this.newField = value.currentKey === '$new'; } + // Set the displayType on the element so editors can read it + this.element.displayType = getDisplayType( + this.element, + props.legacyUUIDDisplayEncoding + ); + this.oldType = this.element.currentType; this._editors = initEditors(this.element /*, props.tz*/); this.editor().start(); @@ -365,12 +419,22 @@ class CellEditor /** * Get the editor for the current type. + * Uses element.displayType if set, otherwise falls back to element.currentType. * * @returns {Editor} The editor. */ editor(): Editor { + // Use displayType if available (for UUID types), otherwise use currentType + const editorType = this.element!.displayType ?? this.element!.currentType; + // Map legacy UUID types to the UUID editor + const editorKey = + editorType === 'LegacyJavaUUID' || + editorType === 'LegacyCSharpUUID' || + editorType === 'LegacyPythonUUID' + ? 'UUID' + : editorType; return ( - this._editors![this.element!.currentType as keyof typeof this._editors] ?? + this._editors![editorKey as keyof typeof this._editors] ?? this._editors!.Standard ); } diff --git a/packages/compass-crud/src/components/table-view/cell-renderer.tsx b/packages/compass-crud/src/components/table-view/cell-renderer.tsx index 963c9fd127c..af474aba242 100644 --- a/packages/compass-crud/src/components/table-view/cell-renderer.tsx +++ b/packages/compass-crud/src/components/table-view/cell-renderer.tsx @@ -2,12 +2,14 @@ import React from 'react'; import { BSONValue, css, + DocumentList, Icon, IconButton, LeafyGreenProvider, spacing, withDarkMode, } from '@mongodb-js/compass-components'; +import type { DocumentList as DocumentListTypes } from '@mongodb-js/compass-components'; import { Element } from 'hadron-document'; import type { ICellRendererReactComp } from 'ag-grid-react'; import type { ICellRendererParams } from 'ag-grid-community'; @@ -89,6 +91,7 @@ export type CellRendererProps = Omit & { drillDown: CrudActions['drillDown']; tz: string; darkMode?: boolean; + legacyUUIDDisplayEncoding?: DocumentListTypes.LegacyUUIDDisplay; }; /** @@ -347,18 +350,22 @@ class CellRenderer return ( // `ag-grid` renders this component outside of the context chain - // so we re-supply the dark mode theme here. + // so we re-supply the dark mode theme and legacy UUID encoding here. - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus*/} -
- {this.renderUndo(canUndo, canExpand)} - {this.renderExpand(canExpand)} - {element} -
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus*/} +
+ {this.renderUndo(canUndo, canExpand)} + {this.renderExpand(canExpand)} + {element} +
+
); } diff --git a/packages/compass-crud/src/components/table-view/document-table-view.tsx b/packages/compass-crud/src/components/table-view/document-table-view.tsx index 3d2e27de0d0..e971b601a58 100644 --- a/packages/compass-crud/src/components/table-view/document-table-view.tsx +++ b/packages/compass-crud/src/components/table-view/document-table-view.tsx @@ -71,6 +71,7 @@ export type DocumentTableViewProps = { tz: string; className?: string; darkMode?: boolean; + legacyUUIDDisplayEncoding?: string; columnWidths: Record; onColumnWidthChange: (newColumnWidths: Record) => void; }; @@ -750,6 +751,7 @@ export class DocumentTableView extends React.Component { parentType: '', tz: this.props.tz, darkMode: this.props.darkMode, + legacyUUIDDisplayEncoding: this.props.legacyUUIDDisplayEncoding, }, editable: false, cellEditorFramework: CellEditor, @@ -823,6 +825,7 @@ export class DocumentTableView extends React.Component { parentType: parentType, tz: this.props.tz, darkMode: this.props.darkMode, + legacyUUIDDisplayEncoding: this.props.legacyUUIDDisplayEncoding, }, editable: function (params) { @@ -865,6 +868,7 @@ export class DocumentTableView extends React.Component { drillDown: this.props.drillDown, tz: this.props.tz, darkMode: this.props.darkMode, + legacyUUIDDisplayEncoding: this.props.legacyUUIDDisplayEncoding, }, resizable: true, width: this.props.columnWidths[String(path[path.length - 1])], diff --git a/packages/compass-import-export/test/docs/all-bson-types.exported.default.ejson b/packages/compass-import-export/test/docs/all-bson-types.exported.default.ejson index bfce47d06c8..3e890070ec3 100644 --- a/packages/compass-import-export/test/docs/all-bson-types.exported.default.ejson +++ b/packages/compass-import-export/test/docs/all-bson-types.exported.default.ejson @@ -91,10 +91,7 @@ } }, "uuid": { - "$binary": { - "base64": "qqqqqqqqSqqqqqqqqqqqqg==", - "subType": "04" - } + "$uuid": "aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa" }, "md5": { "$binary": { diff --git a/packages/compass-import-export/test/json/good.exported.ejson b/packages/compass-import-export/test/json/good.exported.ejson index 79987beccbd..5737be82535 100644 --- a/packages/compass-import-export/test/json/good.exported.ejson +++ b/packages/compass-import-export/test/json/good.exported.ejson @@ -3,10 +3,7 @@ "$oid": "63f656c128ff7868ed701ca3" }, "uuid": { - "$binary": { - "base64": "ACY24RDNTIuppwG3v9OJnA==", - "subType": "04" - } + "$uuid": "002636e1-10cd-4c8b-a9a7-01b7bfd3899c" }, "name": "Arlo" }, @@ -15,10 +12,7 @@ "$oid": "63f656c128ff7868ed701ca4" }, "uuid": { - "$binary": { - "base64": "ADH2ysDsTLCmbB/hTKKcGA==", - "subType": "04" - } + "$uuid": "0031f6ca-c0ec-4cb0-a66c-1fe14ca29c18" }, "name": "Basil" }, @@ -27,10 +21,7 @@ "$oid": "63f656c128ff7868ed701ca5" }, "uuid": { - "$binary": { - "base64": "ADVkMBdRSZauRXdnZILiEA==", - "subType": "04" - } + "$uuid": "00356430-1751-4996-ae45-77676482e210" }, "name": "Kochka" }] \ No newline at end of file diff --git a/packages/hadron-document/src/editor/index.ts b/packages/hadron-document/src/editor/index.ts index 673c971aa11..26870b792c0 100644 --- a/packages/hadron-document/src/editor/index.ts +++ b/packages/hadron-document/src/editor/index.ts @@ -8,6 +8,7 @@ import DateEditor from './date'; import NullEditor from './null'; import UndefinedEditor from './undefined'; import ObjectIdEditor from './objectid'; +import UUIDEditor from './uuid'; import type { Element } from '../element'; const init = (element: Element) => ({ @@ -21,6 +22,7 @@ const init = (element: Element) => ({ Null: new NullEditor(element), Undefined: new UndefinedEditor(element), ObjectId: new ObjectIdEditor(element), + UUID: new UUIDEditor(element), }); export const ElementEditor = Object.assign(init, { @@ -34,6 +36,7 @@ export const ElementEditor = Object.assign(init, { NullEditor, UndefinedEditor, ObjectIdEditor, + UUIDEditor, }); export type Editor = @@ -46,7 +49,8 @@ export type Editor = | Int64Editor | NullEditor | UndefinedEditor - | ObjectIdEditor; + | ObjectIdEditor + | UUIDEditor; export { DateEditor, @@ -59,4 +63,5 @@ export { NullEditor, UndefinedEditor, ObjectIdEditor, + UUIDEditor, }; diff --git a/packages/hadron-document/src/editor/standard.ts b/packages/hadron-document/src/editor/standard.ts index 22a8fbf3c26..f3a2566b948 100644 --- a/packages/hadron-document/src/editor/standard.ts +++ b/packages/hadron-document/src/editor/standard.ts @@ -21,7 +21,7 @@ export default class StandardEditor { /** * Create the editor with the element. * - * @param {Element} element - The hadron document element. + * @param element - The hadron document element. */ constructor(element: Element) { this.element = element; diff --git a/packages/hadron-document/src/editor/uuid.ts b/packages/hadron-document/src/editor/uuid.ts new file mode 100644 index 00000000000..a2a64b597ec --- /dev/null +++ b/packages/hadron-document/src/editor/uuid.ts @@ -0,0 +1,148 @@ +import TypeChecker, { + uuidHexToString, + reverseJavaUUIDBytes, + reverseCSharpUUIDBytes, +} from 'hadron-type-checker'; +import type { Binary } from 'bson'; +import { ElementEvents } from '../element-events'; +import StandardEditor from './standard'; +import type { Element } from '../element'; +import { isUUIDType, type UUIDType } from '../element'; +import type { BSONValue } from '../utils'; + +/** + * Converts a Binary UUID (subtype 4) to a UUID string with hyphens. + */ +const binaryToUUIDString = (binary: Binary): string => { + try { + return binary.toUUID().toString(); + } catch { + // Fallback to hex if toUUID fails + return binary.toString('hex'); + } +}; + +/** + * Converts a Binary Legacy Java UUID (subtype 3) to a UUID string with hyphens. + * Java legacy format reverses byte order for both MSB and LSB. + */ +const binaryToLegacyJavaUUIDString = (binary: Binary): string => { + const hex = binary.toString('hex'); + const reversedHex = reverseJavaUUIDBytes(hex); + return uuidHexToString(reversedHex); +}; + +/** + * Converts a Binary Legacy C# UUID (subtype 3) to a UUID string with hyphens. + * C# legacy format reverses byte order for first 3 groups only. + */ +const binaryToLegacyCSharpUUIDString = (binary: Binary): string => { + const hex = binary.toString('hex'); + const reversedHex = reverseCSharpUUIDBytes(hex); + return uuidHexToString(reversedHex); +}; + +/** + * Converts a Binary Legacy Python UUID (subtype 3) to a UUID string with hyphens. + * Python legacy format uses direct byte order (no reversal). + */ +const binaryToLegacyPythonUUIDString = (binary: Binary): string => { + const hex = binary.toString('hex'); + return uuidHexToString(hex); +}; + +/** + * CRUD editor for UUID values (Binary subtypes 3 and 4). + */ +export default class UUIDEditor extends StandardEditor { + uuidType: UUIDType; + + /** + * Create the UUID editor. + * + * @param element - The hadron document element. + */ + constructor(element: Element) { + super(element); + // Use element.displayType if set and it's a UUID type, otherwise fall back to element.currentType + const effectiveType = element.displayType ?? element.currentType; + this.uuidType = isUUIDType(effectiveType) ? effectiveType : 'UUID'; + } + + /** + * Get the value being edited as a UUID string. + */ + value(): string { + const val = this.element.currentValue; + // If already a string (during editing), return as is + if (typeof val === 'string') { + return val; + } + // If it's a Binary, convert to UUID string based on the type + // Using _bsontype check instead of instanceof for cross-realm compatibility + // and future bson@7.x compatibility + if ( + val && + typeof val === 'object' && + '_bsontype' in val && + val._bsontype === 'Binary' + ) { + const binary = val; + switch (this.uuidType) { + case 'LegacyJavaUUID': + return binaryToLegacyJavaUUIDString(binary); + case 'LegacyCSharpUUID': + return binaryToLegacyCSharpUUIDString(binary); + case 'LegacyPythonUUID': + return binaryToLegacyPythonUUIDString(binary); + case 'UUID': + default: + return binaryToUUIDString(binary); + } + } + return String(val); + } + + /** + * Edit the element with the provided value. + */ + edit(value: BSONValue): void { + try { + TypeChecker.cast(value, this.uuidType); + this.element.currentValue = value; + this.element.setValid(); + this.element._bubbleUp(ElementEvents.Edited, this.element); + } catch (e: any) { + this.element.setInvalid(value, this.element.currentType, e.message); + } + } + + /** + * Start the UUID edit - convert Binary to string for editing. + */ + start(): void { + super.start(); + if (this.element.isCurrentTypeValid()) { + // Update the currentType to the UUID type so the UI displays correctly + // This is needed because the element may have been created with currentType='Binary' + // but we want to edit it as a UUID type + this.element.currentType = this.uuidType; + this.edit(this.value()); + } + } + + /** + * Complete the UUID edit by converting the valid string back to Binary. + */ + complete(): void { + super.complete(); + if (this.element.isCurrentTypeValid()) { + this.element.edit( + TypeChecker.cast(this.element.currentValue, this.uuidType) + ); + // Preserve the UUID type since element.edit() sets currentType to 'Binary' + // for Binary values (TypeChecker.type() returns 'Binary' for Binary objects) + this.element.currentType = this.uuidType; + } + } +} diff --git a/packages/hadron-document/src/element.ts b/packages/hadron-document/src/element.ts index 0847a7e78cf..11c52b7475f 100644 --- a/packages/hadron-document/src/element.ts +++ b/packages/hadron-document/src/element.ts @@ -4,13 +4,13 @@ import EventEmitter from 'eventemitter3'; import { isPlainObject, isArray, isEqual, isString } from 'lodash'; import type { ObjectGeneratorOptions } from './object-generator'; import ObjectGenerator from './object-generator'; -import TypeChecker from 'hadron-type-checker'; -import { UUID } from 'bson'; +import TypeChecker, { convertBinaryUUID } from 'hadron-type-checker'; +import { Binary, UUID } from 'bson'; import DateEditor from './editor/date'; import { ElementEvents, type ElementEventsType } from './element-events'; import type { Document } from './document'; import type { TypeCastTypes } from 'hadron-type-checker'; -import type { Binary, ObjectId } from 'bson'; +import type { ObjectId } from 'bson'; import type { BSONArray, BSONObject, @@ -52,6 +52,39 @@ const UNEDITABLE_TYPES = [ 'DBRef', ]; +/** + * UUID type names for Binary subtypes 3 and 4. + */ +export const UUID_TYPES = [ + 'UUID', + 'LegacyJavaUUID', + 'LegacyCSharpUUID', + 'LegacyPythonUUID', +] as const; + +export type UUIDType = (typeof UUID_TYPES)[number]; + +/** + * Type guard to check if a type string is a UUID type. + */ +export function isUUIDType(type: string): type is UUIDType { + return (UUID_TYPES as readonly string[]).includes(type); +} + +/** + * Type guard to check if a value is a BSON Binary. + * Uses _bsontype property check instead of instanceof for cross-realm compatibility. + * TODO: Once we upgrade to bson@7.x, use value?.[bsonType] === 'Binary' instead. + */ +function isBinary(value: unknown): value is Binary { + return ( + value !== null && + typeof value === 'object' && + '_bsontype' in value && + value._bsontype === 'Binary' + ); +} + export const DEFAULT_VISIBLE_ELEMENTS = 25; export function isValueExpandable( value: BSONValue @@ -86,6 +119,11 @@ export class Element extends EventEmitter { decrypted: boolean; expanded = false; maxVisibleElementsCount = DEFAULT_VISIBLE_ELEMENTS; + // Display type for the element. This is used by editors to determine + // how to display and edit the value. For example, a Binary with subtype 3 + // might have displayType set to 'LegacyJavaUUID' to indicate it should be + // displayed and edited as a Java legacy UUID. + displayType?: TypeCastTypes; /** * Cancel any modifications to the element. @@ -245,8 +283,30 @@ export class Element extends EventEmitter { const editor = new DateEditor(this); editor.edit(this.generateObject()); editor.complete(); + } else if ( + isUUIDType(newType) && + isUUIDType(this.currentType) && + isBinary(this.currentValue) + ) { + // Special handling for converting between UUID types + // We need to use the source type to properly decode the binary + const convertedBinary = convertBinaryUUID( + this.currentValue, + this.currentType, + newType + ); + this.edit(convertedBinary); + this.currentType = newType; + this._bubbleUp(ElementEvents.Edited, this); } else { this.edit(TypeChecker.cast(this.generateObject(), newType)); + // For UUID types, explicitly set the currentType since TypeChecker.type() + // may not return the specific UUID type for legacy UUIDs (subtype 3) + if (isUUIDType(newType)) { + this.currentType = newType; + // Fire another event to notify the UI of the type change + this._bubbleUp(ElementEvents.Edited, this); + } } } catch (e: unknown) { this.setInvalid(this.currentValue, newType, (e as Error).message); @@ -651,9 +711,21 @@ export class Element extends EventEmitter { * @returns If the value is editable. */ isValueEditable(): boolean { + // UUID types are editable even though Binary is in UNEDITABLE_TYPES + const isCurrentTypeUUID = isUUIDType(this.currentType); + // Also check for Binary values that are actually UUIDs (subtype 3 or 4) + // This handles the case where currentType is 'Binary' but it's actually a UUID + const isBinaryUUIDValue = + this.currentType === 'Binary' && + isBinary(this.currentValue) && + (this.currentValue.sub_type === Binary.SUBTYPE_UUID || + (this.currentValue.sub_type === Binary.SUBTYPE_UUID_OLD && + this.currentValue.buffer.length === 16)); return ( this._isKeyLegallyEditable() && - !UNEDITABLE_TYPES.includes(this.currentType) + (isCurrentTypeUUID || + isBinaryUUIDValue || + !UNEDITABLE_TYPES.includes(this.currentType)) ); } diff --git a/packages/hadron-document/src/utils.ts b/packages/hadron-document/src/utils.ts index e552cb639df..7033bcd3af5 100644 --- a/packages/hadron-document/src/utils.ts +++ b/packages/hadron-document/src/utils.ts @@ -24,6 +24,10 @@ const UNCASTED_EMPTY_TYPE_VALUE: { Boolean: false, Undefined: undefined, Null: null, + UUID: '', + LegacyJavaUUID: '', + LegacyCSharpUUID: '', + LegacyPythonUUID: '', }; const maxFourYearDate = new Date('9999-12-31T23:59:59.999Z').valueOf(); @@ -77,6 +81,26 @@ export function objectToIdiomaticEJSON( ); } +/** + * Convert a base64 string to a UUID string (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). + */ +function base64ToUUIDString(base64: string): string { + // Decode base64 to bytes + const bytes = Buffer.from(base64, 'base64'); + if (bytes.length !== 16) { + return ''; // Invalid UUID length + } + const hex = bytes.toString('hex'); + // Format as UUID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + return [ + hex.substring(0, 8), + hex.substring(8, 12), + hex.substring(12, 16), + hex.substring(16, 20), + hex.substring(20, 32), + ].join('-'); +} + function makeEJSONIdiomatic(value: any): void { if (!value || typeof value !== 'object') return; @@ -107,6 +131,18 @@ function makeEJSONIdiomatic(value: any): void { entry.$date = new Date(+number).toISOString(); } } + // Convert Binary subtype 04 (UUID) to $uuid format for better readability + if ( + entry.$binary && + entry.$binary.subType === '04' && + entry.$binary.base64 + ) { + const uuidString = base64ToUUIDString(entry.$binary.base64); + if (uuidString) { + value[key] = { $uuid: uuidString }; + continue; + } + } makeEJSONIdiomatic(entry); } } diff --git a/packages/hadron-document/test/editor/uuid.spec.ts b/packages/hadron-document/test/editor/uuid.spec.ts new file mode 100644 index 00000000000..70eedd8781a --- /dev/null +++ b/packages/hadron-document/test/editor/uuid.spec.ts @@ -0,0 +1,164 @@ +import { Binary } from 'bson'; +import { Element } from '../../src'; +import { UUIDEditor } from '../../src/editor'; +import { expect } from 'chai'; + +describe('UUIDEditor', function () { + const uuidString = '01234567-89ab-cdef-0123-456789abcdef'; + const uuidHex = '0123456789abcdef0123456789abcdef'; + + describe('#value', function () { + context('when the element is a UUID (subtype 4)', function () { + const binary = Binary.createFromHexString(uuidHex, Binary.SUBTYPE_UUID); + const element = new Element('uuid', binary, false); + element.currentType = 'UUID'; + const uuidEditor = new UUIDEditor(element); + + it('returns the UUID string with hyphens', function () { + expect(uuidEditor.value()).to.equal(uuidString); + }); + }); + + context('when the element is a LegacyPythonUUID (subtype 3)', function () { + const binary = Binary.createFromHexString( + uuidHex, + Binary.SUBTYPE_UUID_OLD + ); + const element = new Element('uuid', binary, false); + element.currentType = 'LegacyPythonUUID'; + const uuidEditor = new UUIDEditor(element); + + it('returns the UUID string with hyphens (no byte reversal)', function () { + expect(uuidEditor.value()).to.equal(uuidString); + }); + }); + + context('when the value is already a string', function () { + const element = new Element('uuid', uuidString, false); + element.currentType = 'UUID'; + const uuidEditor = new UUIDEditor(element); + + it('returns the string as-is', function () { + expect(uuidEditor.value()).to.equal(uuidString); + }); + }); + }); + + describe('#edit', function () { + context('when the UUID string is valid', function () { + const binary = Binary.createFromHexString(uuidHex, Binary.SUBTYPE_UUID); + const element = new Element('uuid', binary, false); + element.currentType = 'UUID'; + const uuidEditor = new UUIDEditor(element); + const newValidString = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + + before(function () { + uuidEditor.start(); + uuidEditor.edit(newValidString); + }); + + it('keeps the string as the current value', function () { + expect(element.currentValue).to.equal(newValidString); + }); + + it('sets the current value as valid', function () { + expect(element.isCurrentTypeValid()).to.equal(true); + }); + }); + + context('when the UUID string is invalid', function () { + const binary = Binary.createFromHexString(uuidHex, Binary.SUBTYPE_UUID); + const element = new Element('uuid', binary, false); + element.currentType = 'UUID'; + const uuidEditor = new UUIDEditor(element); + const invalidString = 'not-a-valid-uuid'; + + before(function () { + uuidEditor.start(); + uuidEditor.edit(invalidString); + }); + + it('keeps the string as the current value', function () { + expect(element.currentValue).to.equal(invalidString); + }); + + it('sets the current value as invalid', function () { + expect(element.isCurrentTypeValid()).to.equal(false); + }); + + it('sets the invalid message', function () { + expect(element.invalidTypeMessage).to.include('not a valid UUID'); + }); + }); + }); + + describe('#complete', function () { + context('when the UUID string is valid', function () { + const binary = Binary.createFromHexString(uuidHex, Binary.SUBTYPE_UUID); + const element = new Element('uuid', binary, false); + element.currentType = 'UUID'; + const uuidEditor = new UUIDEditor(element); + const newValidString = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + + before(function () { + uuidEditor.start(); + uuidEditor.edit(newValidString); + uuidEditor.complete(); + }); + + it('casts the current value to Binary', function () { + expect(element.currentValue).to.be.instanceOf(Binary); + expect((element.currentValue as Binary).sub_type).to.equal( + Binary.SUBTYPE_UUID + ); + }); + + it('sets the current value as valid', function () { + expect(element.isCurrentTypeValid()).to.equal(true); + }); + }); + + context('when the UUID string is invalid', function () { + const binary = Binary.createFromHexString(uuidHex, Binary.SUBTYPE_UUID); + const element = new Element('uuid', binary, false); + element.currentType = 'UUID'; + const uuidEditor = new UUIDEditor(element); + const invalidString = 'nope'; + + before(function () { + uuidEditor.start(); + uuidEditor.edit(invalidString); + uuidEditor.complete(); + }); + + it('keeps the string as the current value', function () { + expect(element.currentValue).to.equal('nope'); + }); + + it('sets the current value as invalid', function () { + expect(element.isCurrentTypeValid()).to.equal(false); + }); + }); + }); + + describe('#start', function () { + context('when the current type is valid', function () { + const binary = Binary.createFromHexString(uuidHex, Binary.SUBTYPE_UUID); + const element = new Element('uuid', binary, false); + element.currentType = 'UUID'; + const uuidEditor = new UUIDEditor(element); + + before(function () { + uuidEditor.start(); + }); + + it('converts the Binary to a UUID string for editing', function () { + expect(element.currentValue).to.equal(uuidString); + }); + + it('sets the current value as valid', function () { + expect(element.isCurrentTypeValid()).to.equal(true); + }); + }); + }); +}); diff --git a/packages/hadron-type-checker/src/index.ts b/packages/hadron-type-checker/src/index.ts index 7b7cb12810e..d1328309974 100644 --- a/packages/hadron-type-checker/src/index.ts +++ b/packages/hadron-type-checker/src/index.ts @@ -1,2 +1,9 @@ -export { default } from './type-checker'; +export { + default, + UUID_REGEX, + uuidHexToString, + reverseJavaUUIDBytes, + reverseCSharpUUIDBytes, + convertBinaryUUID, +} from './type-checker'; export type { TypeCastMap, TypeCastTypes } from './type-checker'; diff --git a/packages/hadron-type-checker/src/type-checker.ts b/packages/hadron-type-checker/src/type-checker.ts index afa188945cf..435c1579a00 100644 --- a/packages/hadron-type-checker/src/type-checker.ts +++ b/packages/hadron-type-checker/src/type-checker.ts @@ -23,6 +23,7 @@ import { Code, BSONSymbol, Timestamp, + UUID, } from 'bson'; export type TypeCastMap = { @@ -45,6 +46,10 @@ export type TypeCastMap = { BSONSymbol: BSONSymbol; Timestamp: Timestamp; Undefined: undefined; + UUID: Binary; + LegacyJavaUUID: Binary; + LegacyCSharpUUID: Binary; + LegacyPythonUUID: Binary; }; export type TypeCastTypes = keyof TypeCastMap; @@ -254,6 +259,284 @@ const toBinary = (object: unknown): Binary => { return new Binary(buffer, Binary.SUBTYPE_DEFAULT); }; +/** + * 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)); }; @@ -296,6 +579,10 @@ const CASTERS: { BSONSymbol: toSymbol, Timestamp: toTimestamp, Undefined: toUndefined, + UUID: toUUID, + LegacyJavaUUID: toLegacyJavaUUID, + LegacyCSharpUUID: toLegacyCSharpUUID, + LegacyPythonUUID: toLegacyPythonUUID, }; /** @@ -367,8 +654,17 @@ class TypeChecker { /** * 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): TypeCastTypes { + type( + object: unknown, + legacyUUIDEncoding?: + | 'LegacyJavaUUID' + | 'LegacyCSharpUUID' + | 'LegacyPythonUUID' + ): TypeCastTypes { if (hasIn(object, BSON_TYPE)) { const bsonObj = object as { _bsontype: string }; if (bsonObj._bsontype === LONG) { @@ -380,6 +676,20 @@ class TypeChecker { if (bsonObj._bsontype === SYMBOL) { return 'BSONSymbol'; } + // Handle Binary UUID subtypes + if (bsonObj._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 bsonObj._bsontype as TypeCastTypes; } if (isNumber(object)) { diff --git a/packages/hadron-type-checker/test/type-checker.test.ts b/packages/hadron-type-checker/test/type-checker.test.ts index 1cc4ec23eb0..e9f53ca4171 100644 --- a/packages/hadron-type-checker/test/type-checker.test.ts +++ b/packages/hadron-type-checker/test/type-checker.test.ts @@ -801,6 +801,10 @@ describe('TypeChecker', function () { 'BSONSymbol', 'Timestamp', 'Undefined', + 'UUID', + 'LegacyJavaUUID', + 'LegacyCSharpUUID', + 'LegacyPythonUUID', ]); }); }); @@ -826,6 +830,10 @@ describe('TypeChecker', function () { 'BSONSymbol', 'Timestamp', 'Undefined', + 'UUID', + 'LegacyJavaUUID', + 'LegacyCSharpUUID', + 'LegacyPythonUUID', ]); }); });