lib/bookshelf.js

'use strict';

const _ = require('lodash');

// We've supplemented `Events` with a `triggerThen` method to allow for
// asynchronous event handling via promises. We also mix this into the
// prototypes of the main objects in the library.
const Events = require('./base/events');

// All core modules required for the bookshelf instance.
const BookshelfModel = require('./model');
const BookshelfCollection = require('./collection');
const BookshelfRelation = require('./relation');
const errors = require('./errors');

function preventOverwrite(store, name) {
  if (store[name]) throw new Error(`${name} is already defined in the registry`);
}

/**
 * @class
 * @classdesc
 *
 * The Bookshelf library is initialized by passing an initialized Knex client
 * instance. The knex documentation provides a number of examples for different
 * databases.
 *
 * @constructor
 * @param {Knex} knex Knex instance.
 */
function Bookshelf(knex) {
  if (!knex || knex.name !== 'knex') {
    throw new Error('Invalid knex instance');
  }

  function resolveModel(input) {
    if (typeof input !== 'string') return input;

    return (
      bookshelf.collection(input) ||
      bookshelf.model(input) ||
      (function() {
        throw new errors.ModelNotResolvedError(`The model ${input} could not be resolved from the registry.`);
      })()
    );
  }

  /** @lends Bookshelf.prototype */
  const bookshelf = {
    registry: {
      collections: {},
      models: {}
    },
    VERSION: require('../package.json').version,

    collection(name, Collection, staticProperties) {
      if (Collection) {
        preventOverwrite(this.registry.collections, name);

        if (_.isPlainObject(Collection)) {
          Collection = this.Collection.extend(Collection, staticProperties);
        }

        this.registry.collections[name] = Collection;
      }

      return this.registry.collections[name] || bookshelf.resolve(name);
    },

    /**
     * Registers a model. Omit the second argument `Model` to return a previously registered model that matches the
     * provided name.
     *
     * Note that when registering a model with this method it will also be available to all relation methods, allowing
     * you to use a string name in that case. See the calls to `hasMany()` in the examples above.
     *
     * @example
     * // Defining and registering a model
     * module.exports = bookshelf.model('Customer', {
     *   tableName: 'customers',
     *   orders() {
     *     return this.hasMany('Order')
     *   }
     * })
     *
     * // Retrieving a previously registered model
     * const Customer = bookshelf.model('Customer')
     *
     * // Registering already defined models
     * // file: customer.js
     * const Customer = bookshelf.Model.extend({
     *   tableName: 'customers',
     *   orders() {
     *     return this.hasMany('Order')
     *   }
     * })
     * module.exports = bookshelf.model('Customer', Customer)
     *
     * // file: order.js
     * const Order = bookshelf.Model.extend({
     *   tableName: 'orders',
     *   customer() {
     *     return this.belongsTo('Customer')
     *   }
     * })
     * module.exports = bookshelf.model('Order', Order)
     *
     * @param {string} name
     *   The name to save the model as, or the name of the model to retrieve if no further arguments are passed to this
     *   method.
     * @param {Model|Object} [Model]
     *   The model to register. If a plain object is passed it will be converted to a {@link Model}. See example above.
     * @param {Object} [staticProperties]
     *   If a plain object is passed as second argument, this can be used to specify additional static properties and
     *   methods for the new model that is created.
     * @return {Model} The registered model.
     */
    model(name, Model, staticProperties) {
      if (Model) {
        preventOverwrite(this.registry.models, name);
        if (_.isPlainObject(Model)) Model = this.Model.extend(Model, staticProperties);
        this.registry.models[name] = Model;
      }

      return this.registry.models[name] || bookshelf.resolve(name);
    },

    /**
     * Override this in your bookshelf instance to define a custom function that will resolve the location of a model or
     * collection when using the {@link Bookshelf#model} method or when passing a string with a model name in any of the
     * collection methods (e.g. {@link Model#hasOne}, {@link Model#hasMany}, etc.).
     *
     * This will only be used if the specified name cannot be found in the registry. Note that this function
     * can return anything you'd like, so it's not restricted in functionality.
     *
     * @example
     * const Customer = bookshelf.model('Customer', {
     *   tableName: 'customers'
     * })
     *
     * bookshelf.resolve = (name) => {
     *   if (name === 'SpecialCustomer') return Customer;
     * }
     *
     * @param {string} name The model name to resolve.
     * @return {*} The return value will depend on what your re-implementation of this function does.
     */
    resolve(name) {}
  };

  const Model = (bookshelf.Model = BookshelfModel.extend(
    {
      _builder: builderFn,

      // The `Model` constructor is referenced as a property on the `Bookshelf` instance, mixing in the correct
      // `builder` method, as well as the `relation` method, passing in the correct `Model` & `Collection`
      // constructors for later reference.
      _relation(type, Target, options) {
        Target = resolveModel(Target);

        if (type !== 'morphTo' && !_.isFunction(Target)) {
          throw new Error(
            'A valid target model must be defined for the ' + _.result(this, 'tableName') + ' ' + type + ' relation'
          );
        }
        return new Relation(type, Target, options);
      },

      morphTo(relationName, ...args) {
        let candidates = args;
        let columnNames = null;

        if (Array.isArray(args[0]) || args[0] === null || args[0] === undefined) {
          candidates = args.slice(1);
          columnNames = args[0];
        }

        if (Array.isArray(columnNames)) {
          // Try to use the columnNames as target instead
          try {
            columnNames[0] = resolveModel(columnNames[0]);
          } catch (error) {
            // If it did not work, they were real columnNames
            if (error instanceof errors.ModelNotResolvedError) throw error;
          }
        }

        const models = candidates.map((candidate) => {
          if (!Array.isArray(candidate)) return resolveModel(candidate);

          const model = candidate[0];
          const morphValue = candidate[1];

          return [resolveModel(model), morphValue];
        });

        return BookshelfModel.prototype.morphTo.apply(this, [relationName, columnNames].concat(models));
      },

      through(Source, ...rest) {
        return BookshelfModel.prototype.through.apply(this, [resolveModel(Source), ...rest]);
      }
    },
    {
      /**
       * @method Model.forge
       * @description
       *
       * A simple helper function to instantiate a new Model without needing `new`.
       *
       * @param {Object=} attributes Initial values for this model's attributes.
       * @param {Object=}  options               Hash of options.
       * @param {string=}  options.tableName     Initial value for {@linkcode Model#tableName tableName}.
       * @param {Boolean=} [options.hasTimestamps=false]
       *
       *   Initial value for {@linkcode Model#hasTimestamps hasTimestamps}.
       *
       * @param {Boolean} [options.parse=false]
       *
       *   Convert attributes by {@linkcode Model#parse parse} before being
       *   {@linkcode Model#set set} on the `model`.
       */
      forge: function forge(attributes, options) {
        return new this(attributes, options);
      },

      /**
       * A simple static helper to instantiate a new {@link Collection}, setting the model it's
       * called on as the collection's target model.
       *
       * @example
       * Customer.collection().fetch().then((customers) => {
       *   // ...
       * })
       *
       * @method Model.collection
       * @param {Model[]} [models] Any models to be added to the collection.
       * @param {Object} [options] Additional options to pass to the {@link Collection} constructor.
       * @param {string|function} [options.comparator]
       *   If specified this is used to sort the collection. It can be a string representing the
       *   model attribute to sort by, or a custom function. Check the documentation for {@link
       *   https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
       *   Array.prototype.sort} for more info on how to use a custom comparator function. If this
       *   options is not specified the collection sort order depends on what the database returns.
       * @returns {Collection}
       *   The newly created collection. It will be empty unless any models were passed as the first
       *   argument.
       */
      collection(models, options) {
        return new bookshelf.Collection(models || [], _.extend({}, options, {model: this}));
      },

      /**
       * Shortcut to a model's `count` method so you don't need to instantiate a new model to count
       * the number of records.
       *
       * @example
       * Duck.count().then((count) => {
       *   console.log('number of ducks', count)
       * })
       *
       * @method Model.count
       * @since 0.8.2
       * @see Model#count
       * @param {string} [column='*']
       *   Specify a column to count. Rows with `null` values in this column will be excluded.
       * @param {Object} [options] Hash of options.
       * @param {boolean} [options.debug=false]
       *   Whether to enable debugging mode or not. When enabled will show information about the
       *   queries being run.
       * @returns {Promise<number|string>}
       */
      count(column, options) {
        return this.forge().count(column, options);
      },

      /**
       * @method Model.fetchAll
       * @description
       *
       * Simple helper function for retrieving all instances of the given model.
       *
       * @see Model#fetchAll
       * @returns {Promise<Collection>}
       */
      fetchAll(options) {
        return this.forge().fetchAll(options);
      }
    }
  ));

  const Collection = (bookshelf.Collection = BookshelfCollection.extend(
    {
      _builder: builderFn,
      through(Source, ...args) {
        return BookshelfCollection.prototype.through.apply(this, [resolveModel(Source), ...args]);
      }
    },
    {
      /**
       * @method Collection.forge
       * @description
       *
       * A simple helper function to instantiate a new Collection without needing
       * new.
       *
       * @param {(Object[]|Model[])=} [models]
       *   Set of models (or attribute hashes) with which to initialize the
       *   collection.
       * @param {Object} options Hash of options.
       *
       * @example
       *
       * var Promise = require('bluebird');
       * var Accounts = bookshelf.Collection.extend({
       *   model: Account
       * });
       *
       * var accounts = Accounts.forge([
       *   {name: 'Person1'},
       *   {name: 'Person2'}
       * ]);
       *
       * Promise.all(accounts.invokeMap('save')).then(function() {
       *   // collection models should now be saved...
       * });
       */
      forge: function forge(models, options) {
        return new this(models, options);
      }
    }
  ));

  // The collection also references the correct `Model`, specified above, for
  // creating new `Model` instances in the collection.
  Collection.prototype.model = Model;
  Model.prototype.Collection = Collection;

  const Relation = BookshelfRelation.extend({Model, Collection});

  // A `Bookshelf` instance may be used as a top-level pub-sub bus, as it mixes
  // in the `Events` object. It also contains the version number, and a
  // `Transaction` method referencing the correct version of `knex` passed into
  // the object.
  _.extend(bookshelf, Events, errors, {
    /**
     * An alias to `{@link http://knexjs.org/#Transactions Knex#transaction}`. The `transaction`
     * object must be passed along in the options of any relevant Bookshelf calls, to ensure all
     * queries are on the same connection. The entire transaction block is wrapped around a Promise
     * that will commit the transaction if it resolves successfully, or roll it back if the Promise
     * is rejected.
     *
     * Note that there is no need to explicitly call `transaction.commit()` or
     * `transaction.rollback()` since the entire transaction will be committed if there are no
     * errors inside the transaction block.
     *
     * When fetching inside a transaction it's possible to specify a row-level lock by passing the
     * wanted lock type in the `lock` option to {@linkcode Model#fetch fetch}. Available options are
     * `lock: 'forUpdate'` and `lock: 'forShare'`.
     *
     * @example
     * var Promise = require('bluebird')
     *
     * Bookshelf.transaction((t) => {
     *   return new Library({name: 'Old Books'})
     *     .save(null, {transacting: t})
     *     .tap(function(model) {
     *       return Promise.map([
     *         {title: 'Canterbury Tales'},
     *         {title: 'Moby Dick'},
     *         {title: 'Hamlet'}
     *       ], (info) => {
     *         return new Book(info).save({'shelf_id': model.id}, {transacting: t})
     *       })
     *     })
     * }).then((library) => {
     *   console.log(library.related('books').pluck('title'))
     * }).catch((err) => {
     *   console.error(err)
     * })
     *
     * @method Bookshelf#transaction
     * @param {Bookshelf~transactionCallback} transactionCallback
     *   Callback containing transaction logic. The callback should return a Promise.
     * @returns {Promise}
     *   A promise resolving to the value returned from
     *   {@link Bookshelf~transactionCallback transactionCallback}.
     */
    transaction() {
      return this.knex.transaction.apply(this.knex, arguments);
    },

    /**
     * This is a transaction block to be provided to {@link Bookshelf#transaction}. All of the
     * database operations inside it can be part of the same transaction by passing the
     * `transacting: transaction` option to {@link Model#fetch fetch}, {@link Model#save save} or
     * {@link Model#destroy destroy}.
     *
     * Note that unless you explicitly pass the `transaction` object along to any relevant model
     * operations, those operations will not be part of the transaction, even though they may be
     * inside the transaction callback.
     *
     * @callback Bookshelf~transactionCallback
     * @see {@link http://knexjs.org/#Transactions Knex#transaction}
     * @see Bookshelf#transaction
     *
     * @param {Transaction} transaction
     * @returns {Promise}
     *   The Promise will resolve to the return value of the callback, or be rejected with an error
     *   thrown inside it. If it resolves, the entire transaction is committed, otherwise it is
     *   rolled back.
     */

    /**
     * @method Bookshelf#plugin
     * @memberOf Bookshelf
     * @description
     *
     * This method provides a nice, tested, standardized way of adding plugins
     * to a `Bookshelf` instance, injecting the current instance into the
     * plugin, which should be a `module.exports`.
     *
     * You can add a plugin by specifying a string with the name of the plugin
     * to load. In this case it will try to find a module. It will pass the
     * string to `require()`, so you can either require a third-party dependency
     * by name or one of your own modules by relative path:
     *
     *     bookshelf.plugin('./bookshelf-plugins/my-favourite-plugin');
     *     bookshelf.plugin('plugin-from-npm');
     *
     * There are a few official plugins published in `npm`, along with many
     * independently developed ones. See
     * [the list of available plugins](index.html#official-plugins).
     *
     * You can also provide an array of strings or functions, which is the same
     * as calling `bookshelf.plugin()` multiple times. In this case the same
     * options object will be reused:
     *
     *     bookshelf.plugin(['cool-plugin', './my-plugins/even-cooler-plugin']);
     *
     * Example plugin:
     *
     *     // Converts all string values to lower case when setting attributes on a model
     *     module.exports = function(bookshelf) {
     *       bookshelf.Model = bookshelf.Model.extend({
     *         set(key, value, options) {
     *           if (!key) return this
     *           if (typeof value === 'string') value = value.toLowerCase()
     *           return bookshelf.Model.prototype.set.call(this, key, value, options)
     *         }
     *       })
     *     }
     *
     * @param {string|array|Function} plugin
     *   The plugin or plugins to load. If you provide a string it can
     *   represent an npm package or a file somewhere on your project. You can
     *   also pass a function as argument to add it as a plugin. Finally, it's
     *   also possible to pass an array of strings or functions to add them all
     *   at once.
     * @param {mixed} options
     *    This can be anything you want and it will be passed directly to the
     *    plugin as the second argument when loading it.
     * @return {Bookshelf} The bookshelf instance for chaining.
     */
    plugin(plugin, options) {
      if (_.isString(plugin)) {
        if (plugin === 'pagination') {
          const message =
            'Pagination plugin was moved into core Bookshelf. You can now use `fetchPage()` without having to ' +
            "call `.plugin('pagination')`. Remove any `.plugin('pagination')` calls to clear this message.";
          return console.warn(message); // eslint-disable-line no-console
        }

        if (plugin === 'visibility') {
          const message =
            'Visibility plugin was moved into core Bookshelf. You can now set the `hidden` and `visible` properties ' +
            "without having to call `.plugin('visibility')`. Remove any `.plugin('visibility')` calls to clear this " +
            'message.';
          return console.warn(message); // eslint-disable-line no-console
        }

        if (plugin === 'registry') {
          const message =
            'Registry plugin was moved into core Bookshelf. You can now register models using `bookshelf.model()` ' +
            "and collections using `bookshelf.collection()` without having to call `.plugin('registry')`. Remove " +
            "any `.plugin('registry')` calls to clear this message.";
          return console.warn(message); // eslint-disable-line no-console
        }

        if (plugin === 'processor') {
          const message =
            'Processor plugin was removed from core Bookshelf. To migrate to the new standalone package follow the ' +
            'instructions in https://github.com/bookshelf/bookshelf/wiki/Migrating-from-0.15.1-to-1.0.0#processor-plugin';
          return console.warn(message); // eslint-disable-line no-console
        }

        if (plugin === 'case-converter') {
          const message =
            'Case converter plugin was removed from core Bookshelf. To migrate to the new standalone package follow ' +
            'the instructions in https://github.com/bookshelf/bookshelf/wiki/Migrating-from-0.15.1-to-1.0.0#case-converter-plugin';
          return console.warn(message); // eslint-disable-line no-console
        }

        if (plugin === 'virtuals') {
          const message =
            'Virtuals plugin was removed from core Bookshelf. To migrate to the new standalone package follow ' +
            'the instructions in https://github.com/bookshelf/bookshelf/wiki/Migrating-from-0.15.1-to-1.0.0#virtuals-plugin';
          return console.warn(message); // eslint-disable-line no-console
        }

        require(plugin)(this, options);
      } else if (Array.isArray(plugin)) {
        plugin.forEach((p) => this.plugin(p, options));
      } else {
        plugin(this, options);
      }

      return this;
    }
  });

  /**
   * @member Bookshelf#knex
   * @type {Knex}
   * @description
   * A reference to the {@link http://knexjs.org Knex.js} instance being used by Bookshelf.
   */
  bookshelf.knex = knex;

  function builderFn(tableNameOrBuilder) {
    let builder = null;

    if (_.isString(tableNameOrBuilder)) {
      builder = bookshelf.knex(tableNameOrBuilder);
    } else if (tableNameOrBuilder == null) {
      builder = bookshelf.knex.queryBuilder();
    } else {
      // Assuming here that `tableNameOrBuilder` is a QueryBuilder instance. Not
      // aware of a way to check that this is the case (ie. using
      // `Knex.isQueryBuilder` or equivalent).
      builder = tableNameOrBuilder;
    }

    return builder.on('query', (data) => this.trigger('query', data));
  }

  // Attach `where`, `query`, and `fetchAll` as static methods.
  ['where', 'query'].forEach((method) => {
    Model[method] = Collection[method] = function() {
      const model = this.forge();
      return model[method].apply(model, arguments);
    };
  });

  return bookshelf;
}

module.exports = Bookshelf;