Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ __Options:__
- `raw` (*optional*, default: `true`) - Runs queries faster by returning plain objects instead of Sequelize models.
- `events` (*optional*) - A list of [custom service events](https://docs.feathersjs.com/api/events.html#custom-events) sent by this service
- `paginate` (*optional*) - A [pagination object](https://docs.feathersjs.com/api/databases/common.html#pagination) containing a `default` and `max` page size
- `multi` (*optional*) - Allow `create` with arrays and `update` and `remove` with `id` `null` to change multiple items. Can be `true` for all methods or an array of allowed methods (e.g. `[ 'remove', 'create' ]`)
- `operators` (*optional*) - A mapping from query syntax property names to to [Sequelize secure operators](http://docs.sequelizejs.com/manual/tutorial/querying.html)
- `whitelist` (*optional*) - A list of additional query parameters to allow (e..g `[ '$regex', '$geoNear' ]`). Default is the supported `operators`

### params.sequelize

Expand All @@ -70,6 +73,27 @@ app.service('messages').hooks({
});
```

### operators

Sequelize deprecated string based operators a while ago for security reasons. Starting at version 4.0.0 `feathers-sequelize` converts queries securely. If you want to support additional Sequelize operators, the `operators` service option can contain a mapping from query parameter name to Sequelize operator. By default supported are:

```
'$eq',
'$ne',
'$gte',
'$gt',
'$lte',
'$lt',
'$in',
'$nin',
'$like',
'$notLike',
'$iLike',
'$notILike',
'$or',
'$and'
```

## Sequelize `raw` queries

By default, all `feathers-sequelize` operations will return `raw` data (using `raw: true` when querying the database). This results in faster execution and allows feathers-sequelize to interoperate with feathers-common hooks and other 3rd party integrations. However, this will bypass some of the "goodness" you get when using Sequelize as an ORM:
Expand Down Expand Up @@ -480,8 +504,34 @@ In the unfortunate case where you must revert your app to a previous state, it i
1. Revert your code back to the previous state
1. Start your app

### Migrating

`feathers-sequelize` 4.0.0 comes with important security and usability updates.

> __Important:__ For general migration information to the new database adapter functionality see [crow.docs.feathersjs.com/migrating.html#database-adapters](https://crow.docs.feathersjs.com/migrating.html#database-adapters).

The following breaking changes have been introduced:

- All methods now take `params.sequelize` into account
- All methods allow additional query parameters
- Multiple updates are disabled by default (see the `multi` option)
- Upgraded to secure Sequelize operators (see the [operators](#operators) option)
- Errors no longer contain Sequelize specific information. The original Sequelize error can be retrieved on the server via:

```js
const { ERROR } = require('feathers-sequelize');

try {
await sequelizeService.doSomethign();
} catch(error) {
// error is a FeathersError
// Safely retrieve the Sequelize error
const sequelizeError = error.data[ERROR];
}
```

## License

Copyright (c) 2017
Copyright (c) 2019

Licensed under the [MIT license](LICENSE).
210 changes: 107 additions & 103 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,47 @@
const omit = require('lodash.omit');
const Proto = require('uberproto');
const errors = require('@feathersjs/errors');
const { select, filterQuery } = require('@feathersjs/commons');
const { _ } = require('@feathersjs/commons');
const { select, AdapterService } = require('@feathersjs/adapter-commons');

const utils = require('./utils');

class Service {
const defaultOperators = Op => {
return {
$eq: Op.eq,
$ne: Op.ne,
$gte: Op.gte,
$gt: Op.gt,
$lte: Op.lte,
$lt: Op.lt,
$in: Op.in,
$nin: Op.notIn,
$like: Op.like,
$notLike: Op.notLike,
$iLike: Op.ilike,
$notILike: Op.notILike,
$or: Op.or,
$and: Op.and
};
};

class Service extends AdapterService {
constructor (options) {
if (!options) {
throw new Error('Sequelize options have to be provided');
}

if (!options.Model) {
throw new Error('You must provide a Sequelize Model');
}

this.paginate = options.paginate || false;
this.options = options;
this.Model = options.Model;
this.id = options.id || 'id';
this.events = options.events;
const defaultOps = defaultOperators(options.Model.sequelize.Op);
const operators = Object.assign(defaultOps, options.operators);
const whitelist = Object.keys(operators).concat(options.whitelist || []);

super(Object.assign({
id: 'id',
operators,
whitelist
}, options));
this.raw = options.raw !== false;
}

getModel (params) {
return this.Model;
return this.options.Model;
}

applyScope (params) {
Expand All @@ -34,13 +51,47 @@ class Service {
return this.getModel(params);
}

extend (obj) {
return Proto.extend(obj, this);
filterQuery (params) {
const filtered = super.filterQuery(params);
const operators = this.options.operators;
const convertOperators = query => {
if (Array.isArray(query)) {
return query.map(convertOperators);
}

if (!_.isObject(query)) {
return query;
}

return Object.keys(query).reduce((result, prop) => {
const value = query[prop];
const key = operators[prop] ? operators[prop] : prop;

result[key] = convertOperators(value);

return result;
}, {});
};

filtered.query = convertOperators(filtered.query);

return filtered;
}

_find (params, getFilter = filterQuery, paginate) {
const { filters, query } = getFilter(params.query || {});
const where = utils.getWhere(query);
// returns either the model intance for an id or all unpaginated
// items for `params` if id is null
_getOrFind (id, params) {
if (id === null) {
return this._find(Object.assign(params, {
paginate: false
}));
}

return this._get(id, params);
}

_find (params) {
const { filters, query: where, paginate } = this.filterQuery(params);
const order = utils.getOrder(filters.$sort);

const q = Object.assign({
Expand Down Expand Up @@ -70,7 +121,7 @@ class Service {
delete q.raw;
}

if (paginate) {
if (paginate && paginate.default) {
return Model.findAndCountAll(q).then(result => {
return {
total: result.count,
Expand All @@ -79,28 +130,13 @@ class Service {
data: result.rows
};
}).catch(utils.errorHandler);
} else {
return Model.findAll(q).then(result => {
return {
data: result
};
}).catch(utils.errorHandler);
}
}

find (params) {
const paginate = (params && typeof params.paginate !== 'undefined') ? params.paginate : this.paginate;
const result = this._find(params, where => filterQuery(where, paginate), paginate);

if (!paginate.default) {
return result.then(page => page.data);
}

return result;
return Model.findAll(q).catch(utils.errorHandler);
}

_get (id, params) {
const where = utils.getWhere(params.query);
const { query: where } = this.filterQuery(params);

// Attach 'where' constraints, if any were used.
const q = Object.assign({
Expand All @@ -118,28 +154,12 @@ class Service {
}

return result[0];
})
.then(select(params, this.id))
.catch(error => {
throw new errors.NotFound(`No record found for id '${id}'`, error);
});
}

// returns either the model intance for an id or all unpaginated
// items for `params` if id is null
_getOrFind (id, params) {
if (id === null) {
return this._find(params).then(page => page.data);
}

return this._get(id, params);
}).then(select(params, this.id)).catch(error => {
throw new errors.NotFound(`No record found for id '${id}'`, error);
});
}

get (id, params) {
return this._get(id, params).then(select(params, this.id));
}

create (data, params) {
_create (data, params) {
const options = Object.assign({ raw: this.raw }, params.sequelize);
// Model.create's `raw` option is different from other methods.
// In order to use `raw` consistently to serialize the result,
Expand Down Expand Up @@ -170,9 +190,9 @@ class Service {
}).catch(utils.errorHandler);
}

patch (id, data, params) {
const where = Object.assign({}, filterQuery(params.query || {}).query);
const mapIds = page => page.data.map(current => current[this.id]);
_patch (id, data, params) {
const where = Object.assign({}, this.filterQuery(params).query);
const mapIds = data => data.map(current => current[this.id]);

if (id !== null) {
where[this.id] = id;
Expand All @@ -187,7 +207,7 @@ class Service {
options.returning = true;

return this._getOrFind(id, params)
.then(results => this.getModel(params).update(omit(data, this.id), options))
.then(results => this.getModel(params).update(_.omit(data, this.id), options))
.then(results => {
if (id === null) {
return results[1];
Expand All @@ -209,69 +229,55 @@ class Service {
const ids = id === null ? this._find(params)
.then(mapIds) : Promise.resolve([ id ]);

return ids
.then(idList => {
// Create a new query that re-queries all ids that
// were originally changed
const findParams = Object.assign({}, params, {
query: { [this.id]: { $in: idList } }
return ids.then(idList => {
// Create a new query that re-queries all ids that
// were originally changed
const findParams = Object.assign({}, params, Object.assign({}, {
query: Object.assign({ [this.id]: { $in: idList } }, params.query)
}));

return Model.update(_.omit(data, this.id), options)
.then(() => {
if (params.$returning !== false) {
return this._getOrFind(id, findParams);
} else {
return Promise.resolve([]);
}
});

return Model.update(omit(data, this.id), options)
.then(() => {
if (params.$returning !== false) {
return this._getOrFind(id, findParams);
} else {
return Promise.resolve([]);
}
});
})
.then(select(params, this.id))
.catch(utils.errorHandler);
}).then(select(params, this.id)).catch(utils.errorHandler);
}

update (id, data, params) {
const where = Object.assign({}, filterQuery(params.query || {}).query);
_update (id, data, params) {
const where = Object.assign({}, this.filterQuery(params).query);
const options = Object.assign({ raw: this.raw }, params.sequelize);

if (Array.isArray(data)) {
return Promise.reject(new errors.BadRequest('Not replacing multiple records. Did you mean `patch`?'));
}

// Force the {raw: false} option as the instance is needed to properly
// update
const updateOptions = Object.assign({}, params.sequelize, { raw: false });
const getOptions = Object.assign({}, params, { query: where, sequelize: { raw: false } });

return this._get(id, getOptions).then(instance => {
if (!instance) {
throw new errors.NotFound(`No record found for id '${id}'`);
}
const copy = Object.keys(instance.toJSON()).reduce((result, key) => {
result[key] = typeof data[key] === 'undefined' ? null : data[key];

let copy = {};
Object.keys(instance.toJSON()).forEach(key => {
if (typeof data[key] === 'undefined') {
copy[key] = null;
} else {
copy[key] = data[key];
}
});
return result;
}, {});

return instance.update(copy, updateOptions).then(() => this._get(id, { sequelize: options }));
})
.then(select(params, this.id))
.catch(utils.errorHandler);
}

remove (id, params) {
_remove (id, params) {
const opts = Object.assign({ raw: this.raw }, params);
const where = Object.assign({}, filterQuery(params.query || {}).query);
const where = Object.assign({}, this.filterQuery(params).query);

if (id !== null) {
where[this.id] = id;
}

const options = Object.assign({}, params.sequelize, { where });
const options = Object.assign({}, { where }, params.sequelize);

let Model = this.applyScope(params);

Expand All @@ -289,9 +295,7 @@ class Service {
}
}

function init (options) {
return new Service(options);
}
const init = options => new Service(options);

module.exports = init;

Expand Down
Loading