Source: querySet.js

import stampit from 'stampit';
import Promise from 'bluebird';
import _ from 'lodash';
import Request from './request';
import PaginationError from './errors';
import moment from 'moment';
import {EventEmittable} from './utils';

/**
 * Wrapper around plain JavaScript Array which provides two additional methods for pagination.
 * @constructor
 * @type {ResultSet}

 * @param {QuerySet}  querySet
 * @param {String}  response
 * @param {Array}  objects
 * @returns {ResultSet}

 * @property {Function}  next
 * @property {Function}  prev

 * @example {@lang javascript}
 * Instance.please().list()
 *   // get next page
 *   .then((instances) => instances.next())
 *
 *   // get prev page
 *   .then((instances) => instances.prev())
 *
 *   .then((instances) => console.log('instances', instances));
 */
const ResultSet = function(querySet, response, objects) {
  let results = [];
  results.push.apply(results, objects);
  const query = _.omit(querySet.query, ['page_size', 'last_pk', 'direction']);


  /**
  * Helper method which will fetch next page or throws `PaginationError`.

  * @memberOf ResultSet
  * @instance

  * @throws {PaginationError}
  * @returns {Promise}
  */
  results.next = () => {
    if (!response.next) {
      return Promise.reject(new PaginationError('There is no next page'));
    }

    return new Promise((resolve, reject) => {
      return querySet
        .request(response.next, {query})
        .spread(resolve)
        .catch(reject);
    });
  };

  /**
  * Helper method which will check if next page is available.

  * @memberOf ResultSet
  * @instance

  * @returns {Boolean}
  */
  results.hasNext = () => response.next !== null;

  /**
  * Helper method which will fetch previous page or throws `PaginationError`.

  * @memberOf ResultSet
  * @instance

  * @throws {PaginationError}
  * @returns {Promise}
  */
  results.prev = () => {
    if (!response.prev) {
      return Promise.reject(new PaginationError('There is no previous page'));
    }

    return new Promise((resolve, reject) => {
      return querySet
        .request(response.prev, {query})
        .spread(resolve)
        .catch(reject);
    });
  };

  /**
  * Helper method which will check if prev page is available.

  * @memberOf ResultSet
  * @instance

  * @returns {Boolean}
  */
  results.hasPrev = () => response.prev !== null;

  return results;
}

const QuerySetRequest = stampit().compose(Request)
  .refs({
    model: null
  })
  .props({
    endpoint: 'list',
    method: 'GET',
    headers: {},

    properties: {},
    query: {},
    payload: {},
    attachments: {},
    _serialize: true,
    resultSetEndpoints: ['list']
  })
  .methods({

    /**
    * Converts raw objects to {@link https://github.com/stampit-org/stampit|stampit} instances

    * @memberOf QuerySet
    * @instance
    * @private

    * @param {Object} response raw JavaScript objects
    * @returns {Object}
    */
    serialize(response) {
      if (this._serialize === false) {
        return response;
      }

      if (_.includes(this.resultSetEndpoints, this.endpoint)) {
        return this.asResultSet(response);
      }

      return this.model.fromJSON(response, this.properties);
    },

    /**
    * Converts API response into {ResultSet}

    * @memberOf QuerySet
    * @instance
    * @private

    * @param {Object} response raw JavaScript objects
    * @param {String} lookupField additional field to search for data
    * @returns {ResultSet}
    */
    asResultSet(response, lookupField, additionalProps = {}) {
      const objects = _.map(response.objects, (object) => {
        const obj = lookupField ? object[lookupField] : object;
        return this.model.fromJSON(obj, _.assign({}, this.properties, additionalProps));
      });
      return ResultSet(this, response, objects);
    },

    /**
    * Executes current state of QuerySet

    * @memberOf QuerySet
    * @instance

    * @param {String} [requestPath = null]
    * @param {Object} [requestOptions = {}]
    * @returns {Promise}

    * @example {@lang javascript}
    * Instance.please().list().request().then(function(instance) {});

    */
    request(requestPath = null, requestOptions = {}) {
      const meta = this.model.getMeta();
      const endpoint = meta.endpoints[this.endpoint] || {};
      const allowedMethods = endpoint.methods || [];
      const path = requestPath || meta.resolveEndpointPath(this.endpoint, this.properties);
      const method = this.method.toLowerCase();
      const options = _.defaults(requestOptions, {
        headers: this.headers,
        query: this.query,
        payload: this.payload,
        attachments: this.attachments,
        responseAttr: this.responseAttr
      });

      if (!_.includes(allowedMethods, method)) {
        return Promise.reject(new Error(`Invalid request method: "${this.method}".`));
      }

      return this.makeRequest(method, path, options).then((body) => [this.serialize(body), body]);
    },

    /**
    * Wrapper around {@link Queryset.request} method

    * @memberOf QuerySet
    * @instance

    * @param {function} callback
    * @returns {Promise}
    */
    then(callback) {
      return this.request().spread(callback);
    }
  });

export const Filter = stampit().methods({

  /**
  * Allows to filter the request.
  * @memberOf QuerySet
  * @instance

  * @param {Object} filters object containing the filters.
  * @returns {QuerySet}

  * @example {@lang javascript}
  * DataObject.please().list({instanceName: 'INSTANCE_NAME', className: 'CLASS_NAME'}).filter({ title: { _eq: "Star Wars"} }).then(function(dataobjects) {});

  */
  filter(filters = {}) {
    this.query['query'] = JSON.stringify(filters);
    return this;
  }
});

export const Create = stampit().methods({

  /**
  * A convenience method for creating an object and saving it all in one step.

  * @memberOf QuerySet
  * @instance

  * @param {Object} object
  * @returns {Promise}

  * @example {@lang javascript}
  * // Thus:
  *
  * Instance
  *  .please()
  *  .create({name: 'test-one', description: 'description'})
  *  .then(function(instance) {});
  *
  * // and:
  *
  * var instance = Instance({name: 'test-one', description: 'description'});
  * instance.save().then(function(instance) {});
  *
  * // are equivalent.

  */
  create(object) {
    const attrs = _.assign({}, this.properties, object);
    const instance = this.model(attrs);

    return instance.save();
  }
});

export const SendToDevice = stampit().methods({

  sendToDevice(properties = {}, content = {}) {
    this.properties = _.assign({}, this.properties, properties);
    this.payload = {content};
    this.method = 'POST';
    this.endpoint = 'deviceMessage';

    return this;
  }

});

export const SendToDevices = stampit().methods({

  sendToDevices(properties = {}, content = {}) {
    this.properties = _.assign({}, this.properties, properties);
    this.payload = {content};
    this.method = 'POST';
    this.endpoint = 'list';

    return this;
  }

});

export const ListAll = stampit().methods({
  listAll() {
    this.resultSetEndpoints = ['list', 'all'];
    this.method = 'GET';
    this.endpoint = 'all';
    return this;
  }
})

export const Rename = stampit().methods({
  /**
  * A convenience method for renaming an object that support the action.
  * @memberOf QuerySet
  * @instance

  * @param {Object} properties lookup properties used for path resolving
  * @param {Object} payload object containing the payload to be sent
  * @returns {QuerySet}

  * @example {@lang javascript}
  * Model.please().fetchData({name: 'model_name', instanceName: 'test-one'}, { new_name: 'new_name'}).then(function(model) {});

  */
  rename(properties = {}, payload = {}) {
    this.properties = _.assign({}, this.properties, properties);

    this.method = 'POST';
    this.endpoint = 'rename';
    this.payload = payload;

    return this;
  }
});

const CacheKey = stampit().methods({
  /**
  * Sets the provided cache key in the request query.
  * @memberOf QuerySet
  * @instance

  * @param {String} cache_key the cache key for the result
  * @returns {QuerySet}

  * @example {@lang javascript}
  * DataEndpoint.please().DataEndpoint.please().fetchData({name: 'dataViewName', instanceName: 'test-one'}).cacheKey('123').then(function(data) {});

  */

  cacheKey(cache_key) {
    this.query['cache_key'] = cache_key;
    return this;
  }
});

export const Get = stampit().methods({

  /**
  * Returns the object matching the given lookup properties.
  * @memberOf QuerySet
  * @instance

  * @param {Object} properties lookup properties used for path resolving
  * @returns {QuerySet}

  * @example {@lang javascript}
  * Instance.please().get({name: 'test-one'}).then(function(instance) {});

  */
  get(properties = {}) {
    this.properties = _.assign({}, this.properties, properties);

    this.method = 'GET';
    this.endpoint = 'detail';

    return this;
  }
});

export const GetOrCreate = stampit().methods({

  /**
  * A convenience method for looking up an object with the given lookup properties, creating one if necessary.
  * Successful callback will receive **object, created** arguments.

  * @memberOf QuerySet
  * @instance

  * @param {Object} properties attributes which will be used for object retrieving
  * @param {Object} defaults attributes which will be used for object creation
  * @returns {Promise}

  * @example {@lang javascript}
  * Instance
  *   .please()
  *   .getOrCreate({name: 'test-one'}, {description: 'test'})
  *   .then(function(instance, created) {});
  *
  * // above is equivalent to:
  *
  * Instance
  *   .please()
  *   .get({name: 'test-one'})
  *   .then(function(instance) {
  *     // Get
  *   })
  *   .catch(function() {
  *     // Create
  *     return Instance.please().create({name: 'test-one', description: 'test'});
  *   });

  */
  getOrCreate(properties = {}, defaults = {}) {
    return new Promise((resolve, reject) => {
      this.get(properties)
        .then((object) => resolve(object, false))
        .catch(() => {
          const attrs = _.assign({}, this.properties, properties, defaults);
          return this.create(attrs)
            .then((object) => resolve(object, true))
            .catch(reject);
        });
    });
  }
});

export const List = stampit().methods({

  /**
  * Returns list of objects that match the given lookup properties.

  * @memberOf QuerySet
  * @instance

  * @param {Object} [properties = {}] lookup properties used for path resolving
  * @param {Object} [query = {}]
  * @returns {QuerySet}

  * @example {@lang javascript}
  * Instance.please().list().then(function(instances) {});
  * Class.please().list({instanceName: 'test-one'}).then(function(classes) {});

  */
  list(properties = {}, query = {}) {
    this.properties = _.assign({}, this.properties, properties);
    this.query = _.assign({}, this.query, query);

    this.method = 'GET';
    this.endpoint = 'list';
    return this;
  }
});

export const Delete = stampit().methods({

  /**
  * Removes single object based on provided properties.

  * @memberOf QuerySet
  * @instance

  * @param {Object} properties lookup properties used for path resolving
  * @returns {QuerySet}

  * @example {@lang javascript}
  * Instance.please().delete({name: 'test-instance'}).then(function() {});
  * Class.please().delete({name: 'test', instanceName: 'test-one'}).then(function() {});

  */
  delete(properties = {}) {
    this.properties = _.assign({}, this.properties, properties);

    this.method = 'DELETE';
    this.endpoint = 'detail';
    return this;
  }
});

export const Update = stampit().methods({

  /**
  * Updates single object based on provided arguments

  * @memberOf QuerySet
  * @instance

  * @param {Object} properties lookup properties used for path resolving
  * @param {Object} object attributes to update
  * @returns {QuerySet}

  * @example {@lang javascript}
  * Instance
    .please()
    .update({name: 'test-instance'}, {description: 'new one'})
    .then(function(instance) {});

  * Class
    .please()
    .update({name: 'test', instanceName: 'test-one'}, {description: 'new one'})
    .then(function(cls) {});

  */
  update(properties = {}, object = {}) {
    this.properties = _.assign({}, this.properties, properties);
    this.payload = object;

    this.method = 'PATCH';
    this.endpoint = 'detail';
    return this;
  }
});

const TemplateResponse = stampit().methods({

  /**
  * Renders the api response as a template.

  * @memberOf QuerySet
  * @instance

  * @param {template_name} name of template to be rendered
  * @returns {QuerySet}

  * @example {@lang javascript}
  * DataObject
    .please()
    .list({instanceName: 'my-instance', className: 'my-class'})
    .templateResponse('objects_html_table')
    .then(function(objects) {});
  */
  templateResponse(template_name) {
    this._serialize = false;
    this.responseAttr = 'text';
    this.query['template_response'] = template_name;
    return this;
  }

});

export const UpdateOrCreate = stampit().methods({

  /**
  * A convenience method for updating an object with the given properties, creating a new one if necessary.
  * Successful callback will receive **object, updated** arguments.

  * @memberOf QuerySet
  * @instance

  * @param {Object} properties lookup properties used for path resolving
  * @param {Object} [object = {}] object with (field, value) pairs used in case of update
  * @param {Object} [defaults = {}] object with (field, value) pairs used in case of create
  * @returns {Promise}

  * @example {@lang javascript}
  * Instance
  *   .please()
  *   .updateOrCreate({name: 'test-one'}, {description: 'new-test'}, {description: 'create-test'})
  *   .then(function(instance, updated) {});
  *
  * // above is equivalent to:
  *
  * Instance
  *   .please()
  *   .update({name: 'test-one'}, {description: 'new-test'})
  *   .then(function(instance) {
  *     // Update
  *   })
  *   .catch(function() {
  *     // Create
  *     return Instance.please().create({name: 'test-one', description: 'create-test'});
  *   });

  */
  updateOrCreate(properties = {}, object = {}, defaults = {}) {
    return new Promise((resolve, reject) => {
      this.update(properties, object)
        .then((_object) => resolve(_object, true))
        .catch(() => {
          const attrs = _.assign({}, this.properties, properties, defaults);
          return this.create(attrs)
            .then((_object) => resolve(_object, false))
            .catch(reject);
        })
    });
  }
});

const ExcludedFields = stampit().methods({
  /**
    * Removes specified fields from object response.

    * @memberOf QuerySet
    * @instance

    * @param {Object} fields
    * @returns {QuerySet}

    * @example {@lang javascript}
    * DataObject.please().list({ instanceName: 'test-instace', className: 'test-class' }).excludedFields(['title', 'author']).then(function(dataobjects) {});

    */
  excludedFields(fields = []) {
    this.query['excluded_fields'] = fields.join();
    return this;
  }
});

const Fields = stampit().methods({
  /**
    * Selects specified fields from object.

    * @memberOf QuerySet
    * @instance

    * @param {Object} fields
    * @returns {QuerySet}

    * @example {@lang javascript}
    * DataObject.please().list({ instanceName: 'test-instace', className: 'test-class' }).fields(['title', 'author']).then(function(dataobjects) {});

    */
  fields(fields = []) {
    this.query['fields'] = fields.join();
    return this;
  }
});

export const First = stampit().methods({

  /**
  * Returns the first object matched by the lookup properties or undefined, if there is no matching object.

  * @memberOf QuerySet
  * @instance

  * @param {Object} [properties = {}]
  * @param {Object} [query = {}]
  * @returns {Promise}

  * @example {@lang javascript}
  * Instance.please().first().then(function(instance) {});
  * Class.please().first({instanceName: 'test-one'}).then(function(cls) {});

  */
  first(properties = {}, query = {}) {
    return this.pageSize(1)
      .list(properties, query)
      .then((objects) => {
        if (objects.length) {
          return objects[0];
        }
      });
  }
});

export const PageSize = stampit().methods({

  /**
  * Sets page size.

  * @memberOf QuerySet
  * @instance

  * @param {Number} value
  * @returns {QuerySet}

  * @example {@lang javascript}
  * Instance.please().pageSize(2).then(function(instances) {});
  * Class.please({instanceName: 'test-one'}).pageSize(2).then(function(classes) {});

  */
  pageSize(value) {
    this.query['page_size'] = value;
    return this;
  }
});

export const CurrentMonth = stampit().methods({

  /**
  * Sets the range of Usage query to current month.

  * @memberOf QuerySet
  * @instance

  * @returns {QuerySet}

  * @example {@lang javascript}
  * DailyUsage.please().list().currentMonth().then(function(usage) {});

  */
  currentMonth() {
    this.query['start'] = moment().startOf('month').format('YYYY-MM-DD');
    this.query['end'] = moment().endOf('month').format('YYYY-MM-DD');
    return this;
  }

});

export const StartDate = stampit().methods({

  /**
  * Sets start date for Usage.

  * @memberOf QuerySet
  * @instance

  * @param {Date} date
  * @returns {QuerySet}

  * @example {@lang javascript}
  * DailyUsage.please().list().startDate(new Date()).then(function(usage) {});

  */
  startDate(date) {
    this.query['start'] = moment(date).format('YYYY-MM-DD');
    return this;
  }

});

export const EndDate = stampit().methods({

  /**
  * Sets end date for Usage.

  * @memberOf QuerySet
  * @instance

  * @param {Date} date
  * @returns {QuerySet}

  * @example {@lang javascript}
  * DailyUsage.please().list().endDate(new Date()).then(function(usage) {});

  */
  endDate(date) {
    this.query['end'] = moment(date).format('YYYY-MM-DD');
    return this;
  }

});

export const Total = stampit().methods({

  /**
  * Sets grouping for Usage.

  * @memberOf QuerySet
  * @instance

  * @returns {QuerySet}

  * @example {@lang javascript}
  * DailyUsage.please().list().total().then(function(usage) {});

  */
  total() {
    this.query['total'] = true;
    return this;
  }

});

export const Ordering = stampit().methods({

  /**
  * Sets order of returned objects.

  * @memberOf QuerySet
  * @instance

  * @param {String} [value = 'asc'] allowed choices are "asc" and "desc"
  * @returns {QuerySet}

  * @example {@lang javascript}
  * Instance.please().ordering('desc').then(function(instances) {});
  * Class.please({instanceName: 'test-one'}).ordering('desc').then(function(classes) {});

  */
  ordering(value = 'asc') {
    const allowed = ['asc', 'desc'];
    const ordering = value.toLowerCase();

    if (!_.includes(allowed, ordering)) {
      throw Error(`Invalid order value: "${value}", allowed choices are ${allowed.join()}.`);
    }

    this.query['ordering'] = ordering;
    return this;
  }
});

export const Raw = stampit().methods({

  /**
  * Disables serialization. Callback will will recive raw JavaScript objects.

  * @memberOf QuerySet
  * @instance

  * @returns {QuerySet}

  * @example {@lang javascript}
  * Instance.please().raw().then(function(response) {});
  * Class.please({instanceName: 'test-one'}).raw().then(function(response) {});

  */
  raw() {
    this._serialize = false;
    return this;
  }
});
/**
* Wrapper for fetching all objects (DataObjects, Classes etc.).

* @memberOf QuerySet
* @instance

* @constructor
* @type {AllObjects}

* @property {Number} [timeout = 15000] 15 seconds
* @property {String} [path = null] request path
* @property {Boolean} [abort = false]  used internally to conrole for loop

* @example {@lang javascript}
* var all = AllObjects.setConfig(config)({
*   path: '/v1.1/instances/some-instance/classes/some-class/objects/'
* });
*
* all.on('start', function() {
*   console.log('all::start');
* });
*
* all.on('stop', function() {
*   console.log('all::stop');
* });
*
* all.on('page', function(page) {
*   console.log('all::page', page);
* });
*
* all.on('error', function(error) {
*   console.log('all::error', error);
* });

*/
const AllObjects = stampit()
  .compose(Request, EventEmittable)
  .props({
    timeout: 15000,
    path: null,
    abort: false,
    model: null,
    query: {},
    pages: null,
    currentPage: 0,
    result: []
  })
  .methods({

    request() {
      const options = {
        query: this.query
      }

      return this.makeRequest('GET', this.path, options);
    },

    start() {

      this.currentPage = 0;
      this.result = [];

      const pageLoop = () => {

        if(this.abort === true) {
          this.emit('stop', this.result);
          return this.result;
        }

        return this.request()
          .then((page) => {
            const serializedPage = this.model.please().asResultSet(page);
            this.result = _.concat(this.result, _.reject(serializedPage, _.isFunction));
            this.emit('page', serializedPage);
            this.currentPage++;
            if(serializedPage.hasNext() === true) {
              this.path = page.next;
            }
            if(serializedPage.hasNext() === false || (!_.isEmpty(this, 'pages') && this.currentPage == this.pages)) {
              this.abort = true;
            }
            return serializedPage;
          })
          .finally(pageLoop)
          .catch((error) => {
            if (error.timeout && error.timeout === this.timeout) {
              return this.emit('timeout', error);
            }

            this.emit('error', error);
            this.stop();
          });
      }

      pageLoop();
    },

    stop() {
      this.abort = true;
      return this;
    }

  });
  /**
  * Allows fetching of all objects of a type (DataObjects, Classes etc.) recursively.

  * @memberOf QuerySet
  * @instance

  * @returns {AllObjects}

  * @example {@lang javascript}
  * @example {@lang javascript}
  * var all = DataObject.please().all({ instanceName: 'test-instace', className: 'test-class' });
  *
  * all.on('start', function() {
  *   console.log('all::start');
  * });
  *
  * all.on('stop', function() {
  *   console.log('all::stop');
  * });
  *
  * all.on('page', function(page) {
  *   console.log('all::page', page);
  * });
  *
  * all.on('error', function(error) {
  *   console.log('all::error', error);
  * });

  */
const All = stampit().methods({

  all(properties = {}, query = {}, start = true, pages = 0) {
    this.properties = _.assign({}, this.properties, properties);

    const config = this.getConfig();
    const meta = this.model.getMeta();
    const path = meta.resolveEndpointPath('list', this.properties);

    let options = {}
    options.path = path;
    options.model = this.model;
    options.query = query;
    options.pages = pages;

    const allObjects = AllObjects.setConfig(config)(options);

    if (start === true) {
      allObjects.start();
    }

    return allObjects;
  }

});

export const BulkCreate = stampit().methods({

  /**
  * Creates many objects based on provied Array of objects.

  * @memberOf QuerySet
  * @instance

  * @param {Array} objects
  * @returns {Promise}

  * @example {@lang javascript}
  * const objects = [Instance({name: 'test1'}), Instance({name: 'tes21'})];
  * Instance.please().bulkCreate(objects).then(function(instances) {
  *   console.log('instances', instances);
  * });

  */
  bulkCreate(objects) {
    return Promise.mapSeries(objects, (o) => o.save());
  }
});

/**
 * Base class responsible for all ORM (``please``) actions.
 * @constructor
 * @type {QuerySet}

 * @property {Object}  model
 * @property {String}  [endpoint = 'list']
 * @property {String}  [method = 'GET']
 * @property {Object}  [headers = {}]
 * @property {Object}  [properties = {}]
 * @property {Object}  [query = {}]
 * @property {Object}  [payload = {}]
 * @property {Object}  [attachments = {}]
 * @property {Boolean}  [_serialize = true]
 */
const QuerySet = stampit.compose(
  QuerySetRequest,
  Create,
  BulkCreate,
  Get,
  GetOrCreate,
  List,
  Delete,
  Update,
  UpdateOrCreate,
  First,
  PageSize,
  Ordering,
  Fields,
  ExcludedFields,
  Raw,
  TemplateResponse,
  CacheKey,
  All
);

export const BaseQuerySet = stampit.compose(
  QuerySetRequest,
  Raw,
  Fields,
  ExcludedFields,
  Ordering,
  First,
  PageSize,
  TemplateResponse,
  EventEmittable,
  All
);

export default QuerySet;