arrow-orm
Version:
API Builder ORM
870 lines (800 loc) • 26.8 kB
JavaScript
const inspect = Symbol.for('nodejs.util.inspect.custom');
/**
* @class APIBuilder.Connector
*/
var util = require('util'),
events = require('events'),
_ = require('lodash'),
async = require('async'),
fs = require('fs'),
path = require('path'),
error = require('./error'),
utils = require('./utils'),
Collection = require('./collection'),
ORMError = error.ORMError,
Capabilities = require('./connector/capabilities'),
connectors = [],
ConnectorClass = new events.EventEmitter(),
delegateMethods = [
'create', 'save', 'update', 'upsert', 'findAndModify', 'findByID', 'findById', 'findOne', 'findOneMany',
'findManyByID', 'findManyById', 'findAll', 'find', 'query', 'delete', 'deleteAll', 'distinct', 'upsert',
'count', 'createMany', 'deleteMany'
];
util.inherits(Connector, events.EventEmitter);
module.exports = Connector;
function Connector(impl, config) {
// Recursively merge `impl` properties into `this`, and specifically merge in
// `constructor` if provided.
impl && mergeConnectorWithConstructor(this, impl);
// setup our methods to delegate through...
var methods = impl && _.pick(impl, delegateMethods);
var self = this;
if (methods) {
Object.keys(methods).forEach(function connectorMethodIterator(method) {
var fn = methods[method];
if (typeof(fn) === 'function') {
// if a function, delegate through our wrapper
wrapDelegate(self, method, fn);
} else {
// just assign
self[method] = fn;
}
});
}
// incoming constructor config should overwrite implementation
this.config = _.merge(impl && impl.config || {}, config);
if (!this.pkginfo && this.config && this.config.pkginfo) {
this.pkginfo = this.config.pkginfo;
}
// pull in these into the connector if we don't have them but we have them in our
// package
[ 'description', 'version', 'name', 'author' ].forEach(function (k) {
if (!self[k] && self.pkginfo && (k in self.pkginfo)) {
self[k] = self.pkginfo[k];
}
});
// re-map connect/disconnect for lifecycle
this._connect = impl && impl.connect;
this._disconnect = impl && impl.disconnect;
this.connected = false;
// if we provided a constructor in our impl, use it
if (this.constructor && this.constructor !== Connector && !this.constructor.super_) {
this.constructor.call(this);
Connector.constructor.call(this);
}
if (!this.name) {
throw new ORMError('connector is required to have a name');
}
// map login required
if (impl && _.isFunction(impl.loginRequired)) {
this.delegatedloginRequired = impl.loginRequired;
}
/*
* The arrow-orm needs another look to properly handle both findById and findOne apis.
* Currently when a connector version with the old findOne interface is used, the "Find by id"
* won't show up in the list of APIs for a model. So we need to manually add it.
*/
this.on('init-model', function (model) {
if (!this.findByID && !this.findById && this.findOne) {
model.findOneAPI = model.findByIDAPI;
}
}.bind(this));
connectors.push(this);
ConnectorClass.emit('register', this);
}
/*
* suitable for JSON.stringify
*/
Connector.prototype.toJSON = function () {
return {
name: this.name
};
};
// We leave that in always to work with older node versions
// and to prevent a breaking change when working with newers
Connector.prototype.inspect = function () {
return `[object Connector:${this.name}]`;
};
/* istanbul ignore else */
if (inspect) {
// After Node 10.12 inspect should be used as shared symbol
/* istanbul ignore next */
Connector.prototype[inspect] = function () {
return `[object Connector:${this.name}]`;
};
}
Connector.prototype.loginRequired = function (callback) {
if (this.delegatedloginRequired && this._promise) {
return this.delegatedloginRequired(this.request, callback);
} else {
return callback(null, false);
}
};
function wrapDelegate(connector, method, delegate_) {
connector['delegated' + method] = delegate_;
connector[method] = function methodWrapper() {
var delegate = this['delegated' + method];
// check if we're connected and if so, go ahead and delegate
if (this.connected && !this.loginRequired) {
return delegate.apply(this, arguments);
} else {
// we're not connected, call through to connect before continuing
var callback = arguments[arguments.length - 1],
args = arguments,
self = this;
function connect () {
if (connector.connect) {
connector.connect(function (err) {
if (err) {
return callback && callback(err);
}
delegate.apply(self, args);
});
} else {
delegate.apply(self, args);
}
}
if (self.loginRequired) {
self.loginRequired(function (err, required) {
if (err) {
return callback(err);
}
if (required) {
self.login(self.request, self.response, function (err) {
if (err) {
return callback(err);
}
connect();
});
} else {
connect();
}
});
} else {
connect();
}
}
};
}
/*
* Defines various connector capabilities that you can enable to progressively be guided through
* connector development.
*/
Connector.Capabilities = Capabilities.Capabilities;
/*
* Generates tests based on the defined capabilities of the connector, and based upon minimal
* configuration.
*/
Connector.generateTests = Capabilities.generateTests;
/**
* Returns the active APIBuilder Connectors.
* @static
* @returns {Array<APIBuilder.Connector>}
*/
Connector.getConnectors = function getConnectors() {
return connectors;
};
/**
* Binds a callback to an event.
* @static
* @param {String} name Event name
* @param {Function} cb Callback function to execute.
*/
Connector.on = function on() {
ConnectorClass.on.apply(ConnectorClass, arguments);
};
/**
* Unbinds a callback from an event.
* @static
* @param {String} name Event name
* @param {Function} cb Callback function to remove.
*/
Connector.removeListener = function removeListener() {
ConnectorClass.removeListener.apply(ConnectorClass, arguments);
};
/**
* Unbinds all event callbacks for the specified event.
* @static
* @param {String} [name] Event name. If omitted, unbinds all event listeners.
*/
Connector.removeAllListeners = function removeAllListeners() {
ConnectorClass.removeAllListeners.apply(ConnectorClass, arguments);
};
// NOTE: this is internal and only used by the test and should never be called directly
Connector.clearConnectors = function clearConnectors() {
connectors.length = 0;
};
/*
* called to create a wrapper around this instance which will enforce login, etc.
*/
Connector.prototype.createRequest = function createRequest(request, response) {
var connector = Object.create(this);
connector.request = request;
connector.response = response;
connector._promise = true;
delegateMethods.forEach(name => {
utils.createWrappedFunction(connector, name);
});
return connector;
};
Connector.prototype.endRequest = function createRequest() {
this.request = this.response = null;
this.removeAllListeners();
};
/**
* Creates a new connector.
* @static
* @param {Object} impl Implementation object. See the overview at the top of the page.
* @throws {APIBuilder.ORMError} Missing name parameter.
* @returns {Object} ConnectorConstructor
*/
Connector.extend = function classExtend(impl) {
// Validate.
if (!impl) {
throw new TypeError('Missing required parameter "impl" to Connector.extend!');
}
// Provide some good defaults.
if (impl.filename) {
if (!impl.pkginfo) {
const { name, version, description, author, license, keywords, repository }
= require('pkginfo').read(impl).package;
impl.pkginfo = { name, version, description, author, license, keywords, repository };
}
// Load up (well, delayed load up)
var modelsDir = path.resolve(path.join(impl.filename, '..', '..', 'models'));
if (!impl.models && fs.existsSync(modelsDir) && Connector.APIBuilder) {
impl.modelsDir = modelsDir;
// loadModelsForConnector will most likely defer loading the models until after
// getConnector finishes. So if impl.models is undefined after this, don't fret, it
// will be soon.
impl.models = Connector.APIBuilder
.loadModelsForConnector(impl.name || impl.pkginfo.name, impl);
}
if (!impl.defaultConfig) {
// Look for an example config file.
const confFolder = path.resolve(path.join(impl.filename, '..', '..', 'conf'));
try {
const files = fs.readdirSync(confFolder);
if (files.length && files[0]) {
impl.defaultConfig = fs.readFileSync(path.join(confFolder, files[0], 'UTF-8'));
}
} catch (e) {
// ignore errors
}
}
// Allow convention based loading of modules from directories.
[ 'lifecycle', 'metadata', 'schema', 'utility', 'methods' ].forEach(function (dir) {
var subDir = path.resolve(path.join(impl.filename, '..', dir));
let error;
if (!fs.existsSync(subDir)) {
return;
}
fs.readdirSync(subDir)
.filter(function (f) {
return f.slice(-3) === '.js';
})
.forEach(function (f) {
if (error) {
return;
}
try {
var module = require(path.join(subDir, f));
for (var key in module) {
if (module.hasOwnProperty(key)
&& _.isFunction(module[key])
&& !impl[key]) {
impl[key] = module[key];
}
}
} catch (err) {
/* eslint-disable no-console */
console.error('Failed to load connector sub directory module; skipping it:');
console.error(err);
/* eslint-enable no-console */
error = err;
}
});
});
}
// if we have a logger from APIBuilder, use it
if (!impl.logger && Connector.APIBuilder && Connector.APIBuilder.getGlobal()) {
impl.logger = Connector.APIBuilder.getGlobal().logger;
}
// Recursively apply extend, so connectors can extend connectors.
function ConnectorConstructor(config) {
return new Connector(impl, config);
}
// This is redefining the static `extend` method on an extended Connector. E.g. if
// MongoDB = Connector.extend({...}), then this enables MongoDB to be extended with
// MongoDB.extend. It is unlikely anything uses this. This does not need to omit
// the same properties as is implemented in the Connector constructor above because
// this is not modifying the instance - it is defining a constructor function. The
// impl might end up with "create" for example, but it will be omitted by the
// Connector constructor above.
ConnectorConstructor.extend = function (extendingImpl) {
return classExtend(mergeConnectorWithConstructor({}, impl, extendingImpl));
};
return ConnectorConstructor;
};
/**
* Creates a new connector from this instance.
* @param {Object} impl Implementation object. See the overview at the top of the page.
* @throws {APIBuilder.ORMError} Missing name parameter.
* @returns {Object} A connector
*/
Connector.prototype.extend = function instanceExtend(impl) {
// This is creating an `extend` method on an instance of an extended Connector. E.g.
// if MongoDB = Connector.extend({...}), and we have `mongodb = new MongoDB()`, then
// this would ensure that mongodb.extend() would copy the properties, that are
// defined by MongoDB, are also included in the newly extended Connector. Note that
// this is creating a new Connector constructor, so the target should be a new
// object.
return Connector.extend(mergeConnectorWithConstructor({}, this, impl));
};
/**
* Returns true if connected.
* @returns {Boolean}
*/
Connector.prototype.isConnected = function isConnected() {
return this.connected;
};
/**
* Outputs the default connector configuration to the console.
*/
Connector.prototype.logDefaultConfig = function logDefaultConfig() {
if (this.defaultConfig) {
this.logger.info('The ' + this.name + ' connector recommends the following configuration. Copy and paste it in to your conf/default.js or other configuration file.');
// Note: We do a normal console.log here because the logger can inject little characters
// around \t and \n.
// eslint-disable-next-line no-console
console.log(this.defaultConfig);
this.logger.info('You may need to change some of the values for your particular needs.');
}
};
/**
* Returns the primary key of a Model. Override this method to provide a different value.
* @param {APIBuilder.Model} Model Model class.
* @param {Object} record Record to check.
* @returns {String}
*/
Connector.prototype.getPrimaryKey = function getPrimaryKey(Model, record) {
return record[this.getPrimaryKeyColumnName(Model)];
};
/**
* Connects to the external data source.
*
* The connect lifecycle will call the following methods before the server starts
* (if provided by the implementation):
*
* 1. {@link APIBuilder.Connector#fetchMetadata}
* 2. {@link APIBuilder.Connector#fetchConfig}
* 3. connect
* 4. {@link APIBuilder.Connector#fetchSchema}
* @param {Function} callback Callback function passed an Error object (or null if successful).
* The callback is invoked after each operation.
* @returns {Void}
*/
Connector.prototype.connect = function (callback) {
if (this.connected || this.config.enabled === false) {
return callback();
}
var tasks = [];
if (this.fetchMetadata) {
tasks.push(function fetchMetadataTask(next) {
this.fetchMetadata(function fetchMetadataTaskCallback(err, metadata) {
if (err) {
return next(err);
}
if (metadata) {
this.metadata = _.merge(this.metadata || {}, metadata);
}
next();
}.bind(this));
}.bind(this));
} else {
// no metadata, let's just make an empty one
tasks.push(function metadataTask(next) {
if (!this.metadata) {
this.metadata = { schema: undefined };
}
next();
}.bind(this));
}
if (this.fetchConfig) {
tasks.push(function fetchConfigTask(next) {
this.fetchConfig(function fetchConfigTaskCallback(err, config) {
if (err) {
return next(err);
}
// basically, the constructors config should override
// our default config from the connector
this.config = _.merge(config, this.config);
var possibleErr = this.validateConfig();
if (possibleErr !== true) {
next(possibleErr);
} else {
next();
}
}.bind(this));
}.bind(this));
} else {
tasks.push(function configTask(next) {
if (!this.config) {
this.config = {};
}
var possibleErr = this.validateConfig();
if (possibleErr !== true) {
next(possibleErr);
} else {
next();
}
}.bind(this));
}
if (this._connect) {
tasks.push(function connectTask(next) {
this._connect(next);
}.bind(this));
}
if (this.fetchSchema) {
tasks.push(function fetchSchemaTask(next) {
this.fetchSchema(function fetchSchemaTaskCallback(err, schema) {
if (err) {
return next(err);
}
if (schema) {
this.metadata = _.merge(this.metadata || {}, { schema: schema });
this.schema = schema;
}
if (!this._createdModelsFromSchema
&& this.createModelsFromSchema
&& (
this.config.generateModelsFromSchema === undefined
|| this.config.generateModelsFromSchema
)) {
var proceed = function () {
this._createdModelsFromSchema = true;
var server = Connector.APIBuilder && Connector.APIBuilder.getGlobal();
if (server && this.models) {
server.registerModelsForConnector(this, this.models);
}
next();
}.bind(this);
if (this.createModelsFromSchema.length > 0) {
this.createModelsFromSchema(proceed);
} else {
this.createModelsFromSchema();
proceed();
}
} else {
next();
}
}.bind(this));
}.bind(this));
}
async.series(tasks, function connectCallback(err) {
if (err) {
return callback(err);
}
this.connected = true;
callback();
}.bind(this));
};
/**
* Disconnects from the external data source.
* @param {Function} callback Callback function to be called at the end of the operation.
* @returns {Void}
*/
Connector.prototype.disconnect = function disconnect(callback) {
if (!this.connected || this.config.enabled === false) {
return callback();
}
var tasks = [];
if (this._disconnect) {
tasks.push(function disconnectTask(next) {
this._disconnect(next);
}.bind(this));
}
async.series(tasks, function disconnectCallback(err) {
if (err) {
return callback(err);
}
this.connected = false;
callback();
}.bind(this));
};
/**
* Validates whether or not the config for this connector is valid, based on its metadata.
* @returns {Boolean|Error} Returns true if the config is valid, otherwise returns an Error.
*/
Connector.prototype.validateConfig = function validateConfig() {
var metadata = this.metadata,
config = this.config || {};
if (!metadata || !metadata.fields || !metadata.fields.length) {
return true;
}
for (var i = 0; i < metadata.fields.length; i++) {
var field = metadata.fields[i];
if (!config[field.name]) {
if (field.required) {
this.logDefaultConfig();
return new Error(field.name + ' is a required config property for the ' + this.name + ' connector!');
}
if (field.default !== undefined) {
config[field.name] = field.default;
}
} else if (field.validator) {
var validator;
if (_.isRegExp(field.validator)) {
validator = field.validator;
} else if (field.validator.type === 'regexp') {
validator = new RegExp(field.validator.value, field.validator.flags);
} else {
return new Error('The connector ' + this.name + ' has an invalid validator for ' + field.name + '!');
}
if (!validator.test(config[field.name])) {
this.logDefaultConfig();
return new Error('The value "' + config[field.name] + '" for ' + field.name + ' is invalid for the ' + this.name + ' connector!');
}
}
}
return true;
};
/**
* Returns the column that is used as the primary key internally (not in the model, but in the
* native data source). This is used by the model when translating the query for
* selecting/unselecting columns.
* @param {APIBuilder.Model} Model Model class to check.
* @returns {String} Key name in the data source used as the primary key.
*/
Connector.prototype.getPrimaryKeyColumnName = function (Model) {
return (this.getPrimaryKeyColumn && this.getPrimaryKeyColumn(Model))
|| this.idAttribute || 'id';
};
/**
* Creates a list of instances by a set of values.
* @since 1.2.8
* @param {APIBuilder.Model} Model Model class to check.
* @param {Array} values Set of instances to create
* @param {Function} callback Callback passed an Error object (or null if successful).
*/
Connector.prototype.createMany = function createMany(Model, values, callback) {
async.mapLimit(values, 4, Model.create.bind(Model), callback);
};
/**
* Removes a list of instances by a set of ids.
* @since 1.2.8
* @param {APIBuilder.Model} Model Model class to check.
* @param {Array} ids Set of primary keys to delete
* @param {Function} callback Callback passed an Error object (or null if successful).
*/
Connector.prototype.deleteMany = function deleteMany(Model, ids, callback) {
async.mapLimit(ids, 4, function (id, next) {
Model.findByID(id, function (err, result) {
if (err || !result) {
return next();
}
Model.delete(id, next);
});
}, callback);
};
/**
* Finds a list of instances by a set of ids.
* @since 1.2.8
* @param {APIBuilder.Model} Model Model class to check.
* @param {Array} ids Set of primary keys to find
* @param {Function} callback Callback passed an Error object (or null if successful).
*/
function findOneMany(Model, ids, callback) {
async.mapLimit(ids, 4, Model.findByID.bind(Model), callback);
}
Connector.prototype.findManyByID
= Connector.prototype.findManyById
= Connector.prototype.findOneMany
= findOneMany;
/**
* Performs a find and update in the same query.
* @param {APIBuilder.Model} Model Model class to check.
* @param {APIBuilderQueryOptions} options Query options.
* @param {Object} doc Model fields 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
* models.
*/
Connector.prototype.findAndModify = function findAndModify(Model, options, doc, args, callback) {
if (_.isFunction(args)) {
callback = args;
args = {};
}
options.limit = 1;
this.query(Model, options, function (err, result) {
if (err) {
return callback(err);
}
var hasAResult = result && result.length,
allowInserting = args.upsert || args.insert === true,
allowUpdating = args.update === undefined || args.update === true;
if (!hasAResult && allowInserting) {
return this.create(Model, doc, function (err, record) {
callback(err, args.new ? record : {});
});
}
if (hasAResult && allowUpdating) {
result[0].set(doc, false);
return this.save(Model, result[0], function (err, record) {
callback(err, args.new ? record : result[0]);
});
}
callback();
}.bind(this));
};
/**
* Performs a query and returns a distinct result set based on the field(s).
* @param {APIBuilder.Model} Model Model class to check.
* @param {String} field Comma-separated list of fields.
* @param {APIBuilderQueryOptions} [options] Query options.
* @param {Function} callback Callback passed an Error object (or null if successful) and the
* distinct models.
* @return {undefined}
*/
Connector.prototype.distinct = function distinct(Model, field, options, callback) {
const ignoreMissingFields = true;
const pkName = Model.getPrimaryKeyName();
// If Model does not have the `field`, return an empty collection
if (!Model.fields.hasOwnProperty(field) && field !== pkName) {
// never going to happen
return callback(null, new Collection(Model, []));
}
const skip = +options.skip || 0;
// If no `limit` is provided, the default is 10, but max 1000
const limit = Math.min(+options.limit || 10, 1000);
// Give a reasonable amount of raw record data from which to make unique.
const fetchLimit = Math.min(skip + limit * 3, 1000);
// Make the options for query.
const queryOpts = {
classname: Model.name,
limit: fetchLimit,
order: {
[field]: 1 // unless user provides one
},
sel: {
[field]: 1
}
};
// if an order provided, use that instead of default
if (options.order) {
queryOpts.order = options.order;
}
// if where provided, apply it
if (options.where) {
queryOpts.where = options.where;
}
// Keep a map of unique (distinct) values for `field`
const unique = new Map();
let skipFetch = 0;
this.query(Model, queryOpts, function handler(err, items) {
if (err) {
return callback(err);
}
for (const item of items) {
// This will convert things like Date to a unique key for equivalent values.
// Handle cases where undefined and null values are provided, e.g.:
// - Banana Banana
// - null null
// - 0 0
// - 441763200000 1984-01-01T00:00:00.000Z
const val = item[field];
if (val instanceof Date) {
unique.set(val.valueOf(), val);
} else if (typeof val === 'object' && val !== null) {
unique.set(JSON.stringify(val), val);
} else {
unique.set(val, val);
}
}
// if we have no-more items to fetch, or if we have sufficient number
// of unique items, then we are finished.
const noMoreItems = items.length < fetchLimit;
const sufficientUnique = unique.size >= skip + limit;
if (noMoreItems || sufficientUnique) {
// Make a set of Model instances from the set of unique fields. The user might
// be paging through these items, so employ skip and limit
const uniqueItems = Array.from(unique.values()).slice(skip, skip + limit);
const instances = [];
for (const item of uniqueItems) {
const instance = Model.instance({ [field]: item }, ignoreMissingFields);
if (Model.getPrimaryKeyName() === field) {
instance.setPrimaryKey(item);
}
instances.push(instance);
}
return callback(null, new Collection(Model, instances));
} else {
// Keep fetching. It is necessary to present a unique options
// object to the iterative query so that simple-mock can spy them.
skipFetch += items.length;
const nextOpts = { ...queryOpts, skip: skipFetch };
return this.query(Model, nextOpts, handler.bind(this));
}
}.bind(this));
};
/**
* Performs a query and returns a count of the records. In the options parameter, set the
* `distinct` property to `true` to perform a distinct count.
* @param {APIBuilder.Model} Model Models to check.
* @param {APIBuilderQueryOptions} [options] Query options.
* @param {Function} callback Callback passed an Error object (or null if successful) and the
* number of models found.
*/
Connector.prototype.count = function count(Model, options, callback) {
if (_.isFunction(options)) {
callback = options;
options = {};
}
this.query(Model, options, function (err, results) {
if (err) {
return callback(err);
} else {
var count = results.length;
if (options.distinct) {
var found = {};
count = 0;
for (var c = 0; c < results.length; c++) {
var row = results[c],
value = row.get(options.distinct);
if (value in found) {
continue;
}
found[value] = 1;
count++;
}
}
callback(null, count);
}
});
};
/**
* Creates or updates a model.
* @param {APIBuilder.Model} Model Model to search.
* @param {String} id ID of the model to update (or create).
* @param {Object} document Fields to set in the model.
* @param {Function} callback Callback passed an Error object (or null if successful) and the new
* model.
*/
Connector.prototype.upsert = function upsert(Model, id, document, callback) {
Model.findByID(id, (err, record) => {
if (err) {
return callback(err);
}
if (!record && document) {
document[this.getPrimaryKeyColumnName(Model)] = id;
Model.create(document, callback);
} else {
record.set(document);
record.save(function (err) {
callback(err, record);
});
}
});
};
/**
* Recursively merges own and inherited enumerable properties of `sources` objects
* into the `target` object. This will also maintain a specific interface for the
* `Connector` and will also merge in `constructor`, and also remove any method(s)
* that should not be overridden.
*
* @param {object} target The target object.
* @param {object} sources The sources to merge.
* @return {object} The `target` object.
*/
function mergeConnectorWithConstructor(target, ...sources) {
_.merge(target, ...sources.map(src => _.omit(src, 'connect', 'disconnect', 'loginRequired')));
for (const source of sources) {
if (source.hasOwnProperty('constructor')) {
target.constructor = source.constructor;
}
}
return target;
}