// Sync
// ---------------
'use strict';
const _ = require('lodash');
const Promise = require('bluebird');
const validLocks = ['forShare', 'forUpdate'];
function supportsReturning(client = {}) {
if (!client.config || !client.config.client) return false;
return ['postgresql', 'postgres', 'pg', 'oracle', 'mssql'].includes(client.config.client);
}
// Sync is the dispatcher for any database queries,
// taking the "syncing" `model` or `collection` being queried, along with
// a hash of options that are used in the various query methods.
// If the `transacting` option is set, the query is assumed to be
// part of a transaction, and this information is passed along to `Knex`.
const Sync = function(syncing, options) {
options = options || {};
this.query = syncing.query();
this.syncing = syncing.resetQuery();
this.options = options;
if (options.debug) this.query.debug();
if (options.transacting) {
this.query.transacting(options.transacting);
if (validLocks.indexOf(options.lock) > -1) this.query[options.lock]();
}
if (options.withSchema) this.query.withSchema(options.withSchema);
};
_.extend(Sync.prototype, {
// Prefix all keys of the passed in object with the
// current table name
prefixFields: function(fields) {
const tableName = this.syncing.tableName;
const prefixed = {};
for (const key in fields) {
prefixed[tableName + '.' + key] = fields[key];
}
return prefixed;
},
// Select the first item from the database - only used by models.
first: Promise.method(function(attributes) {
const model = this.syncing;
const query = this.query;
// We'll never use an JSON object for a search, because even
// PostgreSQL, which has JSON type columns, does not support the `=`
// operator.
//
// NOTE: `_.omit` returns an empty object, even if attributes are null.
const whereAttributes = _.omitBy(attributes, (attribute, name) => {
return _.isPlainObject(attribute) || name === model.idAttribute;
});
const formattedAttributes = model.format(whereAttributes);
if (model.idAttribute in attributes) {
formattedAttributes[model.idAttribute] = attributes[model.idAttribute];
}
if (!_.isEmpty(formattedAttributes)) query.where(this.prefixFields(formattedAttributes));
query.limit(1);
return this.select();
}),
// Runs a `count` query on the database, adding any necessary relational
// constraints. Returns a promise that resolves to an integer count.
count: Promise.method(function(column) {
const knex = this.query,
options = this.options,
relatedData = this.syncing.relatedData,
fks = {};
return Promise.bind(this)
.then(function() {
// Inject all appropriate select costraints dealing with the relation
// into the `knex` query builder for the current instance.
if (relatedData)
return Promise.try(function() {
if (relatedData.isThrough()) {
fks[relatedData.key('foreignKey')] = relatedData.parentFk;
const through = new relatedData.throughTarget(fks);
relatedData.pivotColumns = through.parse(relatedData.pivotColumns);
} else if (relatedData.type === 'hasMany') {
const fk = relatedData.key('foreignKey');
knex.where(fk, relatedData.parentFk);
}
});
})
.then(function() {
options.query = knex;
/**
* Counting event.
*
* Fired before a `count` query. A promise may be
* returned from the event handler for async behaviour.
*
* @event Model#counting
* @tutorial events
* @param {Model} model The model firing the event.
* @param {Object} options Options object passed to {@link Model#count count}.
* @returns {Promise}
*/
return this.syncing.triggerThen('counting', this.syncing, options);
})
.then(function() {
return knex.count((column || '*') + ' as count');
})
.then(function(rows) {
return rows[0].count;
});
}),
// Runs a `select` query on the database, adding any necessary relational
// constraints, resetting the query when complete. If there are results and
// eager loaded relations, those are fetched and returned on the model before
// the promise is resolved. Any `success` handler passed in the
// options will be called - used by both models & collections.
select: Promise.method(function() {
const knex = this.query;
const options = this.options;
const relatedData = this.syncing.relatedData;
const fks = {};
let columns = null;
// Check if any `select` style statements have been called with column
// specifications. This could include `distinct()` with no arguments, which
// does not affect inform the columns returned.
const queryContainsColumns = _(knex._statements)
.filter({grouping: 'columns'})
.some('value.length');
return Promise.bind(this)
.then(function() {
// Set the query builder on the options, in-case we need to
// access in the `fetching` event handlers.
options.query = knex;
// Inject all appropriate select costraints dealing with the relation
// into the `knex` query builder for the current instance.
if (relatedData)
return Promise.try(function() {
if (relatedData.isThrough()) {
fks[relatedData.key('foreignKey')] = relatedData.parentFk;
const through = new relatedData.throughTarget(fks);
return through.triggerThen('fetching', through, relatedData.pivotColumns, options).then(function() {
relatedData.pivotColumns = through.parse(relatedData.pivotColumns);
});
}
});
})
.tap(() => {
// If this is a relation, apply the appropriate constraints.
if (relatedData) {
relatedData.selectConstraints(knex, options);
} else {
// Call the function, if one exists, to constrain the eager loaded query.
if (options._beforeFn) options._beforeFn.call(knex, knex);
if (options.columns) {
// Normalize single column name into array.
columns = Array.isArray(options.columns) ? options.columns : [options.columns];
} else if (!queryContainsColumns) {
// If columns have already been selected via the `query` method
// we will use them. Otherwise, select all columns in this table.
columns = [_.result(this.syncing, 'tableName') + '.*'];
}
}
// Set the query builder on the options, for access in the `fetching`
// event handlers.
options.query = knex;
/**
* Fired before a `fetch` operation. A promise may be returned from the event handler for
* async behaviour.
*
* @example
* const MyModel = bookshelf.model('MyModel', {
* initialize() {
* this.on('fetching', function(model, columns, options) {
* options.query.where('status', 'active')
* })
* }
* })
*
* @event Model#fetching
* @tutorial events
* @param {Model} model The model which is about to be fetched.
* @param {string[]} columns The columns to be retrieved by the query.
* @param {Object} options Options object passed to {@link Model#fetch fetch}.
* @param {QueryBuilder} options.query
* Query builder to be used for fetching. This can be used to modify or add to the query
* before it is executed. See example above.
* @return {Promise}
*/
return this.syncing.triggerThen('fetching', this.syncing, columns, options);
})
.then(() => knex.select(columns));
}),
// Issues an `insert` command on the query - only used by models.
insert: Promise.method(function() {
const syncing = this.syncing;
return this.query.insert(
syncing.format(_.extend(Object.create(null), syncing.attributes)),
supportsReturning(this.query.client) && this.options.autoRefresh !== false ? '*' : null
);
}),
// Issues an `update` command on the query - only used by models.
update: Promise.method(function(attrs) {
const syncing = this.syncing,
query = this.query;
if (syncing.id != null) query.where(syncing.format({[syncing.idAttribute]: syncing.id}));
if (_.filter(query._statements, {grouping: 'where'}).length === 0) {
throw new Error('A model cannot be updated without a "where" clause or an idAttribute.');
}
var updating = syncing.format(_.extend(Object.create(null), attrs));
if (syncing.id === updating[syncing.idAttribute]) {
delete updating[syncing.idAttribute];
}
if (supportsReturning(query.client) && this.options.autoRefresh !== false) query.returning('*');
return query.update(updating);
}),
// Issues a `delete` command on the query.
del: Promise.method(function() {
const query = this.query,
syncing = this.syncing;
if (syncing.id != null) query.where(syncing.format({[syncing.idAttribute]: syncing.id}));
if (_.filter(query._statements, {grouping: 'where'}).length === 0) {
throw new Error('A model cannot be destroyed without a "where" clause or an idAttribute.');
}
return this.query.del();
})
});
module.exports = Sync;