528 lines
16 KiB
JavaScript
528 lines
16 KiB
JavaScript
// Copyright IBM Corp. 2013,2016. 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
|
|
'use strict';
|
|
|
|
const _ = require('lodash');
|
|
const i8n = require('inflection');
|
|
const g = require('strong-globalize')();
|
|
const utils = require('./utils');
|
|
const defineCachedRelations = utils.defineCachedRelations;
|
|
const setScopeValuesFromWhere = utils.setScopeValuesFromWhere;
|
|
const mergeQuery = utils.mergeQuery;
|
|
const DefaultModelBaseClass = require('./model.js');
|
|
const collectTargetIds = utils.collectTargetIds;
|
|
const idName = utils.idName;
|
|
const deprecated = require('depd')('loopback-datasource-juggler');
|
|
|
|
/**
|
|
* Module exports
|
|
*/
|
|
exports.defineScope = defineScope;
|
|
|
|
function ScopeDefinition(definition) {
|
|
this.isStatic = definition.isStatic;
|
|
this.modelFrom = definition.modelFrom;
|
|
this.modelTo = definition.modelTo || definition.modelFrom;
|
|
this.name = definition.name;
|
|
this.params = definition.params;
|
|
this.methods = definition.methods || {};
|
|
this.options = definition.options || {};
|
|
}
|
|
|
|
ScopeDefinition.prototype.targetModel = function(receiver) {
|
|
let modelTo;
|
|
if (typeof this.options.modelTo === 'function') {
|
|
modelTo = this.options.modelTo.call(this, receiver) || this.modelTo;
|
|
} else {
|
|
modelTo = this.modelTo;
|
|
}
|
|
if (!(modelTo.prototype instanceof DefaultModelBaseClass)) {
|
|
let msg = 'Invalid target model for scope `';
|
|
msg += (this.isStatic ? this.modelFrom : this.modelFrom.constructor).modelName;
|
|
msg += this.isStatic ? '.' : '.prototype.';
|
|
msg += this.name + '`.';
|
|
throw new Error(msg);
|
|
}
|
|
return modelTo;
|
|
};
|
|
|
|
/*!
|
|
* Find related model instances
|
|
* @param {*} receiver The target model class/prototype
|
|
* @param {Object|Function} scopeParams
|
|
* @param {Boolean|Object} [condOrRefresh] true for refresh or object as a filter
|
|
* @param {Object} [options]
|
|
* @param {Function} cb
|
|
* @returns {*}
|
|
*/
|
|
ScopeDefinition.prototype.related = function(receiver, scopeParams, condOrRefresh, options, cb) {
|
|
const name = this.name;
|
|
const self = receiver;
|
|
|
|
let actualCond = {};
|
|
let actualRefresh = false;
|
|
let saveOnCache = receiver instanceof DefaultModelBaseClass;
|
|
if (typeof condOrRefresh === 'function' &&
|
|
options === undefined && cb === undefined) {
|
|
// related(receiver, scopeParams, cb)
|
|
cb = condOrRefresh;
|
|
options = {};
|
|
condOrRefresh = undefined;
|
|
} else if (typeof options === 'function' && cb === undefined) {
|
|
cb = options;
|
|
options = {};
|
|
}
|
|
options = options || {};
|
|
if (condOrRefresh !== undefined) {
|
|
if (typeof condOrRefresh === 'boolean') {
|
|
actualRefresh = condOrRefresh;
|
|
} else {
|
|
actualCond = condOrRefresh;
|
|
actualRefresh = true;
|
|
saveOnCache = false;
|
|
}
|
|
}
|
|
cb = cb || utils.createPromiseCallback();
|
|
const refreshIsNeeded = !self.__cachedRelations ||
|
|
self.__cachedRelations[name] === undefined ||
|
|
actualRefresh;
|
|
if (refreshIsNeeded) {
|
|
// It either doesn't hit the cache or refresh is required
|
|
const params = mergeQuery(actualCond, scopeParams, {nestedInclude: true});
|
|
const targetModel = this.targetModel(receiver);
|
|
|
|
// If there is a through model
|
|
// run another query to apply filter on relatedModel(targetModel)
|
|
// see github.com/strongloop/loopback-datasource-juggler/issues/166
|
|
const scopeOnRelatedModel = params.collect &&
|
|
params.include.scope !== null &&
|
|
typeof params.include.scope === 'object';
|
|
let filter, queryRelated;
|
|
if (scopeOnRelatedModel) {
|
|
filter = params.include;
|
|
// The filter applied on relatedModel
|
|
queryRelated = filter.scope;
|
|
delete params.include.scope;
|
|
}
|
|
|
|
targetModel.find(params, options, function(err, data) {
|
|
if (!err && saveOnCache) {
|
|
defineCachedRelations(self);
|
|
self.__cachedRelations[name] = data;
|
|
}
|
|
|
|
if (scopeOnRelatedModel === true) {
|
|
const relatedModel = targetModel.relations[filter.relation].modelTo;
|
|
const IdKey = idName(relatedModel);
|
|
|
|
// return {inq: [1,2,3]}}
|
|
const smartMerge = function(idCollection, qWhere) {
|
|
if (!qWhere[IdKey]) return idCollection;
|
|
let merged = {};
|
|
|
|
const idsA = idCollection.inq;
|
|
const idsB = qWhere[IdKey].inq ? qWhere[IdKey].inq : [qWhere[IdKey]];
|
|
|
|
const intersect = _.intersectionWith(idsA, idsB, _.isEqual);
|
|
if (intersect.length === 1) merged = intersect[0];
|
|
if (intersect.length > 1) merged = {inq: intersect};
|
|
|
|
return merged;
|
|
};
|
|
|
|
if (queryRelated.where !== undefined) {
|
|
// Merge queryRelated filter and targetId filter
|
|
const IdKeyCondition = {};
|
|
IdKeyCondition[IdKey] = smartMerge(collectTargetIds(data, IdKey),
|
|
queryRelated.where);
|
|
|
|
// if the id in filter doesn't exist after the merge,
|
|
// return empty result
|
|
if (_.isObject(IdKeyCondition[IdKey]) && _.isEmpty(IdKeyCondition[IdKey])) return cb(null, []);
|
|
|
|
const mergedWhere = {
|
|
and: [
|
|
IdKeyCondition,
|
|
_.omit(queryRelated.where, IdKey),
|
|
],
|
|
};
|
|
queryRelated.where = mergedWhere;
|
|
} else {
|
|
queryRelated.where = {};
|
|
queryRelated.where[IdKey] = collectTargetIds(data, IdKey);
|
|
}
|
|
|
|
relatedModel.find(queryRelated, options, cb);
|
|
} else {
|
|
cb(err, data);
|
|
}
|
|
});
|
|
} else {
|
|
// Return from cache
|
|
cb(null, self.__cachedRelations[name]);
|
|
}
|
|
return cb.promise;
|
|
};
|
|
|
|
/**
|
|
* Define a scope method
|
|
* @param {String} name of the method
|
|
* @param {Function} function to define
|
|
*/
|
|
ScopeDefinition.prototype.defineMethod = function(name, fn) {
|
|
return this.methods[name] = fn;
|
|
};
|
|
|
|
/**
|
|
* Define a scope to the class
|
|
* @param {Model} cls The class where the scope method is added
|
|
* @param {Model} targetClass The class that a query to run against
|
|
* @param {String} name The name of the scope
|
|
* @param {Object|Function} params The parameters object for the query or a function
|
|
* to return the query object
|
|
* @param methods An object of methods keyed by the method name to be bound to the class
|
|
*/
|
|
function defineScope(cls, targetClass, name, params, methods, options) {
|
|
// collect meta info about scope
|
|
if (!cls._scopeMeta) {
|
|
cls._scopeMeta = {};
|
|
}
|
|
|
|
// only makes sense to add scope in meta if base and target classes
|
|
// are same
|
|
if (cls === targetClass) {
|
|
cls._scopeMeta[name] = params;
|
|
} else if (targetClass) {
|
|
if (!targetClass._scopeMeta) {
|
|
targetClass._scopeMeta = {};
|
|
}
|
|
}
|
|
|
|
options = options || {};
|
|
// Check if the cls is the class itself or its prototype
|
|
const isStatic = (typeof cls === 'function') || options.isStatic || false;
|
|
const definition = new ScopeDefinition({
|
|
isStatic: isStatic,
|
|
modelFrom: cls,
|
|
modelTo: targetClass,
|
|
name: name,
|
|
params: params,
|
|
methods: methods,
|
|
options: options,
|
|
});
|
|
|
|
if (isStatic) {
|
|
cls.scopes = cls.scopes || {};
|
|
cls.scopes[name] = definition;
|
|
} else {
|
|
cls.constructor.scopes = cls.constructor.scopes || {};
|
|
cls.constructor.scopes[name] = definition;
|
|
}
|
|
|
|
// Define a property for the scope
|
|
Object.defineProperty(cls, name, {
|
|
enumerable: false,
|
|
configurable: true,
|
|
/**
|
|
* This defines a property for the scope. For example, user.accounts or
|
|
* User.vips. Please note the cls can be the model class or prototype of
|
|
* the model class.
|
|
*
|
|
* The property value is function. It can be used to query the scope,
|
|
* such as user.accounts(condOrRefresh, cb) or User.vips(cb). The value
|
|
* can also have child properties for create/build/delete. For example,
|
|
* user.accounts.create(act, cb).
|
|
*
|
|
*/
|
|
get: function() {
|
|
const targetModel = definition.targetModel(this);
|
|
const self = this;
|
|
|
|
const f = function(condOrRefresh, options, cb) {
|
|
if (arguments.length === 0) {
|
|
if (typeof f.value === 'function') {
|
|
return f.value(self);
|
|
} else if (self.__cachedRelations) {
|
|
return self.__cachedRelations[name];
|
|
}
|
|
} else {
|
|
const condOrRefreshIsCallBack = typeof condOrRefresh === 'function' &&
|
|
options === undefined &&
|
|
cb === undefined;
|
|
if (condOrRefreshIsCallBack) {
|
|
// customer.orders(cb)
|
|
cb = condOrRefresh;
|
|
options = {};
|
|
condOrRefresh = undefined;
|
|
} else if (typeof options === 'function' && cb === undefined) {
|
|
// customer.orders(condOrRefresh, cb);
|
|
cb = options;
|
|
options = {};
|
|
}
|
|
options = options || {};
|
|
// Check if there is a through model
|
|
// see https://github.com/strongloop/loopback/issues/1076
|
|
if (f._scope.collect &&
|
|
condOrRefresh !== null && typeof condOrRefresh === 'object') {
|
|
f._scope.include = {
|
|
relation: f._scope.collect,
|
|
scope: condOrRefresh,
|
|
};
|
|
condOrRefresh = {};
|
|
}
|
|
return definition.related(self, f._scope, condOrRefresh, options, cb);
|
|
}
|
|
};
|
|
|
|
f._receiver = this;
|
|
f._scope = typeof definition.params === 'function' ?
|
|
definition.params.call(self) : definition.params;
|
|
|
|
f._targetClass = targetModel.modelName;
|
|
if (f._scope.collect) {
|
|
const rel = targetModel.relations[f._scope.collect];
|
|
f._targetClass = rel && rel.modelTo && rel.modelTo.modelName || i8n.camelize(f._scope.collect);
|
|
}
|
|
|
|
f.find = function(condOrRefresh, options, cb) {
|
|
if (typeof condOrRefresh === 'function' &&
|
|
options === undefined && cb === undefined) {
|
|
cb = condOrRefresh;
|
|
options = {};
|
|
condOrRefresh = {};
|
|
} else if (typeof options === 'function' && cb === undefined) {
|
|
cb = options;
|
|
options = {};
|
|
}
|
|
options = options || {};
|
|
return definition.related(self, f._scope, condOrRefresh, options, cb);
|
|
};
|
|
|
|
f.getAsync = function() {
|
|
deprecated(g.f('Scope method "getAsync()" is deprecated, use "find()" instead.'));
|
|
return this.find.apply(this, arguments);
|
|
};
|
|
|
|
f.build = build;
|
|
f.create = create;
|
|
f.updateAll = updateAll;
|
|
f.destroyAll = destroyAll;
|
|
f.findById = findById;
|
|
f.findOne = findOne;
|
|
f.count = count;
|
|
|
|
for (const i in definition.methods) {
|
|
f[i] = definition.methods[i].bind(self);
|
|
}
|
|
|
|
if (!targetClass) return f;
|
|
|
|
// Define scope-chaining, such as
|
|
// Station.scope('active', {where: {isActive: true}});
|
|
// Station.scope('subway', {where: {isUndeground: true}});
|
|
// Station.active.subway(cb);
|
|
Object.keys(targetClass._scopeMeta).forEach(function(name) {
|
|
Object.defineProperty(f, name, {
|
|
enumerable: false,
|
|
get: function() {
|
|
mergeQuery(f._scope, targetModel._scopeMeta[name]);
|
|
return f;
|
|
},
|
|
});
|
|
}.bind(self));
|
|
return f;
|
|
},
|
|
});
|
|
|
|
// Wrap the property into a function for remoting
|
|
const fn = function() {
|
|
// primaryObject.scopeName, such as user.accounts
|
|
const f = this[name];
|
|
// set receiver to be the scope property whose value is a function
|
|
f.apply(this[name], arguments);
|
|
};
|
|
|
|
cls['__get__' + name] = fn;
|
|
|
|
const fnCreate = function() {
|
|
const f = this[name].create;
|
|
f.apply(this[name], arguments);
|
|
};
|
|
|
|
cls['__create__' + name] = fnCreate;
|
|
|
|
const fnDelete = function() {
|
|
const f = this[name].destroyAll;
|
|
f.apply(this[name], arguments);
|
|
};
|
|
|
|
cls['__delete__' + name] = fnDelete;
|
|
|
|
const fnUpdate = function() {
|
|
const f = this[name].updateAll;
|
|
f.apply(this[name], arguments);
|
|
};
|
|
|
|
cls['__update__' + name] = fnUpdate;
|
|
|
|
const fnFindById = function(cb) {
|
|
const f = this[name].findById;
|
|
f.apply(this[name], arguments);
|
|
};
|
|
|
|
cls['__findById__' + name] = fnFindById;
|
|
|
|
const fnFindOne = function(cb) {
|
|
const f = this[name].findOne;
|
|
f.apply(this[name], arguments);
|
|
};
|
|
|
|
cls['__findOne__' + name] = fnFindOne;
|
|
|
|
const fnCount = function(cb) {
|
|
const f = this[name].count;
|
|
f.apply(this[name], arguments);
|
|
};
|
|
|
|
cls['__count__' + name] = fnCount;
|
|
|
|
// and it should have create/build methods with binded thisModelNameId param
|
|
function build(data) {
|
|
data = data || {};
|
|
// Find all fixed property values for the scope
|
|
const targetModel = definition.targetModel(this._receiver);
|
|
const where = (this._scope && this._scope.where) || {};
|
|
setScopeValuesFromWhere(data, where, targetModel);
|
|
return new targetModel(data);
|
|
}
|
|
|
|
function create(data, options, cb) {
|
|
if (typeof data === 'function' &&
|
|
options === undefined && cb === undefined) {
|
|
// create(cb)
|
|
cb = data;
|
|
data = {};
|
|
} else if (typeof options === 'function' && cb === undefined) {
|
|
// create(data, cb)
|
|
cb = options;
|
|
options = {};
|
|
}
|
|
options = options || {};
|
|
return this.build(data).save(options, cb);
|
|
}
|
|
|
|
/*
|
|
Callback
|
|
- The callback will be called after all elements are destroyed
|
|
- For every destroy call which results in an error
|
|
- If fetching the Elements on which destroyAll is called results in an error
|
|
*/
|
|
function destroyAll(where, options, cb) {
|
|
if (typeof where === 'function') {
|
|
// destroyAll(cb)
|
|
cb = where;
|
|
where = {};
|
|
} else if (typeof options === 'function' && cb === undefined) {
|
|
// destroyAll(where, cb)
|
|
cb = options;
|
|
options = {};
|
|
}
|
|
options = options || {};
|
|
|
|
const targetModel = definition.targetModel(this._receiver);
|
|
const scoped = (this._scope && this._scope.where) || {};
|
|
const filter = mergeQuery({where: scoped}, {where: where || {}});
|
|
return targetModel.destroyAll(filter.where, options, cb);
|
|
}
|
|
|
|
function updateAll(where, data, options, cb) {
|
|
if (typeof data === 'function' &&
|
|
options === undefined && cb === undefined) {
|
|
// updateAll(data, cb)
|
|
cb = data;
|
|
data = where;
|
|
where = {};
|
|
options = {};
|
|
} else if (typeof options === 'function' && cb === undefined) {
|
|
// updateAll(where, data, cb)
|
|
cb = options;
|
|
options = {};
|
|
}
|
|
options = options || {};
|
|
const targetModel = definition.targetModel(this._receiver);
|
|
const scoped = (this._scope && this._scope.where) || {};
|
|
const filter = mergeQuery({where: scoped}, {where: where || {}});
|
|
return targetModel.updateAll(filter.where, data, options, cb);
|
|
}
|
|
|
|
function findById(id, filter, options, cb) {
|
|
if (options === undefined && cb === undefined) {
|
|
if (typeof filter === 'function') {
|
|
// findById(id, cb)
|
|
cb = filter;
|
|
filter = {};
|
|
}
|
|
} else if (cb === undefined) {
|
|
if (typeof options === 'function') {
|
|
// findById(id, query, cb)
|
|
cb = options;
|
|
options = {};
|
|
if (typeof filter === 'object' && !(filter.include || filter.fields)) {
|
|
// If filter doesn't have include or fields, assuming it's options
|
|
options = filter;
|
|
filter = {};
|
|
}
|
|
}
|
|
}
|
|
|
|
options = options || {};
|
|
filter = filter || {};
|
|
const targetModel = definition.targetModel(this._receiver);
|
|
const idName = targetModel.definition.idName();
|
|
let query = {where: {}};
|
|
query.where[idName] = id;
|
|
query = mergeQuery(query, filter);
|
|
return this.findOne(query, options, cb);
|
|
}
|
|
|
|
function findOne(filter, options, cb) {
|
|
if (typeof filter === 'function') {
|
|
// findOne(cb)
|
|
cb = filter;
|
|
filter = {};
|
|
options = {};
|
|
} else if (typeof options === 'function' && cb === undefined) {
|
|
// findOne(filter, cb)
|
|
cb = options;
|
|
options = {};
|
|
}
|
|
options = options || {};
|
|
const targetModel = definition.targetModel(this._receiver);
|
|
const scoped = (this._scope && this._scope.where) || {};
|
|
filter = mergeQuery({where: scoped}, filter || {});
|
|
return targetModel.findOne(filter, options, cb);
|
|
}
|
|
|
|
function count(where, options, cb) {
|
|
if (typeof where === 'function') {
|
|
// count(cb)
|
|
cb = where;
|
|
where = {};
|
|
} else if (typeof options === 'function' && cb === undefined) {
|
|
// count(where, cb)
|
|
cb = options;
|
|
options = {};
|
|
}
|
|
options = options || {};
|
|
|
|
const targetModel = definition.targetModel(this._receiver);
|
|
const scoped = (this._scope && this._scope.where) || {};
|
|
const filter = mergeQuery({where: scoped}, {where: where || {}});
|
|
return targetModel.count(filter.where, options, cb);
|
|
}
|
|
|
|
return definition;
|
|
}
|