lib/service/couchbase.service.js
'use strict';
const Promise = require('bluebird');
const { BadRequest, NotFound, NotImplemented } = require('@feathersjs/errors');
const { keyNotFound } = require('couchbase').errors;
const { QueryBuilder, QueryConsistency } = require('../querybuilder');
const uuid = require('uuid/v4');
const R = require('ramda');
class Service {
/**
* Couchbase Service
* @param {object} opts - Couchbase Service options
* @param {string} opts.bucket - Bucket name (REQUIRED)
* @param {object|Promise} opts.connection - Couchbase bucket connection or promise which resolves to connection (REQUIRED)
* @param {string} opts.name - Service name (REQUIRED)
* @param {object|array<string>>} opts.multi - Enable multi-CRUD operations, optionally can an array of strings which specify which
* functions allow mutli-access(OPTIONAL)
* @param {object} opts.couchbase - Couchbase package dependency (OPTIONAL)
* @param {string} opts.separator - Key separator (OPTIONAL)
* @param {object} opts.paginate - FeathersJS Paginate object (OPTIONAL)
* @param {string} opts.id - Field to use for unique key (OPTIONAL), DO NOT change this without data migration
*/
constructor (opts) {
this.opts = opts || {};
if (this.opts.couchbase == null) {
this.opts.couchbase = require('couchbase');
}
if (this.opts.bucket == null) { throw new Error('Must pass bucket name'); }
if (this.opts.connection == null) { throw new Error('Must pass bucket connection'); }
if (this.opts.name == null) { throw new Error('Name of service must be specified'); }
this.id = opts.id || 'uuid';
this.paginate = opts.paginate || {};
this.separator = opts.separator || '::';
if (opts.multi && Array.isArray(opts.multi)) {
this.multi = {};
for (const method of opts.multi) {
this.multi[method] = 1;
}
} else { this.multi = !!opts.multi; }
}
/**
* FeathersJS Service Setup
* @param app
* @param path
*/
setup (app, path) {
this.app = app;
this.path = path;
}
/**
* Get couchbase connection
*/
schema () {
// Promisfy the passed in connection in case it's already active and not awaiting it
return Promise.resolve(this.opts.connection);
}
_isMultiAllowed (methodName) {
if (this.multi === false) {
return false;
} else if (this.multi === true || this.multi[methodName]) {
return true;
}
}
/**
* Translate Couchbase Errors to FeathersErrors
* @param err
* @private
*/
_handleError (err) {
/* istanbul ignore else */
if (err.code === keyNotFound) {
throw new NotFound('Does not exist');
}
/* istanbul ignore next */
throw err;
}
/**
* Build key
* @param key Key to prefix
* @returns {string}
* @private
*/
_key (key = '') {
return [this.opts.name || '', key].join(this.separator);
}
/**
* Get id if one is passed in, otherwise provide one
* @param data
* @returns {string}
* @private
*/
_id (data) {
if (!(this.id in data)) { data[this.id] = uuid(); }
return data[this.id];
}
/**
* Strip keys from Service.find response query
* @param selected Selected keys
* @param results Result set
* @return {Array}
* @private
*/
_stripKeys (selected, results) {
return results.map(a => {
const result = {};
for (let key in a) {
if (a.hasOwnProperty(key) && ~selected.indexOf(key)) { result[key] = a[key]; }
}
return result;
});
}
/**
* Build N1QL Query
* @param couchbase {object} Couchbase dependency
* @param str {string} Query String
* @param consistency {Number} N1QL Consistency
* @param readonly {boolean} N1QL readonly
* @private
*/
_buildN1QL (couchbase, str, consistency, readonly = false) {
const $query = couchbase.N1qlQuery.fromString(str);
if (consistency === QueryConsistency.NOT_BOUNDED) {
$query.consistency(couchbase.N1qlQuery.Consistency.NOT_BOUNDED);
} else if (consistency === QueryConsistency.REQUEST_PLUS) {
$query.consistency(couchbase.N1qlQuery.Consistency.REQUEST_PLUS);
} else if (consistency === QueryConsistency.STATEMENT_PLUS) {
$query.consistency(couchbase.N1qlQuery.Consistency.STATEMENT_PLUS);
}
// Find queries shouldn't ever mutate...
$query.readonly(readonly);
return $query;
}
_find (query, paginate, consistency = null) {
const { couchbase } = this.opts;
const QB = (new QueryBuilder(this.opts.bucket));
const { query: queryStr, values } = QB.interpret(query);
let selected = [];
if (query.$select) {
for (let select of query.$select) { selected.push(select); }
delete query.$select;
}
return this.schema()
.then(bucket => new Promise((resolve, reject) => {
bucket.query(
this._buildN1QL(couchbase, queryStr, consistency, true),
values,
(err, result, meta) => {
if (err) { return void reject(err); }
return void resolve([result, meta]);
});
})
.then(([results, queryData]) => {
if (selected.length > 0) {
results = this._stripKeys(selected, results);
}
if (!paginate.default) { return results; }
return {
total: queryData.metrics.resultCount,
limit: query.$limit,
skip: query.$skip != null ? query.$skip : 0,
data: results
};
})
);
}
/**
* FeathersJS Service Find
* @param params
* @returns {bluebird}
*/
find (params) {
return new Promise((resolve, reject) => {
if (params == null) {
throw new BadRequest('Null passed to find');
}
const { query } = params;
const paginate = (params && typeof params.paginate !== 'undefined') ? params.paginate : this.paginate;
if (query == null) {
throw new BadRequest('Null query object passed');
}
query._type = this.opts.name;
if (paginate.default) {
if (query.$limit == null) { query.$limit = paginate.default; }
query.$limit = R.clamp(0, paginate.max)(query.$limit);
}
let consistency = query.$consistency;
if ('$consistency' in query) { delete query.$consistency; }
resolve(this._find(query, paginate, consistency));
});
}
/**
* FeathersJS Service Get
* @param id
* @param params
* @returns {Promise}
*/
get (id, params) {
return this.schema()
.then(bucket => new Promise((resolve, reject) => {
bucket.get(this._key(id), (err, result) => {
if (err) { return void reject(err); }
return void resolve(result);
});
})
.then((element) => {
/* istanbul ignore next */
if (element == null) throw new NotFound('Does not exist');
/* istanbul ignore next */
return element.value || element;
})
.catch(this._handleError)
);
}
/**
* FeathersJS Service Create
* @param data
* @param params
* @returns {bluebird}
*/
create (data, params) {
return new Promise((resolve, reject) => {
if (data == null) {
return void reject(new BadRequest('No data passed to create'));
}
if (Array.isArray(data) && !this._isMultiAllowed('create')) {
throw new BadRequest('Multi-access is not enabled for this service method');
}
if (Array.isArray(data)) {
return void resolve(Promise.map(data, (_data) => {
return this.create(_data, params);
}));
}
data._type = this.opts.name;
return void resolve(
this.schema()
.then(bucket => new Promise((resolve, reject) => {
bucket.insert(this._key(this._id(data)), data, (err, result) => {
if (err) { return void reject(err); }
return void resolve(result);
});
})
.then(() => this.get(this._id(data), params)))
);
});
}
/**
* FeathersJS Service Update
* @param id
* @param data
* @param params
* @returns {Promise}
*/
update (id, data, params) {
return new Promise((resolve) => {
if (id == null) {
throw new BadRequest('ID cannot be null for update');
}
return void resolve(this.get(id, params)
.then(
_obj => this.schema()
.then((bucket) => {
// Keep id field intact upon updates
const newObject = Object.assign(
Object.assign({ [this.id]: _obj[this.id] }, data),
{ _type: this.opts.name }
);
return new Promise((resolve, reject) => {
bucket.replace(this._key(id), newObject, (err, result) => {
if (err) { return void reject(err); }
return void resolve(result);
});
});
})
)
.then(() => this.get(id, params))
.catch(this._handleError)
);
});
}
/**
* FeathersJS Service Patch
* @param id
* @param data
* @param params
* @returns {Promise}
*/
patch (id, data, params) {
return new Promise((resolve) => {
if (id == null && (params || {}).query && !this._isMultiAllowed('patch')) {
throw new BadRequest('Multi-access is not enabled for this service method');
} else if (id == null && this._isMultiAllowed('patch')) {
throw new NotImplemented('Service adapter does not support multi-patch at current time');
}
return void resolve(
this.get(id, params)
.then(
_obj => this.schema()
.then(bucket => new Promise((resolve, reject) => {
bucket.replace(
this._key(id),
Object.assign(Object.assign(_obj, data), { _type: this.opts.name }),
(err, result) => {
if (err) { return void reject(err); }
return void resolve(result);
}
);
})
)
)
.then(() => this.get(id, params))
.catch(this._handleError)
);
});
}
/**
* FeathersJS Service Remove
* @param id
* @param params
*/
remove (id, params) {
return new Promise((resolve) => {
if (id == null && (params || {}).query && !this._isMultiAllowed('remove')) {
throw new BadRequest('Multi-access is not enabled for this service method');
} else if (id == null && this._isMultiAllowed('remove')) {
throw new NotImplemented('Service adapter does not support multi-remove at current time');
}
return void resolve(
this.get(id, params)
.then((data) => this.schema()
.then(bucket => new Promise((resolve, reject) => {
bucket.remove(this._key(this._id(data)), (err, result) => {
if (err) { return void reject(err); }
return void resolve(result);
});
}))
.then((resp) => {
if ((params || {}).$return === false) {
return resp;
}
return data;
})
)
.catch(this._handleError)
);
});
}
}
module.exports = Service;