@onehat/data
Version:
JS data modeling package with adapters for many storage mediums.
1,876 lines (1,654 loc) • 49.9 kB
JavaScript
/** @module Entity */
import EventEmitter from '@onehat/events';
import PropertyTypes from '../Property/index.js';
import moment from 'moment';
import hash from 'object-hash';
import _ from 'lodash';
/**
* Class represents a single Entity (i.e. a record)
* which is a collection of Properties with current values.
*
* Usage:
* Setting data options:
* - entity.users__last_name = 'Smith';
* - entity.setValue('users__last_name', 'Smith');
* - entity.setValues({
* users__last_name: 'Smith',
* });
*
* Getting data options:
* - entity.data; // Gets all property values as single JSON object
* - entity.users__last_name;
* - entity.getPropertySubmitValue('users__last_name');
*
* @extends EventEmitter
* @fires ['change', 'changeValidity', 'reset', 'reload', 'save', 'delete', 'undelete', 'destroy']
*/
class Entity extends EventEmitter {
/**
* @constructor
* @param {Schema} schema - Schema object
* @param {object} rawData - Raw data object. Keys are Property names, Values are Property values.
* @param {Repository} repository
* @param {boolean} isDelayedSave - Should the repository skip autosave when immediately adding the record?
*/
constructor(schema, rawData = {}, repository = null, isDelayedSave = false, isRemotePhantomMode = false) {
super(...arguments);
if (!schema) {
throw new Error('schema cannot be empty');
}
if (!schema.model) {
throw new Error('model cannot be empty');
}
if (_.isNil(rawData)) {
throw new Error('rawData cannot be null');
}
this.registerEvents([
'change',
'changeValidity',
'reset',
'reload',
'save',
'delete',
'undelete',
'destroy',
]);
/**
* @member {string} name
* @readonly
*/
this.name = schema.name;
/**
* @member {Schema} schema
* @private
*/
this.schema = schema;
/**
* @member {Schema} schema
* @private
*/
this.repository = repository;
/**
* @member {object} _originalData - Original data object, *prior to* mapping or parsing.
* @private
*/
this._originalData = _.cloneDeep(rawData); // cloneDeep because we want the internal _originalData object to be separate from anything outside the entity.
/**
* @member {object} _originalDataParsed - Original data object, *after* mapping and parsing.
* @private
*/
this._originalDataParsed = null;
/**
* @member {boolean} originalIsMapped - Has the original data already been mapped according to schema?
* The attribute is no longer used. this._originalData should always be UNmapped.
* @private
* @deprecated
*/
// this.originalIsMapped = originalIsMapped;
/**
* @member {Object} properties - Object of all Properties, keyed by id (for quick access)
* These properties are actually created in the initialize() function.
* @public
*/
this.properties = [];
/**
* @member {string} hash - A hash of this.submitValues, so we can detect changes
*/
this.hash = null;
/**
* @member {boolean} isTree - Whether this Entity is a TreeNode
*/
this.isTree = schema.repository.type === 'tree' || false;
if (this.isTree && !schema.model.parentIdProperty) {
throw new Error('parentIdProperty cannot be empty for a TreeNode');
}
if (this.isTree && this.repository?.isClosureTable && !schema.model.depthProperty) {
throw new Error('depthProperty cannot be empty for a Closure Table TreeNode');
}
if (this.isTree && !schema.model.hasChildrenProperty) {
throw new Error('hasChildrenProperty cannot be empty for a TreeNode');
}
/**
* @member {TreeNode} parent - The parent TreeNode for this TreeNode
* @public
* For trees only
*/
this.parent = null;
/**
* @member {array} children - Contains any children of this TreeNode
* @public
* For trees only
*/
this.children = this._originalData.children && !_.isEmpty(this._originalData.children) ? this._originalData.children : [];
/**
* @member {boolean} areChildrenLoaded - Whether child TreeNodes have loaded for this TreeNode
* @public
* For trees only
*/
this.areChildrenLoaded = this._originalData.areChildrenLoaded || false;
/**
* @member {boolean} isPersisted - Whether this object has been persisted in a storage medium
* @public
*/
this.isPersisted = false;
/**
* @member {Boolean} isInitialized - State: whether or not this entity has been completely initialized
* @public
*/
this.isInitialized = false;
/**
* @member {boolean} isDeleted - Whether this object has been marked for deletion
* @public
*/
this.isDeleted = false;
/**
* @member {boolean} isStaged - Whether this object has been marked for saving
* @public
*/
this.isStaged = false;
/**
* @member {boolean} isSaving - Whether this object is in the process of saving
* @public
*/
this.isSaving = false;
/**
* @member {boolean} isDestroyed - Whether this object has been destroyed
* @public
*/
this.isDestroyed = false;
/**
* @member {boolean} isFrozen - Prevent the entity from being autoSaved on add, so an editor can change it before it gets saved to remote storage.
* @public
*/
this.isDelayedSave = isDelayedSave;
/**
* @member {boolean} isRemotePhantomMode - Whether this Entity uses the "alternate" CRUD mode, with tempIds from server (see OneBuild repository)
* On a Repository, this mode overrides repository.isAutoSave, entity.isPersisted, && entity.isDelayedSave.
* On an Entity, this mode affects the isPhantom getter.
* @public
* @readonly
*/
this.isRemotePhantomMode = isRemotePhantomMode;
/**
* @member {boolean} isRemotePhantom - Whether this entity is actually phantom on server; used only when this.isRemotePhantomMode.
* If this.isRemotePhantomMode, isRemotePhantom defaults to true for all new Entities.
* @public
*/
this.isRemotePhantom = this.isRemotePhantomMode;
/**
* @member {boolean} lastModified - Last time this entity was modified
* @public
*/
this.lastModified = null;
/**
* @member {boolean} isFrozen - Prevent the entity from being destroyed, but don't let it be changed either.
* @public
*/
this.isFrozen = false;
/**
* @member {boolean} isValid - Whether this Entity passes validation
* @public
*/
this.isValid = null;
/**
* @member {object} validationError - Any error in last validation.
* @public
*/
this.validationError = null;
// This ES6 Proxy allows us to create magic getters and setters for all property values.
// However, these getters and setters are *not* available within the Entity itself.
this._proxy = new Proxy(this, {
get (target, name, receiver) {
if (name === 'then') { // special case, otherwise Promises break
return Reflect.get(target, name, receiver);
}
if (!Reflect.has(target, name)) {
if (!target.hasProperty(name)) {
return null;
}
return target.getPropertySubmitValue(name);
}
return Reflect.get(target, name, receiver);
},
set (target, name, value, receiver) {
if (this.isFrozen) {
throw Error('Entity is frozen.');
}
if (!Reflect.has(target, name)) {
target.setValue(name, value);
} else {
Reflect.set(target, name, value, receiver);
}
return true; // Return true, or else we sometimes get a proxy trap type error. https://developer.mozilla.org/en-US/docs/web/javascript/reference/global_objects/proxy/proxy/set
},
});
return this._proxy; // Return the Proxy, not 'this'
}
/**
* Decorator for parent emit() method so we can rehash
*/
emit(name) { // NOTE: Purposefully do not use an arrow-function, so we have access to arguments
if (!this.isDestroyed) {
this.rehash();
}
return super.emit(...arguments);
}
initialize() {
this.properties = this._createProperties();
this._createMethods();
this._createStatics();
this.reset();
this.rehash();
this.isInitialized = true;
}
/**
* Creates the methods for this Entity, based on Schema.
* @private
*/
_createMethods = () => {
if (this.isDestroyed) {
throw Error('this._createMethods is no longer valid. Entity has been destroyed.');
}
const methodsDefinitions = this.schema.entity.methods;
if (!_.isEmpty(methodsDefinitions)) {
_.each(methodsDefinitions, (method, name) => {
this[name] = method; // NOTE: Methods must be defined in schema as "function() {}", not as "() => {}" so "this" will be assigned correctly
});
}
}
/**
* Creates the static properties for this Entity, based on Schema.
* @private
*/
_createStatics = () => {
if (this.isDestroyed) {
throw Error('this._createStatics is no longer valid. Entity has been destroyed.');
}
const staticsDefinitions = this.schema.entity.statics;
if (!_.isEmpty(staticsDefinitions)) {
_.each(staticsDefinitions, (value, key) => {
this[key] = value;
});
}
}
/**
* Generates a new unique id and assigns it to this entity.
*/
createTempId = () => {
if (this.isDestroyed) {
throw Error('this.createTempId is no longer valid. Entity has been destroyed.');
}
if (!_.isNil(this.id)) {
throw new Error('Entity id already exists.');
}
const idProperty = this.getIdProperty();
if (!idProperty.newId) {
throw new Error('idProperty.newId() does not exist');
}
this.setId(idProperty.newId());
idProperty.isTempId = true;
}
/**
* Creates the Properties for this Entity,
* based on Schema propertyDefinitions.
* These properties do not yet have any values set.
* Assigns event handler for property's 'change' event.
* @private
*/
_createProperties = () => {
if (this.isDestroyed) {
throw Error('this._createProperties is no longer valid. Entity has been destroyed.');
}
const propertyDefinitions = this.schema.model.properties;
let properties = {};
_.each(propertyDefinitions, (definition) => {
if (!definition.name) {
throw new Error('Property definition must have "name".');
}
let type = definition.type;
if (!type) {
type = 'string';
}
const PT = PropertyTypes; // Was having ES6 import issues. This fixed it.
if (!PT[type]) {
throw new Error('PropertyType ' + type + ' does not exist.');
}
const
Property = PropertyTypes[type],
property = new Property(definition, this._proxy);
property.on('change', this._onPropertyChange);
properties[definition.name] = property;
});
return properties;
}
/**
* Handler for Property's 'change' event.
* (Someone has directly called property.setValue)
* Recalculate any dependent properties,
* then tell this Entity to fire its own 'change' event.
* @fires change
* @private
*/
_onPropertyChange = () => {
this._recalculateDependentProperties();
this.isValid = null;
this.emit('change', this._proxy);
}
/**
* Manually load originalData into this Entity,
* *after* the entity has already been created.
* This resets the Entity, so it's 'as new'.
* This is mainly for updating Entity with new data
* from remote storage medium.
* Assumes (and sets) isPersisted === true.
* Assumes (and sets) isTempId === false.
* @param {array} originalData - Raw data to load into entity.
*/
loadOriginalData = (originalData) => {
if (this.isDestroyed) {
throw Error('this.loadOriginalData is no longer valid. Entity has been destroyed.');
}
this.isPersisted = true;
this._originalData = originalData || {};
this.reset();
this.getIdProperty().isTempId = false;
}
/**
* Creates an exact copy of this Entity in its current state.
* @return {object} Entity - The clone
* @memberOf Entity
*/
clone = () => {
const clone = new Entity(this.schema, this._originalData, this.repository);
clone.initialize();
if (this.isDirty) {
clone.setValues( this.getRawValues() );
}
clone.isPersisted = this.isPersisted;
clone.isDeleted = this.isDeleted;
return clone;
}
/**
* Resets the Entity to a state as if it had just been created,
* Gets data to restore from _originalData.
*/
reset = () => {
if (this.isDestroyed) {
throw Error('this.reset is no longer valid. Entity has been destroyed.');
}
// Set property values from this._originalData
this._resetPropertyValues();
this._originalDataParsed = this.getParsedValues();
if (this.isDeleted) {
this.undelete();
}
this.markStaged(false);
this.setLastModified();
this.emit('reset', this._proxy);
}
/**
* Helper for reset.
* Resets all Property values for this Entity,
* based on this._originalData.
* @private
*/
_resetPropertyValues = () => {
// We need to partition the properties into those which depend on other properties
// for their local "parse" functions, and those which do not.
const [dependentProperties, nonDependentProperties] = _.partition(this.properties, (property) => {
return property.hasDepends;
});
// Do the non-dependent properties first
_.each(nonDependentProperties, this._resetPropertyValue);
// Notes: This will work for dependent properties which do not depend *on other dependent properties*
// In order to make dependencies multi-layered, we'd have to sort the dependent properties
// so that earlier ones do not depend on later ones.
// TODO: Sort dependentProperties based on dependencies
// Now do the dependent properties
_.each(dependentProperties, this._resetPropertyValue);
}
/**
* Helper for _resetPropertyValues
* @private
*/
_resetPropertyValue = (property) => {
let rawValue;
if (property.hasMapping) {
rawValue = Entity.getMappedValue(property.mapping, this._originalData);
} else {
rawValue = this._originalData[property.name];
}
if (_.isNil(rawValue)) {
rawValue = property.getDefaultValue();
}
property.pauseEvents();
property.setValue(rawValue);
property.resumeEvents();
}
/**
* Helper for _resetPropertyValue.
* Walks the root object through the path provided by property.mapping
*
* Example:
* Given the root object of:
* root = { a: { b: { c: true } } };
* and a mapping of 'a.b.c'
* this function will return true.
*
* @param {Property} property
* @private
* @static
*/
static getMappedValue(mapping, root) {
const mapStack = mapping.split('.');
let value = root;
try {
_.each(mapStack, (path) => {
value = value[path]; // walk the path
});
} catch(err) {
value = null; // dead-end in path. i.e. invalid mapping
}
return value;
}
setLastModified = () => {
this.lastModified = moment(new Date()).format('YYYY-MM-DD HH:mm:ss.SSSS');
}
// ______ __ __
// / ____/__ / /_/ /____ __________
// / / __/ _ \/ __/ __/ _ \/ ___/ ___/
// / /_/ / __/ /_/ /_/ __/ / (__ )
// \____/\___/\__/\__/\___/_/ /____/
/**
* Checks to see if a property exists
* @param {string} propertyName - Name of the Property to check
* @return {boolean} hasProperty
*/
hasProperty = (propertyName) => {
return this.properties && this.properties.hasOwnProperty(propertyName);
}
/**
* Gets the Schema object
* @return {Schema} schema
*/
getSchema = () => {
if (this.isDestroyed) {
throw Error('this.getSchema is no longer valid. Entity has been destroyed.');
}
return this.schema;
}
/**
* Gets the Repository object
* @return {Repository} repository
*/
getRepository = () => {
if (this.isDestroyed) {
throw Error('this.getRepository is no longer valid. Entity has been destroyed.');
}
return this.repository;
}
/**
* Alias for this.properties
*/
get prop() {
return this.properties;
}
/**
* Gets a single Property by name,
* @param {string} propertyName - Name of the Property to retrieve
* @return {object} property - The named Property
*/
getProperty = (propertyName) => {
if (this.isDestroyed) {
throw Error('this.getProperty is no longer valid. Entity has been destroyed.');
}
const property = this.properties[propertyName];
if (!property) {
throw new Error('Property ' + propertyName + ' not found. Are you sure you initialized this Entity?');
}
return property;
}
/**
* Gets the "submit" value of one Property,
* @param {string} propertyName - Name of the Property to query
* @return {any} submitValue
*/
getPropertySubmitValue = (propertyName) => {
return this.getProperty(propertyName).getSubmitValue();
}
/**
* Gets the "display" value of one Property,
* @param {string} propertyName - Name of the Property to query
* @return {any} submitValue
*/
getPropertyDisplayValue = (propertyName) => {
return this.getProperty(propertyName).getDisplayValue();
}
/**
* Gets an object of properties/values for this Entity,
* Values are the "submit" values, not the "raw" or "parsed" or "display" values.
* @return {object} propertyValues
*/
getSubmitValues = () => {
if (this.isDestroyed) {
throw Error('this.getSubmitValues is no longer valid. Entity has been destroyed.');
}
let propertyValues = {};
_.forOwn(this.properties, (property) => {
propertyValues[property.name] = property.getSubmitValue();
});
return propertyValues;
}
/**
* Gets "submit" values for this Entity.
* @return {object} values
*/
get submitValues() {
return this.getSubmitValues();
}
/**
* Gets an object of properties/values for this Entity,
* and returns them with the mapped names
* Values are the "submit" values, not the "raw" or "parsed" or "display" values.
* @return {object} propertyValues
*/
getSubmitValuesMapped = () => {
if (this.isDestroyed) {
throw Error('this.getSubmitValuesMapped is no longer valid. Entity has been destroyed.');
}
let propertyValues = {};
_.forOwn(this.properties, (property) => {
const name = property.hasMapping ? property.getMapping() : property.name;
propertyValues[name] = property.getSubmitValue();
});
return propertyValues;
}
/**
* Gets "submit" values for this Entity, and returns them with the mapped names
* @return {object} values
*/
get submitValuesMapped() {
return this.getSubmitValuesMapped();
}
/**
* Gets an object of values for this Entity,
* Values are the "display" values, not the "raw" or "parsed" or "submit" values.
* @return {object} propertyValues
*/
getDisplayValues = () => {
if (this.isDestroyed) {
throw Error('this.getDisplayValues is no longer valid. Entity has been destroyed.');
}
let propertyValues = {};
_.forOwn(this.properties, (property) => {
propertyValues[property.name] = property.getDisplayValue();
});
return propertyValues;
}
/**
* Gets "display" values for this Entity.
* @return {object} values
*/
get displayValues() {
return this.getDisplayValues();
}
/**
* Gets an object of values for this Entity,
* Values are the "raw" values, not the "parsed" or "submit" or "display" values.
* @return {object} propertyValues
*/
getRawValues = () => {
if (this.isDestroyed) {
throw Error('this.getRawValues is no longer valid. Entity has been destroyed.');
}
let propertyValues = {};
_.forOwn(this.properties, (property) => {
propertyValues[property.name] = property.getRawValue();
});
return propertyValues;
}
/**
* Gets "raw" values for this Entity.
* @return {object} values
*/
get rawValues() {
return this.getRawValues();
}
/**
* Gets an object of values for this Entity,
* Values are the "raw" values in their parsed form, not the "parsed" or "submit" or "display" values.
* @return {object} propertyValues
*/
getParsedRawValues = () => {
if (this.isDestroyed) {
throw Error('this.getParsedRawValues is no longer valid. Entity has been destroyed.');
}
let propertyValues = {};
_.forOwn(this.properties, (property) => {
propertyValues[property.name] = property.getParsedRawValue();
});
return propertyValues;
}
/**
* Gets "raw" values in their parsed form for this Entity.
* @return {object} values
*/
get parsedRawValues() {
return this.getParsedRawValues();
}
/**
* Gets an object of values for this Entity,
* Values are the "parsed" values, not the "raw" or "submit" or "display" values.
* @return {object} propertyValues
*/
getParsedValues = () => {
if (this.isDestroyed) {
throw Error('this.getParsedValues is no longer valid. Entity has been destroyed.');
}
let propertyValues = {};
_.forOwn(this.properties, (property) => {
propertyValues[property.name] = property.getParsedValue();
});
return propertyValues;
}
/**
* Gets "parsed" values for this Entity.
* @return {object} values
*/
get parsedValues() {
return this.getParsedValues();
}
/**
* Reconstructs _originalData from current rawValues of properties.
* @return {object} originalData
* @private
*/
_getReconstructedOriginalData = () => {
if (this.isDestroyed) {
throw Error('this._getReconstructedOriginalData is no longer valid. Entity has been destroyed.');
}
let originalData = {};
_.forOwn(this.properties, (property) => {
if (property.hasMapping) {
const result = Entity.getReverseMappedRawValue(property);
_.merge(originalData, result);
} else {
originalData[property.name] = property.getRawValue();
}
});
return originalData;
}
/**
* Helper for _getReconstructedOriginalData
* @param {object} property - The property to get reverseMappedRawValue for
* @return {object} value - An object representing the 'path' to the raw value.
* e.g. With a mapping of 'a.b.c' and a property rawValue of '47', the
* following object will be returned:{ a: { b: { c: '47' }, }, }
* @private
* @static
*/
static getReverseMappedRawValue(property) {
if (!property.hasMapping) {
const obj = {};
obj[property.name] = property.rawValue;
return obj;
}
const
mapStack = property.mapping.split('.'),
rawValue = property.getRawValue();
// Build up the hierarchy
let value = {},
current = value,
i,
total = mapStack.length;
for (i = 0; i < total; i++) {
let path = mapStack[i];
if (current && !current.hasOwnProperty(path)) {
current[path] = {}; // walk the path
}
if (i < total -1) {
current = current[path];
} else {
current[path] = rawValue; // Last one, so set the value
}
}
return value;
}
/**
* Builds up an object of original values for this entity, from which another entity could be easily created
* @return {object} value - An object representing the 'path' to the raw value.
* e.g. With a mapping of 'a.b.c' and a property rawValue of '47', the
* following object will be returned:{ a: { b: { c: '47' }, }, }
*/
getReverseMappedRawValues = () => {
if (this.isDestroyed) {
throw Error('this.getReverseMappedRawValues is no longer valid. Entity has been destroyed.');
}
let propertyValues = {};
_.forOwn(this.properties, (property) => {
const reverseMapped = Entity.getReverseMappedRawValue(property);
_.merge(propertyValues, reverseMapped);
});
return propertyValues;
}
/**
* Convenience function
* Build a new entity with this data
*/
getDataForNewEntity = () => {
if (this.isDestroyed) {
throw Error('this.getDataForNewEntity is no longer valid. Entity has been destroyed.');
}
return this.getReverseMappedRawValues();
}
/**
* Gets the values that have changed since last time saved
* @return {array|boolean} diff - Array of property names that have changed, or false
*/
getChanged = () => {
const
original = this._originalDataParsed,
current = this.getParsedRawValues(),
diff = Object.keys(original).reduce((result, key) => { // from https://stackoverflow.com/a/40610459/9163076
if (current && !current.hasOwnProperty(key)) {
result.push(key);
} else if (_.isEqual(original[key], current[key])) {
const resultKeyIndex = result.indexOf(key);
result.splice(resultKeyIndex, 1);
}
return result;
}, Object.keys(current));
return !_.isEmpty(diff) ? diff : false;
}
/**
* Gets a comprehensive analysis of what has changed since the last save
* @return {object} changedPropertyValues - Object representing each changed field and both its original and current value
*/
getChangedValues = () => {
const
original = this._originalDataParsed,
current = this.getRawValues(),
names = this.getChanged();
const changedPropertyValues = {};
_.each(names, (name) => {
changedPropertyValues[name] = {
original: original[name],
current: current[name],
};
});
return changedPropertyValues;
}
/**
* Alias for this.submitValues
*/
get data() {
if (this.isDestroyed) {
throw Error('this.data is no longer valid. Entity has been destroyed.');
}
return this.submitValues;
}
/**
* Get all Property objects that pass a supplied filter.
* @param {function} filter - Filter function
* @return {array} properties - Array of Property objects
*/
getPropertiesBy = (filter) => {
if (this.isDestroyed) {
throw Error('this.getPropertiesBy is no longer valid. Entity has been destroyed.');
}
return _.filter(this.properties, filter);
}
/**
* Gets the "id" Property object for this Entity.
* This is the Property whose value represents the id for the whole Entity itself.
* @return {Property} id Property
*/
getIdProperty = () => {
if (this.isDestroyed) {
throw Error('this.getIdProperty is no longer valid. Entity has been destroyed.');
}
const idProperty = this.getSchema()?.model?.idProperty || null;
if (!idProperty) {
throw new Error('No idProperty found for ' + schema.name);
}
return this.getProperty(idProperty);
}
/**
* Gets the id for this Entity.
* @return {any} id - The id
*/
getId = () => {
if (this.isDestroyed) {
return this._id;
}
return this.getIdProperty().getSubmitValue();
}
/**
* Getter of the id for this Entity.
* @return {any} id - The id
*/
get id() {
if (this.isDestroyed) {
return this._id;
}
return this.getId();
}
/**
* Is this Entity's idProperty using a temporary ID?
* @return {boolean} isTempId
*/
get isTempId() {
return this.getIdProperty().isTempId;
}
/**
* Marks this Entity's idProperty's isTempId field
* @return {boolean} isTempId
*/
set isTempId(bool) {
this.getIdProperty().isTempId = bool;
}
/**
* Gets the "Display" Property object for this Entity.
* This is the Property whose value can easily identify the whole Entity itself.
* @return {Property} Display Property
*/
getDisplayProperty = () => {
if (this.isDestroyed) {
throw Error('this.getDisplayProperty is no longer valid. Entity has been destroyed.');
}
const
schema = this.getSchema(),
model = schema?.model,
displayProperty = model && model.displayProperty ? model.displayProperty : null;
if (!displayProperty) {
throw new Error('No displayProperty found for ' + schema.name);
}
return this.getProperty(displayProperty);
}
/**
* Gets the "Display" value for this Entity.
* This value should easily identify the whole Entity itself.
* @return {Property} Display Property
*/
getDisplayValue = () => {
if (this.isDestroyed) {
throw Error('this.getDisplayValue is no longer valid. Entity has been destroyed.');
}
return this.getDisplayProperty().getDisplayValue();
}
/**
* Getter of the "Display" value for this Entity.
* This value should easily identify the whole Entity itself.
* @return {any} displayValue
*/
get displayValue() {
if (this.isDestroyed) {
throw Error('this.displayValue is no longer valid. Entity has been destroyed.');
}
return this.getDisplayValue();
}
/**
* Getter of isPhantom for this Entity.
* Entity is phantom if it has either no id or a temp id.
* @return {boolean} isPhantom
*/
get isPhantom() {
if (this.isDestroyed) {
throw Error('this.isPhantom is no longer valid. Entity has been destroyed.');
}
if (this.isRemotePhantomMode) {
return this.isRemotePhantom;
}
const
idProperty = this.getIdProperty(),
id = idProperty.getSubmitValue();
// No ID
if (_.isNil(id)) {
return true;
}
// ID is temporary
if (idProperty.isTempId) {
return true;
}
return false;
}
/**
* Getter of isDirty for this Entity.
* Entity is dirty if it has any Property changes
* that have not been persisted to storage medium.
* Practically, this means it has any values that are different from this._originalData.
* @return {boolean} isDirty
*/
get isDirty() {
if (this.isDestroyed) {
throw Error('this.isDirty is no longer valid. Entity has been destroyed.');
}
return !_.isEqualWith(this._originalDataParsed, this.getParsedValues());
}
/**
* Gets the original data object for this Entity.
* This is either what was persisted to storage medium, or what was
* loaded in at initialization.
* @return {object} _originalData
* @private
*/
getOriginalData = () => {
if (this.isDestroyed) {
throw Error('this.getOriginalData is no longer valid. Entity has been destroyed.');
}
return this._originalData;
}
/**
* Gets the associated Repository
* @param {string} repositoryName - Name of the Repository to retrieve
* @return {boolean} hasProperty
*/
getAssociatedRepository = (repositoryName) => {
if (this.isDestroyed) {
throw Error('this.getAssociatedRepository is no longer valid. Entity has been destroyed.');
}
const schema = this.getSchema();
if (!schema?.model.associations.hasOne.includes(repositoryName) &&
!schema?.model.associations.hasMany.includes(repositoryName) &&
!schema?.model.associations.belongsTo.includes(repositoryName) &&
!schema?.model.associations.belongsToMany.includes(repositoryName)
) {
throw Error(repositoryName + ' is not associated with this schema');
}
const repository = this.getRepository();
if (!repository) {
throw Error('No repository on this entity');
}
const oneHatData = repository.oneHatData;
if (!oneHatData) {
throw Error('No global oneHatData object');
}
const associatedRepository = oneHatData.getRepository(repositoryName);
if (!associatedRepository) {
throw Error('Repository ' + repositoryName + ' cannot be found');
}
return associatedRepository;
}
// _____ __ __
// / ___/___ / /_/ /____ __________
// \__ \/ _ \/ __/ __/ _ \/ ___/ ___/
// ___/ / __/ /_/ /_/ __/ / (__ )
// /____/\___/\__/\__/\___/_/ /____/
/**
* Sets the id for this entity.
* Note: Does *not* fire any change events.
* @param {any} id - The new id of this entity
* @param {boolean} force - Force the change to _originalData
* @return {boolean} isChanged - Whether id was actually changed
*/
setId = (id, force = false) => {
if (this.isFrozen) {
throw Error('Entity is frozen.');
}
let isChanged = false;
const idProperty = this.getIdProperty();
idProperty.pauseEvents(); // We don't need property_change to fire
if (idProperty.setValue(id)) {
isChanged = true;
}
idProperty.resumeEvents();
if (isChanged || force) {
// Set this id on the _originalData* objects
if (idProperty.hasMapping) {
_.merge(this._originalData, Entity.getReverseMappedRawValue(idProperty));
} else {
this._originalData[idProperty.name] = idProperty.getRawValue();
}
this._originalDataParsed[idProperty.name] = idProperty.getParsedValue();
}
idProperty.isTempId = false;
this.setLastModified();
return isChanged;
}
/**
* Sets a single Property value
* @param {string} propertyName - Name of the Property to alter
* @param {any} rawValue - The raw, unparsed value to assign to the Property.
* What if this property hasMapping?
*
* @return {boolean} isChanged - Whether any values were actually changed
*/
setValue = (propertyName, rawValue) => {
if (this.isFrozen) {
throw Error('Entity is frozen.');
}
if (this.isDestroyed) {
throw Error('this.setValue is no longer valid. Entity has been destroyed.');
}
let propertyValues = {};
propertyValues[propertyName] = rawValue;
return this.setValues(propertyValues);
}
/**
* Sets Property values
* @param {object} rawData - Raw data object. These are prior to mapping,
* similar to what you'd use to create a brand new Entity. Make sure *all*
* values are here, not just a few.
* @return {boolean} isChanged - Whether any values were actually changed
*/
setRawValues = (rawData) => {
if (this.isFrozen) {
throw Error('Entity is frozen.');
}
if (this.isDestroyed) {
throw Error('this.setRawValues is no longer valid. Entity has been destroyed.');
}
return this.setValues(this.getMappedData(rawData));
}
/**
* Maps the data, according to schema
* @param {object} rawData - Raw data object, prior to mapping,
* @returns {object} mappedData
*/
getMappedData = (rawData) => {
const mappedData = {};
function setMappedValue(property) {
let rawValue;
if (property.hasMapping) {
rawValue = Entity.getMappedValue(property.mapping, rawData);
} else {
rawValue = rawData[property.name];
}
if (_.isNil(rawValue)) {
rawValue = property.getDefaultValue();
}
mappedData[property.name] = rawValue;
}
const [dependentProperties, nonDependentProperties] = _.partition(this.properties, (property) => {
return property.hasDepends;
});
_.each(nonDependentProperties, setMappedValue);
_.each(dependentProperties, setMappedValue);
return mappedData;
}
/**
* Sets Property values
* @param {object} data - Raw data object. Keys are Property names, Values are Property values.
* @return {boolean} isChanged - Whether any values were actually changed
* @fires change
*/
setValues = (data) => {
if (this.isFrozen) {
throw Error('Entity is frozen.');
}
if (this.isDestroyed) {
throw Error('this.setValues is no longer valid. Entity has been destroyed.');
}
if (_.indexOf(data, this.getIdProperty().name) !== -1) {
throw new Error('Cannot change id via entity.setValues(). Must use entity.setId() first.');
}
let isChanged = false;
_.each(data, (value, propertyName) => {
const property = this.getProperty(propertyName);
property.pauseEvents(); // We don't need property_change to fire
if (property.setValue(value)) {
isChanged = true;
}
property.resumeEvents();
});
this.setLastModified();
if (isChanged) {
this._recalculateDependentProperties();
this.isValid = null;
this.emit('change', this._proxy);
}
return isChanged;
}
/**
* Helper for _setValues and _onPropertyChange
* @private
*/
_recalculateDependentProperties = () => {
const dependentProperties = this.getPropertiesBy((property) => {
return property.hasDepends;
});
_.each(dependentProperties, (property) => {
property.pauseEvents(); // We don't want property_change to fire
property.setValue( property.getRawValue() ); // Force the property to re-parse the raw value originally submitted to it
property.resumeEvents();
});
}
/**
* Tells the Repository to reload just this one entity from the storage medium.
* @fires reload
*/
reload = () => {
if (this.isDestroyed) {
throw Error('this.reload is no longer valid. Entity has been destroyed.');
}
if (this.repository) {
return this.repository.reloadEntity(this._proxy);
}
this.emit('reload', this._proxy);
}
/**
* Tells the Repository to save this entity to the storage medium.
* @fires save
*/
save = () => {
if (this.isFrozen) {
throw Error('Entity is frozen.');
}
if (this.isDestroyed) {
throw Error('this.save is no longer valid. Entity has been destroyed.');
}
this.emit('save', this._proxy);
if (this.repository) {
return this.repository.save(this._proxy);
}
}
/**
* Marks an entity as having been saved to storage medium.
*/
markSaved = () => {
if (this.isFrozen) {
throw Error('Entity is frozen.');
}
if (this.isDestroyed) {
throw Error('this.markSaved is no longer valid. Entity has been destroyed.');
}
this.isPersisted = true;
this.getIdProperty().isTempId = false;
this._originalData = this._getReconstructedOriginalData();
this._originalDataParsed = this.getParsedValues();
this.markStaged(false);
}
/**
* Marks an entity for deletion.
* @param {boolean} bool - How it should be marked. Defaults to true.
*/
markDeleted = (bool = true) => {
if (this.isFrozen) {
throw Error('Entity is frozen.');
}
if (this.isDestroyed) {
throw Error('this.markDeleted is no longer valid. Entity has been destroyed.');
}
this.isDeleted = bool;
}
/**
* Tells the Repository to delete this entity from the storage medium.
* @fires delete
*/
delete = () => {
if (this.isFrozen) {
throw Error('Entity is frozen.');
}
if (this.isDestroyed) {
throw Error('this.delete is no longer valid. Entity has been destroyed.');
}
this.markDeleted();
this.emit('delete', this._proxy);
if (this.repository) {
return this.repository.save(this._proxy);
}
}
/**
* Marks a deleted entity as undeleted.
* Only works when isAutoSave is off for the containing repository
* @fires delete
*/
undelete = () => {
if (this.isFrozen) {
throw Error('Entity is frozen.');
}
if (this.isDestroyed) {
throw Error('this.undelete is no longer valid. Entity has been destroyed.');
}
const repository = this.getRepository();
if (repository && repository.isAutoSave) {
throw Error('Cannot undelete entities on an isAutoSave repository.');
}
this.markDeleted(false);
this.emit('undelete', this._proxy);
}
/**
* Marks an entity as having been staged for saving.
* @param {boolean} bool - How it should be marked. Defaults to true.
*/
markStaged = (bool = true) => {
if (this.isFrozen) {
throw Error('Entity is frozen.');
}
if (this.isDestroyed) {
throw Error('this.markStaged is no longer valid. Entity has been destroyed.');
}
this.isStaged = bool;
}
/**
* Convenience function.
*/
stage = () => {
this.markStaged(true);
}
/**
* Prevent the entity from being destroyed, but don't let it be changed either.
*/
freeze = () => {
this.isFrozen = true;
}
// _ __ ___ __ __ _
// | | / /___ _/ (_)___/ /___ _/ /_(_)___ ____
// | | / / __ `/ / / __ / __ `/ __/ / __ \/ __ \
// | |/ / /_/ / / / /_/ / /_/ / /_/ / /_/ / / / /
// |___/\__,_/_/_/\__,_/\__,_/\__/_/\____/_/ /_/
/**
* Gets whether or not the Entity validates according to schema's validation rules
* @return {boolean} isValid
*/
validate = async () => {
if (this.isDestroyed) {
throw Error('this.validate is no longer valid. Entity has been destroyed.');
}
const submitValues = this.submitValues;
let isValid = null,
validationResult,
error;
if (this.schema.model.validator) {
const validator = this.schema.model.validator;
try {
validationResult = await validator.validate(submitValues);
error = validationResult.error; // Joi would populate 'error' if validation error. Yup would throw Error
isValid = !error; // 'error' would be truthy if Joi error occurs, would never *get* here if an error with Yup
} catch(e) {
error = e; // yup error only
isValid = false;
}
if (this.validationError !== error) {
this.validationError = error;
}
}
if (this.isValid !== isValid) {
this.emit('changeValidity', this._proxy, isValid);
this.isValid = isValid;
}
return isValid;
}
// ______
// /_ __/_______ ___ _____
// / / / ___/ _ \/ _ \/ ___/
// / / / / / __/ __(__ )
// /_/ /_/ \___/\___/____/
/**
* Gets the "parentId" Property object for this TreeNode.
* This is the Property whose value represents the id for the parent TreeNode.
* @return {Property} parentId Property
*/
getParentIdProperty = () => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getParentIdProperty is no longer valid. TreeNode has been destroyed.');
}
const parentIdProperty = this.getSchema()?.model.parentIdProperty;
return this.getProperty(parentIdProperty);
}
/**
* Gets the parentId for this TreeNode.
* It does this by getting the parentId property's submitValue.
* It doesn't look at some value created by client on the TreeNode.
* @return {any} parentId - The parentId
*/
getParentId = () => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getParentId is no longer valid. TreeNode has been destroyed.');
}
return this.getParentIdProperty().getSubmitValue();
}
/**
* Getter of parentId for this TreeNode.
* @return {any} parentId - The parentId
*/
get parentId() {
return this.getParentId();
}
/**
* Getter of hasParent
* Returns true if this node has a parentId
* @return {boolean} hasParent
*/
get hasParent() {
this.ensureTree();
return !!this.parentId;
}
/**
* Getter of isRoot
* Returns true if this node has no parent
* @return {boolean} hasParent
*/
get isRoot() {
this.ensureTree();
return !this.hasParent;
}
/**
* Gets the "depth" Property object for this TreeNode.
* This is the Property whose value represents the depth of the TreeNode.
* @return {Property} parentId Property
*/
getDepthProperty = () => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getDepthProperty is no longer valid. TreeNode has been destroyed.');
}
const depthProperty = this.getSchema()?.model.depthProperty;
return this.getProperty(depthProperty);
}
/**
* Gets the depth for this TreeNode.
* It does this by getting the depth property's submitValue.
* @return {any} depth - The depth
*/
getDepth = () => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getDepth is no longer valid. TreeNode has been destroyed.');
}
return this.getDepthProperty().getSubmitValue();
}
/**
* Getter of depth for this TreeNode.
* @return {any} depth - The depth
*/
get depth() {
return this.getDepth();
}
/**
* Gets the "hasChildren" Property object for this TreeNode.
* This is the Property whose value represents whether this TreeNode has any children.
* @return {Property} parentId Property
*/
getHasChildrenProperty = () => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getHasChildrenProperty is no longer valid. TreeNode has been destroyed.');
}
const hasChildrenProperty = this.getSchema()?.model.hasChildrenProperty;
return this.getProperty(hasChildrenProperty);
}
/**
* Gets the hasChildren value for this TreeNode.
* It does this by getting the hasChildren property's submitValue.
* It doesn't look at some value created by client on the TreeNode.
* @return {any} parentId - The parentId
*/
getHasChildren = () => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getHasChildren is no longer valid. TreeNode has been destroyed.');
}
if (!_.isEmpty(this.children)) {
return true; // In case the hasChildrenProperty is stale. i.e. That property came from server, and we now have children here
}
return this.getHasChildrenProperty().getSubmitValue();
}
/**
* Getter of hasChildren for this TreeNode.
* @return {any} parentId - The parentId
*/
get hasChildren() {
return this.getHasChildren();
}
/**
* Getter of parent TreeNode for this TreeNode.
* @return {TreeNode} parent - The parent TreeNode
*/
getParent = () => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getParent is no longer valid. TreeNode has been destroyed.');
}
if (!this.hasParent) {
return null;
}
return this.parent;
}
/**
* Getter of child TreeNodes for this TreeNode.
* @return {array} children - The children
*/
getChildren = async () => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getChildren is no longer valid. TreeNode has been destroyed.');
}
if (!this.areChildrenLoaded) {
await this.loadChildren();
}
return this.children;
}
/**
* Whether the supplied TreeNode is a child of this TreeNode.
* @return {boolean} hasThisChild
*/
hasThisChild = async (treeNode) => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.hasThisChild is no longer valid. TreeNode has been destroyed.');
}
const children = await this.getChildren();
return _.includes(children, treeNode);
}
/**
* Loads the children of this TreeNode from repository.
*/
loadChildren = async (depth = 1) => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.loadChildren is no longer valid. TreeNode has been destroyed.');
}
if (!this.repository?.loadNode) {
throw Error('repository.loadNode is not defined.');
}
const children = await this.repository.loadNode(this, depth);
this.areChildrenLoaded = true;
return children;
}
/**
* Alias for loadChildren
*/
reloadChildren = (depth = 1) => { // alias
return this.loadChildren(depth);
}
/**
* Gets the previous sibling of this TreeNode from repository.
* @return {TreeNode} sibling
*/
getPrevousSibling = async () => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getPrevousSibling is no longer valid. TreeNode has been destroyed.');
}
const
parent = this.getParent(),
siblings = await parent.getChildren();
let previous = null;
_.each(siblings, (treeNode) => {
if (treeNode.id === this.id) {
return false;
}
previous = treeNode;
})
return previous;
}
/**
* Gets the next sibling of this TreeNode from repository.
* @return {TreeNode} sibling
*/
getNextSibling = async () => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getNextSibling is no longer valid. TreeNode has been destroyed.');
}
const
parent = this.getParent(),
siblings = await parent.getChildren();
let returnNext = false,
next = null;
_.each(siblings, (treeNode) => {
if (returnNext) {
next = treeNode;
return false;
}
if (treeNode.id === this.id) {
returnNext = true;
}
})
return next;
}
/**
* Gets the child of this TreeNode at index ix from repository.
* @return {TreeNode} child
*/
getChildAt = (ix) => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getChildAt is no longer valid. TreeNode has been destroyed.');
}
if (!this.children[ix]) {
return null;
}
return this.children[ix];
}
/**
* Gets the first child of this TreeNode from repository.
* @return {TreeNode} child
*/
getFirstChild = () => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getFirstChild is no longer valid. TreeNode has been destroyed.');
}
if (!this.children[0]) {
return null;
}
return this.children[0];
}
/**
* Gets the last child of this TreeNode from repository.
* @return {TreeNode} child
*/
getLastChild = () => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getLastChild is no longer valid. TreeNode has been destroyed.');
}
const child = this.children.slice(-1)[0];
if (!child) {
return null;
}
return child;
}
/**
* Gets the path to this node.
* @return {string} path
*/
getPath = () => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.getPath is no longer valid. TreeNode has been destroyed.');
}
const parentIds = [];
let parent = this;
while(parent.hasParent) { // stops at root
parentIds.push(parent.id);
parent = parent.parent;
}
// if (parent.id !== this.id) {
parentIds.push(parent.id); // add root id
// }
return parentIds.reverse().join('/');
}
/**
* Gets the model that this entity uses
* @return {string} model
*/
getModel = () => {
if (this.isTree && this.nodeType) {
// Trees can have entities of different models
return this.nodeType;
}
return this.repository.getModel();
}
/**
* Moves this TreeNode to another parentId.
*/
moveTreeNode = (newParentId) => {
this.ensureTree();
if (this.isDestroyed) {
throw Error('this.moveTreeNode is no longer valid. TreeNode has been destroyed.');
}
if (!this.repository?.moveTreeNode) {
throw Error('repository.moveTreeNode is not defined.');
}
return this.repository.moveTreeNode(this, newParentId);
}
/**
* Helper to make sure this Repository is a tree
* @private
*/
ensureTree = () => {
if (!this.isTree) {
throw Error('This Entity is not a tree!');
return false;
}
return true;
}
/**
* Sets the hash of the current submitValues and state.
* This allows easy detection of changes in data.
* @return {integer} hash
*/
rehash = () => {
const toHash = JSON.stringify(_.merge({}, this.submitValues, {
// include Entity state in hash
isDestroyed: this.isDestroyed,
isPhantom: this.isPhantom,
isDirty: this.isDirty,
isTempId: this.isTempId,
}));
this.hash = hash(toHash);
}
/**
* Destroy this object.
* - Removes all circular references to parent objects
* - Removes child objects
* - Removes event listeners
* @fires destroy
*/
destroy = () => {
if (this.isFrozen || this.isDestroyed) {
return;
}
// Save destroyed properties
this.destroyedProperties = this.displayValues;
this._id = this.id; // save id, so we can query it later--even on a destroyed entity
// parent objects
this.schema = null;
this._proxy = null;
this.repository = null;
// child objects
_.each(this.properties, (property) => {
property.destroy();
})
this.properties = null;
this.isDestroyed = true;
this.emit('destroy', this._proxy);
// listeners
this.removeAllListeners();
}
get [Symbol.toStringTag]() {
return 'Entity {' + this.id + '} - ' + (this.isDestroyed ? 'destroyed' : this.displayValue);
}
get toJSON() {
if (this.isDestroyed) {
throw Error('this.toJSON is no longer valid. Entity has been destroyed.');
}
return this.getRawValues();
}
}
export default Entity;