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;