UNPKG

arrow-orm

Version:

API Builder ORM

1,731 lines (1,631 loc) 48.5 kB
const inspect = Symbol.for('nodejs.util.inspect.custom'); /** * @class APIBuilder.Model */ var util = require('util'), events = require('events'), _ = require('lodash'), LRUCache = require('lru-cache'), pluralize = require('pluralize'), utils = require('./utils'), Instance = require('./instance'), error = require('./error'), { parseValue, prepareQueryOptions } = require('./transform'), ORMError = error.ORMError, ValidationError = error.ValidationError, models = [], ModelClass = new events.EventEmitter(), apiBuilderConfig = require('@axway/api-builder-config'); util.inherits(Model, events.EventEmitter); module.exports = Model; const fieldOptionalDeprecation = util.deprecate(() => {}, 'Using the \'optional\' property in API parameters and Model fields has been deprecated. Use \'required\' instead. See https://docs.axway.com/bundle/api-builder/page/docs/deprecations/index.html#D034', 'D034' ); const modelPrefixDeprecation = util.deprecate(() => {}, 'Creating Models with \'prefix\' has been deprecated. See https://docs.axway.com/bundle/api-builder/page/docs/deprecations/index.html#D024', 'D024' ); function Model(name, definition, skipValidation) { this.name = name; var ModelFields = [ 'name', 'fields', 'connector', '_extended', 'metadata', 'mappings', 'actions', 'disabledActions', 'singular', 'plural', 'autogen', 'generated', 'cache' ]; this.autogen = definition ? definition.autogen === undefined ? true : definition.autogen : true; _.defaults(this, _.pick(definition, ModelFields), { singular: pluralize(name.toLowerCase(), 1), plural: pluralize(name.toLowerCase()), metadata: {}, mappings: {}, actions: VALID_ACTIONS, disabledActions: [], generated: false }); validateActions(this.actions); if (name !== encodeURI(name)) { throw new ValidationError('name', 'Model names cannot contain characters that need to be encoded in a URL: "' + this.name + '"'); } var modelSplit = name.split('/').pop(); if (modelSplit.indexOf('.') >= 0) { throw new ValidationError('name', 'Model names cannot contain periods: "' + this.name + '"'); } if (!skipValidation && !definition) { throw new ORMError('missing required definition'); } if (this.cache === true || (_.isPlainObject(this.cache) && (this.cache.max || this.cache.maxAge))) { this.cache = new LRUCache(this.cache === true ? { max: 500, maxAge: 10 * 60 * 1000 /* 10 minutes */ } : this.cache); } validateFields(this.fields, this.getPrimaryKeyName()); // If no primary key then remove the methods that require one. if (!this.hasPrimaryKey()) { this.deleteAPI = undefined; this.findAndModifyAPI = undefined; this.findByIDAPI = undefined; this.upsertAPI = undefined; this.updateAPI = undefined; } // pull out any method definitions this.methods = definition && _.omit(definition, ModelFields); this._wireMethods(); this.setConnector(definition.connector); if (this.prefix) { modelPrefixDeprecation(); } models.push(this); ModelClass.emit('register', this); } // We leave that in always to work with older node versions // and to prevent a breaking change when working with newers Model.prototype.inspect = function () { return `[object Model:${this.name}]`; }; /* istanbul ignore else */ if (inspect) { // After Node 10.12 inspect should be used as shared symbol /* istanbul ignore next */ Model.prototype[inspect] = function () { return `[object Model:${this.name}]`; }; } /* * suitable for JSON.stringify */ Model.prototype.toJSON = function () { return { name: this.name, fields: this.fields, connector: this.connector }; }; const VALID_ACTIONS = [ 'read', 'write', 'create', 'upsert', 'findAll', 'findByID', 'findById', 'findOne', 'findAndModify', 'count', 'query', 'distinct', 'update', 'delete', 'deleteAll' ]; function validateActions(actions) { if (actions === undefined || actions === null) { return null; } if (!Array.isArray(actions)) { throw new ORMError('actions must be an array with one or more of the following: ' + VALID_ACTIONS.join(', ')); } return actions; } function validateFields(fields, pk) { if (!fields) { return; } Object.keys(fields).forEach(function (name) { if (pk === name) { throw new ValidationError(name, `Model cannot contain a field with the same name as the primary key (${pk})`); } // should all be the same // type: Array // type: 'Array' // type: 'array' var field = fields[name]; field.type = field.type || 'string'; var fname = _.isObject(field.type) ? field.type.name : field.type; field.type = fname.toLowerCase(); if (field.optional !== undefined) { fieldOptionalDeprecation(); } setOptionality(field); }); } function setOptionality(field) { // since we allow both, make sure both are set if (field.required !== undefined) { field.optional = !field.required; } else { // defaults to optional field.required = false; } if (field.optional !== undefined) { field.required = !field.optional; } else { field.optional = true; } } const excludeMethods = [ 'getConnector', 'getMeta', 'setMeta', 'get', 'set', 'keys', 'instance', 'getModels', 'payloadKeys', 'translateKeysForPayload', 'toJSON', 'inspect' ]; var dispatchers = { deleteAll: 'deleteAll', removeAll: 'deleteAll', fetch: 'query', find: 'query', query: 'query', findAll: 'findAll', findByID: 'findByID', findById: 'findById', findOne: 'findOne', delete: 'delete', remove: 'delete', update: 'save', save: 'save', create: 'create', distinct: 'distinct', count: 'count', findAndModify: 'findAndModify', upsert: 'upsert' }; Model.prototype._wireMethods = function _wireMethods() { if (this._promise) { // Bind functions. for (var name in this) { var fn = this[name]; if (typeof fn === 'function') { // we don't have a connector fn, skip it if (this.connector && dispatchers[name] && typeof this[dispatchers[name]] !== 'function') { continue; } if (excludeMethods.indexOf(name) < 0) { utils.createWrappedFunction(this, name); } } } } // Bind method functions. this.methods && Object.keys(this.methods).forEach(name => { const fn = this.methods[name]; this[name] = fn; if (this._promise && typeof fn === 'function') { utils.createWrappedFunction(this, name); } }); }; /** * Returns a list of available Models. * @static * @returns {Array<APIBuilder.Model>} */ Model.getModels = function getModels() { return models; }; /** * Returns a specific Model by name. * @static * @param {String} name Name of the Model. * @returns {APIBuilder.Model} */ Model.getModel = function getModel(name) { for (var c = 0; c < models.length; c++) { if (models[c].name === name) { return models[c]; } } }; /** * Binds a callback to an event. * @static * @param {String} name Event name * @param {Function} cb Callback function to execute. */ Model.on = function on() { ModelClass.on.apply(ModelClass, arguments); }; /** * Unbinds a callback from an event. * @static * @param {String} name Event name * @param {Function} cb Callback function to remove. */ Model.removeListener = function removeListener() { ModelClass.removeListener.apply(ModelClass, arguments); }; /** * Unbinds all event callbacks for the specified event. * @static * @param {String} [name] Event name. If omitted, unbinds all event listeners. */ Model.removeAllListeners = function removeAllListeners() { ModelClass.removeAllListeners.apply(ModelClass, arguments); }; // NOTE: this is internal and only used by the test and should never be called directly Model.clearModels = function clearModels() { models.length = 0; }; /** * Checks for a valid connector and returns it, throwing an ORMError * if a connector is not set. * @param {Boolean} dontRaiseException Set to true to not throw an error if the model is missing a * connector. * @return {APIBuilder.Connector} Connector used by the Model. * @throws {APIBuilder.ORMError} */ Model.prototype.getConnector = function getConnector(dontRaiseException) { if (!this.connector && !dontRaiseException) { throw new ORMError('missing required connector'); } return this.connector; }; /** * Sets the connector for the model. * @param {Object} connector {APIBuilder.Connector} */ Model.prototype.setConnector = function setConnector(connector) { this.connector = connector; // compute the connector name and short name (which is `name`, minus connector name) this.connectorName = typeof connector === 'object' ? connector.name : connector; this.shortName = this.name; if (this.connectorName !== undefined) { // unfortunately, unit tests allow models to be defined without connector const connectorPrefix = `${this.connectorName}/`; if (this.name.startsWith(connectorPrefix)) { this.shortName = this.name.replace(connectorPrefix, ''); } } }; Model.prototype.endRequest = function () { if (this.connector) { this.connector.endRequest(); this.connector.model = null; } this.request = null; this.response = null; this.removeAllListeners(); }; Model.prototype.createRequest = function createRequest(request, response) { var connector = this.getConnector().createRequest(request, response); var model = Object.create(this); model.request = request; model.response = response; model.connector = connector; model.connector.model = model; model._promise = true; model._wireMethods(); return model; }; /** * @method define * @static * Extends a new Model object. * @param {String} name Name of the new Model. * @param {APIBuilderModelDefinition} definition Model definition object. * @return {APIBuilder.Model} * @throws {APIBuilder.ValidationError} Using a reserved key name in the definition object. * @throws {APIBuilder.ORMError} Missing definition object. */ /** * @method extend * @alias #static-define * @inheritDoc */ Model.extend = function define(name, definition) { return new Model(name, definition); }; Model.define = util.deprecate(function define(name, definition) { return new Model(name, definition); }, 'Model.define is deprecated. See: https://docs.axway.com/bundle/api-builder/page/docs/deprecations/index.html#D016', 'D016'); function extendOrReduce(instance, name, definition, extend) { var model; if (typeof name === 'string') { model = new Model(name, definition, true); } else if (name instanceof Model) { model = name; } else { throw new ORMError('invalid argument passed to extend. Must either be a model class or model definition'); } model.metadata = _.merge(_.cloneDeep(instance.metadata), model.metadata); model.mappings = _.merge(_.cloneDeep(instance.mappings), model.mappings); if (model.fields) { var fields = instance.fields; if (extend) { for (var key in model.fields) { if (model.fields.hasOwnProperty(key)) { model.fields[key]._own = true; } } model.fields = mergeFields(model.fields, fields); } else { // allow the extending model to just specify the fields keys and pull out // the actual values from the extended model field (or merge them) Object.keys(model.fields).forEach(function (name) { if (name in fields) { model.fields[name] = _.merge(_.cloneDeep(fields[name]), model.fields[name]); } else { model.fields[name]._own = true; } }); } } else { model.fields = _.cloneDeep(instance.fields); } model.connector = model.connector || instance.connector; model.methods = _.merge(_.cloneDeep(instance.methods), model.methods); model.autogen = instance.autogen; model.actions = (definition && definition.actions) ? definition.actions : instance.actions; model.disabledActions = (definition && definition.disabledActions) ? definition.disabledActions : instance.disabledActions; model._extended = !!extend; model._supermodel = instance.name; model._parent = instance; // If no primary key then remove the methods that require one. if (!model.hasPrimaryKey()) { model.deleteAPI = undefined; model.findAndModifyAPI = undefined; model.findByIDAPI = undefined; model.upsertAPI = undefined; model.updateAPI = undefined; } model._wireMethods(); return model; } /** * Creates a new Model which extends the current Model object. The fields specified in the * definition object will be merged with the ones defined in the current Model object. * @param {String} name Name of the new Model. * @param {APIBuilderModelDefinition} definition Model definition object. * @return {APIBuilder.Model} * @throws {APIBuilder.ValidationError} Using a reserved key name in the definition object. * @throws {APIBuilder.ORMError} Model is not valid or missing the definition object. */ Model.prototype.extend = util.deprecate(function extend(name, definition) { return extendOrReduce(this, name, definition, true); }, 'Extending a model instance is deprecated. See: https://docs.axway.com/bundle/api-builder/page/docs/deprecations/index.html#D025', 'D025'); /** * Creates a new Model which reduces fields from the current Model class. * Only the fields specified in the definition object that are found in the current Model object * will be used. * @param {String} name Name of the new Model. * @param {APIBuilderModelDefinition} definition Model definition object. * @return {APIBuilder.Model} * @throws {APIBuilder.ValidationError} Using a reserved key name in the definition object. * @throws {APIBuilder.ORMError} Model is not valid or missing the definition object. */ Model.prototype.reduce = util.deprecate(function extend(name, definition) { return extendOrReduce(this, name, definition, false); }, 'Model.reduce is deprecated. See: https://docs.axway.com/bundle/api-builder/page/docs/deprecations/index.html#D017', 'D017'); /** * Creates an instance of this Model. * @param {Object} values Attributes 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.ORMError} Model class is missing fields. * @throws {APIBuilder.ValidationError} Missing required field or field failed validation. */ Model.prototype.instance = function instance(values, skipNotFound) { if (typeof values === 'string') { throw new ORMError('The first argument to model.instance() cannot be a string: ' + values); } return new Instance(this, values, skipNotFound); }; function resolveOptionality(field, param) { setOptionality(field); if (field.default !== undefined) { param.default = field.default; } param.required = field.required; param.optional = field.optional; return param; } /** * Documents the create method for API usage. * @return {Object} */ Model.prototype.createAPI = function createAPI() { var model = this; var parameters = {}; Object.keys(model.fields).forEach(function (k) { var field = model.fields[k]; parameters[k] = resolveOptionality(field, { description: field.description || k + ' field', dataType: field.type, type: 'body' }); }); // If primary key is not auto-generated primary key then allow insert (if it's not set then // default to autogen true). if (this.hasPrimaryKey() && !this.hasAutogeneratedPrimaryKey()) { const pkType = this.getPrimaryKeyType(); parameters[this.getPrimaryKeyName()] = { required: true, optional: false, description: 'Primary key field', dataType: pkType, type: 'body' }; } let headers = {}; if (this.hasPrimaryKey()) { headers = { Location: { description: 'The URL to the newly created instance.', type: 'string' } }; } return { generated: true, actionGroup: 'write', method: 'POST', nickname: 'Create', description: this.createDescription || 'Create a ' + this.singular, parameters: parameters, responses: { 201: { description: 'The create succeeded.', headers } }, action: function createAction(req, resp, next) { try { req.model.create(req.params, next); } catch (E) { return next(E); } } }; }; /** * Creates a new Model or Collection object. * @param {Array.<Object>|Object} [values] Attributes to set on the new model(s). * @param {Function} callback Callback passed an Error object (or null if successful), and the new * model or collection. * @throws {Error} * @returns {Void} */ Model.prototype.create = function create(values, callback) { if (_.isFunction(values)) { callback = values; values = {}; } // if we have an array of values, create all the users in one shot // in the case of a DB, you want to send them in batch if (Array.isArray(values)) { return this.getConnector().createMany(this, values.map(function (v) { return this.instance(v, false).toPayload(); }.bind(this)), callback); } try { // we need to create an instance to run the validator logic if any const instance = this.instance(values, false); const payload = instance.toPayload(); if (this.hasPrimaryKey()) { const pk = this.getPrimaryKeyName(); if (values[pk]) { payload[pk] = values[pk]; } } if (this.cache && this.hasPrimaryKey()) { let next = callback; callback = (err, result) => { if (result && result.getPrimaryKey) { this.cache.del('count'); this.cache.del('findAll'); this.cache.set(result.getPrimaryKey(), result); } return next && next(err, result); }; } this.getConnector().create(this, payload, callback); } catch (E) { if (E instanceof ORMError) { if (callback) { return callback(E); } } throw E; } }; /** * Documents the update method for API usage. * @returns {Object} */ Model.prototype.updateAPI = function updateAPI() { const parameters = {}; const pkName = this.getPrimaryKeyName(); const pkType = this.getPrimaryKeyType(); parameters[pkName] = { required: true, optional: false, description: 'The ' + this.singular + ' ID', dataType: pkType, type: 'path' }; Object.entries(this.fields).forEach(([ key, field ]) => { parameters[key] = resolveOptionality(field, { description: field.description || key + ' field', dataType: field.type, type: 'body' }); parameters[key].required = false; parameters[key].optional = true; }); return { generated: true, path: `./:${pkName}`, actionGroup: 'write', method: 'PUT', nickname: 'Update', description: this.updateDescription || 'Update a specific ' + this.singular, parameters: parameters, dependsOnAll: [ 'save' ], responses: { 204: { description: 'The update succeeded.' } }, action: function updateAction(req, resp, next) { req.model.fetch( req.params[pkName], resp.createCallback(null, function putSuccessCallback(model, cb) { try { model.set(req.params); model.save(cb); } catch (E) { return next(E); } }, next) ); } }; }; /** * @method save * Updates a Model instance. * @param {APIBuilder.Instance} instance Model instance to update. * @param {Function} callback Callback passed an Error object (or null if successful) and the * updated model. */ /** * @method update * @alias #save * @inheritDoc */ Model.prototype.update = Model.prototype.save = function save(instance, callback) { if (instance.isDeleted && instance.isDeleted()) { callback && callback(new ORMError('instance has already been deleted')); return; } if (!(instance instanceof Instance) || instance.isUnsaved()) { if (!(instance instanceof Instance)) { // do we have an id? then we can look up the instance directly! const pkName = this.getPrimaryKeyName(); if (instance.hasOwnProperty(pkName)) { return this.findByID( instance[pkName], function findByIDCallback(err, _instance) { if (err) { return callback && callback(err); } if (!_instance) { return callback && callback(new ORMError(`trying to update, couldn't find record with primary key: ${instance.id} for ${this.name}`)); } try { _instance.set(instance); } catch (err) { return callback && callback(err); } this.update(_instance, callback); }.bind(this) ); } // otherwise, we can try instantiating the instance directly right here. try { this.instance(instance, false); } catch (err) { return callback && callback(err); } } var cache = this.cache; this.getConnector().save(this, instance, function saveCallback(err, result) { if (err) { return callback && callback(err); } if (result) { result._dirty = false; result.emit('save'); if (cache) { cache.del('findAll'); cache.set(result.getPrimaryKey(), result); } } else if (cache) { cache.reset(); } callback && callback(null, result); }); } else { return callback && callback(null, instance); } }; /** * Documents the delete method for API usage. * @returns {Object} */ Model.prototype.deleteAPI = function deleteAPI() { const pkName = this.getPrimaryKeyName(); const pkType = this.getPrimaryKeyType(); return { generated: true, path: `./:${pkName}`, actionGroup: 'write', method: 'DELETE', nickname: 'Delete One', description: this.deleteDescription || 'Delete a specific ' + this.singular, parameters: { [pkName]: { description: 'The ' + this.singular + ' ID', optional: false, required: true, type: 'path', dataType: pkType } }, responses: { 204: { description: 'The delete succeeded.' } }, action: function deleteAction(req, resp, next) { try { req.model.remove(req.params[pkName], next); } catch (E) { return next(E); } } }; }; /** * @method delete * Deletes the model instance. * @param {APIBuilder.instance} instance Model instance. * @param {Function} callback Callback passed an Error object * @return {Void} */ /** * @method remove * @alias delete * @inheritDoc */ Model.prototype.delete = Model.prototype.remove = function remove(instance, callback) { if (typeof instance === 'object' && instance._deleted) { return callback && callback(new ORMError('instance has already been deleted')); } // quick validation if (_.isFunction(instance)) { callback = instance; instance = undefined; } // array of ids means multiple delete if (Array.isArray(instance)) { return this.getConnector().deleteMany(this, instance, callback); } // if we specified a non-Instance, we need to findByID to get the instance // and then delete it if (typeof instance !== 'object') { return this.findByID(instance, function findByIDCallback(err, _instance) { if (err) { return callback(err); } if (!_instance) { return callback(new ORMError(`trying to remove, couldn't find record with primary key: ${instance} for ${this.name}`)); } this.remove(_instance, callback); }.bind(this)); } var cache = this.cache; this.getConnector().delete(this, instance, function deleteCallback(err, result) { if (err) { return callback && callback(err); } if (result) { result._deleted = true; result.emit('delete'); if (cache) { cache.del('count'); cache.del('findAll'); cache.set(result.getPrimaryKey(), result); } } else if (cache) { cache.reset(); } callback && callback(null, result); }); }; /** * Documents the delete all method for API usage. * @returns {Object} */ Model.prototype.deleteAllAPI = function deleteAllAPI() { return { generated: true, method: 'DELETE', actionGroup: 'write', nickname: 'Delete All', description: this.deleteAllDescription || 'Deletes all ' + this.plural, responses: { 204: { description: 'The delete succeeded.' } }, action: function deleteAction(req, resp, next) { req.model.deleteAll(resp.createCallback( null, function delAllSuccessCallback(count, cb) { if (count !== undefined) { resp.noContent(cb); } else { resp.notFound(cb); } }, next )); } }; }; /** * @method removeAll * Deletes all the data records. * @param {Function} callback Callback passed an Error object (or null if successful), and the * deleted models. */ /** * @method deleteAll * @alias #removeAll * @inheritDoc */ Model.prototype.deleteAll = Model.prototype.removeAll = function removeAll(callback) { var next = callback, cache = this.cache; if (cache) { next = function () { cache.reset(); return callback && callback.apply(this, arguments); }; } this.getConnector().deleteAll(this, next); }; /** * Documents the distinct method for API usage. * @returns {Object} */ Model.prototype.distinctAPI = function distinctAPI() { var result = this.queryAPI(); // delete unused options delete result.parameters.sel; delete result.parameters.unsel; result.nickname = 'Distinct'; result.path = './distinct/:field'; result.description = this.distinctDescription || 'Find distinct ' + this.plural; result.dependsOnAny = [ 'query' ]; result.responses = { 200: { description: 'The request succeeded, and the results are available.', schema: { type: 'integer' } } }; result.parameters.field = { type: 'path', optional: false, required: true, dataType: 'string', description: 'The field name that must be distinct.' }; result.responses[200] = { description: 'Distinct fields response.', schema: { type: 'array', items: { type: 'string' }, uniqueItems: true } }; result.action = function distinctAction(req, resp, next) { var field = req.params.field; delete req.params.field; resp.stream(req.model.distinct, field, req.params, next); }; return result; }; /** * Finds unique values using the provided field. * @param {String} field The field that must be distinct. * @param {APIBuilderQueryOptions} options Query options. * @param {Function} callback Callback passed an Error object (or null if successful) and the * distinct models. * @throws {Error} Failed to parse query options. * @returns {Void} */ Model.prototype.distinct = function distinct(field, options, callback) { try { options = prepareQueryOptions(this, options); } catch (E) { return callback(E); } this.getConnector().distinct(this, field, options, callback); }; /** * Documents the findByID method for API usage. * @return {Object} */ Model.prototype.findByIDAPI = function findByIDAPI() { const pkName = this.getPrimaryKeyName(); const pkType = this.getPrimaryKeyType(); return { generated: true, path: `./:${pkName}`, actionGroup: 'read', nickname: 'Find By ID', method: 'GET', description: this.findByIDDescription || this.findByIdDescription || this.findOneDescription || `Find one ${this.singular} by ID`, parameters: { [pkName]: { description: `The ${this.singular} ID`, optional: false, required: true, type: 'path', dataType: pkType } }, responses: { 200: { description: 'The find succeeded, and the results are available.', schema: { $ref: `#/definitions/${this.name.replace(/\//g, '_')}-full` } } }, action: function findByIDAction(req, resp, next) { try { resp.stream(req.model.findByID, req.params[pkName], next); } catch (E) { return next(E); } } }; }; /** * Warning: This method is being deprecated and should not be used in your implementation. * Finds a model instance using the primary key. * @returns {Void} */ Model.prototype.findOne = function () { // Get connector's logger or fallbacks to console.warn if it not exists var connector = this.getConnector(); let log; if (connector.logger) { log = connector.logger.warn.bind(connector.logger); } else { // eslint-disable-next-line no-console log = console.warn.bind(console); } log('The findOne method of a model is deprecated and will be removed in an upcoming major release. Please use findById instead.'); // Fallback to findByID return this.findByID.apply(this, arguments); }; /** * Finds a model instance using the primary key. * @param {String} id ID of the model to find. * @param {Function} callback Callback passed an Error object (or null if successful) and the found * model. * @returns {Void} */ Model.prototype.findByID = Model.prototype.findById = function findByID(id, callback) { try { var next = callback, cache = this.cache; if (!Array.isArray(id) && cache) { var cached = cache.get(id); if (cached) { return callback(null, cached); } next = function (err, result) { if (result && result.getPrimaryKey) { cache.set(result.getPrimaryKey(), result); } callback.apply(this, arguments); }; } var connector = this.getConnector(); if (connector.findByID) { connector[Array.isArray(id) ? 'findManyByID' : 'findByID'](this, id, next); } else if (connector.findById) { connector[Array.isArray(id) ? 'findManyById' : 'findById'](this, id, next); } else { connector[Array.isArray(id) ? 'findOneMany' : 'findOne'](this, id, next); } } catch (E) { return callback(E); } }; const queryParameters = { limit: { type: 'query', optional: true, required: false, dataType: 'number', default: 10, description: 'The number of records to fetch. The value must be greater than 0, and no greater than 1000.' }, skip: { type: 'query', optional: true, required: false, dataType: 'number', default: 0, description: 'The number of records to skip. The value must not be less than 0.' }, where: { type: 'query', optional: true, required: false, dataType: 'object', description: 'Constrains values for fields. The value should be encoded JSON.' }, order: { type: 'query', optional: true, required: false, dataType: 'object', description: 'A dictionary of one or more fields specifying sorting of results. In general, you can sort based on any predefined field that you can query using the where operator, as well as on custom fields. The value should be encoded JSON.' }, sel: { type: 'query', optional: true, required: false, dataType: 'object', description: 'Selects which fields to return from the query. Others are excluded. The value should be encoded JSON.' }, unsel: { type: 'query', optional: true, required: false, dataType: 'object', description: 'Selects which fields to not return from the query. Others are included. The value should be encoded JSON.' } }; /** * Documents the query method for API usage. * @returns {Object} */ Model.prototype.findAndModifyAPI = function findAndModifyAPI() { var model = this; const parameters = { ...queryParameters }; Object.keys(model.fields).forEach(function (k) { var field = model.fields[k]; parameters[k] = resolveOptionality(field, { description: field.description || k + ' field', optional: field.optional, required: field.required, type: 'body', dataType: field.type }); }); return { generated: true, path: './findAndModify', actionGroup: 'write', method: 'PUT', nickname: 'Find and Modify', dependsOnAll: [ 'query', 'create', 'save' ], description: this.findAndModifyDescription || `Modifies a single ${this.singular}. Although the query may match multiple ${this.plural}, only the first one will be modified.`, parameters: parameters, responses: { 204: { description: 'The find and modify succeeded.' } }, action: function queryAction(req, resp, next) { try { resp.stream(req.model.findAndModify, req.query, req.body, next); } catch (E) { return next(E); } } }; }; /** * Modifies a single instance. * Although the query may match multiple instances, only the first one will be modified. * * @param {APIBuilderQueryOptions} options Query options. * @param {Object} doc Attributes to modify. * @param {Object} [args] Optional parameters. * @param {Boolean} [args.new=false] Set to `true` to return the new model instead of the original * model. * @param {Boolean} [args.upsert=false] Set to `true` to allow the method to create a new model. * @param {Function} callback Callback passed an Error object (or null if successful) and the * modified model. * @throws {Error} Failed to parse query options. */ Model.prototype.findAndModify = function findAndModify(options, doc, args, callback) { try { options = prepareQueryOptions(this, options); var next = callback, cache = this.cache; if (cache) { next = function () { cache.reset(); return callback && callback.apply(this, arguments); }; } this.getConnector().findAndModify(this, options, doc, args, next); } catch (E) { callback(E); } }; /** * Documents the findAll method for API usage. * @returns {Object} */ Model.prototype.findAllAPI = function findAllAPI() { return { generated: true, nickname: 'Find All', description: this.findAllDescription || 'Find all ' + this.plural, actionGroup: 'read', method: 'GET', dependsOnAny: [ 'findAll', 'query' ], responses: { 200: { description: 'The find all succeeded, and the results are available.', schema: { type: 'array', items: { $ref: `#/definitions/${this.name.replace(/\//g, '_')}${this.hasPrimaryKey() ? '-full' : ''}` } } } }, action: function findAllAction(req, resp, next) { try { resp.stream(req.model.findAll, next); } catch (E) { return next(E); } } }; }; /** * Finds all model instances. A maximum of 1000 models are returned. * @param {Function} callback Callback passed an Error object (or null if successful) and the * models. * @returns {Void} */ Model.prototype.findAll = function findAll(callback) { try { var next = callback, cache = this.cache; if (cache) { var cached = cache.get('findAll'); if (cached) { return callback(null, cached); } next = function (err, results) { if (results) { cache.set('findAll', results); if (Array.isArray(results)) { for (var i = 0; i < results.length; i++) { var result = results[i]; if (result.getPrimaryKey()) { cache.set(result.getPrimaryKey(), result); } } } } // FIX ME!!!! (do not call callback inside try) - fix all the others too callback.apply(this, arguments); }; } if (this.getConnector().findAll) { return this.getConnector().findAll(this, next); } else { return this.query({ limit: 1000 }, next); } } catch (E) { callback(E); } }; /** * Documents the count method for API usage. * @returns {Object} */ Model.prototype.countAPI = function countAPI() { var result = this.queryAPI(); result.nickname = 'Count'; result.path = './count'; result.description = this.countDescription || 'Count ' + this.plural; result.dependsOnAny = [ 'query' ]; result.parameters = { where: result.parameters.where }; result.responses = { 200: { description: 'The count succeeded, and the results are available.', schema: { type: 'integer' } } }; result.action = function countAction(req, resp, next) { resp.stream(req.model.count, req.params, function (err, results) { var count = 0; if (Array.isArray(results)) { count = results.length; } else if (typeof(results) === 'number') { count = results; } return next(null, count); }); }; return result; }; /** * Gets a count of records. * @param {APIBuilderQueryOptions} options Query options. * @param {Function} callback Callback passed an Error object (or null if successful) and the * number of models found. * @returns {Void} */ Model.prototype.count = function count(options, callback) { try { if (_.isFunction(options) && !callback) { callback = options; options = {}; } else { options = options || {}; } options = prepareQueryOptions(this, options, { where: 1 }); var next = callback, cache = this.cache; if (cache && (!options || Object.keys(options).length === 0)) { var cached = cache.get('count'); if (cached) { return callback(null, cached); } next = function (err, results) { if (results) { cache.set('count', results); } callback.apply(this, arguments); }; } this.getConnector().count(this, options, next); } catch (E) { callback(E); } }; /** * Documents the upsert method for API usage. * @returns {Object} */ Model.prototype.upsertAPI = function upsertAPI() { const result = this.createAPI(); const pkName = this.getPrimaryKeyName(); const pkType = this.getPrimaryKeyType(); result.nickname = 'Upsert'; result.path = './upsert'; result.actionGroup = 'write'; result.description = this.upsertDescription || 'Create or update a ' + this.singular; result.parameters[pkName] = { description: 'The ' + this.singular + ' ID', type: 'body', optional: true, required: false, dataType: pkType }; result.dependsOnAll = [ 'save', 'create' ]; result.responses = { 201: { description: 'The upsert succeeded, and resulted in an insert.', headers: { Location: { description: 'The URL to the newly created instance.', type: 'string' } } }, 204: { description: 'The upsert succeeded, and resulted in an update.' } }; result.action = function upsertAction(req, resp, next) { try { req.model.upsert(req.params[pkName], req.params, next); } catch (E) { return next(E); } }; return result; }; /** * Updates a model or creates the model if it cannot be found. * @param {String} id ID of the model to update. * @param {Object} doc Model attributes to set. * @param {Function} callback Callback passed an Error object (or null if successful) and the * updated or new model. */ Model.prototype.upsert = function upsert(id, doc, callback) { // we need to create an instance to run the validator logic if any try { var instance = this.instance(doc, false); var payload = instance.toPayload(); var pk = this.getConnector().getPrimaryKeyColumnName(this); if (doc[pk]) { payload[pk] = doc[pk]; } var next = callback, cache = this.cache; if (cache) { next = function () { cache.del('count'); cache.del('findAll'); if (id) { cache.del(id); } return callback && callback.apply(this, arguments); }; } this.getConnector().upsert(this, id, payload, next); } catch (E) { callback(E); } }; /** * Documents the query method for API usage. * @returns {Object} */ Model.prototype.queryAPI = function queryAPI() { return { nickname: 'Query', generated: true, path: './query', method: 'GET', description: this.queryDescription || 'Query ' + this.plural, actionGroup: 'read', parameters: { ...queryParameters }, responses: { 200: { description: 'The query succeeded, and the results are available.', schema: { type: 'array', items: { $ref: `#/definitions/${this.name.replace(/\//g, '_')}${this.hasPrimaryKey() ? '-full' : ''}` } } } }, action: function queryAction(req, resp, next) { try { resp.stream(req.model.query, req.params, next); } catch (E) { return next(E); } } }; }; /** * Queries for particular model records. * @param {APIBuilderQueryOptions} options Query options. * @param {Function} callback Callback passed an Error object (or null if successful) and the model * records. * @throws {Error} Failed to parse query options. * @returns {Void} */ Model.prototype.query = function query(options, callback) { if (typeof options === 'function') { callback = options; options = {}; } try { options = prepareQueryOptions(this, options); this.getConnector().query( this, options, ((options && options.limit === 1) ? function (err, collection) { if (err) { return callback(err); } // if we asked for limit 1 record on query, // just return an object instead of an array if (collection) { var instance = collection[0]; return callback(null, instance); } return callback(null, collection); } : callback) ); } catch (E) { return callback(E); } }; /** * @method find * Finds a particular model record or records. * @param {Object/String} [options] Key-value pairs or ID of the model to find. If omitted, performs * a findAll operation. * @param {Function} callback Callback passed an Error object (or null if successful) and the model * record(s). * @throws {Error} Wrong number of arguments. * @returns {Void} */ /** * @method fetch * @alias #find * @inheritDoc */ Model.prototype.fetch = Model.prototype.find = function find() { switch (arguments.length) { case 1: { return this.findAll(arguments[0]); } case 2: { var options = arguments[0], callback = arguments[1]; if (_.isObject(options)) { return this.query(options, callback); } return this.findByID(options, callback); } default: { throw new Error('wrong number of parameters passed'); } } }; /** * Returns model metadata. * @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} */ Model.prototype.getMeta = function getMeta(key, def) { var m1 = this._connector && this.metadata[this._connector]; if (m1 && m1[key]) { return m1[key]; } var m2 = this.getConnector() && this.metadata[this.getConnector().name]; if (m2 && m2[key]) { return m2[key]; } var m3 = this.metadata; if (m3 && m3[key]) { return m3[key]; } return def || null; }; /** * Sets metadata for the model. * @param {String} key Key name. * @param {Any} value Value to set. */ Model.prototype.setMeta = function setMeta(key, value) { var connector = this.getConnector(); var entry = this.metadata[connector.name]; if (!entry) { entry = this.metadata[connector.name] = {}; } entry[key] = value; }; /** * Returns the field keys for the Model. * @returns {Array<String>} */ Model.prototype.keys = function keys() { return Object.keys(this.fields); }; /** * Returns the payload keys (model field names) for the Model. * @return {Array<String>} */ Model.prototype.payloadKeys = function keys() { var retVal = []; for (var key in this.fields) { if (this.fields.hasOwnProperty(key) && !this.fields[key].custom) { retVal.push(this.fields[key].name || key); } } return retVal; }; /** * Returns an object containing keys translated from field keys to payload keys. This is useful for * translating objects like "where", "order", "sel" and "unsel" to their proper named underlying * payload objects. * @param {(string|object)} obj Object containing keys translated from field keys * @returns {Object} */ Model.prototype.translateKeysForPayload = function translateKeysForPayload(obj) { if (obj && typeof obj === 'string') { try { obj = JSON.parse(obj); } catch (e) { /* do nothing */ } } if (!obj || typeof(obj) !== 'object') { return obj; } const keys = Object.keys(obj); if (!keys.length) { return obj; } const translation = {}; for (const fieldKey in this.fields) { if (this.fields.hasOwnProperty(fieldKey)) { const field = this.fields[fieldKey]; // cache case-insensitive translation[fieldKey.toLowerCase()] = field.name || fieldKey; } } const retVal = {}; for (const key of keys) { // For legacy purposes, also parse the value of the field. // this isn't going to work most of the time // and also has a chance for going wrong if the value is a string // and is meant to be one. let value = obj[key]; if (this.fields.hasOwnProperty(key)) { value = parseValue(obj[key], this.fields[key].type); } // get the translation being case-insensitive or use the original // key if we don't find one in the model retVal[translation[key.toLowerCase()] || key] = value; } return retVal; }; /** * Checks to see if the specified key in the object is a function * and converts it to a Function if it was converted to a string. * This is a helper function for the {@link #set} and {@link #get} methods. * @static * @param {Object} obj Object to check. * @param {String} key Key to check. * @returns {Function/String} If the key is not a function, returns the string, else returns the * function. */ Model.toFunction = function (obj, key) { // if this is a string, return var fn = obj[key]; if (fn && typeof fn === 'string' && /^function/.test(fn.trim())) { var vm = require('vm'); var code = 'var f = (' + fn + '); f'; fn = vm.runInThisContext(code, { timeout: 10000 }); // re-write it so we only need to remap once obj[key] = fn; } return fn; }; /** * Processes the field value before its returned to the client. * This function executes the field's `get` method defined in either the Model's {@link #mappings} * property or the model definition object. * @param {String} name Field name. * @param {Any} value Value of the field. * @param {APIBuilder.Instance} instance Model instance. * @returns {Any} Value you want to return to the client. */ Model.prototype.get = function get(name, value, instance) { var mapper = this.mappings[name] || this.fields[name]; if (mapper) { var fn = Model.toFunction(mapper, 'get'); if (fn) { return fn(value, name, instance); } } return value; }; /** * Processes the field value before its returned to the connector. * This function executes the field's `set` method defined in either the Model's {@link #mappings} * property or the model definition object. * @param {String} name Field name. * @param {Any} value Value of the field. * @param {APIBuilder.Instance} instance Model instance. * @returns {Any} Value you want to return to the connector. */ Model.prototype.set = function set(name, value, instance) { var mapper = this.mappings[name] || this.fields[name]; if (mapper) { var fn = Model.toFunction(mapper, 'set'); if (fn) { return fn(value, name, instance); } } return value; }; /** * Check if the model has a primary key. * @returns {boolean} Flag to indicate if this model has a primary key. */ Model.prototype.hasPrimaryKey = function hasPrimaryKey() { return apiBuilderConfig.flags.enableModelsWithNoPrimaryKey ? !(this.metadata && this.metadata.primarykey === null) : true; }; /** * Check if the model has am autogenerated primary key. * @returns {boolean} Flag to indicate if this model has an autogenerated primary key. */ Model.prototype.hasAutogeneratedPrimaryKey = function hasAutogeneratedPrimaryKey() { return this.hasPrimaryKey() && !(this.metadata && this.metadata.primaryKeyDetails && this.metadata.primaryKeyDetails.hasOwnProperty('autogenerated') && !this.metadata.primaryKeyDetails.autogenerated); }; /** * Returns model primarykey name. * @returns {String} Primary key name value. */ Model.prototype.getPrimaryKeyName = function getPrimaryKeyName() { if (!this.hasPrimaryKey()) { return null; } // returns the model's primary key column name or the default name 'id' return (this.metadata && this.metadata.primarykey) || 'id'; }; /** * Returns model primarykey type. * @returns {String} Primary key type value. */ Model.prototype.getPrimaryKeyType = function getPrimaryKeyType() { // returns the model's primary key proper type or the default type 'string' let pkType = 'string'; // Note that PK type support was added in RDPP-4532. mysql already had some type support // but return type as an object (e.g. `String`). Full PK type support rolled out to all // connectors should see the `type` metadata attribute, and that type should be a string. // If the connector does not report this, then fall back to the default `pkType`. const supportsPKType = this.metadata && this.metadata.primarykey && this.metadata.primaryKeyDetails && this.metadata.primaryKeyDetails.type && typeof this.metadata.primaryKeyDetails.type === 'string'; if (apiBuilderConfig.flags.usePrimaryKeyType && supportsPKType) { pkType = this.metadata.primaryKeyDetails.type; } return pkType; }; /* * Merges the fields, taking in to consideration renamed fields. * @param definedFields * @param inheritedFields * @returns {*} */ function mergeFields(definedFields, inheritedFields) { var retVal = _.merge({}, inheritedFields, definedFields); for (var key in definedFields) { if (definedFields.hasOwnProperty(key)) { var definedField = definedFields[key]; if (definedField.name && definedField.name !== key && inheritedFields[definedField.name]) { delete retVal[definedField.name]; } } } return retVal; }