UNPKG

hadron-document

Version:
1,014 lines (1,013 loc) • 31.9 kB
'use strict'; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ElementList = exports.Element = exports.DEFAULT_VISIBLE_ELEMENTS = exports.ElementEvents = exports.DATE_FORMAT = void 0; exports.isInternalFieldPath = isInternalFieldPath; exports.isValueExpandable = isValueExpandable; const eventemitter3_1 = __importDefault(require("eventemitter3")); const lodash_1 = require("lodash"); const object_generator_1 = __importDefault(require("./object-generator")); const hadron_type_checker_1 = __importDefault(require("hadron-type-checker")); const bson_1 = require("bson"); const date_1 = __importDefault(require("./editor/date")); const element_events_1 = require("./element-events"); Object.defineProperty(exports, "ElementEvents", { enumerable: true, get: function () { return element_events_1.ElementEvents; } }); const utils_1 = require("./utils"); const document_events_1 = require("./document-events"); exports.DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss.SSS'; /** * Id field constant. */ const ID = '_id'; /** * Is this the path to a field that is used internally by the * MongoDB driver or server and not for user consumption? */ function isInternalFieldPath(path) { return typeof path === 'string' && /^__safeContent__($|\.)/.test(path); } /** * Types that are not editable. */ const UNEDITABLE_TYPES = [ 'Binary', 'Code', 'MinKey', 'MaxKey', 'Timestamp', 'BSONRegExp', 'Undefined', 'Null', 'DBRef', ]; exports.DEFAULT_VISIBLE_ELEMENTS = 25; function isValueExpandable(value) { return (0, lodash_1.isPlainObject)(value) || (0, lodash_1.isArray)(value); } /** * Represents an element in a document. */ class Element extends eventemitter3_1.default { /** * Cancel any modifications to the element. */ cancel() { if (this.elements) { // Cancel will remove elements from iterator, clone it before iterating // otherwise we will skip items for (const element of Array.from(this.elements)) { element.cancel(); } } if (this.isModified()) { this.revert(); } } /** * Create the element. * * @param key - The key. * @param value - The value. * @param parent - The parent element. * @param added - Is the element a new 'addition'? */ constructor(key, value, parent = null, added = false) { super(); this.expanded = false; this.maxVisibleElementsCount = exports.DEFAULT_VISIBLE_ELEMENTS; this.uuid = new bson_1.UUID().toHexString(); this.key = key; this.currentKey = key; this.parent = parent; this.added = added; this.removed = false; this.type = hadron_type_checker_1.default.type(value); this.currentType = this.type; this.level = this._getLevel(); this.setValid(); // Make sure that all values that element will hold onto will be explicit // bson types: convert JavaScript numbers to either Int32 or Double if (typeof value === 'number') { value = hadron_type_checker_1.default.cast(value, hadron_type_checker_1.default.type(value)); } if (isValueExpandable(value)) { // NB: Important to set `originalExpandableValue` first as element // generation will depend on it this.originalExpandableValue = value; this.elements = this._generateElements(value); } else { this.value = value; this.currentValue = value; } // The AutoEncrypter marks decrypted entries in a document // for us with a special symbol. We opt into this behavior // in the devtools-connect package. let parentValue; if (this.parent) { parentValue = this.parent.isRoot() ? this.parent.doc : this.parent.originalExpandableValue; } const parentDecryptedKeys = parentValue && parentValue[Symbol.for('@@mdb.decryptedKeys')]; this.decrypted = (parentDecryptedKeys || []) .map(String) .includes(String(key)); } get nextElement() { return this.parent?.elements?.findNext(this); } get previousElement() { return this.parent?.elements?.findPrevious(this); } _getLevel() { let level = -1; let parent = this.parent; while (parent) { level++; parent = parent.parent; } return level; } /** * Edit the element. * * @param value - The new value. */ edit(value) { this.currentType = hadron_type_checker_1.default.type(value); if (isValueExpandable(value) && !isValueExpandable(this.currentValue)) { this.currentValue = null; this.elements = this._generateElements(value); } else if (!isValueExpandable(value) && this.elements) { this.currentValue = value; this.elements = undefined; } else { // if they are both integer types and equal if made the previous type, don't update. this.currentValue = value; } this.setValid(); this._bubbleUp(element_events_1.ElementEvents.Edited, this); } preserveType(otherElement) { switch (this.currentType) { case 'Object': { if (!this.elements) { return; } for (const child of this.elements) { const otherChild = otherElement.get(child.currentKey); if (!otherChild) { continue; } child.preserveType(otherChild); } break; } case 'Int32': { const otherType = otherElement.currentType; if (otherType === 'Double') { this.changeType('Double'); } if (otherType === 'Int64') { this.changeType('Int64'); } break; } default: // NOTE: Purposefully leaving Array elements alone because deciding // whether to turn on Int32 into a Double or Int64 or not is complex and // error-prone. Also leaving every other type alone. break; } } changeType(newType) { if (newType === 'Object') { this._convertToEmptyObject(); } else if (newType === 'Array') { this._convertToEmptyArray(); } else { try { if (newType === 'Date') { const editor = new date_1.default(this); editor.edit(this.generateObject()); editor.complete(); } else { this.edit(hadron_type_checker_1.default.cast(this.generateObject(), newType)); } } catch (e) { this.setInvalid(this.currentValue, newType, e.message); } } } getRoot() { let parent = this.parent; while (parent?.parent) { parent = parent.parent; } return parent; } /** * Get an element by its key. * * @param key - The key name. * * @returns The element. */ get(key) { return this.elements?.get(key); } /** * Get an element by its index. * * @param i - The index. * * @returns The element. */ at(i) { return this.elements?.at(i); } /** * Rename the element. Update the parent's mapping if available. * * @param key - The new key. */ rename(key) { this.currentKey = key; this._bubbleUp(element_events_1.ElementEvents.Edited, this); } /** * Generate the javascript object for this element. * * @returns The javascript object. */ generateObject(options) { if (this.currentType === 'Array') { return object_generator_1.default.generateArray(this.elements, options); } if (this.currentType === 'Object') { return object_generator_1.default.generate(this.elements, options); } return this.currentValue; } /** * Generate the javascript object representing the original values * for this element (pre-element removal, renaming, editing). * * @returns The javascript object. */ generateOriginalObject(options) { if (this.type === 'Array') { const originalElements = this._generateElements(this.originalExpandableValue); return object_generator_1.default.generateOriginalArray(originalElements, options); } if (this.type === 'Object') { const originalElements = this._generateElements(this.originalExpandableValue); return object_generator_1.default.generateOriginal(originalElements, options); } return this.value; } /** * Generate the Extended JSON string representation of this element. * * @returns The Extended JSON string. */ toEJSON(source = 'current', options = {}) { const generated = source === 'original' ? this.generateOriginalObject() : this.generateObject(); return (0, utils_1.objectToIdiomaticEJSON)(generated, options); } /** * Insert an element after the provided element. If this element is an array, * then ignore the key specified by the caller and use the correct index. * Update the keys of the rest of the elements in the LinkedList. * * @param element - The element to insert after. * @param key - The key. * @param value - The value. * * @returns The new element. */ insertAfter(element, key, value) { if (!this.elements) { throw new Error('Cannot insert values on non-array/non-object elements'); } const newElement = this.elements.insertAfter(element, key, value); newElement._bubbleUp(element_events_1.ElementEvents.Added, newElement, this); this.emitVisibleElementsChanged(); return newElement; } /** * Add a new element to this element. * * @param| Number} key - The element key. * @param value - The value. * * @returns The new element. */ insertEnd(key, value) { if (!this.elements) { throw new Error('Cannot insert values on non-array/non-object elements'); } const newElement = this.elements.insertEnd(key, value, true); this._bubbleUp(element_events_1.ElementEvents.Added, newElement); this.emitVisibleElementsChanged(); return newElement; } /** * Insert a placeholder element at the end of the element. * * @returns The placeholder element. */ insertPlaceholder() { // When adding a placeholder value to an array we default to the type // of the last value currently in the array. Otherwise empty string. const placeholderValue = this.currentType === 'Array' && this.elements?.lastElement ? (0, utils_1.getDefaultValueForType)(this.elements?.lastElement.currentType) : ''; return this.insertEnd('', placeholderValue); } insertSiblingPlaceholder() { // When adding a sibling placeholder value to an array we default the // new values' type to the preceding element's type to hopefully make // it so folks don't have to change the type later. Otherwise empty string. const placeholderValue = this.parent?.currentType === 'Array' ? (0, utils_1.getDefaultValueForType)(this.currentType) : ''; return this.parent.insertAfter(this, '', placeholderValue); } /** * Is the element a newly added element * * @returns If the element is newly added. */ isAdded() { return this.added || !!this.parent?.isAdded(); } /** * Is the element blank? * * @returns If the element is blank. */ isBlank() { return this.currentKey === '' && this.currentValue === ''; } /** * Does the element have a valid value for the current type? * * @returns If the value is valid. */ isCurrentTypeValid() { return !!this.currentTypeValid; } /** * Set the element as valid. */ setValid() { this.currentTypeValid = true; this.invalidTypeMessage = undefined; this._bubbleUp(element_events_1.ElementEvents.Valid, this); } /** * Set the element as invalid. * * @param value - The value. * @param newType - The new type. * @param message - The error message. */ setInvalid(value, newType, message) { this.currentValue = value; this.currentType = newType; this.currentTypeValid = false; this.invalidTypeMessage = message; this._bubbleUp(element_events_1.ElementEvents.Invalid, this); } /** * Determine if the key is a duplicate. * * @param value - The value to check. * * @returns If the key is a duplicate. */ isDuplicateKey(value) { if (value === '') { return false; } for (const element of this.parent?.elements ?? []) { if (element.uuid !== this.uuid && element.currentKey === value) { return true; } } return false; } /** * Determine if the element is edited - returns true if * the key or value changed. Does not count array values whose keys have * changed as edited. * * @returns If the element is edited. */ isEdited() { return ((this.isRenamed() || !this._valuesEqual() || this.type !== this.currentType) && !this.isAdded()); } /** * Check for value equality. * @returns If the value is equal. */ _valuesEqual() { if (this.currentType === 'Date' && (0, lodash_1.isString)(this.currentValue)) { return (0, lodash_1.isEqual)(this.value, new Date(this.currentValue)); } else if (this.currentType === 'ObjectId' && (0, lodash_1.isString)(this.currentValue)) { return this._isObjectIdEqual(); } return (0, lodash_1.isEqual)(this.value, this.currentValue); } _isObjectIdEqual() { try { return this.value.toHexString() === this.currentValue; } catch { return false; } } _isExpandable() { return this.currentType === 'Array' || this.currentType === 'Object'; } /** * Is the element the last in the elements. * * @returns If the element is last. */ isLast() { return this.parent?.elements?.lastElement === this; } /** * Determine if the element was explicitly renamed by the user. * * @returns If the element was explicitly renamed by the user. */ isRenamed() { if (!this.parent || this.parent.isRoot() || this.parent.currentType === 'Object') { return this.key !== this.currentKey; } return false; } /** * Determine if the element was renamed, potentially as part * of moving array elements. * * @returns If the element was renamed, explicitly or implicitly. */ hasChangedKey() { return this.key !== this.currentKey; } /** * Can changes to the element be reverted? * * @returns If the element can be reverted. */ isRevertable() { return this.isEdited() || this.isRemoved(); } /** * Can the element be removed? * * @returns If the element can be removed. */ isRemovable() { return !this.parent.isRemoved(); } /** * Can no action be taken on the element * * @returns If no action can be taken. */ isNotActionable() { return (((this.key === ID || this.isInternalField()) && !this.isAdded()) || !this.isRemovable()); } /** * Determine if the value has been decrypted via CSFLE. * * Warning: This does *not* apply to the children of decrypted elements! * This only returns true for the exact field that was decrypted. * * a: Object * \-- b: Object <- decrypted * \-- c: number * * a.isValueDecrypted() === false * a.get('b')?.isValueDecrypted() === true * a.get('b')?.get('c')?.isValueDecrypted() === true * * @returns If the value was encrypted on the server and is now decrypted. */ isValueDecrypted() { return this.decrypted; } /** * Detemine if this value or any of its children were marked * as having been decrypted with CSFLE. * * Warning: This does *not* apply to the children of decrypted elements! * This only returns true for the exact field that was decrypted * and its parents. * * a: Object * \-- b: Object <- decrypted * \-- c: number * * a.containsDecryptedChildren() === true * a.get('b')?.containsDecryptedChildren() === true * a.get('b')?.get('c')?.containsDecryptedChildren() === false * * @returns If any child of this element has been decrypted directly. */ containsDecryptedChildren() { if (this.isValueDecrypted()) { return true; } for (const element of this.elements || []) { if (element.containsDecryptedChildren()) { return true; } } return false; } /** * Determine if the value is editable. * * @returns If the value is editable. */ isValueEditable() { return (this._isKeyLegallyEditable() && !UNEDITABLE_TYPES.includes(this.currentType)); } /** * Determine if the key of the parent element is editable. * * @returns If the parent's key is editable. */ isParentEditable() { if (this.parent && !this.parent.isRoot()) { return this.parent._isKeyLegallyEditable(); } return true; } _isKeyLegallyEditable() { return (this.isParentEditable() && (this.isAdded() || ((this.currentKey !== ID || !this.parent?.isRoot()) && !this.isInternalField()))); } /** * Determine if the key is editable. * * @returns If the key is editable. */ isKeyEditable() { return this._isKeyLegallyEditable() && !this.containsDecryptedChildren(); } /** * Is this a field that is used internally by the MongoDB driver or server * and not for user consumption? * * @returns */ isInternalField() { if (!this.parent) { return false; } if (!this.parent.isRoot() && this.parent.isInternalField()) { return true; } if (this.parent.isRoot() && isInternalFieldPath(this.currentKey)) { return true; } return false; } /** * Determine if the element is modified at all. * * @returns If the element is modified. */ isModified() { if (this.elements) { for (const element of this.elements) { if (element.isModified()) { return true; } } } return this.isAdded() || this.isEdited() || this.isRemoved(); } /** * Is the element flagged for removal? * * @returns If the element is flagged for removal. */ isRemoved() { return this.removed; } /** * Are any immediate children of this element flagged for removal? * * @returns If any immediate children of this element are flagged for removal. */ hasAnyRemovedChild() { if (this.elements) { for (const element of this.elements) { if (element.isRemoved()) { return true; } } } return false; } /** * Elements themselves are not the root. * * @returns Always false. */ isRoot() { return false; } /** * Flag the element for removal. */ remove() { this.revert(); this.removed = true; if (this.parent) { this._bubbleUp(element_events_1.ElementEvents.Removed, this, this.parent); this.emitVisibleElementsChanged(this.parent); } } /** * Revert the changes to the element. */ revert() { if (this.isAdded()) { this.parent?.elements?.remove(this); this._bubbleUp(element_events_1.ElementEvents.Removed, this, this.parent); if (this.parent) { this.emitVisibleElementsChanged(this.parent); } delete this.parent; } else { if (this.originalExpandableValue) { this.elements = this._generateElements(this.originalExpandableValue); this.currentValue = undefined; } else { if (this.currentValue === null && this.value !== null) { delete this.elements; } else { this._removeAddedElements(); } this.currentValue = this.value; } this.currentKey = this.key; this.currentType = this.type; this.removed = false; } this.setValid(); this._bubbleUp(element_events_1.ElementEvents.Reverted, this); } /** * Expands the target element and optionally its children as well. * Document.expand is when we would want to expand the children otherwise we * will most expand the element itself. */ expand(expandChildren = false) { if (!this._isExpandable()) { return; } this.expanded = true; if (expandChildren && this.elements) { for (const element of this.elements) { element.expand(expandChildren); } } this.emit(element_events_1.ElementEvents.Expanded, this); this.emitVisibleElementsChanged(); } /** * Collapses only the target element */ collapse() { if (!this._isExpandable()) { return; } this.expanded = false; if (this.elements) { for (const element of this.elements) { element.collapse(); } } this.emit(element_events_1.ElementEvents.Collapsed, this); this.emitVisibleElementsChanged(); } /** * Fire and bubble up the event. * * @param evt - The event. * @paramdata - Optional. */ _bubbleUp(evt, ...data) { this.emit(evt, ...data); const element = this.parent; if (element) { if (element.isRoot()) { element.emit(evt, ...data); } else { element._bubbleUp(evt, ...data); } } } /** * Convert this element to an empty object. */ _convertToEmptyObject() { this.edit({}); this.insertPlaceholder(); } /** * Convert to an empty array. */ _convertToEmptyArray() { this.edit([]); this.insertPlaceholder(); } /** * Is the element empty? * * @param element - The element to check. * * @returns If the element is empty. */ _isElementEmpty(element) { return !!element && element.isAdded() && element.isBlank(); } /** * Generates a sequence of child elements. * * @param object - The object to generate from. * * @returns The elements. */ _generateElements(object) { return new ElementList(this, object); } /** * Removes the added elements from the element. */ _removeAddedElements() { if (this.elements) { for (const element of this.elements) { if (element.isAdded()) { this.elements.remove(element); } } } } getVisibleElements() { if (!this.elements || !this.expanded) { return []; } return [...this.elements].slice(0, this.maxVisibleElementsCount); } setMaxVisibleElementsCount(newCount) { if (!this._isExpandable()) { return; } this.maxVisibleElementsCount = newCount; this.emitVisibleElementsChanged(); } getTotalVisibleElementsCount() { if (!this.elements || !this.expanded) { return 0; } return this.getVisibleElements().reduce((totalVisibleChildElements, element) => { return (totalVisibleChildElements + 1 + element.getTotalVisibleElementsCount()); }, 0); } findUUIDs() { let subtype4Count = 0; let subtype3Count = 0; if (!this.elements) { return { subtype4Count, subtype3Count }; } for (const element of this.elements) { if (element.currentType === 'Binary') { if (element.currentValue.sub_type === 4) { subtype4Count++; } if (element.currentValue.sub_type === 3) { subtype3Count++; } } else if (element.currentType === 'Object' || element.currentType === 'Array') { const { subtype3Count: sub3, subtype4Count: sub4 } = element.findUUIDs(); subtype3Count += sub3; subtype4Count += sub4; } } return { subtype4Count, subtype3Count }; } emitVisibleElementsChanged(targetElement = this) { if (targetElement.isRoot()) { targetElement.emit(document_events_1.DocumentEvents.VisibleElementsChanged, targetElement); } else if (targetElement.expanded) { targetElement._bubbleUp(element_events_1.ElementEvents.VisibleElementsChanged, targetElement, targetElement.getRoot()); } } /** * @deprecated Use ElementEvents import instead */ static get Events() { return element_events_1.ElementEvents; } } exports.Element = Element; /** * Represents a doubly linked list. */ class ElementList { constructor(parent, originalDoc) { this.parent = parent; this.elements = Object.entries(originalDoc ?? {}).map(([k, v]) => { return new Element(this.isArray() ? parseInt(k, 10) : k, v, parent, parent.isRoot() ? parent.cloned : false); }); } isArray() { return this.parent.currentType === 'Array'; } get size() { return this.elements.length; } at(index) { return this.elements[index]; } get(key) { return this.elements.find((el) => { return el.currentKey === key; }); } some(predicate) { return this.elements.some(predicate); } every(predicate) { return this.elements.every(predicate); } get firstElement() { return this.elements[0]; } get lastElement() { return this.elements[this.elements.length - 1]; } /** * Insert data after the provided element. * * @param afterElement - The element to insert after. * @param key - The element key. * @param value - The element value. * @param added - If the element is new. * * @returns The inserted element. */ insertAfter(afterElement, key, value, added = true) { let newElement; let newElementIdx = -1; for (const [idx, el] of this.elements.entries()) { if (afterElement === el) { newElementIdx = idx + 1; newElement = new Element(this.isArray() ? newElementIdx : key, value, this.parent, added); continue; } if (newElement && this.isArray()) { el.currentKey = idx + 1; } } if (newElement) { this.elements.splice(newElementIdx, 0, newElement); } return newElement; } /** * Insert data before the provided element. * * @param beforeElement - The element to insert before. * @param key - The element key. * @param value - The element value. * @param added - If the element is new. * * @returns The inserted element. */ insertBefore(beforeElement, key, value, added = true) { let newElement; let newElementIdx = -1; for (const [idx, el] of this.elements.entries()) { if (beforeElement === el) { newElementIdx = idx; newElement = new Element(this.isArray() ? newElementIdx : key, value, this.parent, added); } if (newElement && this.isArray()) { el.currentKey = idx + 1; } } if (newElement) { this.elements.splice(newElementIdx, 0, newElement); } return newElement; } /** * Insert data at the beginning of the list. * * @param key - The element key. * @param value - The element value. * @param added - If the element is new. * * @returns The data element. */ insertBeginning(key, value, added = true) { const newElement = new Element(this.isArray() ? 0 : key, value, this.parent, added); if (this.isArray()) { this.elements.forEach((el) => { el.currentKey += 1; }); } this.elements.unshift(newElement); return newElement; } /** * Insert data at the end of the list. * * @param key - The element key. * @param value - The element value. * @param added - If the element is new. * * @returns The data element. */ insertEnd(key, value, added = true) { const newElement = new Element(this.isArray() ? this.elements.length : key, value, this.parent, added); this.elements.push(newElement); return newElement; } /** * Remove the element from the list. * * @param removeElement - The element to remove. * * @returns The list with the element removed. */ remove(removeElement) { let removeIdx = -1; for (const [idx, el] of this.elements.entries()) { if (el === removeElement) { removeIdx = idx; continue; } if (removeIdx !== -1 && this.isArray()) { el.currentKey -= 1; } } if (removeIdx !== -1) { this.elements.splice(removeIdx, 1); } return this; } findNext(el) { const idx = this.elements.indexOf(el); return idx !== -1 ? this.elements[idx + 1] : undefined; } findPrevious(el) { const idx = this.elements.indexOf(el); return idx !== -1 ? this.elements[idx - 1] : undefined; } *[Symbol.iterator]() { yield* this.elements; } } exports.ElementList = ElementList; //# sourceMappingURL=element.js.map