feathers-cassandra
Version:
Feathers service adapter for Cassandra DB based on Express-Cassandra ORM and CassanKnex query builder
768 lines (752 loc) • 26.4 kB
JavaScript
"use strict";
function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
const {
AdapterService
} = require('@feathersjs/adapter-commons');
const errors = require('@feathersjs/errors');
const types = require('cassandra-driver').types;
const flatten = require('arr-flatten');
const utils = require('./utils');
const errorHandler = require('./error-handler');
const METHODS = {
$or: 'orWhere',
// not supported
$and: 'andWhere',
$token: 'tokenWhere',
$minTimeuuid: 'minTimeuuidWhere',
$maxTimeuuid: 'maxTimeuuidWhere',
$if: 'if',
$ifExists: 'ifExists',
$ifNotExists: 'ifNotExists',
$add: 'add',
$remove: 'remove',
$increment: 'increment',
$decrement: 'decrement'
};
const OPERATORS = {
eq: '$eq',
ne: '$ne',
// applicable for IF conditions only
gte: '$gte',
gt: '$gt',
lte: '$lte',
lt: '$lt',
in: '$in',
notIn: '$nin',
// not supported
like: '$like',
// applicable for SASI indexes only
notLike: '$notLike',
// not supported
ilike: '$ilike',
// not supported
notILike: '$notILike',
// not supported
or: '$or',
// not supported
and: '$and'
};
const OPERATORS_MAP = {
$eq: '=',
$ne: '!=',
// applicable for IF conditions only
$isnt: 'IS NOT',
// applicable for materialized view filters only
$gt: '>',
$lt: '<',
$gte: '>=',
$lte: '<=',
$in: 'IN',
$nin: 'NOT IN',
// not supported
$like: 'LIKE',
// applicable for SASI indexes only
$notLike: 'NOT LIKE',
// not supported
$iLike: 'ILIKE',
// not supported
$notILike: 'NOT ILIKE',
// not supported
$contains: 'CONTAINS',
// applicable for indexed collections only
$containsKey: 'CONTAINS KEY' // applicable for indexed maps only
};
/**
* Class representing a feathers adapter for ExpressCassandra ORM & CassanKnex query builder.
* @param {object} options
* @param {string} [options.idSeparator=','] - id field primary keys separator char
* @param {object} options.model - an ExpressCassandra model
* @param {object} options.paginate
* @param {object} options.events
*/
class Service extends AdapterService {
constructor(options) {
if (!options.model) {
throw new errors.GeneralError('You must provide an ExpressCassandra Model');
}
const whitelist = Object.values(OPERATORS).concat(options.whitelist || []);
let id = flatten(options.model._properties.schema.key);
id = id.length === 1 ? id[0] : id;
super(_extends({
id
}, options, {
whitelist
}));
this.idSeparator = options.idSeparator || ',';
this.keyspace = options.model.get_keyspace_name();
this.tableName = options.model.get_table_name();
this.materializedViews = options.materializedViews || [];
this.modelOptions = options.model._properties.schema.options || {};
this.fields = options.model._properties.schema.fields;
this.filters = options.model._properties.schema.filters || {};
}
get Model() {
return this.options.model;
}
getModel(params) {
return this.options.model;
}
filterQuery(params) {
const filter = query => {
Object.keys(query).forEach(key => {
const value = query[key];
if (key[0] !== '$' && this.fields[key] && utils.getFieldType(this.fields[key]) === 'boolean' && typeof value === 'string') {
query[key] = value !== '' && value !== '0' && value !== 'false';
} else if (value instanceof types.Uuid || Buffer.isBuffer(value)) {
query[key] = value.toString();
} else if (Array.isArray(value)) {
value.forEach((fieldValue, fieldKey) => {
if (fieldValue instanceof types.Uuid || Buffer.isBuffer(fieldValue)) {
query[key][fieldKey] = fieldValue.toString();
}
});
} else if (utils.isPlainObject(value)) {
return filter(value);
}
});
};
if (params.query) {
filter(params.query);
}
return super.filterQuery(params);
}
/**
* Create a new query that re-queries all ids that were originally changed
* @param id
* @param idList
*/
getIdsQuery(id, idList) {
const query = {};
if (Array.isArray(this.id)) {
let ids = id;
if (id && !Array.isArray(id)) {
ids = utils.extractIds(id, this.id, this.idSeparator);
}
this.id.forEach((idKey, index) => {
if (!ids) {
if (idList) {
if (idList[index]) {
query[idKey] = idList[index].length === 1 ? idList[index] : {
$in: idList[index]
};
}
} else {
query[idKey] = null;
}
} else if (ids[index]) {
query[idKey] = ids[index];
} else {
throw new errors.BadRequest('When using composite primary key, id must contain values for all primary keys');
}
});
} else {
query[`${this.id}`] = idList ? idList.length === 1 ? idList[0] : {
$in: idList
} : id;
}
return query;
}
/**
* Maps a feathers query to the CassanKnex schema builder functions.
* @param query - a query object. i.e. { type: 'fish', age: { $lte: 5 }
* @param params
* @param parentKey
* @param methodKey
*/
objectify(query, params, parentKey, methodKey) {
if (params.$filters) {
delete params.$filters;
}
if (params.$allowFiltering) {
delete params.$allowFiltering;
}
if (!isNaN(params.$ttl)) {
delete params.$ttl;
}
if (params.$timestamp) {
delete params.$timestamp;
}
if (params.$noSelect) {
delete params.$noSelect;
}
if (params.$limitPerPartition) {
delete params.$limitPerPartition;
}
Object.keys(params || {}).forEach(key => {
if (parentKey === '$token' && key === '$condition') {
return;
}
let value = params[key];
if (utils.isPlainObject(value)) {
return this.objectify(query, value, key, parentKey);
}
const column = parentKey && parentKey[0] !== '$' ? parentKey : key;
const method = METHODS[methodKey] || METHODS[parentKey] || METHODS[key];
const operator = OPERATORS_MAP[key] || '=';
if (method) {
if (!methodKey && (key === '$or' || key === '$and')) {
value.forEach(condition => {
this.objectify(query, condition, null, key);
});
return;
}
if (parentKey === '$token') {
return query.tokenWhere(params.$keys, OPERATORS_MAP[Object.keys(params.$condition)[0]], params.$condition[Object.keys(params.$condition)[0]]);
}
if (method === METHODS.$or) {
throw new errors.BadRequest('`$or` is not supported');
}
if (method === METHODS.$if && value === 'null') {
value = null;
}
return query[method].call(query, column, operator, value); // eslint-disable-line no-useless-call
}
if (operator === 'NOT IN') {
throw new errors.BadRequest('`$nin` is not supported');
}
return query.where(column, operator, value);
});
}
_createQuery(type) {
let q = null;
if (!Service.cassanknex) {
throw new errors.GeneralError('You must bind FeathersCassandra with an initialized CassanKnex object');
}
if (!type) {
q = Service.cassanknex(this.keyspace).from(this.tableName);
}
if (type === 'create') {
q = Service.cassanknex(this.keyspace).into(this.tableName);
}
if (type === 'update') {
q = Service.cassanknex(this.keyspace).update(this.tableName);
}
if (type === 'delete') {
q = Service.cassanknex(this.keyspace).delete().from(this.tableName);
}
return q;
}
createQuery(filters, query) {
let q = this._createQuery();
// $select uses a specific find syntax, so it has to come first.
if (filters.$select) {
const fieldsToRemove = [];
for (const field of filters.$select) {
const match = field && field.match(/(ttl|writetime|dateOf|unixTimestampOf|toDate|toTimestamp|toUnixTimestamp)\((.+)\)/);
if (match) {
const fieldMethod = match[1];
const fieldName = match[2];
q[fieldMethod]({
[fieldName]: `${fieldMethod}(${fieldName})`
});
fieldsToRemove.push(field);
}
}
filters.$select = filters.$select.filter(val => !fieldsToRemove.includes(val));
q = q.select(...filters.$select.concat(this.id));
} else {
q = q.select();
}
this.objectify(q, query);
if (filters.$sort) {
Object.keys(filters.$sort).forEach(key => {
q = q.orderBy(key, filters.$sort[key] === 1 ? 'asc' : 'desc');
});
}
return q;
}
validate(data, type) {
const model = new this.Model();
const fields = type === 'patch' ? Object.keys(data) : Object.keys(this.fields);
if (type === 'update') {
if (Array.isArray(this.id)) {
for (const idKey of this.id) {
fields.splice(fields.indexOf(idKey), 1);
}
} else {
fields.splice(fields.indexOf(this.id), 1);
}
}
for (const field of fields) {
let value = data[field];
const fieldRule = utils.getFieldRule(this.fields[field]);
const fieldType = utils.getFieldType(this.fields[field]);
if (value === undefined || value === null) {
if (fieldRule && fieldRule.required) {
throw new errors.BadRequest(`\`${field}\` field is required`);
}
}
let methodKey = null;
if (value && utils.isPlainObject(value) && METHODS[Object.keys(value)[0]]) {
methodKey = Object.keys(value)[0];
}
if (!methodKey || methodKey !== '$remove' || fieldType !== 'map') {
if (methodKey === '$increment' || methodKey === '$decrement') {
value = Number(value);
}
let valueToValidate = methodKey ? value[methodKey] : value;
if (valueToValidate) {
if (fieldType === 'timeuuid') {
if (valueToValidate instanceof types.TimeUuid || valueToValidate.constructor.name === 'TimeUuid' || Buffer.isBuffer(valueToValidate)) {
data[field] = valueToValidate.toString();
} else {
valueToValidate = types.TimeUuid.fromString(valueToValidate.toString());
}
if (valueToValidate instanceof types.TimeUuid) {
// workaround for issue with express-cassandra validator when fromString is used
continue;
}
} else if (fieldType === 'uuid') {
if (valueToValidate instanceof types.Uuid || valueToValidate.constructor.name === 'Uuid' || Buffer.isBuffer(valueToValidate)) {
data[field] = valueToValidate.toString();
} else {
valueToValidate = types.Uuid.fromString(valueToValidate.toString());
}
if (valueToValidate instanceof types.Uuid) {
// workaround for issue with express-cassandra validator when fromString is used
continue;
}
}
}
const validated = model.validate(field, valueToValidate);
if (validated !== true) {
throw new errors.BadRequest(validated(valueToValidate, field, fieldType));
}
}
}
}
/**
* `find` service function for FeathersCassandra.
* @param params
*/
_find(params = {}) {
const find = (params, count, filters, query) => {
let allowFiltering = false;
let filtersQueue = null;
const materializedView = utils.getMaterializedView(query, this.materializedViews);
const q = this.createQuery(filters, query);
if (materializedView) {
q.from(materializedView);
}
if (params.query) {
if (params.query.$allowFiltering) {
allowFiltering = true;
q.allowFiltering();
delete params.query.$allowFiltering;
}
if (params.query.$filters) {
filtersQueue = utils.runFilters(params, q, params.query.$filters, this.filters);
delete params.query.$filters;
}
if (params.query.$limitPerPartition) {
q.limitPerPartition(params.query.$limitPerPartition);
delete params.query.$limitPerPartition;
}
}
if (filters.$limit) {
q.limit(filters.$limit);
}
let executeQuery = res => {
const total = res ? Number(res.rows[0].count) : undefined;
return utils.exec(q, params).then(res => {
return {
total,
limit: filters.$limit,
data: res.rows
};
});
};
if (filters.$limit === 0) {
executeQuery = res => {
const total = res ? Number(res.rows[0].count) : undefined;
return Promise.resolve({
total,
limit: filters.$limit,
data: []
});
};
}
if (count) {
const countQuery = this._createQuery().select().count('*');
if (allowFiltering) {
countQuery.allowFiltering();
}
if (filtersQueue) {
for (const filter of filtersQueue) {
filter(countQuery);
}
}
this.objectify(countQuery, query);
return utils.exec(countQuery, params).then(res => {
return executeQuery(res);
}).catch(errorHandler);
}
return executeQuery().catch(errorHandler);
};
const {
filters,
query,
paginate
} = this.filterQuery(params);
const result = find(params, Boolean(paginate && paginate.default), filters, query);
if (!paginate || !paginate.default) {
return result.then(page => page.data || page);
}
return result;
}
_get(id, params = {}) {
const query = _extends({}, params.query, this.getIdsQuery(id));
return this._find(_extends({}, params, {
query
})).then(page => {
const data = page.data || page;
if (data.length !== 1) {
throw new errors.NotFound(`No record found for id '${id}'`);
}
return data[0];
});
}
/**
* `create` service function for FeathersCassandra.
* @param {object} data
* @param {object} params
*/
_create(data, params = {}) {
const create = (data, params, hookOptions, batch = false) => {
this.validate(data);
utils.setTimestampFields(data, true, true, this.modelOptions.timestamps);
utils.setVersionField(data, this.modelOptions.versions);
const beforeHook = this.Model._properties.schema.before_save;
const afterHook = this.Model._properties.schema.after_save;
if (beforeHook && beforeHook(data, hookOptions) === false) {
throw new errors.BadRequest('Error in before_save lifecycle function');
}
const q = this._createQuery('create');
if (params.query) {
if (params.query.$ifNotExists) {
q.ifNotExists();
delete params.query.$ifNotExists;
}
if (!isNaN(params.query.$ttl)) {
q.usingTTL(Number(params.query.$ttl));
delete params.query.$ttl;
}
if (params.query.$timestamp) {
q.usingTimestamp(params.query.$timestamp);
delete params.query.$timestamp;
}
}
if (batch) {
return q.insert(data);
}
return utils.exec(q.insert(data), params).then(row => {
if (afterHook && afterHook(data, hookOptions) === false) {
throw new errors.BadRequest('Error in after_save lifecycle function');
}
if (params.query && params.query.$noSelect) {
return data;
}
let id;
if (Array.isArray(this.id)) {
id = [];
for (const idKey of this.id) {
id.push(data[idKey]);
}
} else {
id = data[this.id];
}
if (!id || Array.isArray(id) && !id.length) {
return data;
}
return this._get(id, params);
}).catch(errorHandler);
};
if (Array.isArray(data)) {
const dataCopy = data.map(val => _extends({}, val));
const query = this.filterQuery(params).query;
if (query.$batch) {
const afterHook = this.Model._properties.schema.after_save;
const hookOptions = utils.getHookOptions(query);
return utils.batch(Service.cassanknex, dataCopy.map(current => create(current, params, hookOptions, true)), params).then(res => {
dataCopy.forEach(item => {
if (afterHook && afterHook(item, hookOptions) === false) {
throw new errors.BadRequest('Error in after_save lifecycle function');
}
});
return dataCopy;
}).catch(errorHandler);
}
return Promise.all(dataCopy.map(current => create(current, params)));
}
return create(_extends({}, data), params);
}
/**
* `update` service function for FeathersCassandra.
* @param id
* @param data
* @param params
*/
_update(id, data, params = {}) {
const update = (id, data, params, oldData) => {
const fields = Object.keys(oldData || this.fields);
const createdAtField = this.modelOptions.timestamps && this.modelOptions.timestamps.createdAt;
let newObject = {};
// Set missing fields to null
for (const key of fields) {
if (data[key] === undefined) {
if (!createdAtField || key !== createdAtField) {
newObject[key] = null;
}
} else {
newObject[key] = data[key];
}
}
// Delete id field so we don't update it
if (Array.isArray(this.id)) {
for (const idKey of this.id) {
delete newObject[idKey];
}
} else {
delete newObject[this.id];
}
const q = this._createQuery('update');
const idsQuery = this.getIdsQuery(id);
if (params.query && !isNaN(params.query.$ttl)) {
q.usingTTL(Number(params.query.$ttl));
delete params.query.$ttl;
}
if (params.query && params.query.$timestamp) {
q.usingTimestamp(params.query.$timestamp);
delete params.query.$timestamp;
}
utils.prepareData(q, newObject, METHODS);
q.set(newObject);
Object.keys(idsQuery).forEach(key => {
q.where(key, '=', idsQuery[key]);
});
if (!oldData) {
const query = this.filterQuery(params).query;
utils.prepareIfCondition(id, query, this.id);
this.objectify(q, query);
}
return utils.exec(q, params).then(() => {
// Restore the createdAt field so we can return it to the client
if (createdAtField && !newObject[createdAtField] && oldData) {
newObject[createdAtField] = oldData[createdAtField];
}
// Restore the id field so we can return it to the client
if (Array.isArray(this.id)) {
newObject = _extends({}, newObject, this.getIdsQuery(id));
} else {
newObject[this.id] = id;
}
if (oldData) {
utils.prepareUpdateResult(data, oldData, newObject, this.fields);
}
if (params.query && params.query.$select) {
const selectedFields = {};
for (const field of params.query.$select) {
selectedFields[field] = newObject[field];
}
return selectedFields;
}
return newObject;
}).catch(errorHandler);
};
this.validate(data, 'update');
utils.setTimestampFields(data, true, false, this.modelOptions.timestamps);
utils.setVersionField(data, this.modelOptions.versions);
const beforeHook = this.Model._properties.schema.before_update;
const afterHook = this.Model._properties.schema.after_update;
const hookOptions = utils.getHookOptions(params.query);
if (beforeHook && beforeHook(params.query, data, hookOptions, id) === false) {
throw new errors.BadRequest('Error in before_update lifecycle function');
}
if (params.query && params.query.$noSelect) {
delete params.query.$noSelect;
return update(id, data, params).then(data => {
if (afterHook && afterHook(params.query, data, hookOptions, id) === false) {
throw new errors.BadRequest('Error in after_update lifecycle function');
}
return data;
});
}
return this._get(id, params).then(oldData => {
return update(id, data, params, oldData).then(data => {
if (afterHook && afterHook(params.query, data, hookOptions, id) === false) {
throw new errors.BadRequest('Error in after_update lifecycle function');
}
return data;
});
});
}
/**
* `patch` service function for FeathersCassandra.
* @param id
* @param data
* @param params
*/
_patch(id, data, params = {}) {
this.validate(data, 'patch');
utils.setTimestampFields(data, true, false, this.modelOptions.timestamps);
utils.setVersionField(data, this.modelOptions.versions);
const beforeHook = this.Model._properties.schema.before_update;
const afterHook = this.Model._properties.schema.after_update;
const hookOptions = utils.getHookOptions(params.query);
if (beforeHook && beforeHook(params.query, data, hookOptions, id) === false) {
throw new errors.BadRequest('Error in before_update lifecycle function');
}
let {
filters,
query
} = this.filterQuery(params);
const dataCopy = _extends({}, data);
const mapIds = page => Array.isArray(this.id) ? this.id.map(idKey => [...new Set((page.data || page).map(current => current[idKey]))]) : (page.data || page).map(current => current[this.id]);
// By default we will just query for the one id. For multi patch
// we create a list of the ids of all items that will be changed
// to re-query them after the update
const ids = id === null ? this._find(params).then(mapIds) : Promise.resolve([id]);
if (id !== null) {
if (Array.isArray(this.id)) {
query = _extends({}, query, this.getIdsQuery(id));
} else {
query[this.id] = id;
}
}
const q = this._createQuery('update');
if (params.query) {
if (!isNaN(params.query.$ttl)) {
q.usingTTL(Number(params.query.$ttl));
delete params.query.$ttl;
}
if (params.query.$timestamp) {
q.usingTimestamp(params.query.$timestamp);
delete params.query.$timestamp;
}
}
utils.prepareIfCondition(id, query, this.id);
this.objectify(q, query);
if (Array.isArray(this.id)) {
for (const idKey of this.id) {
delete dataCopy[idKey];
}
} else {
delete dataCopy[this.id];
}
return ids.then(idList => {
// Create a new query that re-queries all ids that
// were originally changed
const selectParam = filters.$select ? {
$select: filters.$select
} : undefined;
const findParams = _extends({}, params, {
query: _extends({}, this.getIdsQuery(id, idList), selectParam)
});
utils.prepareData(q, dataCopy, METHODS);
q.set(dataCopy);
return utils.exec(q, params).then(() => {
if (afterHook && afterHook(params.query, data, hookOptions, id) === false) {
throw new errors.BadRequest('Error in after_update lifecycle function');
}
return params.query && params.query.$noSelect ? {} : this._find(findParams).then(page => {
const items = page.data || page;
if (id !== null) {
if (items.length === 1) {
return items[0];
} else {
throw new errors.NotFound(`No record found for id '${id}'`);
}
} else if (!items.length) {
throw new errors.NotFound(`No record found for id '${id}'`);
}
return items;
});
});
}).catch(errorHandler);
}
/**
* `remove` service function for FeathersCassandra.
* @param id
* @param params
*/
_remove(id, params = {}) {
const beforeHook = this.Model._properties.schema.before_delete;
const afterHook = this.Model._properties.schema.after_delete;
const hookOptions = utils.getHookOptions(params.query);
if (beforeHook && beforeHook(params.query, hookOptions, id) === false) {
throw new errors.BadRequest('Error in before_delete lifecycle function');
}
params.query = _extends({}, params.query);
// First fetch the record so that we can return
// it when we delete it.
if (id !== null) {
if (Array.isArray(this.id)) {
params.query = _extends({}, params.query, this.getIdsQuery(id));
} else {
params.query[this.id] = id;
}
}
const {
query: queryParams
} = this.filterQuery(params);
const query = this._createQuery('delete');
utils.prepareIfCondition(id, queryParams, this.id);
this.objectify(query, queryParams);
if (params.query && params.query.$noSelect) {
return utils.exec(query, params).then(() => {
if (afterHook && afterHook(params.query, hookOptions, id) === false) {
throw new errors.BadRequest('Error in after_delete lifecycle function');
}
return {};
}).catch(errorHandler);
} else {
return this._find(params).then(page => {
const items = page.data || page;
return utils.exec(query, params).then(() => {
if (afterHook && afterHook(params.query, hookOptions, id) === false) {
throw new errors.BadRequest('Error in after_delete lifecycle function');
}
if (id !== null) {
if (items.length === 1) {
return items[0];
} else {
throw new errors.NotFound(`No record found for id '${id}'`);
}
} else if (!items.length) {
throw new errors.NotFound(`No record found for id '${id}'`);
}
return items;
});
}).catch(errorHandler);
}
}
}
const init = options => {
return new Service(options);
};
init.cassanknex = val => {
Service.cassanknex = val;
};
init.Service = Service;
init.ERROR = errorHandler.ERROR;
module.exports = init;