// Copyright IBM Corp. 2013,2019. All Rights Reserved. // Node module: loopback-datasource-juggler // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT // Turning on strict for this file breaks lots of test cases; // disabling strict for this file /* eslint-disable strict */ /*! * Module exports class Model */ module.exports = ModelBaseClass; /*! * Module dependencies */ const g = require('strong-globalize')(); const util = require('util'); const jutil = require('./jutil'); const List = require('./list'); const DataAccessUtils = require('./model-utils'); const Observer = require('./observer'); const Hookable = require('./hooks'); const validations = require('./validations'); const _extend = util._extend; const utils = require('./utils'); const fieldsToArray = utils.fieldsToArray; const uuid = require('uuid'); const shortid = require('shortid'); // Set up an object for quick lookup const BASE_TYPES = { 'String': true, 'Boolean': true, 'Number': true, 'Date': true, 'Text': true, 'ObjectID': true, }; /** * Model class: base class for all persistent objects. * * `ModelBaseClass` mixes `Validatable` and `Hookable` classes methods * * @class * @param {Object} data Initial object data * @param {Object} options An object to control the instantiation * @returns {ModelBaseClass} an instance of the ModelBaseClass */ function ModelBaseClass(data, options) { options = options || {}; if (!('applySetters' in options)) { // Default to true options.applySetters = true; } if (!('applyDefaultValues' in options)) { options.applyDefaultValues = true; } this._initProperties(data, options); } /** * Initialize the model instance with a list of properties * @param {Object} data The data object * @param {Object} options An object to control the instantiation * @property {Boolean} applySetters Controls if the setters will be applied * @property {Boolean} applyDefaultValues Default attributes and values will be applied * @property {Boolean} strict Set the instance level strict mode * @property {Boolean} persisted Whether the instance has been persisted * @private */ ModelBaseClass.prototype._initProperties = function(data, options) { const self = this; const ctor = this.constructor; if (typeof data !== 'undefined' && data !== null && data.constructor && typeof (data.constructor) !== 'function') { throw new Error(g.f('Property name "{{constructor}}" is not allowed in %s data', ctor.modelName)); } if (data instanceof ctor) { // Convert the data to be plain object to avoid pollutions data = data.toObject(false); } const properties = _extend({}, ctor.definition.properties); data = data || {}; if (typeof ctor.applyProperties === 'function') { ctor.applyProperties(data); } options = options || {}; const applySetters = options.applySetters; const applyDefaultValues = options.applyDefaultValues; let strict = options.strict; if (strict === undefined) { strict = ctor.definition.settings.strict; } else if (strict === 'throw') { g.warn('Warning: Model %s, {{strict mode: `throw`}} has been removed, ' + 'please use {{`strict: true`}} instead, which returns' + '{{`Validation Error`}} for unknown properties,', ctor.modelName); } const persistUndefinedAsNull = ctor.definition.settings.persistUndefinedAsNull; if (ctor.hideInternalProperties) { // Object.defineProperty() is expensive. We only try to make the internal // properties hidden (non-enumerable) if the model class has the // `hideInternalProperties` set to true Object.defineProperties(this, { __cachedRelations: { writable: true, enumerable: false, configurable: true, value: {}, }, __data: { writable: true, enumerable: false, configurable: true, value: {}, }, // Instance level data source __dataSource: { writable: true, enumerable: false, configurable: true, value: options.dataSource, }, // Instance level strict mode __strict: { writable: true, enumerable: false, configurable: true, value: strict, }, __persisted: { writable: true, enumerable: false, configurable: true, value: false, }, }); if (strict) { Object.defineProperty(this, '__unknownProperties', { writable: true, enumerable: false, configrable: true, value: [], }); } } else { this.__cachedRelations = {}; this.__data = {}; this.__dataSource = options.dataSource; this.__strict = strict; this.__persisted = false; if (strict) { this.__unknownProperties = []; } } if (options.persisted !== undefined) { this.__persisted = options.persisted === true; } if (data.__cachedRelations) { this.__cachedRelations = data.__cachedRelations; } let keys = Object.keys(data); if (Array.isArray(options.fields)) { keys = keys.filter(function(k) { return (options.fields.indexOf(k) != -1); }); } let size = keys.length; let p, propVal; for (let k = 0; k < size; k++) { p = keys[k]; propVal = data[p]; if (typeof propVal === 'function') { continue; } if (propVal === undefined && persistUndefinedAsNull) { propVal = null; } if (properties[p]) { // Managed property if (applySetters || properties[p].id) { self[p] = propVal; } else { self.__data[p] = propVal; } } else if (ctor.relations[p]) { const relationType = ctor.relations[p].type; let modelTo; if (!properties[p]) { modelTo = ctor.relations[p].modelTo || ModelBaseClass; const multiple = ctor.relations[p].multiple; const typeName = multiple ? 'Array' : modelTo.modelName; const propType = multiple ? [modelTo] : modelTo; properties[p] = {name: typeName, type: propType}; /* Issue #1252 this.setStrict(false); */ } // Relation if (relationType === 'belongsTo' && propVal != null) { // If the related model is populated self.__data[ctor.relations[p].keyFrom] = propVal[ctor.relations[p].keyTo]; if (ctor.relations[p].options.embedsProperties) { const fields = fieldsToArray(ctor.relations[p].properties, modelTo.definition.properties, modelTo.settings.strict); if (!~fields.indexOf(ctor.relations[p].keyTo)) { fields.push(ctor.relations[p].keyTo); } self.__data[p] = new modelTo(propVal, { fields: fields, applySetters: false, persisted: options.persisted, }); } } self.__cachedRelations[p] = propVal; } else { // Un-managed property if (strict === false || self.__cachedRelations[p]) { self[p] = self.__data[p] = (propVal !== undefined) ? propVal : self.__cachedRelations[p]; // Throw error for properties with unsupported names if (/\./.test(p)) { throw new Error(g.f( 'Property names containing dot(s) are not supported. ' + 'Model: %s, dynamic property: %s', this.constructor.modelName, p )); } } else { if (strict !== 'filter') { this.__unknownProperties.push(p); } } } } keys = Object.keys(properties); if (Array.isArray(options.fields)) { keys = keys.filter(function(k) { return (options.fields.indexOf(k) != -1); }); } size = keys.length; for (let k = 0; k < size; k++) { p = keys[k]; propVal = self.__data[p]; const type = properties[p].type; // Set default values if (applyDefaultValues && propVal === undefined && appliesDefaultsOnWrites(properties[p])) { let def = properties[p]['default']; if (def !== undefined) { if (typeof def === 'function') { if (def === Date) { // FIXME: We should coerce the value in general // This is a work around to {default: Date} // Date() will return a string instead of Date def = new Date(); } else { def = def(); } } else if (type.name === 'Date' && def === '$now') { def = new Date(); } // FIXME: We should coerce the value // will implement it after we refactor the PropertyDefinition self.__data[p] = propVal = def; } } // Set default value using a named function if (applyDefaultValues && propVal === undefined) { const defn = properties[p].defaultFn; switch (defn) { case undefined: break; case 'guid': case 'uuid': // Generate a v1 (time-based) id propVal = uuid.v1(); break; case 'uuidv4': // Generate a RFC4122 v4 UUID propVal = uuid.v4(); break; case 'now': propVal = new Date(); break; case 'shortid': propVal = shortid.generate(); break; default: // TODO Support user-provided functions via a registry of functions g.warn('Unknown default value provider %s', defn); } // FIXME: We should coerce the value // will implement it after we refactor the PropertyDefinition if (propVal !== undefined) self.__data[p] = propVal; } if (propVal === undefined && persistUndefinedAsNull) { self.__data[p] = propVal = null; } // Handle complex types (JSON/Object) if (!BASE_TYPES[type.name]) { if (typeof self.__data[p] !== 'object' && self.__data[p]) { try { self.__data[p] = JSON.parse(self.__data[p] + ''); } catch (e) { self.__data[p] = String(self.__data[p]); } } if (type.prototype instanceof ModelBaseClass) { if (!(self.__data[p] instanceof type) && typeof self.__data[p] === 'object' && self.__data[p] !== null) { self.__data[p] = new type(self.__data[p]); } } else if (type.name === 'Array' || Array.isArray(type)) { if (!(self.__data[p] instanceof List) && self.__data[p] !== undefined && self.__data[p] !== null) { self.__data[p] = List(self.__data[p], type, self); } } } } this.trigger('initialize'); }; // Helper function for determing the applyDefaultOnWrites value of a property function appliesDefaultsOnWrites(property) { if (property && ('applyDefaultOnWrites' in property)) { return property.applyDefaultOnWrites; } return true; } /** * Define a property on the model. * @param {String} prop Property name * @param {Object} params Various property configuration */ ModelBaseClass.defineProperty = function(prop, params) { if (this.dataSource) { this.dataSource.defineProperty(this.modelName, prop, params); } else { this.modelBuilder.defineProperty(this.modelName, prop, params); } }; /** * Get model property type. * @param {String} propName Property name * @returns {String} Name of property type */ ModelBaseClass.getPropertyType = function(propName) { const prop = this.definition.properties[propName]; if (!prop) { // The property is not part of the definition return null; } if (!prop.type) { throw new Error(g.f('Type not defined for property %s.%s', this.modelName, propName)); // return null; } return prop.type.name; }; /** * Get model property type. * @param {String} propName Property name * @returns {String} Name of property type */ ModelBaseClass.prototype.getPropertyType = function(propName) { return this.constructor.getPropertyType(propName); }; /** * Return string representation of class * This overrides the default `toString()` method */ ModelBaseClass.toString = function() { return '[Model ' + this.modelName + ']'; }; /** * Convert model instance to a plain JSON object. * Returns a canonical object representation (no getters and setters). * * @param {Boolean} onlySchema Restrict properties to dataSource only. Default is false. If true, the function returns only properties defined in the schema; Otherwise it returns all enumerable properties. * @param {Boolean} removeHidden Boolean flag as part of the transformation. If true, then hidden properties should not be brought out. * @param {Boolean} removeProtected Boolean flag as part of the transformation. If true, then protected properties should not be brought out. * @returns {Object} returns Plain JSON object */ ModelBaseClass.prototype.toObject = function(onlySchema, removeHidden, removeProtected) { if (typeof onlySchema === 'object' && onlySchema != null) { const options = onlySchema; onlySchema = options.onlySchema; removeHidden = options.removeHidden; removeProtected = options.removeProtected; } if (onlySchema === undefined) { onlySchema = true; } const data = {}; const self = this; const Model = this.constructor; // if it is already an Object if (Model === Object) { return self; } const strict = this.__strict; const schemaLess = (strict === false) || !onlySchema; const persistUndefinedAsNull = Model.definition.settings.persistUndefinedAsNull; const props = Model.definition.properties; let keys = Object.keys(props); let propertyName, val; for (let i = 0; i < keys.length; i++) { propertyName = keys[i]; val = self[propertyName]; // Exclude functions if (typeof val === 'function') { continue; } // Exclude hidden properties if (removeHidden && Model.isHiddenProperty(propertyName)) { continue; } if (removeProtected && Model.isProtectedProperty(propertyName)) { continue; } if (val instanceof List) { data[propertyName] = val.toObject(!schemaLess, removeHidden, true); } else { if (val !== undefined && val !== null && val.toObject) { data[propertyName] = val.toObject(!schemaLess, removeHidden, true); } else { if (val === undefined && persistUndefinedAsNull) { val = null; } data[propertyName] = val; } } } if (schemaLess) { // Find its own properties which can be set via myModel.myProperty = 'myValue'. // If the property is not declared in the model definition, no setter will be // triggered to add it to __data keys = Object.keys(self); let size = keys.length; for (let i = 0; i < size; i++) { propertyName = keys[i]; if (props[propertyName]) { continue; } if (propertyName.indexOf('__') === 0) { continue; } if (removeHidden && Model.isHiddenProperty(propertyName)) { continue; } if (removeProtected && Model.isProtectedProperty(propertyName)) { continue; } if (data[propertyName] !== undefined) { continue; } val = self[propertyName]; if (val !== undefined) { if (typeof val === 'function') { continue; } if (val !== null && val.toObject) { data[propertyName] = val.toObject(!schemaLess, removeHidden, true); } else { data[propertyName] = val; } } else if (persistUndefinedAsNull) { data[propertyName] = null; } } // Now continue to check __data keys = Object.keys(self.__data); size = keys.length; for (let i = 0; i < size; i++) { propertyName = keys[i]; if (propertyName.indexOf('__') === 0) { continue; } if (data[propertyName] === undefined) { if (removeHidden && Model.isHiddenProperty(propertyName)) { continue; } if (removeProtected && Model.isProtectedProperty(propertyName)) { continue; } const ownVal = self[propertyName]; // The ownVal can be a relation function val = (ownVal !== undefined && (typeof ownVal !== 'function')) ? ownVal : self.__data[propertyName]; if (typeof val === 'function') { continue; } if (val !== undefined && val !== null && val.toObject) { data[propertyName] = val.toObject(!schemaLess, removeHidden, true); } else if (val === undefined && persistUndefinedAsNull) { data[propertyName] = null; } else { data[propertyName] = val; } } } } return data; }; /** * Convert an array of strings into an object as the map * @param {string[]} arr An array of strings */ function asObjectMap(arr) { const obj = {}; if (Array.isArray(arr)) { for (let i = 0; i < arr.length; i++) { obj[arr[i]] = true; } return obj; } return arr || obj; } /** * Checks if property is protected. * @param {String} propertyName Property name * @returns {Boolean} true or false if protected or not. */ ModelBaseClass.isProtectedProperty = function(propertyName) { const settings = (this.definition && this.definition.settings) || {}; const protectedProperties = settings.protectedProperties || settings.protected; settings.protectedProperties = asObjectMap(protectedProperties); return settings.protectedProperties[propertyName]; }; /** * Checks if property is hidden. * @param {String} propertyName Property name * @returns {Boolean} true or false if hidden or not. */ ModelBaseClass.isHiddenProperty = function(propertyName) { const settings = (this.definition && this.definition.settings) || {}; const hiddenProperties = settings.hiddenProperties || settings.hidden; settings.hiddenProperties = asObjectMap(hiddenProperties); return settings.hiddenProperties[propertyName]; }; ModelBaseClass.prototype.toJSON = function() { return this.toObject(false, true, false); }; ModelBaseClass.prototype.fromObject = function(obj) { for (const key in obj) { this[key] = obj[key]; } }; /** * Reset dirty attributes. * This method does not perform any database operations; it just resets the object to its * initial state. */ ModelBaseClass.prototype.reset = function() { const obj = this; for (const k in obj) { if (k !== 'id' && !obj.constructor.dataSource.definitions[obj.constructor.modelName].properties[k]) { delete obj[k]; } } }; // Node v0.11+ allows custom inspect functions to return an object // instead of string. That way options like `showHidden` and `colors` // can be preserved. const versionParts = process.versions && process.versions.node ? process.versions.node.split(/\./g).map(function(v) { return +v; }) : [1, 0, 0]; // browserify ships 1.0-compatible version of util.inspect const INSPECT_SUPPORTS_OBJECT_RETVAL = versionParts[0] > 0 || versionParts[1] > 11 || (versionParts[0] === 11 && versionParts[1] >= 14); ModelBaseClass.prototype.inspect = function(depth) { if (INSPECT_SUPPORTS_OBJECT_RETVAL) return this.__data; // Workaround for older versions // See also https://github.com/joyent/node/commit/66280de133 return util.inspect(this.__data, { showHidden: false, depth: depth, colors: false, }); }; if (util.inspect.custom) { // Node.js 12+ no longer recognizes "inspect" method, // it uses "inspect.custom" symbol as the key instead // TODO(semver-major) always use the symbol key only (requires Node.js 8+). ModelBaseClass.prototype[util.inspect.custom] = ModelBaseClass.prototype.inspect; } /** * * @param {String} anotherClass could be string or class. Name of the class or the class itself * @param {Object} options An object to control the instantiation * @returns {ModelClass} */ ModelBaseClass.mixin = function(anotherClass, options) { if (typeof anotherClass === 'string') { this.modelBuilder.mixins.applyMixin(this, anotherClass, options); } else { if (anotherClass.prototype instanceof ModelBaseClass) { const props = anotherClass.definition.properties; for (const i in props) { if (this.definition.properties[i]) { continue; } this.defineProperty(i, props[i]); } } return jutil.mixin(this, anotherClass, options); } }; ModelBaseClass.prototype.getDataSource = function() { return this.__dataSource || this.constructor.dataSource; }; ModelBaseClass.getDataSource = function() { return this.dataSource; }; ModelBaseClass.prototype.setStrict = function(strict) { this.__strict = strict; }; /** * * `getMergePolicy()` provides model merge policies to apply when extending * a child model from a base model. Such a policy drives the way parent/child model * properties/settings are merged/mixed-in together. * * Below is presented the expected merge behaviour for each option. * NOTE: This applies to top-level settings properties * * * - Any * - `{replace: true}` (default): child replaces the value from parent * - assignin `null` on child setting deletes the inherited setting * * - Arrays: * - `{replace: false}`: unique elements of parent and child cumulate * - `{rank: true}` adds the model inheritance rank to array * elements of type Object {} as internal property `__rank` * * - Object {}: * - `{replace: false}`: deep merges parent and child objects * - `{patch: true}`: child replaces inner properties from parent * * * The recommended built-in merge policy is as follows. It is returned by getMergePolicy() * when calling the method with option `{configureModelMerge: true}`. * * ``` * { * description: {replace: true}, // string or array * options: {patch: true}, // object * hidden: {replace: false}, // array * protected: {replace: false}, // array * indexes: {patch: true}, // object * methods: {patch: true}, // object * mixins: {patch: true}, // object * relations: {patch: true}, // object * scope: {replace: true}, // object * scopes: {patch: true}, // object * acls: {rank: true}, // array * // this setting controls which child model property's value allows deleting * // a base model's property * __delete: null, * // this setting controls the default merge behaviour for settings not defined * // in the mergePolicy specification * __default: {replace: true}, * } * ``` * * The legacy built-in merge policy is as follows, it is retuned by `getMergePolicy()` * when avoiding option `configureModelMerge`. * NOTE: it also provides the ACLs ranking in addition to the legacy behaviour, as well * as fixes for settings 'description' and 'relations': matching relations from child * replace relations from parents. * * ``` * { * description: {replace: true}, // string or array * properties: {patch: true}, // object * hidden: {replace: false}, // array * protected: {replace: false}, // array * relations: {acls: true}, // object * acls: {rank: true}, // array * } * ``` * * * `getMergePolicy()` can be customized using model's setting `configureModelMerge` as follows: * * ``` json * { * // .. * options: { * configureModelMerge: { * // merge options * } * } * // .. * } * ``` * * NOTE: mergePolicy parameter can also defined at JSON model definition root * * `getMergePolicy()` method can also be extended programmatically as follows: * * ``` js * myModel.getMergePolicy = function(options) { * const origin = myModel.base.getMergePolicy(options); * return Object.assign({}, origin, { * // new/overriding options * }); * }; * ``` * * @param {Object} options option `configureModelMerge` can be used to alter the * returned merge policy: * - `configureModelMerge: true` will have the method return the recommended merge policy. * - `configureModelMerge: {..}` will actually have the method return the provided object. * - not providing this options will have the method return a merge policy emulating the * the model merge behaviour up to datasource-juggler v3.6.1, as well as the ACLs ranking. * @returns {Object} mergePolicy The model merge policy to apply when using the * current model as base class for a child model */ ModelBaseClass.getMergePolicy = function(options) { // NOTE: merge policy equivalent to datasource-juggler behaviour up to v3.6.1 // + fix for description arrays that should not be merged // + fix for relations that should patch matching relations // + ranking of ACLs let mergePolicy = { description: {replace: true}, // string or array properties: {patch: true}, // object hidden: {replace: false}, // array protected: {replace: false}, // array relations: {patch: true}, // object acls: {rank: true}, // array }; const config = (options || {}).configureModelMerge; if (config === true) { // NOTE: recommended merge policy from datasource-juggler v3.6.2 mergePolicy = { description: {replace: true}, // string or array options: {patch: true}, // object // properties: {patch: true}, // object // NOTE: not part of configurable merge hidden: {replace: false}, // array protected: {replace: false}, // array indexes: {patch: true}, // object methods: {patch: true}, // object mixins: {patch: true}, // object // validations: {patch: true}, // object // NOTE: not implemented relations: {patch: true}, // object scope: {replace: true}, // object scopes: {patch: true}, // object acls: {rank: true}, // array // this option controls which value assigned on child model allows deleting // a base model's setting __delete: null, // this option controls the default merge behaviour for settings not defined // in the mergePolicy specification __default: {replace: true}, }; } // override mergePolicy with provided model setting if required if (config && typeof config === 'object' && !Array.isArray(config)) { // config is an object mergePolicy = config; } return mergePolicy; }; /** * Gets properties defined with 'updateOnly' flag set to true from the model. This flag is also set to true * internally for the id property, if this property is generated and IdInjection is true. * @returns {updateOnlyProps} List of properties with updateOnly set to true. */ ModelBaseClass.getUpdateOnlyProperties = function() { const props = this.definition.properties; return Object.keys(props).filter(key => props[key].updateOnly); }; // Mix in utils jutil.mixin(ModelBaseClass, DataAccessUtils); // Mixin observer jutil.mixin(ModelBaseClass, Observer); jutil.mixin(ModelBaseClass, Hookable); jutil.mixin(ModelBaseClass, validations.Validatable);