26 Commits

Author SHA1 Message Date
05793eb749 Moved "getModelInstance" function to the outside "parseInclude" function and added README and an "include" test about complex include feature 2016-11-11 09:03:01 +07:00
e632f79e2b Added support for include relationship alias (as) and nested includes, fixed wrong joi json validation implementation inside parseInclude() function, fixed some associated routes resulted 404 because of prefix option 2016-11-10 01:46:42 +07:00
6fa9e90ec5 New line at EOF 2016-11-08 08:26:56 +07:00
49d24ea265 Added try catch block and JSON validation logic of relationship/association parser 2016-11-08 08:23:37 +07:00
11306667d6 Refactored codes to not modify get-config-for-method.test.js, moved validationAssociations definition logic from get-config-for-method.js to crud.js, fixed README.md 2016-11-07 08:48:52 +07:00
1977304287 Added README about association filtering 2016-11-04 14:24:01 +07:00
a141a38fe5 Fixed getConfig test error about association validation 2016-11-04 14:16:30 +07:00
72452a0088 Added feature to allow filtering relationships/associations based on http://docs.sequelizejs.com/en/latest/docs/querying/#relations-associations 2016-11-04 11:31:56 +07:00
5ba9d7d261 2.9.1 2016-11-01 19:10:04 -07:00
07837ef36c Merge pull request #33 from mdibaiee/add-limit-offset-tests
Add integration tests for limit/offset
2016-11-01 19:09:45 -07:00
25501bbb10 Test add integration tests for limit/offset
#28
2016-11-01 17:39:35 -07:00
a335471f02 Fix(crud/list) 404 on no results 2016-11-01 17:39:16 -07:00
ce26814f74 Test add a returnsAll scope to player fixture 2016-11-01 17:38:55 -07:00
d1fc6d46e8 2.9.0 2016-10-31 14:24:12 -07:00
4e94c7f825 Merge pull request #32 from mdibaiee/more-order-integration
Feat ordering by associated models now works
2016-10-31 14:23:43 -07:00
c289fb2ed4 Feat ordering by associated models now works
It's now possible to order by associated models. This technically might
have worked before b/c we were parsing JSON sent to `order`, but I'm
pretty sure it wouldn't actually work b/c we never grabbed the actual
model to associate by. Regardless, this actually enables things and adds
tests to prove it.

Note: there's a sequelize bug that's poorly reported but definitely
known where `order` with associated models can fail because the sql
generated doesn't include a join. So, I added docs noting that and a
`test.failing` so that we'll be notified when that bug is fixed and can
remove the note.
2016-10-31 12:48:34 -07:00
e1b851f932 Test: add a second mock team
So that we can test complex sorts
2016-10-31 12:41:34 -07:00
34e37217f1 2.8.0 2016-10-28 11:28:23 -07:00
6a80149916 Merge pull request #31 from mdibaiee/code-coverage
Add more integration tests
2016-10-28 11:28:06 -07:00
cb6ea51836 Test add integration tests for scope 2016-10-28 11:22:52 -07:00
5aec1242db Test add integration tests for ordering lists 2016-10-28 11:22:38 -07:00
8fb3f2e849 Fix(crud) actually enable multiple orders
This was supposed to work, but adding integration tests I realized it
didn't. #oops
2016-10-28 11:22:05 -07:00
11e6ff596c Fix(crud) scope now 404s on no results 2016-10-28 11:20:59 -07:00
6a2290f064 Docs add docs for additional order option 2016-10-28 11:20:12 -07:00
1daa68e03e Fix(crud) destroyScope sends 404 when not found 2016-10-27 21:03:57 -07:00
01081db7a3 Test add destroyScope tests 2016-10-27 21:03:32 -07:00
17 changed files with 751 additions and 126 deletions

View File

@ -109,10 +109,17 @@ Getting related models is easy, just use a query parameter `include`.
```js
// returns all teams with their related City model
// GET /teams?include=city
// GET /teams?include=city or
// GET /teams?include={"model": "City"}
// results in a Sequelize query:
Team.findAll({include: City})
or if association defined with an alias
// GET /players?include={"model": "Master", "as": "Couch"}
// results in a Sequelize query:
Players.findAll({include: Master, as: 'Couch'})
```
If you want to get multiple related models, just pass multiple `include` parameters.
@ -133,6 +140,56 @@ For models that have a many-to-many relationship, you can also pass the plural v
Team.findAll({include: [Player]})
```
Filtering by related models property, you can pass **where** paremeter inside each **include** items object.
```js
// returns all team with their related City where City property name equals Healdsburg
// GET /teams?include={"model": "City", "where": {"name": "Healdsburg"}}
// results in a Sequelize query:
Team.findAll({include: {model: City, where: {name: 'Healdsburg'}}})
```
More complex example with nested include, association alias and association filtering.
```js
// returns all team with its players along with its couch of each player
// GET /cities?include[]={
// "model": "Team",
// "include": {
// "model": "Player",
// "where": {
// "name": "Pinot"
// },
// "include": {
// "model": "Master",
// "as": "Coach",
// "where": {
// "name": "Shifu"
// }
// }
// }
// }
// results in a Sequelize query:
City.findAll({
include: {
model: Team,
include: {
model: Player,
where: {
name: Pinot
},
include: {
model: Master,
as: 'Coach',
where: {
name: 'Shifu'
}
}
}
}
})
```
## `limit` and `offset` queries
Restricting list (`GET`) and scope queries to a restricted count can be done by passing `limit=<number>` and/or `offset=<number>`.
@ -158,6 +215,7 @@ Team.findAll({order: ['name']})
```js
// returns the teams ordered by the name column, descending
// GET /teams?order[0]=name&order[0]=DESC
// GET /teams?order=name%20DESC
// results in a Sequelize query:
Team.findAll({order: [['name', 'DESC']]})
@ -171,6 +229,22 @@ Team.findAll({order: [['name', 'DESC']]})
Team.findAll({order: [['name'], ['city']]})
```
You can even order by associated models. Though there is a [sequelize bug](https://github.com/sequelize/sequelize/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20order%20join%20) that might prevent this from working properly. A workaround is to `&include` the model you're ordering by.
```js
// returns the players ordered by the team name
// GET /players?order[0]={"model": "Team"}&order[0]=name
// results in a Sequelize query:
Player.findAll({order: [[{model: Team}, 'name']]})
// if the above returns a Sequelize error: `No such column Team.name`,
// you can work around this by forcing the join into the query:
// GET /players?order[0]={"model": "Team"}&order[0]=name&include=team
// results in a Sequelize query:
Player.findAll({order: [[{model: Team}, 'name']], include: [Team]})
```
## Authorization and other hooks
You can use Hapi's [`ext` option](http://hapijs.com/api#route-options) to interact with the request both before and after this module does. This is useful if you want to enforce authorization, or modify the request before or after this module does. Hapi [has a full list of hooks](http://hapijs.com/api#request-lifecycle) you can use.

View File

@ -1,6 +1,6 @@
{
"name": "hapi-sequelize-crud",
"version": "2.7.3",
"version": "2.9.1",
"description": "Hapi plugin that automatically generates RESTful API for CRUD",
"main": "build/index.js",
"config": {

View File

@ -19,14 +19,14 @@ export default (server, a, b, names, options) => {
update(server, a, b, names);
};
export const get = (server, a, b, names) => {
export const get = async (server, a, b, names) => {
server.route({
method: 'GET',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.singular}/{bid}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.singular}/{bid}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const base = await a.findOne({
where: {
@ -51,14 +51,14 @@ export const get = (server, a, b, names) => {
});
};
export const list = (server, a, b, names) => {
export const list = async (server, a, b, names) => {
server.route({
method: 'GET',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.plural}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({
@ -77,16 +77,16 @@ export const list = (server, a, b, names) => {
});
};
export const scope = (server, a, b, names) => {
export const scope = async (server, a, b, names) => {
const scopes = Object.keys(b.options.scopes);
server.route({
method: 'GET',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}/{scope}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.plural}/{scope}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({
@ -116,7 +116,7 @@ export const scope = (server, a, b, names) => {
});
};
export const scopeScope = (server, a, b, names) => {
export const scopeScope = async (server, a, b, names) => {
const scopes = {
a: Object.keys(a.options.scopes),
b: Object.keys(b.options.scopes),
@ -124,11 +124,11 @@ export const scopeScope = (server, a, b, names) => {
server.route({
method: 'GET',
path: `${prefix}/${names.a.plural}/{scopea}/${names.b.plural}/{scopeb}`,
path: `${prefix}${names.a.plural}/{scopea}/${names.b.plural}/{scopeb}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const list = await b.scope(request.params.scopeb).findAll({
@ -152,14 +152,14 @@ export const scopeScope = (server, a, b, names) => {
});
};
export const destroy = (server, a, b, names) => {
export const destroy = async (server, a, b, names) => {
server.route({
method: 'DELETE',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.plural}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({
@ -179,16 +179,16 @@ export const destroy = (server, a, b, names) => {
});
};
export const destroyScope = (server, a, b, names) => {
export const destroyScope = async (server, a, b, names) => {
const scopes = Object.keys(b.options.scopes);
server.route({
method: 'DELETE',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}/{scope}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.plural}/{scope}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({
@ -221,14 +221,14 @@ export const destroyScope = (server, a, b, names) => {
});
};
export const update = (server, a, b, names) => {
export const update = async (server, a, b, names) => {
server.route({
method: 'PUT',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.plural}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({

View File

@ -14,14 +14,14 @@ export default (server, a, b, names, options) => {
update(server, a, b, names);
};
export const get = (server, a, b, names) => {
export const get = async (server, a, b, names) => {
server.route({
method: 'GET',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.singular}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.singular}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({
@ -47,7 +47,7 @@ export const get = (server, a, b, names) => {
export const create = (server, a, b, names) => {
server.route({
method: 'POST',
path: `${prefix}/${names.a.singular}/{id}/${names.b.singular}`,
path: `${prefix}${names.a.singular}/{id}/${names.b.singular}`,
@error
async handler(request, reply) {
@ -67,14 +67,14 @@ export const create = (server, a, b, names) => {
});
};
export const destroy = (server, a, b, names) => {
export const destroy = async (server, a, b, names) => {
server.route({
method: 'DELETE',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.singular}/{bid}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.singular}/{bid}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({
@ -99,11 +99,11 @@ export const destroy = (server, a, b, names) => {
export const update = (server, a, b, names) => {
server.route({
method: 'PUT',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.singular}/{bid}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.singular}/{bid}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({

View File

@ -4,6 +4,7 @@ import setup from '../test/integration-setup.js';
const STATUS_OK = 200;
const STATUS_NOT_FOUND = 404;
const STATUS_BAD_REQUEST = 400;
setup(test);
@ -112,3 +113,65 @@ test('not found /notamodel', async (t) => {
const { statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_NOT_FOUND);
});
test('destroyScope /players/returnsOne', async (t) => {
const { server, instances, sequelize: { models: { Player } } } = t.context;
const { player1, player2 } = instances;
// this doesn't exist in our fixtures
const url = '/players/returnsOne';
const method = 'DELETE';
const presentPlayers = await Player.findAll();
const playerIds = presentPlayers.map(({ id }) => id);
t.truthy(playerIds.includes(player1.id));
t.truthy(playerIds.includes(player2.id));
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.id, player1.id);
const nonDeletedPlayers = await Player.findAll();
t.is(nonDeletedPlayers.length, presentPlayers.length - 1);
});
test('destroyScope /players/returnsNone', async (t) => {
const { server, instances, sequelize: { models: { Player } } } = t.context;
const { player1, player2 } = instances;
// this doesn't exist in our fixtures
const url = '/players/returnsNone';
const method = 'DELETE';
const presentPlayers = await Player.findAll();
const playerIds = presentPlayers.map(({ id }) => id);
t.truthy(playerIds.includes(player1.id));
t.truthy(playerIds.includes(player2.id));
const { statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_NOT_FOUND);
const nonDeletedPlayers = await Player.findAll();
const nonDeletedPlayerIds = nonDeletedPlayers.map(({ id }) => id);
t.truthy(nonDeletedPlayerIds.includes(player1.id));
t.truthy(nonDeletedPlayerIds.includes(player2.id));
});
test('destroyScope invalid scope /players/invalid', async (t) => {
const { server, instances, sequelize: { models: { Player } } } = t.context;
const { player1, player2 } = instances;
// this doesn't exist in our fixtures
const url = '/players/invalid';
const method = 'DELETE';
const presentPlayers = await Player.findAll();
const playerIds = presentPlayers.map(({ id }) => id);
t.truthy(playerIds.includes(player1.id));
t.truthy(playerIds.includes(player2.id));
const { statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_BAD_REQUEST);
const nonDeletedPlayers = await Player.findAll();
const nonDeletedPlayerIds = nonDeletedPlayers.map(({ id }) => id);
t.truthy(nonDeletedPlayerIds.includes(player1.id));
t.truthy(nonDeletedPlayerIds.includes(player2.id));
});

View File

@ -6,7 +6,7 @@ const STATUS_OK = 200;
setup(test);
test('belongsTo /team?include=city', async (t) => {
test('belongsTo /team?include=city', async(t) => {
const { server, instances } = t.context;
const { team1, city1 } = instances;
const path = `/team/${team1.id}?include=city`;
@ -17,7 +17,7 @@ test('belongsTo /team?include=city', async (t) => {
t.is(result.City.id, city1.id);
});
test('belongsTo /team?include=cities', async (t) => {
test('belongsTo /team?include=cities', async(t) => {
const { server, instances } = t.context;
const { team1, city1 } = instances;
const path = `/team/${team1.id}?include=cities`;
@ -28,7 +28,7 @@ test('belongsTo /team?include=cities', async (t) => {
t.is(result.City.id, city1.id);
});
test('hasMany /team?include=player', async (t) => {
test('hasMany /team?include=player', async(t) => {
const { server, instances } = t.context;
const { team1, player1, player2 } = instances;
const path = `/team/${team1.id}?include=player`;
@ -42,7 +42,7 @@ test('hasMany /team?include=player', async (t) => {
t.truthy(playerIds.includes(player2.id));
});
test('hasMany /team?include=players', async (t) => {
test('hasMany /team?include=players', async(t) => {
const { server, instances } = t.context;
const { team1, player1, player2 } = instances;
const path = `/team/${team1.id}?include=players`;
@ -56,7 +56,18 @@ test('hasMany /team?include=players', async (t) => {
t.truthy(playerIds.includes(player2.id));
});
test('multiple includes /team?include=players&include=city', async (t) => {
test('belongsTo with alias /player?include={"model": "Master", "as": "Coach"}', async(t) => {
const { server, instances } = t.context;
const { team1, master1 } = instances;
const path = `/player/${team1.id}?include={"model": "Master", "as": "Coach"}`;
const { result, statusCode } = await server.inject(path);
t.is(statusCode, STATUS_OK);
t.is(result.id, team1.id);
t.is(result.Coach.id, master1.id);
});
test('multiple includes /team?include=players&include=city', async(t) => {
const { server, instances } = t.context;
const { team1, player1, player2, city1 } = instances;
const path = `/team/${team1.id}?include=players&include=city`;
@ -71,7 +82,7 @@ test('multiple includes /team?include=players&include=city', async (t) => {
t.is(result.City.id, city1.id);
});
test('multiple includes /team?include[]=players&include[]=city', async (t) => {
test('multiple includes /team?include[]=players&include[]=city', async(t) => {
const { server, instances } = t.context;
const { team1, player1, player2, city1 } = instances;
const path = `/team/${team1.id}?include[]=players&include[]=city`;
@ -85,3 +96,67 @@ test('multiple includes /team?include[]=players&include[]=city', async (t) => {
t.truthy(playerIds.includes(player2.id));
t.is(result.City.id, city1.id);
});
test('multiple includes /team?include[]=players&include[]={"model": "City"}', async(t) => {
const { server, instances } = t.context;
const { team1, player1, player2, city1 } = instances;
const path = `/team/${team1.id}?include[]=players&include[]={"model": "City"}`;
const { result, statusCode } = await server.inject(path);
t.is(statusCode, STATUS_OK);
t.is(result.id, team1.id);
const playerIds = result.Players.map(({ id }) => id);
t.truthy(playerIds.includes(player1.id));
t.truthy(playerIds.includes(player2.id));
t.is(result.City.id, city1.id);
});
test('include filter /teams?include[]={"model": "City", "where": {"name": "Healdsburg"}}'
, async(t) => {
const { server } = t.context;
const url = '/teams?include[]={"model": "City", "where": {"name": "Healdsburg"}}';
const method = 'GET';
const { statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
});
test('nested include filter ' +
'/citiy?include[]=' +
'{"model": "Team", "include": {"model": "City", "where": {"name": "Healdsburg"}}}'
, async(t) => {
const { instances, server } = t.context;
const { city1, team1, team2 } = instances;
const url = '/city?include[]=' +
'{"model": "Team", "include": {"model": "City", "where": {"name": "Healdsburg"}}}';
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.id, city1.id);
const teamIds = result.Teams.map(({ id }) => id);
t.truthy(teamIds.includes(team1.id));
t.truthy(teamIds.includes(team2.id));
});
test('complex include ' +
'/cities?include[]={"model":"Team", ' +
'"include":{ "model":"Player", "where":{"name": "Pinot"}, ' +
'"include":{ "model":"Master", "as":"Coach", "where":{"name": "Shifu"}}}}'
, async(t) => {
const { instances, server } = t.context;
const { city1, master1, player2, team1 } = instances;
const method = 'GET';
const url = '/cities?include[]={"model":"Team", ' +
'"include":{ "model":"Player", "where":{"name": "Pinot"}, ' +
'"include":{ "model":"Master", "as":"Coach", "where":{"name": "Shifu"}}}}';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result[0].id, city1.id);
t.is(result[0].Teams[0].id, team1.id);
t.is(result[0].Teams[0].Players[0].id, player2.id);
t.is(result[0].Teams[0].Players[0].Coach.id, master1.id);
});

View File

@ -0,0 +1,94 @@
import test from 'ava';
import 'sinon-bluebird';
import setup from '../test/integration-setup.js';
const STATUS_OK = 200;
const STATUS_NOT_FOUND = 404;
setup(test);
test('/players?limit=2', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players?limit=${limit}`;
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.length, limit);
});
test('/players?limit=2&offset=1', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players?limit=${limit}&offset=1`;
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.length, limit);
});
test('/players?limit=2&offset=2', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players?limit=${limit}&offset=2`;
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.length, 1, 'with only 3 players, only get 1 back with an offset of 2');
});
test('/players?limit=2&offset=20', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players?limit=${limit}&offset=20`;
const method = 'GET';
const { statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_NOT_FOUND, 'with a offset/limit greater than the data, returns a 404');
});
test('scope /players/returnsAll?limit=2', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players/returnsAll?limit=${limit}`;
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.length, limit);
});
test('scope /players/returnsAll?limit=2&offset=1', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players/returnsAll?limit=${limit}&offset=1`;
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.length, limit);
});
test('scope /players/returnsAll?limit=2&offset=2', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players/returnsAll?limit=${limit}&offset=2`;
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.length, 1, 'with only 3 players, only get 1 back with an offset of 2');
});
test('scope /players/returnsAll?limit=2&offset=20', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players/returnsAll?limit=${limit}&offset=20`;
const method = 'GET';
const { statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_NOT_FOUND, 'with a offset/limit greater than the data, returns a 404');
});

View File

@ -0,0 +1,141 @@
import test from 'ava';
import 'sinon-bluebird';
import setup from '../test/integration-setup.js';
const STATUS_OK = 200;
const STATUS_BAD_QUERY = 502;
setup(test);
test('/players?order=name', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = instances;
const url = '/players?order=name';
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
// this is the order we'd expect the names to be in
t.is(result[0].name, player1.name);
t.is(result[1].name, player2.name);
t.is(result[2].name, player3.name);
});
test('/players?order=name%20ASC', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = instances;
const url = '/players?order=name%20ASC';
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
// this is the order we'd expect the names to be in
t.is(result[0].name, player1.name);
t.is(result[1].name, player2.name);
t.is(result[2].name, player3.name);
});
test('/players?order=name%20DESC', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = instances;
const url = '/players?order=name%20DESC';
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
// this is the order we'd expect the names to be in
t.is(result[0].name, player3.name);
t.is(result[1].name, player2.name);
t.is(result[2].name, player1.name);
});
test('/players?order[]=name', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = instances;
const url = '/players?order[]=name';
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
// this is the order we'd expect the names to be in
t.is(result[0].name, player1.name);
t.is(result[1].name, player2.name);
t.is(result[2].name, player3.name);
});
test('/players?order[0]=name&order[0]=DESC', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = instances;
const url = '/players?order[0]=name&order[0]=DESC';
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
// this is the order we'd expect the names to be in
t.is(result[0].name, player3.name);
t.is(result[1].name, player2.name);
t.is(result[2].name, player1.name);
});
// multiple sorts
test('/players?order[0]=active&order[0]=DESC&order[1]=name&order[1]=DESC', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = instances;
const url = '/players?order[0]=name&order[0]=DESC&order[1]=teamId&order[1]=DESC';
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
// this is the order we'd expect the names to be in
t.is(result[0].name, player3.name);
t.is(result[1].name, player2.name);
t.is(result[2].name, player1.name);
});
// this will fail b/c sequelize doesn't correctly do the join when you pass
// an order. There are many issues for this:
// eslint-disable-next-line
// https://github.com/sequelize/sequelize/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20order%20join%20
//
// https://github.com/sequelize/sequelize/issues/5353 is a good example
// if this test passes, that's great! Just remove the workaround note in the
// docs
// eslint-disable-next-line
test.failing('sequelize bug /players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = instances;
const url = '/players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC';
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
// this is the order we'd expect the names to be in
t.is(result[0].name, player3.name);
t.is(result[1].name, player1.name);
t.is(result[2].name, player2.name);
});
// b/c the above fails, this is a work-around
test('/players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC&include=team', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = instances;
const url = '/players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC&include=team';
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
// this is the order we'd expect the names to be in
t.is(result[0].name, player3.name);
t.is(result[1].name, player1.name);
t.is(result[2].name, player2.name);
});
test('invalid column /players?order[0]=invalid', async (t) => {
const { server } = t.context;
const url = '/players?order[]=invalid';
const method = 'GET';
const { statusCode, result } = await server.inject({ url, method });
t.is(statusCode, STATUS_BAD_QUERY);
t.truthy(result.message.includes('invalid'));
});

View File

@ -0,0 +1,40 @@
import test from 'ava';
import 'sinon-bluebird';
import setup from '../test/integration-setup.js';
const STATUS_OK = 200;
const STATUS_NOT_FOUND = 404;
const STATUS_BAD_REQUEST = 400;
setup(test);
test('/players/returnsOne', async (t) => {
const { server, instances } = t.context;
const { player1 } = instances;
const url = '/players/returnsOne';
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.length, 1);
t.truthy(result[0].id, player1.id);
});
test('/players/returnsNone', async (t) => {
const { server } = t.context;
const url = '/players/returnsNone';
const method = 'GET';
const { statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_NOT_FOUND);
});
test('invalid scope /players/invalid', async (t) => {
const { server } = t.context;
// this doesn't exist in our fixtures
const url = '/players/invalid';
const method = 'GET';
const { statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_BAD_REQUEST);
});

View File

@ -5,16 +5,16 @@ import _ from 'lodash';
import { parseInclude, parseWhere, parseLimitAndOffset, parseOrder } from './utils';
import { notFound } from 'boom';
import * as associations from './associations/index';
import getConfigForMethod from './get-config-for-method.js';
import getConfigForMethod, { sequelizeOperators } from './get-config-for-method.js';
const createAll = ({
server,
model,
prefix,
config,
attributeValidation,
associationValidation,
scopes,
server,
model,
prefix,
config,
attributeValidation,
associationValidation,
scopes,
}) => {
Object.keys(methods).forEach((method) => {
methods[method]({
@ -35,27 +35,28 @@ const createAll = ({
export { associations };
/*
The `models` option, becomes `permissions`, and can look like:
The `models` option, becomes `permissions`, and can look like:
```
models: ['cat', 'dog']
```
```
models: ['cat', 'dog']
```
or
or
```
models: {
cat: ['list', 'get']
, dog: true // all
}
```
```
models: {
cat: ['list', 'get']
, dog: true // all
}
```
*/
*/
export default (server, model, { prefix, defaultConfig: config, models: permissions }) => {
const modelName = model._singular;
const modelAttributes = Object.keys(model.attributes);
const associatedModelNames = Object.keys(model.associations);
const associatedModelAliases = _.map(model.associations, (assoc => assoc.as));
const modelAssociations = [
...associatedModelNames,
..._.flatMap(associatedModelNames, (associationName) => {
@ -71,13 +72,28 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi
return params;
}, {});
const validAssociations = modelAssociations.length
? joi.string().valid(...modelAssociations)
: joi.valid(null);
const modelsHasAssociations = modelAssociations && modelAssociations.length;
const validAssociationsString = modelsHasAssociations
? joi.string().valid(...modelAssociations)
: joi.valid(null);
const validAssociationsObject = modelsHasAssociations
? joi.object().keys({
model: joi.string().valid(...modelAssociations),
where: joi.object().keys({
...attributeValidation,
...sequelizeOperators,
}),
as: joi.string().valid(...associatedModelAliases),
include: joi.any(), // @Todo: should validate the same as associationValidation var below
})
: joi.valid(null);
const associationValidation = {
include: [joi.array().items(validAssociations), validAssociations],
include: [
joi.array().items(validAssociationsString, validAssociationsObject),
validAssociationsString,
validAssociationsObject,
],
};
const scopes = Object.keys(model.options.scopes);
// if we don't have any permissions set, just create all the methods
@ -91,11 +107,11 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi
associationValidation,
scopes,
});
// if permissions are set, but we can't parse them, throw an error
// if permissions are set, but we can't parse them, throw an error
} else if (!Array.isArray(permissions)) {
throw new Error('hapi-sequelize-crud: `models` property must be an array');
// if permissions are set, but the only thing we've got is a model name, there
// are no permissions to be set, so just create all methods and move on
// if permissions are set, but the only thing we've got is a model name, there
// are no permissions to be set, so just create all methods and move on
} else if (permissions.includes(modelName)) {
createAll({
server,
@ -106,7 +122,7 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi
associationValidation,
scopes,
});
// if we've gotten here, we have complex permissions and need to set them
// if we've gotten here, we have complex permissions and need to set them
} else {
const permissionOptions = permissions.filter((permission) => {
return permission.model === modelName;
@ -147,14 +163,14 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi
}
};
export const list = ({ server, model, prefix = '/', config }) => {
export const list = async ({ server, model, prefix = '/', config }) => {
server.route({
method: 'GET',
path: path.join(prefix, model._plural),
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const { limit, offset } = parseLimitAndOffset(request);
const order = parseOrder(request);
@ -165,6 +181,8 @@ export const list = ({ server, model, prefix = '/', config }) => {
where, include, limit, offset, order,
});
if (!list.length) return void reply(notFound('Nothing found.'));
reply(list.map((item) => item.toJSON()));
},
@ -172,14 +190,14 @@ export const list = ({ server, model, prefix = '/', config }) => {
});
};
export const get = ({ server, model, prefix = '/', config }) => {
export const get = async ({ server, model, prefix = '/', config }) => {
server.route({
method: 'GET',
path: path.join(prefix, model._singular, '{id?}'),
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const { id } = request.params;
if (id) where[model.primaryKeyField] = id;
@ -196,14 +214,14 @@ export const get = ({ server, model, prefix = '/', config }) => {
});
};
export const scope = ({ server, model, prefix = '/', config }) => {
export const scope = async ({ server, model, prefix = '/', config }) => {
server.route({
method: 'GET',
path: path.join(prefix, model._plural, '{scope}'),
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const { limit, offset } = parseLimitAndOffset(request);
const order = parseOrder(request);
@ -214,6 +232,8 @@ export const scope = ({ server, model, prefix = '/', config }) => {
include, where, limit, offset, order,
});
if (!list.length) return void reply(notFound('Nothing found.'));
reply(list.map((item) => item.toJSON()));
},
config,
@ -251,9 +271,9 @@ export const destroy = ({ server, model, prefix = '/', config }) => {
if (!list.length) {
return void reply(id
? notFound(`${id} not found.`)
: notFound('Nothing found.')
);
? notFound(`${id} not found.`)
: notFound('Nothing found.')
);
}
await Promise.all(list.map(instance => instance.destroy()));
@ -280,9 +300,9 @@ export const destroyAll = ({ server, model, prefix = '/', config }) => {
if (!list.length) {
return void reply(id
? notFound(`${id} not found.`)
: notFound('Nothing found.')
);
? notFound(`${id} not found.`)
: notFound('Nothing found.')
);
}
await Promise.all(list.map(instance => instance.destroy()));
@ -295,20 +315,22 @@ export const destroyAll = ({ server, model, prefix = '/', config }) => {
});
};
export const destroyScope = ({ server, model, prefix = '/', config }) => {
export const destroyScope = async ({ server, model, prefix = '/', config }) => {
server.route({
method: 'DELETE',
path: path.join(prefix, model._plural, '{scope}'),
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
if (include instanceof Error) return void reply(include);
const list = await model.scope(request.params.scope).findAll({ include, where });
if (!list.length) return void reply(notFound('Nothing found.'));
await Promise.all(list.map(instance => instance.destroy()));
const listAsJSON = list.map((item) => item.toJSON());

View File

@ -225,7 +225,7 @@ test('crud#list handler with order', async (t) => {
t.deepEqual(
findAllArgs.order,
[request.query.order],
[[request.query.order]],
'queries with the order as an array b/c that\'s what sequelize wants'
);
});

View File

@ -144,7 +144,7 @@ export default ({
.keys({
limit: joi.number().min(0).integer(),
offset: joi.number().min(0).integer(),
order: joi.array(),
order: [joi.array(), joi.string()],
}),
get(methodConfig, 'validate.query')
);

View File

@ -1,40 +1,85 @@
import { omit, identity, toNumber, isString, isUndefined } from 'lodash';
import { notImplemented } from 'boom';
import joi from 'joi';
import Promise from 'bluebird';
const sequelizeKeys = ['include', 'order', 'limit', 'offset'];
export const parseInclude = request => {
const include = Array.isArray(request.query.include)
? request.query.include
: [request.query.include]
;
const getModels = (request) => {
const noGetDb = typeof request.getDb !== 'function';
const noRequestModels = !request.models;
if (noGetDb && noRequestModels) {
return notImplemented('`request.getDb` or `request.models` are not defined.'
+ 'Be sure to load hapi-sequelize before hapi-sequelize-crud.');
+ 'Be sure to load hapi-sequelize before hapi-sequelize-crud.');
}
const { models } = noGetDb ? request : request.getDb();
return include.map(a => {
const singluarOrPluralMatch = Object.keys(models).find((modelName) => {
const { _singular, _plural } = models[modelName];
return _singular === a || _plural === a;
});
return models;
};
if (singluarOrPluralMatch) return models[singluarOrPluralMatch];
const getModelInstance = (models, includeItem) => {
return new Promise(async(resolve) => {
if (includeItem) {
if (typeof includeItem !== 'object') {
const singluarOrPluralMatch = Object.keys(models).find((modelName) => {
const { _singular, _plural } = models[modelName];
return _singular === includeItem || _plural === includeItem;
});
if (typeof a === 'string') return models[a];
if (singluarOrPluralMatch) {
return resolve(models[singluarOrPluralMatch]);
}
}
if (a && typeof a.model === 'string' && a.model.length) {
a.model = models[a.model];
if (typeof includeItem === 'string' && models.hasOwnProperty(includeItem)) {
return resolve(models[includeItem]);
} else if (typeof includeItem === 'object') {
if (
typeof includeItem.model === 'string' &&
includeItem.model.length &&
models.hasOwnProperty(includeItem.model)
) {
includeItem.model = models[includeItem.model];
}
if (includeItem.hasOwnProperty('include')) {
includeItem.include = await getModelInstance(models, includeItem.include);
return resolve(includeItem);
} else {
return resolve(includeItem);
}
}
}
return resolve(includeItem);
});
};
export const parseInclude = async(request) => {
if (typeof request.query.include === 'undefined') return [];
const include = Array.isArray(request.query.include)
? request.query.include
: [request.query.include]
;
const models = getModels(request);
if (models.isBoom) return models;
const jsonValidation = joi.string().regex(/^\{.*?"model":.*?\}$/);
const includes = include.map(async(b) => {
let a = b;
try {
if (!jsonValidation.validate(a).error) {
a = JSON.parse(b);
}
} catch (e) {
//
}
return a;
return getModelInstance(models, a);
}).filter(identity);
return await Promise.all(includes);
};
export const parseWhere = request => {
@ -63,24 +108,40 @@ export const parseLimitAndOffset = (request) => {
return out;
};
const parseOrderArray = (order, models) => {
return order.map((requestColumn) => {
if (Array.isArray(requestColumn)) {
return parseOrderArray(requestColumn, models);
}
let column;
try {
column = JSON.parse(requestColumn);
} catch (e) {
column = requestColumn;
}
if (column.model) column.model = models[column.model];
return column;
});
};
export const parseOrder = (request) => {
const { order } = request.query;
if (!order) return null;
const models = getModels(request);
if (models.isBoom) return models;
// transform to an array so sequelize will escape the input for us and
// maintain security. See http://docs.sequelizejs.com/en/latest/docs/querying/#ordering
if (isString(order)) return order.split(' ');
const requestOrderColumns = isString(order) ? [order.split(' ')] : order;
for (const key of Object.keys(order)) {
try {
order[key] = JSON.parse(order[key]);
} catch (e) {
//
}
}
const parsedOrder = parseOrderArray(requestOrderColumns, models);
return order;
return parsedOrder;
};
export const getMethod = (model, association, plural = true, method = 'get') => {

View File

@ -2,7 +2,8 @@ import test from 'ava';
import { parseLimitAndOffset, parseOrder, parseWhere } from './utils.js';
test.beforeEach((t) => {
t.context.request = { query: {} };
const models = t.context.models = { User: {} };
t.context.request = { query: {}, models };
});
test('parseLimitAndOffset is a function', (t) => {
@ -57,19 +58,18 @@ test('parseOrder returns order when a string', (t) => {
t.deepEqual(
parseOrder(request)
, [order]
, [[order]]
);
});
test('parseOrder returns order when json', (t) => {
const { request } = t.context;
const order = [{ model: 'User' }, 'DESC'];
const { request,models } = t.context;
request.query.order = [JSON.stringify({ model: 'User' }), 'DESC'];
request.query.thing = 'hi';
t.deepEqual(
parseOrder(request)
, order
, [{ model: models.User }, 'DESC']
);
});

18
test/fixtures/models/master.js vendored Normal file
View File

@ -0,0 +1,18 @@
export default (sequelize, DataTypes) => {
return sequelize.define('Master', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: DataTypes.STRING,
}, {
classMethods: {
associate: (models) => {
models.Master.hasMany(models.Player, {
foreignKey: 'coachId'
});
},
},
});
};

View File

@ -7,12 +7,36 @@ export default (sequelize, DataTypes) => {
},
name: DataTypes.STRING,
teamId: DataTypes.INTEGER,
active: DataTypes.BOOLEAN,
}, {
classMethods: {
associate: (models) => {
models.Player.belongsTo(models.Team, {
foreignKey: { name: 'teamId' },
});
models.Player.belongsTo(models.Master, {
foreignKey: 'coachId',
as: 'Coach',
});
},
},
scopes: {
returnsOne: {
where: {
active: true,
},
},
returnsNone: {
where: {
name: 'notaname',
},
},
returnsAll: {
where: {
name: {
$ne: 'notaname',
},
},
},
},
});

View File

@ -14,15 +14,16 @@ const modelNames = [
{ Singluar: 'City', singular: 'city', Plural: 'Cities', plural: 'cities' },
{ Singluar: 'Team', singular: 'team', Plural: 'Teams', plural: 'teams' },
{ Singluar: 'Player', singular: 'player', Plural: 'Players', plural: 'players' },
{ Singluar: 'Master', singular: 'master', Plural: 'Masters', plural: 'masters' },
];
export default (test) => {
test.beforeEach('get an open port', async (t) => {
test.beforeEach('get an open port', async(t) => {
t.context.port = await getPort();
});
test.beforeEach('setup server', async (t) => {
test.beforeEach('setup server', async(t) => {
const sequelize = t.context.sequelize = new Sequelize({
dialect: 'sqlite',
logging: false,
@ -46,21 +47,33 @@ export default (test) => {
});
await server.register({
register: require('../src/index.js'),
options: {
name: dbName,
register: require('../src/index.js'),
options: {
name: dbName,
},
},
},
);
});
test.beforeEach('create data', async (t) => {
const { Player, Team, City } = t.context.sequelize.models;
test.beforeEach('create data', async(t) => {
const { Player, Master, Team, City } = t.context.sequelize.models;
const city1 = await City.create({ name: 'Healdsburg' });
const team1 = await Team.create({ name: 'Baseballs', cityId: city1.id });
const player1 = await Player.create({ name: 'Pinot', teamId: team1.id });
const player2 = await Player.create({ name: 'Syrah', teamId: team1.id });
t.context.instances = { city1, team1, player1, player2 };
const team2 = await Team.create({ name: 'Footballs', cityId: city1.id });
const master1 = await Master.create({ name: 'Shifu' });
const master2 = await Master.create({ name: 'Oogway' });
const player1 = await Player.create({
name: 'Cat', teamId: team1.id, active: true, coachId: master1.id
});
const player2 = await Player.create({
name: 'Pinot', teamId: team1.id, coachId: master1.id
});
const player3 = await Player.create({
name: 'Syrah', teamId: team2.id, coachId: master2.id
});
t.context.instances = {
city1, team1, team2, player1, player2, player3, master1, master2
};
});
// kill the server so that we can exit and don't leak memory