hadron-document
Version:
Hadron Document
1,014 lines (1,013 loc) • 31.9 kB
JavaScript
'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