lib/collection.js

const _ = require('lodash');
const Sync = require('./sync');
const Helpers = require('./helpers');
const EagerRelation = require('./eager');
const Errors = require('./errors');
const CollectionBase = require('./base/collection');
const Promise = require('./base/promise');
const createError = require('create-error');

/**
 * @class Collection
 * @extends CollectionBase
 * @inheritdoc
 * @classdesc
 *
 * Collections are ordered sets of models returned from the database, from a
 * {@link Model#fetchAll fetchAll} call. They may be used with a suite of
 * {@link http://lodash.com/ Lodash} methods.
 *
 * @constructor
 * @description
 *
 * When creating a {@link Collection}, you may choose to pass in the initial
 * array of {@link Model models}. The collection's {@link Collection#comparator
 * comparator} may be included as an option. Passing `false` as the comparator
 * option will prevent sorting. If you define an {@link Collection#initialize
 * initialize} function, it will be invoked when the collection is created.
 *
 * @example
 * let tabs = new TabSet([tab1, tab2, tab3]);
 *
 * @param {(Model[])=} models Initial array of models.
 * @param {Object=} options
 * @param {Boolean} [options.comparator=false]
 *   {@link Collection#comparator Comparator} for collection, or `false` to disable sorting.
 */
const BookshelfCollection = (module.exports = CollectionBase.extend(
  {
    /**
     * @method Collection#through
     * @description
     * Used to define passthrough relationships - `hasOne`, `hasMany`, `belongsTo`
     * or `belongsToMany`, "through" an `Interim` model or collection.
     *
     * @param {Model} Interim Pivot model.
     *
     * @param {string=} throughForeignKey
     *
     *   Foreign key in this collection model. By default, the `foreignKey` is assumed to
     *   be the singular form of the `Target` model's tableName, followed by `_id` /
     *   `_{{{@link Model#idAttribute idAttribute}}}`.
     *
     * @param {string=} otherKey
     *
     *   Foreign key in the `Interim` model. By default, the `otherKey` is assumed to
     *   be the singular form of this model's tableName, followed by `_id` /
     *   `_{{{@link Model#idAttribute idAttribute}}}`.
     *
     * @param {string=} throughForeignKeyTarget
     *
     *   Column in the `Target` model which `throughForeignKey` references, if other
     *   than `Target` model's `id` / `{@link Model#idAttribute idAttribute}`.
     *
     * @param {string=} otherKeyTarget
     *
     *   Column in this collection model which `otherKey` references, if other
     *   than `id` / `{@link Model#idAttribute idAttribute}`.
     *
     * @returns {Collection}
     */
    through: function(Interim, throughForeignKey, otherKey, throughForeignKeyTarget, otherKeyTarget) {
      return this.relatedData.through(this, Interim, {
        throughForeignKey,
        otherKey,
        throughForeignKeyTarget,
        otherKeyTarget
      });
    },

    /**
     * @method Collection#fetch
     * @description
     * Fetch the default set of models for this collection from the database,
     * resetting the collection when they arrive. If you wish to trigger an error
     * if the fetched collection is empty, pass `{require: true}` as one of the
     * options to the {@link Collection#fetch fetch} call. A {@link
     * Collection#fetched "fetched"} event will be fired when records are
     * successfully retrieved. If you need to constrain the query performed by
     * `fetch`, you can call the {@link Collection#query query} method before
     * calling `fetch`.
     *
     * *If you'd like to only fetch specific columns, you may specify a `columns`
     * property in the options for the `fetch` call.*
     *
     * The `withRelated` option may be specified to fetch the models of the
     * collection, eager loading any specified {@link Relation relations} named on
     * the model. A single property, or an array of properties can be specified as
     * a value for the `withRelated` property. The results of these relation
     * queries will be loaded into a relations property on the respective models,
     * may be retrieved with the {@link Model#related related} method.
     *
     * @fires Collection#fetched
     * @throws {Collection.EmptyError}
     *   Upon a sucessful query resulting in no records returns. Only fired if `require: true` is passed as an option.
     *
     * @param {Object=} options
     * @param {Boolean} [options.require=false] Trigger a {@link Collection.EmptyError} if no records are found.
     * @param {string|string[]} [options.withRelated=[]] A relation, or list of relations, to be eager loaded as part of the `fetch` operation.
     * @returns {Promise<Collection>}
     */
    fetch: Promise.method(function(options) {
      options = options ? _.clone(options) : {};
      return (
        this.sync(options)
          .select()
          .bind(this)
          .tap(function(response) {
            if (!response || response.length === 0) {
              throw new this.constructor.EmptyError('EmptyResponse');
            }
          })

          // Now, load all of the data onto the collection as necessary.
          .tap(function(response) {
            return this._handleResponse(response, options);
          })

          // If the "withRelated" is specified, we also need to eager load all of the
          // data on the collection, as a side-effect, before we ultimately jump into the
          // next step of the collection. Since the `columns` are only relevant to the current
          // level, ensure those are omitted from the options.
          .tap(function(response) {
            if (options.withRelated) {
              return this._handleEager(response, _.omit(options, 'columns'));
            }
          })
          .tap(function(response) {
            /**
             * @event Collection#fetched
             * @tutorial events
             *
             * @description
             * Fired after a `fetch` operation. A promise may be returned from the
             * event handler for async behaviour.
             *
             * @param {Collection} collection The collection performing the {@link Collection#fetch}.
             * @param {Object} response Knex query response.
             * @param {Object} options Options object passed to {@link Collection#fetch fetch}.
             * @returns {Promise}
             */
            return this.triggerThen('fetched', this, response, options);
          })
          .catch(this.constructor.EmptyError, function(err) {
            if (options.require) {
              throw err;
            }
            this.reset([], {silent: true});
          })
          .return(this)
      );
    }),

    /**
     * @method Collection#count
     * @since 0.8.2
     * @description
     *
     * Get the number of records in the collection's table.
     *
     * @example
     *
     * // select count(*) from shareholders where company_id = 1 and share &gt; 0.1;
     * Company.forge({id:1})
     *   .shareholders()
     *   .query('where', 'share', '>', '0.1')
     *   .count()
     *   .then(function(count) {
     *     assert(count === 3);
     *   });
     *
     * @param {string} [column='*']
     *   Specify a column to count - rows with null values in this column will be excluded.
     * @param {Object=} options
     *   Hash of options.
     * @returns {Promise<Number>}
     *   A promise resolving to the number of matching rows.
     */
    count: Promise.method(function(column, options) {
      if (!_.isString(column)) {
        options = column;
        column = undefined;
      }
      if (options) options = _.clone(options);
      return this.sync(options).count(column);
    }),

    /**
     * @method Collection#fetchOne
     * @description
     *
     * Fetch and return a single {@link Model model} from the collection,
     * maintaining any {@link Relation relation} data from the collection, and
     * any {@link Collection#query query} parameters that have already been passed
     * to the collection. Especially helpful on relations, where you would only
     * like to return a single model from the associated collection.
     *
     * @example
     *
     * // select * from authors where site_id = 1 and id = 2 limit 1;
     * new Site({id:1})
     *   .authors()
     *   .query({where: {id: 2}})
     *   .fetchOne()
     *   .then(function(model) {
     *     // ...
     *   });
     *
     * @param {Object=}  options
     * @param {Boolean} [options.require=false]
     *   If `true`, will reject the returned response with a {@link
     *   Model.NotFoundError NotFoundError} if no result is found.
     * @param {(string|string[])} [options.columns='*']
     *   Limit the number of columns fetched.
     * @param {Transaction} [options.transacting]
     *  Optionally run the query in a transaction.
     * @param {string} [options.lock]
     *  Type of row-level lock to use. Valid options are `forShare` and
     *  `forUpdate`. This only works in conjunction with the `transacting`
     *  option, and requires a database that supports it.
     *
     * @throws {Model.NotFoundError}
     * @returns {Promise<Model|null>}
     *  A promise resolving to the fetched {@link Model model} or `null` if none exists.
     */
    fetchOne: Promise.method(function(options) {
      const model = new this.model();
      model._knex = this.query().clone();
      this.resetQuery();
      if (this.relatedData) model.relatedData = this.relatedData;
      return model.fetch(options);
    }),

    /**
     * @method Collection#load
     * @description
     * `load` is used to eager load relations onto a Collection, in a similar way
     * that the `withRelated` property works on {@link Collection#fetch fetch}.
     * Nested eager loads can be specified by separating the nested relations with
     * `'.'`.
     *
     *  @param {string|string[]} relations The relation, or relations, to be loaded.
     *  @param {Object=}      options Hash of options.
     *  @param {Transaction=} options.transacting
     *  @param {string=} options.lock
     *  Type of row-level lock to use. Valid options are `forShare` and
     *  `forUpdate`. This only works in conjunction with the `transacting`
     *  option, and requires a database that supports it.
     *
     *  @returns {Promise<Collection>} A promise resolving to this {@link
     *  Collection collection}
     */
    load: Promise.method(function(relations, options) {
      if (!Array.isArray(relations)) relations = [relations];
      options = _.assignIn({}, options, {
        shallow: true,
        withRelated: relations
      });
      return new EagerRelation(this.models, this.toJSON(options), new this.model()).fetch(options).return(this);
    }),

    /**
     * @method Collection#create
     * @description
     *
     * Convenience method to create a new {@link Model model} instance within a
     * collection. Equivalent to instantiating a model with a hash of {@link
     * Model#attributes attributes}, {@link Model#save saving} the model to the
     * database then adding the model to the collection.
     *
     * When used on a relation, `create` will automatically set foreign key
     * attributes before persisting the `Model`.
     *
     * ```
     * const { courses, ...attributes } = req.body;
     *
     * Student.forge(attributes).save().tap(student =>
     *   Promise.map(courses, course => student.related('courses').create(course))
     * ).then(student =>
     *   res.status(200).send(student)
     * ).catch(error =>
     *   res.status(500).send(error.message)
     * );
     * ```
     *
     * @param {Object} model A set of attributes to be set on the new model.
     * @param {Object=} options
     * @param {Transaction=} options.transacting
     *
     * @returns {Promise<Model>} A promise resolving with the new {@link Modle
     * model}.
     */
    create: Promise.method(function(model, options) {
      options = options != null ? _.clone(options) : {};
      const relatedData = this.relatedData;
      model = this._prepareModel(model, options);

      // If we've already added things on the query chain,
      // these are likely intended for the model.
      if (this._knex) {
        model._knex = this._knex;
        this.resetQuery();
      }
      return Helpers.saveConstraints(model, relatedData)
        .save(null, options)
        .bind(this)
        .then(function() {
          if (relatedData && relatedData.type === 'belongsToMany') {
            return this.attach(model, _.omit(options, 'query'));
          }
        })
        .then(function() {
          this.add(model, options);
        })
        .return(model);
    }),

    /**
     * @method Collection#resetQuery
     * @description
     * Used to reset the internal state of the current query builder instance.
     * This method is called internally each time a database action is completed
     * by {@link Sync}.
     *
     * @returns {Collection} Self, this method is chainable.
     */
    resetQuery: function() {
      this._knex = null;
      return this;
    },

    /**
     * @method Collection#query
     * @description
     *
     * `query` is used to tap into the underlying Knex query builder instance for
     * the current collection. If called with no arguments, it will return the
     * query builder directly. Otherwise, it will call the specified `method` on
     * the query builder, applying any additional arguments from the
     * `collection.query` call. If the `method` argument is a function, it will be
     * called with the Knex query builder as the context and the first argument.
     *
     * @example
     *
     * let qb = collection.query();
     *     qb.where({id: 1}).select().then(function(resp) {
     *       // ...
     *     });
     *
     * collection.query(function(qb) {
     *   qb.where('id', '>', 5).andWhere('first_name', '=', 'Test');
     * }).fetch()
     *   .then(function(collection) {
     *     // ...
     *   });
     *
     * collection
     *   .query('where', 'other_id', '=', '5')
     *   .fetch()
     *   .then(function(collection) {
     *     // ...
     *   });
     *
     * @param {function|Object|...string=} arguments The query method.
     * @returns {Collection|QueryBuilder}
     *   Will return this model or, if called with no arguments, the underlying query builder.
     *
     * @see {@link http://knexjs.org/#Builder Knex `QueryBuilder`}
     */
    query: function() {
      return Helpers.query(this, Array.from(arguments));
    },

    /**
     * @method Collection#orderBy
     * @since 0.9.3
     * @description
     *
     * Specifies the column to sort on and sort order.
     *
     * The order parameter is optional, and defaults to 'ASC'. You may
     * also specify 'DESC' order by prepending a hyphen to the sort column
     * name. `orderBy("date", 'DESC')` is the same as `orderBy("-date")`.
     *
     * Unless specified using dot notation (i.e., "table.column"), the default
     * table will be the table name of the model `orderBy` was called on.
     *
     * @example
     *
     * Cars.forge().orderBy('color', 'ASC').fetch()
     *    .then(function (rows) { // ...
     *
     * @param sort {string}
     *   Column to sort on
     * @param order {string}
     *   Ascending ('ASC') or descending ('DESC') order
     */
    orderBy() {
      return Helpers.orderBy.apply(null, [this].concat(Array.from(arguments)));
    },

    /**
     * @method Collection#query
     * @private
     * @description Creates and returns a new `Bookshelf.Sync` instance.
     */
    sync: function(options) {
      return new Sync(this, options);
    },

    /* Ensure that QueryBuilder is copied on clone. */
    clone() {
      const cloned = BookshelfCollection.__super__.clone.apply(this, arguments);
      if (this._knex != null) {
        cloned._knex = cloned._builder(this._knex.clone());
      }
      return cloned;
    },

    /**
     * @method Collection#_handleResponse
     * @private
     * @description
     * Handles the response data for the collection, returning from the
     * collection's `fetch` call.
     */
    _handleResponse: function(response, options) {
      const relatedData = this.relatedData;

      this.set(response, {
        merge: options.merge,
        remove: options.remove,
        silent: true,
        parse: true
      }).invokeMap(function() {
        this.formatTimestamps();
        this._reset();
        this._previousAttributes = _.cloneDeep(this.attributes);
      });

      if (relatedData && relatedData.isJoined()) {
        relatedData.parsePivot(this.models);
      }
    },

    /**
     * @method Collection#_handleEager
     * @private
     * @description
     * Handle the related data loading on the collection.
     */
    _handleEager: function(response, options) {
      return new EagerRelation(this.models, response, new this.model()).fetch(options);
    }
  },
  {
    extended: function(child) {
      /**
       * @class Collection.EmptyError
       * @description
       *   Thrown when no records are found by {@link Collection#fetch fetch},
       *   {@link Model#fetchAll}, or {@link Model.fetchAll} when called with
       *   the `{require: true}` option.
       */
      child.EmptyError = createError(this.EmptyError);
    }
  }
));

BookshelfCollection.EmptyError = Errors.EmptyError;