lib/sync.js

// 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;