Compare commits

...

30 Commits

Author SHA1 Message Date
Joey Baker
facde8d542
Merge pull request #39 from rithalnz/patch-1
Create LICENSE
2021-03-13 11:35:24 -08:00
rithalnz
0cbc28ef90
Create LICENSE
Added MIT License
2021-03-10 12:49:07 -08:00
Mahdi Dibaiee
a6590a9650 fix(crud): see 83eadf0929a2506541e34b4a6f73d46bfa2404d3 and #37 2017-02-04 22:18:22 +03:30
Mahdi Dibaiee
85b52fd5ab fix(crud): should ignore JoinTable models while creating the routes -
solves #37
2017-02-04 22:11:58 +03:30
Joey Baker
5ba9d7d261 2.9.1 2016-11-01 19:10:04 -07:00
Joey Baker
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
Joey Baker
25501bbb10 Test add integration tests for limit/offset
#28
2016-11-01 17:39:35 -07:00
Joey Baker
a335471f02 Fix(crud/list) 404 on no results 2016-11-01 17:39:16 -07:00
Joey Baker
ce26814f74 Test add a returnsAll scope to player fixture 2016-11-01 17:38:55 -07:00
Joey Baker
d1fc6d46e8 2.9.0 2016-10-31 14:24:12 -07:00
Joey Baker
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
Joey Baker
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
Joey Baker
e1b851f932 Test: add a second mock team
So that we can test complex sorts
2016-10-31 12:41:34 -07:00
Joey Baker
34e37217f1 2.8.0 2016-10-28 11:28:23 -07:00
Joey Baker
6a80149916 Merge pull request #31 from mdibaiee/code-coverage
Add more integration tests
2016-10-28 11:28:06 -07:00
Joey Baker
cb6ea51836 Test add integration tests for scope 2016-10-28 11:22:52 -07:00
Joey Baker
5aec1242db Test add integration tests for ordering lists 2016-10-28 11:22:38 -07:00
Joey Baker
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
Joey Baker
11e6ff596c Fix(crud) scope now 404s on no results 2016-10-28 11:20:59 -07:00
Joey Baker
6a2290f064 Docs add docs for additional order option 2016-10-28 11:20:12 -07:00
Joey Baker
1daa68e03e Fix(crud) destroyScope sends 404 when not found 2016-10-27 21:03:57 -07:00
Joey Baker
01081db7a3 Test add destroyScope tests 2016-10-27 21:03:32 -07:00
Joey Baker
3b962ce4d8 2.7.3 2016-10-27 13:20:46 -07:00
Joey Baker
f638680e29 Merge pull request #30 from mdibaiee/code-coverage
Add integration tests
2016-10-27 13:20:18 -07:00
Joey Baker
94e9870133 Fix(crud) 404 errors for destroy and destroyAll 2016-10-27 12:33:31 -07:00
Joey Baker
0713f81301 Test add CRUD tests
boosting our test coverage
2016-10-27 12:33:02 -07:00
Joey Baker
f49e4daf79 Test fix error checking in include tests #oops 2016-10-27 12:32:36 -07:00
Joey Baker
087e64607c Merge pull request #29 from mdibaiee/code-coverage
Add code coverage
2016-10-26 18:14:37 -07:00
Joey Baker
57f95f8c95 Test(CI) setup code coverage
Also moves ava config to package.json
2016-10-26 18:10:32 -07:00
Joey Baker
10d108878a Chore(deps) install codecov and nyc 2016-10-26 17:24:59 -07:00
22 changed files with 803 additions and 49 deletions

3
.gitignore vendored
View File

@ -36,3 +36,6 @@ npm-debug.log
# System # System
.DS_Store .DS_Store
coverage.lcov
.nyc_output

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2021 The hapi-sequelize-crud Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -158,6 +158,7 @@ Team.findAll({order: ['name']})
```js ```js
// returns the teams ordered by the name column, descending // returns the teams ordered by the name column, descending
// GET /teams?order[0]=name&order[0]=DESC // GET /teams?order[0]=name&order[0]=DESC
// GET /teams?order=name%20DESC
// results in a Sequelize query: // results in a Sequelize query:
Team.findAll({order: [['name', 'DESC']]}) Team.findAll({order: [['name', 'DESC']]})
@ -171,6 +172,22 @@ Team.findAll({order: [['name', 'DESC']]})
Team.findAll({order: [['name'], ['city']]}) 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 ## 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. 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,9 +1,13 @@
machine: machine:
node: node:
version: 6.5.0 version: 6.9.0
dependencies: dependencies:
pre: pre:
- npm prune - npm prune
post: post:
- mkdir -p $CIRCLE_TEST_REPORTS/ava - mkdir -p $CIRCLE_TEST_REPORTS/ava
test:
post:
- npm run coverage

View File

@ -1,6 +1,6 @@
{ {
"name": "hapi-sequelize-crud", "name": "hapi-sequelize-crud",
"version": "2.7.2", "version": "2.9.3",
"description": "Hapi plugin that automatically generates RESTful API for CRUD", "description": "Hapi plugin that automatically generates RESTful API for CRUD",
"main": "build/index.js", "main": "build/index.js",
"config": { "config": {
@ -10,10 +10,11 @@
}, },
"scripts": { "scripts": {
"lint": "eslint src", "lint": "eslint src",
"test": "ava --require babel-register --source='src/**/*.js' --source='!build/**/*' --tap=${CI-false} src/**/*.test.js | $(if [ -z ${CI:-} ]; then echo 'tail'; else tap-xunit > $CIRCLE_TEST_REPORTS/ava/ava.xml; fi;)", "test": "SCRIPTY_SILENT=true scripty",
"tdd": "ava --require babel-register --source='src/**/*.js' --source='!build/**/*' --watch src/**/*.test.js", "coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov",
"build": "scripty", "tdd": "ava --watch",
"watch": "scripty" "build": "SCRIPTY_SILENT=true scripty",
"watch": "SCRIPTY_SILENT=true scripty"
}, },
"repository": { "repository": {
"git": "https://github.com/mdibaiee/hapi-sequelize-crud" "git": "https://github.com/mdibaiee/hapi-sequelize-crud"
@ -33,12 +34,14 @@
"babel-preset-stage-1": "^6.16.0", "babel-preset-stage-1": "^6.16.0",
"babel-register": "^6.16.3", "babel-register": "^6.16.3",
"bluebird": "^3.4.6", "bluebird": "^3.4.6",
"codecov": "^1.0.1",
"eslint": "^3.8.1", "eslint": "^3.8.1",
"eslint-config-pichak": "^1.1.2", "eslint-config-pichak": "^1.1.2",
"eslint-plugin-ava": "^3.1.1", "eslint-plugin-ava": "^3.1.1",
"ghooks": "^1.3.2", "ghooks": "^1.3.2",
"hapi": "^15.2.0", "hapi": "^15.2.0",
"hapi-sequelize": "^3.0.4", "hapi-sequelize": "^3.0.4",
"nyc": "^8.3.2",
"portfinder": "^1.0.9", "portfinder": "^1.0.9",
"scripty": "^1.6.0", "scripty": "^1.6.0",
"sequelize": "^3.24.6", "sequelize": "^3.24.6",
@ -54,5 +57,21 @@
}, },
"optionalDependencies": { "optionalDependencies": {
"babel-polyfill": "^6.13.0" "babel-polyfill": "^6.13.0"
},
"nyc": {
"cache": true
},
"ava": {
"source": [
"src/**/*.js",
"!build/**/*"
],
"files": [
"**/*.test.js",
"!build/**/*"
],
"require": [
"babel-register"
]
} }
} }

14
scripts/test.sh Executable file
View File

@ -0,0 +1,14 @@
#!/bin/bash
# strict mode http://redsymbol.net/articles/unofficial-bash-strict-mode/
set -euo pipefail
IFS=$'\n\t'
nyc=./node_modules/.bin/nyc
ava=./node_modules/.bin/ava
if [ ! -z ${CI:-} ]; then
$nyc $ava --tap=${CI-false} | tap-xunit > $CIRCLE_TEST_REPORTS/ava/ava.xml
else
$nyc $ava
fi

View File

@ -0,0 +1,44 @@
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('where /player {name: "Chard"}', async (t) => {
const { server, sequelize: { models: { Player } } } = t.context;
const url = '/player';
const method = 'POST';
const payload = { name: 'Chard' };
const notPresentPlayer = await Player.findOne({ where: payload });
t.falsy(notPresentPlayer);
const { result, statusCode } = await server.inject({ url, method, payload });
t.is(statusCode, STATUS_OK);
t.truthy(result.id);
t.is(result.name, payload.name);
});
test('not found /notamodel {name: "Chard"}', async (t) => {
const { server } = t.context;
const url = '/notamodel';
const method = 'POST';
const payload = { name: 'Chard' };
const { statusCode } = await server.inject({ url, method, payload });
t.is(statusCode, STATUS_NOT_FOUND);
});
test('no payload /player/1', async (t) => {
const { server } = t.context;
const url = '/player';
const method = 'POST';
const { statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_BAD_REQUEST);
});

View File

@ -0,0 +1,177 @@
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('destroy where /player?name=Baseball', async (t) => {
const { server, instances, sequelize: { models: { Player } } } = t.context;
const { player1, player2 } = instances;
const url = `/player?name=${player1.name}`;
const method = 'DELETE';
const presentPlayer = await Player.findById(player1.id);
t.truthy(presentPlayer);
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.id, player1.id);
const deletedPlayer = await Player.findById(player1.id);
t.falsy(deletedPlayer);
const stillTherePlayer = await Player.findById(player2.id);
t.truthy(stillTherePlayer);
});
test('destroyAll where /players?name=Baseball', async (t) => {
const { server, instances, sequelize: { models: { Player } } } = t.context;
const { player1, player2 } = instances;
const url = `/players?name=${player1.name}`;
const method = 'DELETE';
const presentPlayer = await Player.findById(player1.id);
t.truthy(presentPlayer);
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.id, player1.id);
const deletedPlayer = await Player.findById(player1.id);
t.falsy(deletedPlayer);
const stillTherePlayer = await Player.findById(player2.id);
t.truthy(stillTherePlayer);
});
test('destroyAll /players', async (t) => {
const { server, instances, sequelize: { models: { Player } } } = t.context;
const { player1, player2 } = instances;
const url = '/players';
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);
const resultPlayerIds = result.map(({ id }) => id);
t.truthy(resultPlayerIds.includes(player1.id));
t.truthy(resultPlayerIds.includes(player2.id));
const deletedPlayers = await Player.findAll();
t.is(deletedPlayers.length, 0);
});
test('destroy not found /player/10', async (t) => {
const { server, instances, sequelize: { models: { Player } } } = t.context;
const { player1, player2 } = instances;
// this doesn't exist in our fixtures
const url = '/player/10';
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();
t.is(nonDeletedPlayers.length, presentPlayers.length);
});
test('destroyAll not found /players?name=no', async (t) => {
const { server, instances, sequelize: { models: { Player } } } = t.context;
const { player1, player2 } = instances;
// this doesn't exist in our fixtures
const url = '/players?name=no';
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();
t.is(nonDeletedPlayers.length, presentPlayers.length);
});
test('not found /notamodel', async (t) => {
const { server } = t.context;
const url = '/notamodel';
const method = 'DELETE';
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

@ -2,6 +2,8 @@ import test from 'ava';
import 'sinon-bluebird'; import 'sinon-bluebird';
import setup from '../test/integration-setup.js'; import setup from '../test/integration-setup.js';
const STATUS_OK = 200;
setup(test); setup(test);
test('belongsTo /team?include=city', async (t) => { test('belongsTo /team?include=city', async (t) => {
@ -9,8 +11,8 @@ test('belongsTo /team?include=city', async (t) => {
const { team1, city1 } = instances; const { team1, city1 } = instances;
const path = `/team/${team1.id}?include=city`; const path = `/team/${team1.id}?include=city`;
const { result, response } = await server.inject(path); const { result, statusCode } = await server.inject(path);
t.falsy(response instanceof Error); t.is(statusCode, STATUS_OK);
t.is(result.id, team1.id); t.is(result.id, team1.id);
t.is(result.City.id, city1.id); t.is(result.City.id, city1.id);
}); });
@ -20,8 +22,8 @@ test('belongsTo /team?include=cities', async (t) => {
const { team1, city1 } = instances; const { team1, city1 } = instances;
const path = `/team/${team1.id}?include=cities`; const path = `/team/${team1.id}?include=cities`;
const { result, response } = await server.inject(path); const { result, statusCode } = await server.inject(path);
t.falsy(response instanceof Error); t.is(statusCode, STATUS_OK);
t.is(result.id, team1.id); t.is(result.id, team1.id);
t.is(result.City.id, city1.id); t.is(result.City.id, city1.id);
}); });
@ -31,8 +33,8 @@ test('hasMany /team?include=player', async (t) => {
const { team1, player1, player2 } = instances; const { team1, player1, player2 } = instances;
const path = `/team/${team1.id}?include=player`; const path = `/team/${team1.id}?include=player`;
const { result, response } = await server.inject(path); const { result, statusCode } = await server.inject(path);
t.falsy(response instanceof Error); t.is(statusCode, STATUS_OK);
t.is(result.id, team1.id); t.is(result.id, team1.id);
const playerIds = result.Players.map(({ id }) => id); const playerIds = result.Players.map(({ id }) => id);
@ -45,8 +47,8 @@ test('hasMany /team?include=players', async (t) => {
const { team1, player1, player2 } = instances; const { team1, player1, player2 } = instances;
const path = `/team/${team1.id}?include=players`; const path = `/team/${team1.id}?include=players`;
const { result, response } = await server.inject(path); const { result, statusCode } = await server.inject(path);
t.falsy(response instanceof Error); t.is(statusCode, STATUS_OK);
t.is(result.id, team1.id); t.is(result.id, team1.id);
const playerIds = result.Players.map(({ id }) => id); const playerIds = result.Players.map(({ id }) => id);
@ -59,8 +61,8 @@ test('multiple includes /team?include=players&include=city', async (t) => {
const { team1, player1, player2, city1 } = instances; const { team1, player1, player2, city1 } = instances;
const path = `/team/${team1.id}?include=players&include=city`; const path = `/team/${team1.id}?include=players&include=city`;
const { result, response } = await server.inject(path); const { result, statusCode } = await server.inject(path);
t.falsy(response instanceof Error); t.is(statusCode, STATUS_OK);
t.is(result.id, team1.id); t.is(result.id, team1.id);
const playerIds = result.Players.map(({ id }) => id); const playerIds = result.Players.map(({ id }) => id);
@ -74,8 +76,8 @@ test('multiple includes /team?include[]=players&include[]=city', async (t) => {
const { team1, player1, player2, city1 } = instances; const { team1, player1, player2, city1 } = instances;
const path = `/team/${team1.id}?include[]=players&include[]=city`; const path = `/team/${team1.id}?include[]=players&include[]=city`;
const { result, response } = await server.inject(path); const { result, statusCode } = await server.inject(path);
t.falsy(response instanceof Error); t.is(statusCode, STATUS_OK);
t.is(result.id, team1.id); t.is(result.id, team1.id);
const playerIds = result.Players.map(({ id }) => id); const playerIds = result.Players.map(({ id }) => 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

@ -0,0 +1,54 @@
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('where /player/1 {name: "Chard"}', async (t) => {
const { server, instances } = t.context;
const { player1 } = instances;
const url = `/player/${player1.id}`;
const method = 'PUT';
const payload = { name: 'Chard' };
const { result, statusCode } = await server.inject({ url, method, payload });
t.is(statusCode, STATUS_OK);
t.is(result.id, player1.id);
t.is(result.name, payload.name);
});
test('not found /player/10 {name: "Chard"}', async (t) => {
const { server } = t.context;
// this doesn't exist in our fixtures
const url = '/player/10';
const method = 'PUT';
const payload = { name: 'Chard' };
const { statusCode } = await server.inject({ url, method, payload });
t.is(statusCode, STATUS_NOT_FOUND);
});
test('no payload /player/1', async (t) => {
const { server, instances } = t.context;
const { player1 } = instances;
const url = `/player/${player1.id}`;
const method = 'PUT';
const { statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_BAD_REQUEST);
});
test('not found /notamodel {name: "Chard"}', async (t) => {
const { server } = t.context;
const url = '/notamodel';
const method = 'PUT';
const payload = { name: 'Chard' };
const { statusCode } = await server.inject({ url, method, payload });
t.is(statusCode, STATUS_NOT_FOUND);
});

View File

@ -0,0 +1,53 @@
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('single result /team?name=Baseball', async (t) => {
const { server, instances } = t.context;
const { team1 } = instances;
const path = `/team?name=${team1.name}`;
const { result, statusCode } = await server.inject(path);
t.is(statusCode, STATUS_OK);
t.is(result.id, team1.id);
t.is(result.name, team1.name);
});
test('no results /team?name=Baseball&id=2', async (t) => {
const { server, instances } = t.context;
const { team1 } = instances;
// this doesn't exist in our fixtures
const path = `/team?name=${team1.name}&id=2`;
const { statusCode } = await server.inject(path);
t.is(statusCode, STATUS_NOT_FOUND);
});
test('single result from list query /teams?name=Baseball', async (t) => {
const { server, instances } = t.context;
const { team1 } = instances;
const path = `/team?name=${team1.name}`;
const { result, statusCode } = await server.inject(path);
t.is(statusCode, STATUS_OK);
t.is(result.id, team1.id);
t.is(result.name, team1.name);
});
test('multiple results from list query /players?teamId=1', async (t) => {
const { server, instances } = t.context;
const { team1, player1, player2 } = instances;
const path = `/players?teamId=${team1.id}`;
const { result, statusCode } = await server.inject(path);
t.is(statusCode, STATUS_OK);
const playerIds = result.map(({ id }) => id);
t.truthy(playerIds.includes(player1.id));
t.truthy(playerIds.includes(player2.id));
});

View File

@ -165,6 +165,8 @@ export const list = ({ server, model, prefix = '/', config }) => {
where, include, limit, offset, order, where, include, limit, offset, order,
}); });
if (!list.length) return void reply(notFound('Nothing found.'));
reply(list.map((item) => item.toJSON())); reply(list.map((item) => item.toJSON()));
}, },
@ -214,6 +216,8 @@ export const scope = ({ server, model, prefix = '/', config }) => {
include, where, limit, offset, order, include, where, limit, offset, order,
}); });
if (!list.length) return void reply(notFound('Nothing found.'));
reply(list.map((item) => item.toJSON())); reply(list.map((item) => item.toJSON()));
}, },
config, config,
@ -244,10 +248,18 @@ export const destroy = ({ server, model, prefix = '/', config }) => {
@error @error
async handler(request, reply) { async handler(request, reply) {
const where = parseWhere(request); const where = parseWhere(request);
if (request.params.id) where[model.primaryKeyField] = request.params.id; const { id } = request.params;
if (id) where[model.primaryKeyField] = id;
const list = await model.findAll({ where }); const list = await model.findAll({ where });
if (!list.length) {
return void reply(id
? notFound(`${id} not found.`)
: notFound('Nothing found.')
);
}
await Promise.all(list.map(instance => instance.destroy())); await Promise.all(list.map(instance => instance.destroy()));
const listAsJSON = list.map((item) => item.toJSON()); const listAsJSON = list.map((item) => item.toJSON());
@ -266,9 +278,17 @@ export const destroyAll = ({ server, model, prefix = '/', config }) => {
@error @error
async handler(request, reply) { async handler(request, reply) {
const where = parseWhere(request); const where = parseWhere(request);
const { id } = request.params;
const list = await model.findAll({ where }); const list = await model.findAll({ where });
if (!list.length) {
return void reply(id
? notFound(`${id} not found.`)
: notFound('Nothing found.')
);
}
await Promise.all(list.map(instance => instance.destroy())); await Promise.all(list.map(instance => instance.destroy()));
const listAsJSON = list.map((item) => item.toJSON()); const listAsJSON = list.map((item) => item.toJSON());
@ -293,6 +313,8 @@ export const destroyScope = ({ server, model, prefix = '/', config }) => {
const list = await model.scope(request.params.scope).findAll({ include, where }); 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())); await Promise.all(list.map(instance => instance.destroy()));
const listAsJSON = list.map((item) => item.toJSON()); const listAsJSON = list.map((item) => item.toJSON());

View File

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

View File

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

View File

@ -38,7 +38,6 @@ const register = (server, options = {}, next) => {
// Join tables // Join tables
if (model.options.name.singular !== model.name) continue; if (model.options.name.singular !== model.name) continue;
for (const key of Object.keys(model.associations)) { for (const key of Object.keys(model.associations)) {
const association = model.associations[key]; const association = model.associations[key];
const { source, target } = association; const { source, target } = association;
@ -95,11 +94,15 @@ const register = (server, options = {}, next) => {
// build the methods for each model now that we've defined all the // build the methods for each model now that we've defined all the
// associations // associations
Object.keys(models).forEach((modelName) => { Object.keys(models).filter((modelName) => {
const model = models[modelName];
return model.options.name.singular === model.name;
}).forEach((modelName) => {
const model = models[modelName]; const model = models[modelName];
crud(server, model, options); crud(server, model, options);
}); });
next(); next();
}; };

View File

@ -3,21 +3,27 @@ import { notImplemented } from 'boom';
const sequelizeKeys = ['include', 'order', 'limit', 'offset']; const sequelizeKeys = ['include', 'order', 'limit', 'offset'];
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.');
}
const { models } = noGetDb ? request : request.getDb();
return models;
};
export const parseInclude = request => { export const parseInclude = request => {
const include = Array.isArray(request.query.include) const include = Array.isArray(request.query.include)
? request.query.include ? request.query.include
: [request.query.include] : [request.query.include]
; ;
const noGetDb = typeof request.getDb !== 'function'; const models = getModels(request);
const noRequestModels = !request.models; if (models.isBoom) return models;
if (noGetDb && noRequestModels) {
return notImplemented('`request.getDb` or `request.models` are not defined.'
+ 'Be sure to load hapi-sequelize before hapi-sequelize-crud.');
}
const { models } = noGetDb ? request : request.getDb();
return include.map(a => { return include.map(a => {
const singluarOrPluralMatch = Object.keys(models).find((modelName) => { const singluarOrPluralMatch = Object.keys(models).find((modelName) => {
@ -63,24 +69,40 @@ export const parseLimitAndOffset = (request) => {
return out; 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) => { export const parseOrder = (request) => {
const { order } = request.query; const { order } = request.query;
if (!order) return null; 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 // 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 // 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)) { const parsedOrder = parseOrderArray(requestOrderColumns, models);
try {
order[key] = JSON.parse(order[key]);
} catch (e) {
//
}
}
return order; return parsedOrder;
}; };
export const getMethod = (model, association, plural = true, method = 'get') => { 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'; import { parseLimitAndOffset, parseOrder, parseWhere } from './utils.js';
test.beforeEach((t) => { test.beforeEach((t) => {
t.context.request = { query: {} }; const models = t.context.models = { User: {} };
t.context.request = { query: {}, models };
}); });
test('parseLimitAndOffset is a function', (t) => { test('parseLimitAndOffset is a function', (t) => {
@ -57,19 +58,18 @@ test('parseOrder returns order when a string', (t) => {
t.deepEqual( t.deepEqual(
parseOrder(request) parseOrder(request)
, [order] , [[order]]
); );
}); });
test('parseOrder returns order when json', (t) => { test('parseOrder returns order when json', (t) => {
const { request } = t.context; const { request,models } = t.context;
const order = [{ model: 'User' }, 'DESC'];
request.query.order = [JSON.stringify({ model: 'User' }), 'DESC']; request.query.order = [JSON.stringify({ model: 'User' }), 'DESC'];
request.query.thing = 'hi'; request.query.thing = 'hi';
t.deepEqual( t.deepEqual(
parseOrder(request) parseOrder(request)
, order , [{ model: models.User }, 'DESC']
); );
}); });

View File

@ -7,6 +7,7 @@ export default (sequelize, DataTypes) => {
}, },
name: DataTypes.STRING, name: DataTypes.STRING,
teamId: DataTypes.INTEGER, teamId: DataTypes.INTEGER,
active: DataTypes.BOOLEAN,
}, { }, {
classMethods: { classMethods: {
associate: (models) => { associate: (models) => {
@ -15,5 +16,24 @@ export default (sequelize, DataTypes) => {
}); });
}, },
}, },
scopes: {
returnsOne: {
where: {
active: true,
},
},
returnsNone: {
where: {
name: 'notaname',
},
},
returnsAll: {
where: {
name: {
$ne: 'notaname',
},
},
},
},
}); });
}; };

View File

@ -58,9 +58,13 @@ export default (test) => {
const { Player, Team, City } = t.context.sequelize.models; const { Player, Team, City } = t.context.sequelize.models;
const city1 = await City.create({ name: 'Healdsburg' }); const city1 = await City.create({ name: 'Healdsburg' });
const team1 = await Team.create({ name: 'Baseballs', cityId: city1.id }); const team1 = await Team.create({ name: 'Baseballs', cityId: city1.id });
const player1 = await Player.create({ name: 'Pinot', teamId: team1.id }); const team2 = await Team.create({ name: 'Footballs', cityId: city1.id });
const player2 = await Player.create({ name: 'Syrah', teamId: team1.id }); const player1 = await Player.create({
t.context.instances = { city1, team1, player1, player2 }; name: 'Cat', teamId: team1.id, active: true,
});
const player2 = await Player.create({ name: 'Pinot', teamId: team1.id });
const player3 = await Player.create({ name: 'Syrah', teamId: team2.id });
t.context.instances = { city1, team1, team2, player1, player2, player3 };
}); });
// kill the server so that we can exit and don't leak memory // kill the server so that we can exit and don't leak memory