UNPKG

arrow-orm

Version:

API Builder ORM

799 lines (746 loc) 22.4 kB
/** * @class APIBuilder.Instance */ const inspect = Symbol.for('nodejs.util.inspect.custom'); const util = require('util'); const events = require('events'); const error = require('./error'); const _ = require('lodash'); const apiBuilderConfig = require('@axway/api-builder-config'); const ORMError = error.ORMError; const ValidationError = error.ValidationError; util.inherits(Instance, events.EventEmitter); var builtInTypes = [ 'string', 'number', 'boolean', 'object', 'date', 'array' ]; function Instance(model, values, skipNotFound) { if (!model.fields) { throw new ORMError('missing model "fields" property'); } this._values = {}; this._model = model; this._dirty = false; this._deleted = false; this._metadata = model.metadatada || {}; this._dirtyfields = {}; this._fieldmap = {}; this._fieldmap_by_name = {}; this._skipNotFoundFields = null; this._events = {}; var self = this; [ '_values', '_model', '_dirty', '_deleted', '_metadata', '_dirtyfields', '_fieldmap', '_fieldmap_by_name', '_skipNotFoundFields', '_events' ].forEach(function (k) { Object.defineProperty(self, k, { enumerable: false }); }); // map our field properties into this instance Object.keys(model.fields).forEach(function instanceIterator(property) { var field = model.fields[property]; if (!field.type) { throw new ValidationError(property, 'required type property missing for field'); } if (!field.type.name) { field.type = String(field.type); } else { field.type = field.type.name.toLowerCase(); } if (typeof field.model === 'string') { if (Instance.APIBuilder && Instance.APIBuilder.getModel(field.model)) { field.Model = Instance.APIBuilder.getModel(field.model); } else if (model.connector && model.connector.models && model.connector.models[field.model]) { field.Model = model.connector.models[field.model]; } } else if (_.isObject(field.model)) { field.Model = field.model; field.model = field.Model.name; } if (field.default !== undefined) { this._values[property] = field.default; } // only set on the the field map if the aliased field does not come from another model if (field.name && !field.model) { this._fieldmap[field.name] = property; this._fieldmap_by_name[property] = field; } Object.defineProperty(this, property, { get: function () { return this.get(property); }, set: function (value) { this.set(property, value); } }); }.bind(this)); // map in the methods from the Model on to the instance model.methods && Object.keys(model.methods).forEach(function modelMethodIterator(name) { var o = model.methods[name]; if (_.isFunction(o)) { this[name] = o.bind(this); } else { this[name] = o; } }.bind(this)); // set the values values && this.set(values, skipNotFound); // set dirty false when we are constructing this._dirty = false; this._dirtyfields = {}; this._skipNotFoundFields = skipNotFound && Object.keys(values); // perform initial validation !skipNotFound && this.validateFields(); } function isOK(value) { return !(value === undefined || value === null); } /** * Tests if `field` is joined from another model as either an "array" or * "object". It is a model that has a field with "model" but does *not* have a * "name". In the UI, this is taking a model A, composing it into model B, and * joining it with model C where a field from C is joined as an array or object. * This does not have to check `metadata.inner_join` or `metadata.left_join` * because not having a `field.name` (and having a referenced "model") is * sufficient. * @param {object} field - The field to test. * @returns {boolean} True if the field is joined. */ function isJoinedAsArrayOrObject(field) { return !field.name && field.Model && field.Model.instance; } /** * Retrieves the Model class used by this model instance. * @returns {APIBuilder.Model} */ Instance.prototype.getModel = function getModel() { return this._model; }; /** * Retrieves the Connector class used by this model instance. * @returns {APIBuilder.Connector} */ Instance.prototype.getConnector = function getConnector() { return this._model.getConnector(); }; /** * Validates if the model field can be implictly converted to the value. * If it can, sets the converted value. * @param {Any} field Field value to validate. * @param {String} name Field name to set. * @param {Any} value Value to validate. * @returns {Boolean} Returns `true` if the type can be implicitly converted else `false`. */ Instance.prototype.validateCoersiveTypes = function validateCoersiveTypes(field, name, value) { const type = (typeof value).toLowerCase(); const fieldType = field.type.toLowerCase(); switch (fieldType) { case 'boolean': { if (type === 'number') { this.set(name, value >= 1); return true; } if (type === 'string') { switch (value.trim().toLowerCase()) { case 'false': case 'no': case '0': this.set(name, false); return true; case 'true': case 'yes': case '1': this.set(name, true); return true; } } break; } case 'number': { if (type === 'string') { var parsedInt = parseInt(value, 10); if (value == parsedInt) { // eslint-disable-line this.set(name, parsedInt); return true; } var parsedFloat = parseFloat(value); if (value == parsedFloat) { // eslint-disable-line this.set(name, parsedFloat); return true; } } break; } case 'date': { if (type === 'number') { this.set(name, new Date(value)); return true; } else if (type === 'string') { const d = new Date(value); try { // Field type is date so why are we setting string? // (and why does this work?) this.set(name, d.toISOString()); return true; } catch (e) { return false; } } break; } case 'object': { if (value instanceof Date) { return true; } if (typeof(value) === 'string' && value === '') { this.set(name, {}); return true; } if (typeof value === 'string') { try { value = JSON.parse(value); // need to check the type is correct if (Object.prototype.toString.call(value).toLowerCase() !== `[object ${fieldType}]`) { return false; } this.set(name, value); return true; } catch (e) { return false; } } break; } case 'array': { if (typeof value === 'string') { try { value = JSON.parse(value); if (Array.isArray(value)) { this.set(name, value); return true; } } catch (e) { return false; } } break; } } return false; }; /** * Validates a field in the model. Throws a validation error if validation fails. * @param {String} name Field name to validate. * @param {Any} v Value to validate. * @throws {APIBuilder.ValidationError} */ Instance.prototype.validateField = function validateField(name, v) { var field = this._model.fields[name], value = isOK(v) ? v : this.get(name), hasValue = isOK(value); if ((field.optional === false || field.required) && !hasValue) { throw new ValidationError(name, 'required field value missing: ' + name); } const valueType = (typeof value).toLowerCase(); const fieldType = field.type.toLowerCase(); if (hasValue && Object.prototype.toString.call(value).toLowerCase() !== `[object ${fieldType}]`) { const isBuiltInType = builtInTypes.indexOf(fieldType) >= 0; const builtInCoerce = isBuiltInType && this.validateCoersiveTypes(field, name, value); const externalCoerce = !isBuiltInType && this._model.connector && this._model.connector.coerceCustomType && this._model.connector.coerceCustomType(this, field, name, value); if (!builtInCoerce && !externalCoerce) { throw new ValidationError(name, 'invalid type (' + (valueType) + ') for field: ' + name + '. Should be ' + field.type + '. Value was: ' + util.inspect(value)); } } if (value !== undefined && value !== null && typeof value === 'string' || Array.isArray(value)) { if (field.minlength !== undefined && value.length < field.minlength) { throw new ValidationError(name, `field value must be at least ${field.minlength} characters long: ${name}`); } if (field.maxlength !== undefined && value.length > field.maxlength) { throw new ValidationError(name, `field value must be at most ${field.maxlength} characters long: ${name}`); } if (field.length !== undefined && value.length !== field.length) { throw new ValidationError(name, `field value must be exactly ${field.length} characters long: ${name}`); } } if (isJoinedAsArrayOrObject(field)) { if (fieldType === 'array' && Array.isArray(value)) { // Run validation on array of joined sub-models (if we have them). for (var i = 0; i < value.length; i++) { if (!_.isObject(value[i])) { throw new ValidationError(name, `field "${name}" array item "${value}" is not an object`); } field.Model.instance(value[i], false); } } else if (fieldType === 'object' && _.isObject(value)) { // Run validation on joined sub-model object (if we have them). field.Model.instance(value, false); } } if (field.validator) { // only run validators if required or if we have a value if (field.required || hasValue) { if (field.validator instanceof RegExp) { if (!field.validator.test(value)) { throw new ValidationError(name, 'field "' + name + '" failed validation using expression "' + field.validator + '" and value: ' + value); } } else if (typeof field.validator === 'function') { var msg; try { msg = field.validator(value); } catch (E) { if (E instanceof ValidationError) { throw E; } else { throw new ValidationError(name, E.message); } } if (msg) { throw new ValidationError(name, msg); } } } } }; /** * Validates the fields in the model. * @throws {APIBuilder.ValidationError} */ Instance.prototype.validateFields = function validateFields() { // map our field properties into this instance var errors = []; Object.keys(this._model.fields).forEach(function iterator(property) { try { this.validateField(property); } catch (err) { errors.push(err); } }.bind(this)); if (errors.length > 0) { if (errors.length === 1) { throw errors[0]; } else { throw new ValidationError(errors.map( e => e.field).join(', '), errors.map(e => e.message).join('\n')); } } }; /** * Sets the primary key for the model. * @param {String} value Field name to use as the primary key. */ Instance.prototype.setPrimaryKey = function setPrimaryKey(value) { this.setMeta(Instance.PRIMARY_KEY, value); }; /** * Retrieves the field name used as the primary key. * @returns {String} */ Instance.prototype.getPrimaryKey = function getPrimaryKey() { return this.getMeta(Instance.PRIMARY_KEY); }; [ 'primaryKey', 'ID', 'Id', 'id', '_id' ].forEach(function (alias) { Object.defineProperty(Instance.prototype, alias, { get: Instance.prototype.getPrimaryKey, set: Instance.prototype.setPrimaryKey }); }); /** * Sets metadata for the model instance. * @param {String} key Key to set. * @param {Any} value Value to set. * @returns {APIBuilder.Instance} */ Instance.prototype.setMeta = function setMeta(key, value) { this._metadata[key] = value; return this; }; /** * Retrieves metadata from the model instance. * @param {String} key Key to retrieve. * @param {Any} def Default value to return if the key is not set. * Does not set the value of the key. * @returns {Any} */ Instance.prototype.getMeta = function getMeta(key, def) { return this._metadata[key] !== undefined ? this._metadata[key] : def; }; // We leave that in always to work with older node versions // and to prevent a breaking change when working with newers Instance.prototype.inspect = function () { // node 8 breakLength default is 60, in 14 it is 80 return util.inspect(this.toJSON(), { breakLength: 60 }); }; /* istanbul ignore else */ if (inspect) { // After Node 10.12 inspect should be used as shared symbol /* istanbul ignore next */ Instance.prototype[inspect] = function () { return util.inspect(this.toJSON()); }; } /** * Converts the model instance to JSON. * @returns {Object} */ Instance.prototype.toJSON = function toJSON() { var obj = {}, fields = this._model.fields, pkName = this._model.getPrimaryKeyName(), pk = this.getPrimaryKey(); // only add the primary key if we have one if (pk !== undefined) { obj[pkName] = pk; } for (var key in this._model.fields) { if (this._model.fields.hasOwnProperty(key)) { // if we have skip fields, only return fields contained in the model - that what // if we query with sel or unsel, we only return a model that also contains those same // field keys (assuming it's not a calculated field) var field = fields[key]; if (this._skipNotFoundFields && this._skipNotFoundFields.indexOf(key) < 0 // not custom and not primary key && field && !field.custom && key !== pkName // if its not a custom mapped field name && field.name === key) { continue; } var v = this._model.get(key, this._values[key], this); if (!_.isFunction(v)) { if (v !== undefined) { // undefined means remove it obj[key] = v; } } } } // allow the model to have a global serialize callback if (this._model.serialize) { obj = this._model.serialize(obj, this, this._model); } return obj; }; /** * Converts the model fields to a JSON payload. * @returns {Object} */ Instance.prototype.toPayload = function toPayload() { var obj = {}, fields = this._model.fields, values = this.values(); for (var key in values) { /* if (values.hasOwnProperty(key) && values[key] !== null && !fields[key].custom) { obj[fields[key].name || key] = values[key]; }*/ if (values.hasOwnProperty(key) && !fields[key].custom) { obj[fields[key].name || key] = this._model.set(key, this._values[key], this); } } // allow the model to have a global deserialize callback if (this._model.deserialize) { obj = this._model.deserialize(obj, this, this._model); } return obj; }; /** * Returns `true` if the model instance has not been saved to the external source. * @returns {Boolean} */ Instance.prototype.isUnsaved = function isUnsaved() { return this._dirty; }; /** * Returns `true` if the model instance has been deleted. * @returns {Boolean} */ Instance.prototype.isDeleted = function isDeleted() { return this._deleted; }; /** * Retrieves the value of the model field. * @param {String} name Field name to retrieve. * @returns {Any} */ Instance.prototype.get = function get(name) { var field = this._model.fields[name], isBuiltInType = field && field.type && builtInTypes.indexOf(field.type.toLowerCase()) >= 0, result, notFound = true; if (field && field.get) { var Model = require('./model'); var fn = Model.toFunction(field, 'get'); result = fn(this._values[name], name, this); notFound = false; } if (undefined === result && name in this._values) { result = this._values[name]; notFound = false; } if (!isBuiltInType && this._model.connector && this._model.connector.getCustomType) { result = this._model.connector.getCustomType(this, field, name, result); } else if (_.isObject(result)) { if (result.toJSON) { // if we've stored a model instance, don't clone it. } else { // we need to return a cloned value otherwise if you mutate it and then attempt // to update it, it won't think it's changed when you call set. result = JSON.parse(JSON.stringify(result)); } } if (!notFound) { return result; } else { return undefined; } }; /** * Changes a field with a new value. This will force the internal state to be dirty regardless of * whether the value is the same as what's already set. * @param {String} name Name of the model attribute. * @param {Any} value Value to set. */ Instance.prototype.change = function (name, value) { if (name in this._values) { this._values[name] = value; this._dirty = true; this._dirtyfields[name] = value; } else { throw new ORMError('field not found: ' + name); } }; /** * Returns the fields that have been changed. * @returns {Object} */ Instance.prototype.getChangedFields = function getChangedFields() { return this._dirtyfields; }; /** * Returns the values for the model instance excluding the primary key. * @param {Boolean} [dirtyOnly=false] Set to `true` to only return unsaved fields. * @returns {Object} */ Instance.prototype.values = function values(dirtyOnly) { var retVal = {}; for (var key in this._model.fields) { if (this._model.fields.hasOwnProperty(key)) { var field = this._model.fields[key], isDirty = dirtyOnly && key in this._dirtyfields; if (field.readonly && isDirty) { retVal[key] = this._values[key]; continue; } else if (field.readonly && !isDirty) { continue; } if (!dirtyOnly || isDirty) { retVal[key] = this._values[key]; } } } return retVal; }; /** * Returns the field keys for the instance. * @returns {Array<String>} */ Instance.prototype.keys = function keys() { return this._model.keys(); }; const internal = [ '_dirty', '_deleted', '_metadata', '_dirtyfields', '_events', '_values', '_model', '_fieldmap', '_skipNotFoundFields' ]; /** * Sets the values on the model instance. * @param {String} [name] Name of the field to set. * @param {Object/Any} value If `name` is used, the value to set for the field. * Otherwise, use an object of key-value pairs to set. * @param {Boolean} skipNotFound Set to `true` to skip fields passed in to * the `value` parameter that are not defined by the model's schema. By default, * an error will be thrown if an undefined field is passed in. * @return {APIBuilder.Instance} * @throws {APIBuilder.ValidationError} */ Instance.prototype.set = function set() { var skipNotFound; if (typeof(arguments[0]) === 'object') { var obj = arguments[0]; skipNotFound = arguments[1]; var keys = Object.keys(obj), errors = []; keys.forEach(function iterator(key) { try { this.set(key, obj[key], skipNotFound); } catch (err) { errors.push(err); } }.bind(this)); if (errors.length > 0) { if (errors.length === 1) { throw errors[0]; } else { throw new ValidationError(errors.map(e => e.field).join(', '), errors.map(e => e.message).join('\n')); } } if (skipNotFound) { // we need to remove any keys not found in the incoming payload // in case the user did a sel/unsel var removeKeys = _.difference(Object.keys(this._values), keys); if (removeKeys.length) { removeKeys.forEach(function (k) { if (!(k in this._fieldmap_by_name)) { // only undefine if not in the field mapping this._values[k] = undefined; } }.bind(this)); } } if (this._model.validator) { // only run validators if required or if we have a value if (typeof this._model.validator === 'function') { var msg; try { msg = this._model.validator(this); } catch (E) { if (E instanceof ValidationError) { throw E; } else { throw new ValidationError(this._model.name, E.message); } } if (msg) { throw new ValidationError(this._model.name, msg); } } } } else { var name = arguments[0]; // if internal, we can skip if (name.charAt(0) === '_') { if (internal.indexOf(name) !== -1) { return; } } skipNotFound = arguments[2]; // see if we have a field remapping if (name in this._fieldmap) { name = this._fieldmap[name]; } var value = arguments[1], definition = this._model.fields[name], current_value = this._values[name]; // don't set primary key, skip it if (name === this._model.getPrimaryKeyName()) { return; } if (!definition && !skipNotFound) { throw new ValidationError(name, 'invalid field: ' + name); } else if (!definition && skipNotFound) { // don't set it if we can't find definition and // we have told it to skip these types of fields // this is useful when a connector wants to add // values from DB but filter by the Model field // definitions and skip others return this; } if (!skipNotFound && definition.readonly) { throw new ValidationError(name, 'cannot set read-only field: ' + name); } if (apiBuilderConfig.flags.enableNullModelFields) { // If the value being set is null or undefined, try to use the default. // But only use the default if it's defined. This allows the value to // stay null if there is no default. if (value === undefined || value === null && definition.default !== undefined) { value = definition.default; } } else { // Deprecated behavior: Use the default value if value is null or undefined. // This means that if default is undefined, then null values will be replaced // with undefined. value = isOK(value) ? value : definition.default; } // do serialization if (!skipNotFound) { value = this._model.set(name, value, this); } if (/date/i.test(definition.type) && typeof value === 'string') { var dt = new Date(value); value = isNaN(dt) ? null : dt; } // validate this field !skipNotFound && this.validateField(name, value); if (current_value !== value) { this._values[name] = value; this._dirty = true; this._dirtyfields[name] = value; this.emit('change:' + name, value, current_value); } } return this; }; /** * @method save * Saves the model instance. * @param {Function} callback Callback passed an Error object (or null if successful) and the * updated model. */ /** * @method update * @alias #save * @inheritDoc */ Instance.prototype.update = Instance.prototype.save = function save(callback) { return this._model.update(this, callback); }; /** * @method remove * Deletes the model instance. * @param {Function} callback Callback passed an Error object (or null if successful), and the * deleted model. */ /** * @method delete * @alias #remove * @inheritDoc */ Instance.prototype.delete = Instance.prototype.remove = function remove(callback) { return this._model.delete(this, callback); }; Instance.PRIMARY_KEY = 'primarykey'; module.exports = Instance;