From 93e3c0a14ba7515066e0aac42242dc5ee20f8f6d Mon Sep 17 00:00:00 2001 From: Zachary Golba Date: Sun, 5 Jun 2016 16:55:33 -0400 Subject: [PATCH] feat: add chainable query interface feat: add #includes() to query class refactor: move repeatable logic to built in middleware and simplify controller actions feat: add chainable query interface refactor: remove half-baked polymorphism --- .babelrc | 4 +- .eslintrc.json | 1 + README.md | 2 +- decl/http.js | 8 +- examples/social-network/.babelrc | 11 +- .../app/controllers/application.js | 6 +- .../app/controllers/comments.js | 6 - .../social-network/app/controllers/posts.js | 6 - .../app/controllers/reactions.js | 6 - .../social-network/app/controllers/users.js | 47 +- examples/social-network/app/index.js | 4 +- .../app/middleware/authenticate.js | 51 - .../social-network/app/middleware/set-user.js | 24 - examples/social-network/app/models/comment.js | 4 + examples/social-network/app/routes.js | 14 +- .../social-network/app/serializers/actions.js | 5 +- examples/social-network/bin/app.js | 2 - .../config/environments/development.js | 5 +- .../config/environments/production.js | 7 +- .../config/environments/test.js | 7 +- examples/social-network/db/seed.js | 12 +- examples/social-network/package.json | 17 +- examples/todo/.babelrc | 11 +- examples/todo/app/index.js | 4 +- examples/todo/app/routes.js | 4 +- .../todo/config/environments/development.js | 5 +- .../todo/config/environments/production.js | 5 +- examples/todo/config/environments/test.js | 5 +- examples/todo/db/seed.js | 4 +- examples/todo/package.json | 19 +- package.json | 12 +- src/packages/application/index.js | 33 +- src/packages/application/initialize.js | 36 +- src/packages/cli/templates/config.js | 8 +- .../compiler/utils/create-boot-script.js | 2 +- src/packages/controller/index.js | 262 +++--- .../controller/utils/create-page-links.js | 111 --- .../controller/utils/format-include.js | 44 - src/packages/controller/utils/get-record.js | 48 - src/packages/database/collection/index.js | 98 -- .../database/collection/utils/insert.js | 11 - src/packages/database/index.js | 2 +- src/packages/database/model/index.js | 411 ++++---- .../database/model/utils/fetch-has-many.js | 55 -- .../database/model/utils/format-select.js | 7 - .../database/model/utils/get-offset.js | 11 - .../database/model/utils/initialize.js | 230 +++-- src/packages/database/query/index.js | 437 +++++++++ .../database/query/utils/build-results.js | 87 ++ .../database/query/utils/format-select.js | 14 + src/packages/route/index.js | 4 +- .../middleware/sanitize-params.js | 27 +- src/packages/route/middleware/set-fields.js | 23 + src/packages/route/middleware/set-include.js | 82 ++ src/packages/route/middleware/set-limit.js | 24 + src/packages/route/utils/create-action.js | 120 ++- src/packages/route/utils/create-page-links.js | 81 ++ .../route/utils/get-dynamic-segments.js | 11 +- src/packages/router/index.js | 45 +- .../serializer/content-stream/index.js | 26 + src/packages/serializer/index.js | 882 +++++++++--------- src/packages/server/index.js | 2 + src/packages/server/utils/format-params.js | 2 +- src/utils/camelize-keys.js | 31 - src/utils/insert.js | 13 + src/utils/omit.js | 2 +- src/utils/pick.js | 2 +- src/utils/promise-hash.js | 27 + src/utils/transform-keys.js | 63 ++ test/integration/controller.js | 11 +- test/test-app/app/controllers/posts.js | 4 + test/test-app/app/models/post.js | 14 + .../config/environments/development.js | 3 +- .../config/environments/production.js | 3 +- test/test-app/config/environments/test.js | 3 +- test/test-app/package.json | 4 +- test/unit/utils/camelize-keys.js | 2 +- 77 files changed, 2089 insertions(+), 1647 deletions(-) delete mode 100644 examples/social-network/app/middleware/authenticate.js delete mode 100644 examples/social-network/app/middleware/set-user.js delete mode 100644 examples/social-network/bin/app.js delete mode 100644 src/packages/controller/utils/create-page-links.js delete mode 100644 src/packages/controller/utils/format-include.js delete mode 100644 src/packages/controller/utils/get-record.js delete mode 100644 src/packages/database/collection/index.js delete mode 100644 src/packages/database/collection/utils/insert.js delete mode 100644 src/packages/database/model/utils/fetch-has-many.js delete mode 100644 src/packages/database/model/utils/format-select.js delete mode 100644 src/packages/database/model/utils/get-offset.js create mode 100644 src/packages/database/query/index.js create mode 100644 src/packages/database/query/utils/build-results.js create mode 100644 src/packages/database/query/utils/format-select.js rename src/packages/{controller => route}/middleware/sanitize-params.js (82%) create mode 100644 src/packages/route/middleware/set-fields.js create mode 100644 src/packages/route/middleware/set-include.js create mode 100644 src/packages/route/middleware/set-limit.js create mode 100644 src/packages/route/utils/create-page-links.js create mode 100644 src/packages/serializer/content-stream/index.js delete mode 100644 src/utils/camelize-keys.js create mode 100644 src/utils/insert.js create mode 100644 src/utils/promise-hash.js create mode 100644 src/utils/transform-keys.js diff --git a/.babelrc b/.babelrc index f0d48c88..44a162c7 100644 --- a/.babelrc +++ b/.babelrc @@ -5,8 +5,6 @@ "plugins": [ "syntax-flow", "syntax-trailing-function-commas", - "transform-decorators-legacy", - "transform-flow-strip-types", - "transform-decorators" + "transform-flow-strip-types" ] } diff --git a/.eslintrc.json b/.eslintrc.json index 95f3f61e..0cc4991b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -10,6 +10,7 @@ "Map": true, "Set": true, "Promise": true, + "Proxy": true, "Symbol": true, "WeakMap": true, "Iterable": true, diff --git a/README.md b/README.md index ed051c6d..5c9a8a7b 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ With Lux your code from before can now look like this: ```javascript class PostsController extends Controller { index(req, res) { - return Post.findAll(); + return Post.all(); } } ``` diff --git a/decl/http.js b/decl/http.js index f4857504..da00f1a2 100644 --- a/decl/http.js +++ b/decl/http.js @@ -6,6 +6,8 @@ import { import { Model } from '../src/packages/database'; +import type Route from '../src/packages/route'; + type params = { data?: { id: number | string | Buffer; @@ -17,18 +19,20 @@ type params = { fields: Object; filter: Object; id?: number | string | Buffer; - include: Array; + include: Array | Object; limit: number; page: number; - sort: string; + sort: string | [string, string]; }; declare module 'http' { declare class Server extends http$Server {} declare class IncomingMessage extends http$IncomingMessage { + route?: Route; params: params; record?: Model; + method: string; parsedURL: { protocol?: string; diff --git a/examples/social-network/.babelrc b/examples/social-network/.babelrc index 95a5926a..0d5dc68c 100644 --- a/examples/social-network/.babelrc +++ b/examples/social-network/.babelrc @@ -1,10 +1,3 @@ { - "presets": [ - "es2015", - "stage-1" - ], - "plugins": [ - "transform-runtime", - "transform-decorators-legacy" - ] -} \ No newline at end of file + "presets": ["lux"] +} diff --git a/examples/social-network/app/controllers/application.js b/examples/social-network/app/controllers/application.js index 3f5ac7f1..d94c7510 100644 --- a/examples/social-network/app/controllers/application.js +++ b/examples/social-network/app/controllers/application.js @@ -1,11 +1,7 @@ import { Controller } from 'lux-framework'; -import authenticate from '../middleware/authenticate'; - class ApplicationController extends Controller { - beforeAction = [ - authenticate - ]; + } export default ApplicationController; diff --git a/examples/social-network/app/controllers/comments.js b/examples/social-network/app/controllers/comments.js index c83bf429..34dc9bd4 100644 --- a/examples/social-network/app/controllers/comments.js +++ b/examples/social-network/app/controllers/comments.js @@ -1,16 +1,10 @@ import { Controller } from 'lux-framework'; -import setUser from '../middleware/set-user'; - class CommentsController extends Controller { params = [ 'message', 'edited' ]; - - beforeAction = [ - setUser - ]; } export default CommentsController; diff --git a/examples/social-network/app/controllers/posts.js b/examples/social-network/app/controllers/posts.js index 6246701e..4123bb8d 100644 --- a/examples/social-network/app/controllers/posts.js +++ b/examples/social-network/app/controllers/posts.js @@ -1,17 +1,11 @@ import { Controller } from 'lux-framework'; -import setUser from '../middleware/set-user'; - class PostsController extends Controller { params = [ 'body', 'title', 'isPublic' ]; - - beforeAction = [ - setUser - ]; } export default PostsController; diff --git a/examples/social-network/app/controllers/reactions.js b/examples/social-network/app/controllers/reactions.js index 9940bbd3..8ddd3659 100644 --- a/examples/social-network/app/controllers/reactions.js +++ b/examples/social-network/app/controllers/reactions.js @@ -1,15 +1,9 @@ import { Controller } from 'lux-framework'; -import setUser from '../middleware/set-user'; - class ReactionsController extends Controller { params = [ 'type' ]; - - beforeAction = [ - setUser - ]; } export default ReactionsController; diff --git a/examples/social-network/app/controllers/users.js b/examples/social-network/app/controllers/users.js index b84a5662..822978bb 100644 --- a/examples/social-network/app/controllers/users.js +++ b/examples/social-network/app/controllers/users.js @@ -1,6 +1,4 @@ -import { Controller, action } from 'lux-framework'; - -import User from '../models/user'; +import { Controller } from 'lux-framework'; class UsersController extends Controller { params = [ @@ -8,49 +6,6 @@ class UsersController extends Controller { 'email', 'password' ]; - - @action - async login(req, res) { - const { - session, - - params: { - data: { - attributes: { - email, - password - } - } - } - } = req; - - const user = await User.authenticate(email, password); - - if (user) { - session.set('currentUserId', user.id); - } - - return { - data: user - }; - } - - @action - logout(req, res) { - const { - session, - - session: { - isAuthenticated - } - } = req; - - if (isAuthenticated) { - session.delete('currentUserId'); - } - - return isAuthenticated; - } } export default UsersController; diff --git a/examples/social-network/app/index.js b/examples/social-network/app/index.js index 83131b8e..9a3032a7 100644 --- a/examples/social-network/app/index.js +++ b/examples/social-network/app/index.js @@ -1,6 +1,6 @@ -import Lux from 'lux-framework'; +import { Application } from 'lux-framework'; -class SocialNetwork extends Lux { +class SocialNetwork extends Application { } diff --git a/examples/social-network/app/middleware/authenticate.js b/examples/social-network/app/middleware/authenticate.js deleted file mode 100644 index 2257a724..00000000 --- a/examples/social-network/app/middleware/authenticate.js +++ /dev/null @@ -1,51 +0,0 @@ -export default async function authenticate(req, res) { - const { modelName } = this; - const { url, method, record, session } = req; - const currentUserId = session.get('currentUserId'); - let authenticated = true; - - switch (modelName) { - case 'action': - case 'comment': - case 'post': - case 'reaction': - switch (method) { - case 'DELETE': - case 'PATCH': - authenticated = record.userId === currentUserId; - break; - - case 'POST': - authenticated = !!currentUserId; - break; - } - break; - - case 'friendship': - switch (method) { - case 'DELETE': - case 'PATCH': - case 'POST': - authenticated = !!currentUserId; - break; - } - break; - - case 'user': - if (/^(DELETE|PATCH)$/g.test(method)) { - authenticated = url.pathname.includes('logout') || - currentUserId === record.id; - } - break; - - case 'notification': - if (record) { - authenticated = record.userId === currentUserId; - } else { - authenticated = false; - } - break; - } - - return authenticated; -} diff --git a/examples/social-network/app/middleware/set-user.js b/examples/social-network/app/middleware/set-user.js deleted file mode 100644 index 9edc22a3..00000000 --- a/examples/social-network/app/middleware/set-user.js +++ /dev/null @@ -1,24 +0,0 @@ -import User from '../models/user'; - -export default async function setUser(req, res) { - const { method } = req; - - if (/^(PUT|POST)$/g.test(method)) { - const { - session, - - params: { - data: { - attributes - } - } - } = req; - - if (attributes) { - req.params.data.attributes = { - ...attributes, - user: await User.find(session.get('currentUserId')) - }; - } - } -} diff --git a/examples/social-network/app/models/comment.js b/examples/social-network/app/models/comment.js index f57d3f56..0d5938f4 100644 --- a/examples/social-network/app/models/comment.js +++ b/examples/social-network/app/models/comment.js @@ -14,6 +14,10 @@ class Comment extends Model { }; static hasMany = { + actions: { + inverse: 'trackable' + }, + reactions: { inverse: 'comment' } diff --git a/examples/social-network/app/routes.js b/examples/social-network/app/routes.js index 45ba0f9c..2da6aac3 100644 --- a/examples/social-network/app/routes.js +++ b/examples/social-network/app/routes.js @@ -1,20 +1,10 @@ -export default (route, resource) => { +export default function routes(route, resource) { resource('comments'); resource('friendships'); resource('posts'); resource('reactions'); resource('users'); - route('users/login', { - method: 'POST', - action: 'login' - }); - - route('users/logout', { - method: 'DELETE', - action: 'logout' - }); - route('actions', { method: 'GET', action: 'index' @@ -34,4 +24,4 @@ export default (route, resource) => { method: 'GET', action: 'show' }); -}; +} diff --git a/examples/social-network/app/serializers/actions.js b/examples/social-network/app/serializers/actions.js index 0209f07e..ff4e37a7 100644 --- a/examples/social-network/app/serializers/actions.js +++ b/examples/social-network/app/serializers/actions.js @@ -1,10 +1,7 @@ import { Serializer } from 'lux-framework'; class ActionsSerializer extends Serializer { - attributes = [ - 'trackableId', - 'trackableType' - ]; + } export default ActionsSerializer; diff --git a/examples/social-network/bin/app.js b/examples/social-network/bin/app.js deleted file mode 100644 index 23509bc4..00000000 --- a/examples/social-network/bin/app.js +++ /dev/null @@ -1,2 +0,0 @@ -require('babel-core/register'); -module.exports = require('../app').default; \ No newline at end of file diff --git a/examples/social-network/config/environments/development.js b/examples/social-network/config/environments/development.js index 1c4420d0..9c1d9d46 100644 --- a/examples/social-network/config/environments/development.js +++ b/examples/social-network/config/environments/development.js @@ -1,6 +1,3 @@ export default { - log: true, - domain: 'http://localhost:4000', - sessionKey: 'social-network::development::session', - sessionSecret: 'a6d027382e6c6405674d951fe4e39d50c567020dee231938de063524d339c758' + log: true }; diff --git a/examples/social-network/config/environments/production.js b/examples/social-network/config/environments/production.js index 462c8328..b9122bca 100644 --- a/examples/social-network/config/environments/production.js +++ b/examples/social-network/config/environments/production.js @@ -1,6 +1,3 @@ export default { - log: false, - domain: 'http://localhost:4000', - sessionKey: 'social-network::session', - sessionSecret: 'd54dc706d6b7f4f88ddefab178ec8e4370d3ba12d977d8aaff36b782402c207c' -}; \ No newline at end of file + log: false +}; diff --git a/examples/social-network/config/environments/test.js b/examples/social-network/config/environments/test.js index b4848865..b9122bca 100644 --- a/examples/social-network/config/environments/test.js +++ b/examples/social-network/config/environments/test.js @@ -1,6 +1,3 @@ export default { - log: true, - domain: 'http://localhost:4000', - sessionKey: 'social-network::test::session', - sessionSecret: 'a893efc5d2677a5aa8dc5adb599f4eee2ea660b426b6c053123eaab28c01ebff' -}; \ No newline at end of file + log: false +}; diff --git a/examples/social-network/db/seed.js b/examples/social-network/db/seed.js index 1318998c..462f4d74 100644 --- a/examples/social-network/db/seed.js +++ b/examples/social-network/db/seed.js @@ -4,6 +4,7 @@ import Comment from '../app/models/comment'; import Post from '../app/models/post'; import Reaction from '../app/models/reaction'; import User from '../app/models/user'; +import Friendship from '../app/models/friendship'; import range from '../app/utils/range'; @@ -18,7 +19,7 @@ const { } } = faker; -export default async () => { +export default async function seed() { await Promise.all( [...range(1, 100)].map(() => { return User.create({ @@ -29,6 +30,15 @@ export default async () => { }) ); + await Promise.all( + [...range(1, 100)].map(() => { + return Friendship.create({ + userId: randomize([...range(1, 100)]), + friendId: randomize([...range(1, 100)]) + }); + }) + ); + await Promise.all( [...range(1, 100)].map(() => { return Post.create({ diff --git a/examples/social-network/package.json b/examples/social-network/package.json index 48f2de89..86e6e1dc 100644 --- a/examples/social-network/package.json +++ b/examples/social-network/package.json @@ -2,7 +2,7 @@ "name": "social-network", "version": "0.0.1", "description": "", - "main": "bin/app.js", + "main": "app/index.js", "scripts": { "start": "lux serve", "test": "lux test" @@ -10,16 +10,13 @@ "author": "", "license": "MIT", "dependencies": { - "babel-core": "6.9.0", - "babel-eslint": "6.0.4", - "babel-plugin-transform-decorators-legacy": "1.3.4", - "babel-plugin-transform-runtime": "6.9.0", - "babel-preset-es2015": "6.9.0", - "babel-preset-stage-1": "6.5.0", - "babel-runtime": "6.9.0", - "faker": "3.1.0", - "knex": "0.11.4", + "babel-core": "6.9.1", + "babel-preset-lux": "1.0.0", + "knex": "0.11.5", "lux-framework": "0.0.1-beta.10", "sqlite3": "3.1.4" + }, + "devDependencies": { + "faker": "3.1.0" } } diff --git a/examples/todo/.babelrc b/examples/todo/.babelrc index 95a5926a..0d5dc68c 100644 --- a/examples/todo/.babelrc +++ b/examples/todo/.babelrc @@ -1,10 +1,3 @@ { - "presets": [ - "es2015", - "stage-1" - ], - "plugins": [ - "transform-runtime", - "transform-decorators-legacy" - ] -} \ No newline at end of file + "presets": ["lux"] +} diff --git a/examples/todo/app/index.js b/examples/todo/app/index.js index ac245609..defcf9ec 100644 --- a/examples/todo/app/index.js +++ b/examples/todo/app/index.js @@ -1,6 +1,6 @@ -import Lux from 'lux-framework'; +import { Application } from 'lux-framework'; -class Todo extends Lux { +class Todo extends Application { } diff --git a/examples/todo/app/routes.js b/examples/todo/app/routes.js index e4f3e756..fc7c08af 100644 --- a/examples/todo/app/routes.js +++ b/examples/todo/app/routes.js @@ -1,4 +1,4 @@ -export default (route, resource) => { +export default function routes(route, resource) { resource('tasks'); resource('lists'); -}; +} diff --git a/examples/todo/config/environments/development.js b/examples/todo/config/environments/development.js index f6172719..9c1d9d46 100644 --- a/examples/todo/config/environments/development.js +++ b/examples/todo/config/environments/development.js @@ -1,6 +1,3 @@ export default { - log: true, - domain: 'http://localhost:4000', - sessionKey: 'todo::development::session', - sessionSecret: 'f993c2c64fd6fa6a503fb550a0ecb9d17323170c972d152a6fd6a0a64bf973a7' + log: true }; diff --git a/examples/todo/config/environments/production.js b/examples/todo/config/environments/production.js index 1cf7eb33..b9122bca 100644 --- a/examples/todo/config/environments/production.js +++ b/examples/todo/config/environments/production.js @@ -1,6 +1,3 @@ export default { - log: false, - domain: 'http://localhost:4000', - sessionKey: 'todo::session', - sessionSecret: '7764e5b0e98e574eb3867254b53399ceea42640b725f09e7bcb349bc0c364366' + log: false }; diff --git a/examples/todo/config/environments/test.js b/examples/todo/config/environments/test.js index 490b2b35..b9122bca 100644 --- a/examples/todo/config/environments/test.js +++ b/examples/todo/config/environments/test.js @@ -1,6 +1,3 @@ export default { - log: true, - domain: 'http://localhost:4000', - sessionKey: 'todo::test::session', - sessionSecret: '5bb39b824a8f85a00a2f6d967a59a866cd1859830211f55b05458d2825e6c3bc' + log: false }; diff --git a/examples/todo/db/seed.js b/examples/todo/db/seed.js index d6186380..4142929d 100644 --- a/examples/todo/db/seed.js +++ b/examples/todo/db/seed.js @@ -16,7 +16,7 @@ const { } } = faker; -export default async () => { +export default async function seed() { await Promise.all( [...range(1, 4)].map(() => { return List.create({ @@ -35,4 +35,4 @@ export default async () => { }) }) ); -}; +} diff --git a/examples/todo/package.json b/examples/todo/package.json index 6b8c5470..86e6e1dc 100644 --- a/examples/todo/package.json +++ b/examples/todo/package.json @@ -1,8 +1,8 @@ { - "name": "todo", + "name": "social-network", "version": "0.0.1", "description": "", - "main": "bin/app.js", + "main": "app/index.js", "scripts": { "start": "lux serve", "test": "lux test" @@ -10,16 +10,13 @@ "author": "", "license": "MIT", "dependencies": { - "babel-core": "6.9.0", - "babel-eslint": "6.0.4", - "babel-plugin-transform-decorators-legacy": "1.3.4", - "babel-plugin-transform-runtime": "6.9.0", - "babel-preset-es2015": "6.9.0", - "babel-preset-stage-1": "6.5.0", - "babel-runtime": "6.9.0", - "faker": "3.1.0", - "knex": "0.11.4", + "babel-core": "6.9.1", + "babel-preset-lux": "1.0.0", + "knex": "0.11.5", "lux-framework": "0.0.1-beta.10", "sqlite3": "3.1.4" + }, + "devDependencies": { + "faker": "3.1.0" } } diff --git a/package.json b/package.json index 2b5664cc..1d03b267 100644 --- a/package.json +++ b/package.json @@ -39,18 +39,18 @@ "bluebird": "3.4.0", "chalk": "1.1.3", "commander": "2.9.0", - "eslint": "2.11.1", + "eslint": "2.12.0", "fb-watchman": "1.9.0", "inflection": "1.10.0", "moment": "2.13.0", "ora": "0.2.3", - "rollup": "0.26.3", + "rollup": "0.31.0", "rollup-plugin-alias": "1.2.0", "rollup-plugin-babel": "2.4.0", - "rollup-plugin-commonjs": "2.2.1", + "rollup-plugin-commonjs": "3.0.0", "rollup-plugin-eslint": "2.0.0", "rollup-plugin-json": "2.0.0", - "rollup-plugin-node-resolve": "1.5.0", + "rollup-plugin-node-resolve": "1.7.0", "source-map-support": "0.4.0" }, "devDependencies": { @@ -61,9 +61,9 @@ "babel-preset-lux": "1.0.0", "chai": "3.5.0", "documentation": "4.0.0-beta5", - "flow-bin": "0.26.0", + "flow-bin": "0.27.0", "isomorphic-fetch": "2.2.1", "mocha": "2.5.3", - "rollup-plugin-multi-entry": "1.2.0" + "rollup-plugin-multi-entry": "1.4.0" } } diff --git a/src/packages/application/index.js b/src/packages/application/index.js index c1958c47..135987d0 100644 --- a/src/packages/application/index.js +++ b/src/packages/application/index.js @@ -5,6 +5,9 @@ import type Database from '../database'; import type Logger from '../logger'; import type Router from '../router'; import type Server from '../server'; +import type Controller from '../controller'; +import type Serializer from '../serializer'; +import typeof { Model } from '../database'; /** * The `Application` class is responsible for constructing an application and @@ -46,15 +49,14 @@ class Application { store: Database; /** - * The public domain where the `Application` instance is located. This is - * primarily used for creating `links` resource objects. + * A map containing each `Model` class in an application instance. * - * @property domain + * @property models * @memberof Application * @instance * @readonly */ - domain: string; + models: Map; /** * A reference to the instance of `Logger`. @@ -66,6 +68,26 @@ class Application { */ logger: Logger; + /** + * A map containing each `Controller` class in an application instance. + * + * @property controllers + * @memberof Application + * @instance + * @readonly + */ + controllers: Map; + + /** + * A map containing each `Serializer` class in an application instance. + * + * @property serializers + * @memberof Application + * @instance + * @readonly + */ + serializers: Map; + /** * A reference to the instance of `Router`. * @@ -98,20 +120,17 @@ class Application { log = true, path, port, - domain = 'http://localhost', database }: { log: boolean, path: string, port: number, - domain: string, database: {} } = {}): Promise { return initialize(this, { log, path, port, - domain, database }); } diff --git a/src/packages/application/initialize.js b/src/packages/application/initialize.js index 7f1bf3c8..99eaaf25 100644 --- a/src/packages/application/initialize.js +++ b/src/packages/application/initialize.js @@ -21,13 +21,11 @@ export default async function initialize(app: Application, { log, path, port, - domain, database }: { log: boolean, path: string, port: number, - domain: string, database: {} } = {}): Promise { const routes = loader(path, 'routes'); @@ -75,13 +73,6 @@ export default async function initialize(app: Application, { configurable: false }, - domain: { - value: domain, - writable: false, - enumerable: true, - configurable: false - }, - logger: { value: logger, writable: false, @@ -106,6 +97,14 @@ export default async function initialize(app: Application, { await store.define(models); + Object.freeze(store); + + Object.assign(app, { + models, + controllers, + serializers + }); + models.forEach((model, name) => { const resource = pluralize(name); @@ -123,12 +122,16 @@ export default async function initialize(app: Application, { serializer = new serializer({ model, - domain, serializers }); if (model) { - model.serializer = serializer; + Object.defineProperty(model, 'serializer', { + value: serializer, + writable: false, + enumerable: false, + configurable: false + }); } serializers.set(name, serializer); @@ -137,7 +140,6 @@ export default async function initialize(app: Application, { let appController = controllers.get('application'); appController = new appController({ store, - domain, serializers, serializer: serializers.get('application') }); @@ -151,7 +153,6 @@ export default async function initialize(app: Application, { controller = new controller({ store, model, - domain, serializers, serializer: serializers.get(key), parentController: appController @@ -174,5 +175,14 @@ export default async function initialize(app: Application, { } }); + Object.freeze(app); + Object.freeze(logger); + Object.freeze(router); + Object.freeze(server); + + models.forEach(Object.freeze); + controllers.forEach(Object.freeze); + serializers.forEach(Object.freeze); + return app; } diff --git a/src/packages/cli/templates/config.js b/src/packages/cli/templates/config.js index 220d63e1..198a7b6f 100644 --- a/src/packages/cli/templates/config.js +++ b/src/packages/cli/templates/config.js @@ -6,16 +6,10 @@ import template from '../../template'; */ export default (name: string, env: string): string => { const isProdENV = env === 'production'; - let keyPrefix = `${name}`; - - if (!isProdENV) { - keyPrefix += `::${env}`; - } return template` export default { - log: ${!isProdENV}, - domain: 'http://localhost:4000' + log: ${!isProdENV} }; `; }; diff --git a/src/packages/compiler/utils/create-boot-script.js b/src/packages/compiler/utils/create-boot-script.js index 3428315c..a39da237 100644 --- a/src/packages/compiler/utils/create-boot-script.js +++ b/src/packages/compiler/utils/create-boot-script.js @@ -12,7 +12,7 @@ export default async function createBootScript(dir: string): Promise { const { env: { PWD, PORT } } = process; const { Application, config, database } = require('./bundle'); - new Application( + module.exports = new Application( Object.assign(config, { database, path: PWD, diff --git a/src/packages/controller/index.js b/src/packages/controller/index.js index 7d4c3f67..184dcb5a 100644 --- a/src/packages/controller/index.js +++ b/src/packages/controller/index.js @@ -1,12 +1,10 @@ import Model from '../database/model'; -import omit from '../../utils/omit'; -import getRecord from './utils/get-record'; -import formatInclude from './utils/format-include'; +import insert from '../../utils/insert'; import type { IncomingMessage, ServerResponse } from 'http'; -import type Database, { Collection } from '../database'; +import type Database from '../database'; import type Serializer from '../serializer'; /** @@ -19,41 +17,6 @@ import type Serializer from '../serializer'; * and returns data relative to what the client has request. */ class Controller { - /** - * Whitelisted parameter keys to allow in incoming PATCH and POST requests. - * - * For security reasons, parameters passed to controller actions from an - * incoming request must have their key whitelisted. - * - * @example - * class UsersController extends Controller { - * // Do not allow incoming PATCH or POST requests to modify User#isAdmin. - * params = [ - * 'name', - * 'email', - * 'password', - * // 'isAdmin' - * ]; - * } - * - * @property params - * @memberof Controller - * @instance - */ - params: Array = []; - - /** - * Middleware functions to execute on each request handled by a `Controller`. - * - * Middleware functions declared in beforeAction on an `ApplicationController` - * will be executed before ALL route handlers. - * - * @property beforeAction - * @memberof Controller - * @instance - */ - beforeAction: Array = []; - /** * The number of records to return for the #index action when a `?limit` * parameter is not specified. @@ -82,15 +45,6 @@ class Controller { */ model: typeof Model; - /** - * @property domain - * @memberof Controller - * @instance - * @readonly - * @private - */ - domain: string; - /** * @property modelName * @memberof Controller @@ -145,35 +99,15 @@ class Controller { */ parentController: ?Controller; - /** - * @property _sort - * @memberof Controller - * @instance - * @readonly - * @private - */ - _sort: Array = []; - - /** - * @property _filter - * @memberof Controller - * @instance - * @readonly - * @private - */ - _filter: Array = []; - constructor({ store, model, - domain, serializer, serializers = new Map(), parentController }: { store: Database, model: ?Model, - domain: string, serializer: Serializer, serializers: Map, parentController: ?Controller @@ -197,6 +131,9 @@ class Controller { relationships = relationshipNames.filter(relationship => { return serializedRelationships.indexOf(relationship) >= 0; }); + + Object.freeze(attributes); + Object.freeze(relationships); } Object.defineProperties(this, { @@ -221,13 +158,6 @@ class Controller { configurable: false }, - domain: { - value: domain, - writable: false, - enumerable: false, - configurable: false - }, - modelName: { value: model ? model.modelName : null, writable: false, @@ -267,6 +197,75 @@ class Controller { return this; } + /** + * Whitelisted parameter keys to allow in incoming PATCH and POST requests. + * + * For security reasons, parameters passed to controller actions from an + * incoming request must have their key whitelisted. + * + * @example + * class UsersController extends Controller { + * // Do not allow incoming PATCH or POST requests to modify User#isAdmin. + * params = [ + * 'name', + * 'email', + * 'password', + * // 'isAdmin' + * ]; + * } + * + * @property params + * @memberof Controller + * @instance + */ + get params(): Array { + return Object.freeze([]); + } + + set params(value: Array): void { + if (value && value.length) { + const params = new Array(value.length); + + insert(params, value); + + Object.defineProperty(this, 'params', { + value: Object.freeze(params), + writable: false, + enumerable: true, + configurable: false + }); + } + } + + /** + * Middleware functions to execute on each request handled by a `Controller`. + * + * Middleware functions declared in beforeAction on an `ApplicationController` + * will be executed before ALL route handlers. + * + * @property beforeAction + * @memberof Controller + * @instance + */ + get beforeAction(): Array { + return Object.freeze([]); + } + + set beforeAction(value: Array): void { + if (value && value.length) { + const beforeAction = new Array(value.length); + + insert(beforeAction, value); + + Object.defineProperty(this, 'beforeAction', { + value: Object.freeze(beforeAction), + writable: false, + enumerable: true, + configurable: false + }); + } + } + /** * Whitelisted `?sort` parameter values. * @@ -278,13 +277,22 @@ class Controller { * @instance */ get sort(): Array { - const { attributes, _sort: sort } = this; - - return sort.length ? sort : attributes; + return this.attributes; } set sort(value: Array): void { - this._sort = value; + if (value && value.length) { + const sort = new Array(sort.length); + + insert(sort, value); + + Object.defineProperty(this, 'sort', { + value: Object.freeze(sort), + writable: false, + enumerable: true, + configurable: false + }); + } } /** @@ -298,13 +306,22 @@ class Controller { * @instance */ get filter(): Array { - const { attributes, _filter: filter } = this; - - return filter.length ? filter : attributes; + return this.attributes; } set filter(value: Array): void { - this._filter = value; + if (value && value.length) { + const filter = new Array(filter.length); + + insert(filter, value); + + Object.defineProperty(this, 'filter', { + value: Object.freeze(filter), + writable: false, + enumerable: true, + configurable: false + }); + } } /** @@ -316,15 +333,24 @@ class Controller { */ get middleware(): Array { const { beforeAction, parentController } = this; + let middleware; if (parentController) { - return [ + const length = beforeAction.length + parentController.beforeAction.length; + + middleware = new Array(length); + + insert(middleware, [ ...parentController.middleware, ...beforeAction - ]; + ]); } else { - return beforeAction; + middleware = new Array(beforeAction.length); + + insert(middleware, beforeAction); } + + return middleware; } /** @@ -334,42 +360,24 @@ class Controller { * This method supports filtering, sorting, pagination, including * relationships, and sparse fieldsets via query parameters. */ - async index(req: IncomingMessage, res: ServerResponse): Promise { - const { model, modelName, relationships } = this; - - let { + index(req: IncomingMessage, res: ServerResponse): Promise> { + const { params: { + sort, page, limit, + filter, fields, - include = [], - sort: order, - filter: where + include } } = req; - let select = fields[modelName]; - let includedFields = omit(fields, modelName); - - if (!limit) { - limit = this.defaultPerPage; - req.params.limit = limit; - } - - if (!select) { - select = this.attributes; - } - - include = formatInclude(model, include, includedFields, relationships); - - return await model.findAll({ - page, - limit, - where, - order, - select, - include - }, true); + return this.model.select(...fields) + .include(include) + .limit(limit) + .page(page) + .where(filter) + .order(...sort); } /** @@ -379,14 +387,24 @@ class Controller { * query parameters. */ show(req: IncomingMessage, res: ServerResponse): Promise { - return getRecord(this, req, res); + const { + params: { + id, + fields, + include + } + } = req; + + return this.model.find(id) + .select(...fields) + .include(include); } /** * Create and return a single `Model` instance that the Controller instance * represents. */ - async create(req: IncomingMessage, res: ServerResponse): Promise { + create(req: IncomingMessage, res: ServerResponse): Promise { const { params: { data: { @@ -395,7 +413,7 @@ class Controller { } } = req; - return await this.model.create(attributes); + return this.model.create(attributes); } /** @@ -403,16 +421,20 @@ class Controller { * represents. */ async update(req: IncomingMessage, res: ServerResponse): Promise { - const record = await getRecord(this, req, res); + let record; const { params: { + id, + data: { attributes } } } = req; + record = await this.model.find(id); + if (record) { await record.update(attributes); } @@ -424,7 +446,7 @@ class Controller { * Destroy a single `Model` instance that the Controller instance represents. */ async destroy(req: IncomingMessage, res: ServerResponse): Promise { - const record = await getRecord(this, req, res); + const record = await this.model.find(req.params.id); if (record) { await record.destroy(); diff --git a/src/packages/controller/utils/create-page-links.js b/src/packages/controller/utils/create-page-links.js deleted file mode 100644 index 9213536b..00000000 --- a/src/packages/controller/utils/create-page-links.js +++ /dev/null @@ -1,111 +0,0 @@ -import { dasherize, underscore } from 'inflection'; - -export default function createPageLinks(domain, path, params, total) { - let i, key, str, val, first, last, prev, next, filterKeys, fieldKeys; - let { page, limit, sort, filter, include, fields } = params; - let base = domain + path; - let lastPageNum = total === 0 ? 1 : Math.ceil(total / limit); - - first = `${base}?page=1`; - last = `${base}?page=${lastPageNum}`; - - if (page > 1) { - prev = `${base}?page=${page - 1}`; - } else { - prev = null; - } - - if (page !== lastPageNum && lastPageNum !== 1) { - next = `${base}?page=${page + 1}`; - } else { - next = null; - } - - if (limit !== 25) { - first += `&limit=${limit}`; - last += `&limit=${limit}`; - - if (next) { - next += `&limit=${limit}`; - } - - if (prev) { - prev += `&limit=${limit}`; - } - } - - if (sort !== 'createdAt') { - sort = dasherize(underscore(sort)); - - first += `&sort=${sort}`; - last += `&sort=${sort}`; - - if (next) { - next += `&sort=${sort}`; - } - - if (prev) { - prev += `&sort=${sort}`; - } - } - - filterKeys = Object.keys(filter); - - for (i = 0; i < filterKeys.length; i++) { - key = filterKeys[i]; - val = filter[key]; - str = `&${encodeURIComponent(`filter[${key}]`)}=${encodeURIComponent(val)}`; - - first += str; - last += str; - - if (next) { - next += str; - } - - if (prev) { - prev += str; - } - } - - if (include.length) { - str = `&include=${include.join(encodeURIComponent(','))}`; - - first += str; - last += str; - - if (next) { - next += str; - } - - if (prev) { - prev += str; - } - } - - fieldKeys = Object.keys(fields); - - for (i = 0; i < fieldKeys.length; i++) { - key = fieldKeys[i]; - val = fields[key]; - str = `&${encodeURIComponent(`fields[${key}]`)}=${encodeURIComponent(val)}`; - - first += str; - last += str; - - if (next) { - next += str; - } - - if (prev) { - prev += str; - } - } - - return { - first, - last, - prev, - next - }; -} diff --git a/src/packages/controller/utils/format-include.js b/src/packages/controller/utils/format-include.js deleted file mode 100644 index 508b3b91..00000000 --- a/src/packages/controller/utils/format-include.js +++ /dev/null @@ -1,44 +0,0 @@ -import { singularize } from 'inflection'; - -export default function formatInclude(model, include, fields, relationships) { - return relationships.reduce((included, value) => { - const relationship = model.getRelationship(value); - - if (!relationship) { - return included; - } - - if (include.indexOf(value) >= 0) { - const { - model: { - serializer: { - attributes: relatedAttrs - } - } - } = relationship; - - let fieldsForRelationship = fields[singularize(value)]; - - if (fieldsForRelationship) { - fieldsForRelationship = fieldsForRelationship.filter(attr => { - return relatedAttrs.indexOf(attr) >= 0; - }); - } else { - fieldsForRelationship = relatedAttrs; - } - - value = { - [value]: [ - 'id', - ...fieldsForRelationship - ] - }; - } else { - value = { - [value]: ['id'] - }; - } - - return [...included, value]; - }, []); -} diff --git a/src/packages/controller/utils/get-record.js b/src/packages/controller/utils/get-record.js deleted file mode 100644 index 369ecb18..00000000 --- a/src/packages/controller/utils/get-record.js +++ /dev/null @@ -1,48 +0,0 @@ -// @flow -import omit from '../../../utils/omit'; -import tryCatch from '../../../utils/try-catch'; -import formatInclude from './format-include'; - -import type { IncomingMessage, ServerResponse } from 'http'; - -import type Controller from '../index'; -import type { Model } from '../../database'; - -/** - * Retrieve a record relative to request parameters for a member route. - * - * @private - */ -export default async function getRecord( - controller: Controller, - req: IncomingMessage, - res: ServerResponse -): Promise { - return tryCatch(async () => { - const { model, modelName, relationships } = controller; - - let { - params: { - fields, - include, - id: pk - } - } = req; - - const includedFields = omit(fields, modelName); - let select: ?Array = fields[modelName]; - - if (pk) { - if (!select) { - select = controller.attributes; - } - - include = formatInclude(model, include, includedFields, relationships); - - return await model.find(pk, { - select, - include - }); - } - }); -} diff --git a/src/packages/database/collection/index.js b/src/packages/database/collection/index.js deleted file mode 100644 index 7a814b46..00000000 --- a/src/packages/database/collection/index.js +++ /dev/null @@ -1,98 +0,0 @@ -// @flow -import Model from '../model'; - -import insert from './utils/insert'; -import entries from '../../../utils/entries'; - -/** - * @private - */ -class Collection extends Array { - total: number; - - constructor({ - model, - total, - records = [], - related = {} - }: { - model: typeof Model, - total: ?number, - records: Array, - related: Object - } = {}): Collection { - const { length } = records; - const { tableName, primaryKey } = model; - const pkPattern = new RegExp(`^.+\.${primaryKey}$`); - - super(length); - - records = records.map((row): model => { - entries(related) - .forEach(([name, relatedRecords]: [string, Array<{}>]) => { - const match = relatedRecords - .filter((relatedRecord): boolean => { - const pk: ?string = relatedRecord[`${tableName}.${primaryKey}`]; - - return pk === row[primaryKey]; - }) - .map(relatedRecord => { - return entries(relatedRecord) - .reduce((rR, [key, value]: [string, Object]) => { - if (key.indexOf('.') >= 0) { - return { - ...rR, - [key.replace(`${name}.`, '')]: value - }; - } else { - return rR; - } - }, {}); - }); - - if (match.length) { - row[name] = match; - } - }); - - row = entries(row) - .reduce((r, [key, value]) => { - if (!value && pkPattern.test(key)) { - return r; - } else if (key.indexOf('.') >= 0) { - const [a, b] = key.split('.'); - let parent: ?Object = r[a]; - - if (!parent) { - parent = {}; - } - - key = a; - value = { - ...parent, - [b]: value - }; - } - - return { - ...r, - [key]: value - }; - }, {}); - - return new model(row); - }); - - if (!total) { - total = length; - } - - this.total = total; - - insert(this, records); - - return this; - } -} - -export default Collection; diff --git a/src/packages/database/collection/utils/insert.js b/src/packages/database/collection/utils/insert.js deleted file mode 100644 index 500b44b3..00000000 --- a/src/packages/database/collection/utils/insert.js +++ /dev/null @@ -1,11 +0,0 @@ -import type Model from '../../model'; -import type Collection from '../index'; - -export default function insert( - collection: Collection, - records: Array -): void { - for (let i = 0; i < collection.length; i++) { - collection[i] = records[i]; - } -} diff --git a/src/packages/database/index.js b/src/packages/database/index.js index e2fb4ec7..59eae490 100644 --- a/src/packages/database/index.js +++ b/src/packages/database/index.js @@ -145,6 +145,6 @@ export { default as createMigrations } from './utils/create-migrations'; export { default as pendingMigrations } from './utils/pending-migrations'; export { default as Model } from './model'; +export { default as Query } from './query'; export { default as Migration } from './migration'; -export { default as Collection } from './collection'; export default Database; diff --git a/src/packages/database/model/index.js b/src/packages/database/model/index.js index e4c3e854..9f6d9a2c 100644 --- a/src/packages/database/model/index.js +++ b/src/packages/database/model/index.js @@ -1,14 +1,10 @@ import { dasherize, pluralize } from 'inflection'; -import Collection from '../collection'; +import Query from '../query'; import { sql } from '../../logger'; import validate from './utils/validate'; -import getOffset from './utils/get-offset'; -import formatSelect from './utils/format-select'; -import fetchHasMany from './utils/fetch-has-many'; -import K from '../../../utils/k'; import pick from '../../../utils/pick'; import omit from '../../../utils/omit'; import entries from '../../../utils/entries'; @@ -38,48 +34,13 @@ class Model { /** * @private */ - static attributes: {}; - - /** - * - */ - static belongsTo: {}; - - /** - * - */ - static hasOne: {}; - - /** - * - */ - static hasMany: {}; - - /** - * @private - */ - static _tableName: ?string; - - /** - * - */ - static hooks: {} = {}; - - /** - * - */ - static validates: {} = {}; + static attributes: Object; /** * */ static primaryKey: string = 'id'; - /** - * - */ - static defaultPerPage: number = 25; - constructor(attrs: {} = {}, initialize: boolean = true): Model { const { constructor: { @@ -111,6 +72,10 @@ class Model { } }); + if (initialize) { + Object.freeze(this); + } + Object.assign( this, pick(attrs, ...attributeNames, ...relationshipNames) @@ -127,20 +92,116 @@ class Model { return this.constructor.modelName; } + static get hasOne(): Object { + return Object.freeze({}); + } + + static set hasOne(value: Object): void { + if (value && Object.keys(value).length) { + Object.defineProperty(this, 'hasOne', { + value, + writable: true, + enumerable: false, + configurable: true + }); + } + } + + static get hasMany(): Object { + return Object.freeze({}); + } + + static set hasMany(value: Object): void { + if (value && Object.keys(value).length) { + Object.defineProperty(this, 'hasMany', { + value, + writable: true, + enumerable: false, + configurable: true + }); + } + } + + static get belongsTo(): Object { + return Object.freeze({}); + } + + static set belongsTo(value: Object): void { + if (value && Object.keys(value).length) { + Object.defineProperty(this, 'belongsTo', { + value, + writable: true, + enumerable: false, + configurable: true + }); + } + } + + static get hooks(): Object { + return Object.freeze({}); + } + + static set hooks(value: Object): void { + if (value && Object.keys(value).length) { + Object.defineProperty(this, 'hooks', { + value, + writable: true, + enumerable: false, + configurable: true + }); + } + } + + static get scopes(): Object { + return Object.freeze({}); + } + + static set scopes(value: Object): void { + if (value && Object.keys(value).length) { + Object.defineProperty(this, 'scopes', { + value, + writable: true, + enumerable: false, + configurable: true + }); + } + } + + static get validates(): Object { + return Object.freeze({}); + } + + static set validates(value: Object): void { + if (value && Object.keys(value).length) { + Object.defineProperty(this, 'validates', { + value, + writable: true, + enumerable: false, + configurable: true + }); + } + } + static get modelName(): string { return dasherize(underscore(this.name)); } static get tableName(): string { - return this._tableName ? - this._tableName : pluralize(underscore(this.name)); + return pluralize(underscore(this.name)); } - static set tableName(value): void { - this._tableName = value; + static set tableName(value: string): void { + if (value && value.length) { + Object.defineProperty(this, 'tableName', { + value, + writable: false, + enumerable: false, + configurable: false + }); + } } - static get relationships(): {} { + static get relationships(): Object { const { belongsTo, hasOne, @@ -162,7 +223,7 @@ class Model { return Object.keys(this.relationships); } - async update(props = {}): Model { + async update(attributes: Object = {}): Model { const { constructor: { primaryKey, @@ -183,16 +244,26 @@ class Model { } } = this; - Object.assign(this, props); + Object.assign(this, attributes); if (this.isDirty) { - await beforeValidation(this); + if (typeof beforeValidation === 'function') { + await beforeValidation(this); + } validate(this); - await afterValidation(this); - await beforeUpdate(this); - await beforeSave(this); + if (typeof afterValidation === 'function') { + await afterValidation(this); + } + + if (typeof beforeUpdate === 'function') { + await beforeUpdate(this); + } + + if (typeof beforeSave === 'function') { + await beforeSave(this); + } this.updatedAt = new Date(); @@ -212,8 +283,13 @@ class Model { this.dirtyAttributes.clear(); - await afterUpdate(this); - await afterSave(this); + if (typeof afterUpdate === 'function') { + await afterUpdate(this); + } + + if (typeof afterSave === 'function') { + await afterSave(this); + } } return this; @@ -236,7 +312,9 @@ class Model { } } = this; - await beforeDestroy(this); + if (typeof beforeDestroy === 'function') { + await beforeDestroy(this); + } const query = table() .where({ [primaryKey]: this[primaryKey] }) @@ -256,7 +334,9 @@ class Model { await query; - await afterDestroy(this); + if (typeof afterDestroy === 'function') { + await afterDestroy(this); + } return this; } @@ -315,13 +395,23 @@ class Model { updatedAt: datetime }, false); - await beforeValidation(instance); + if (typeof beforeValidation === 'function') { + await beforeValidation(instance); + } validate(instance); - await afterValidation(instance); - await beforeCreate(instance); - await beforeSave(instance); + if (typeof afterValidation === 'function') { + await afterValidation(instance); + } + + if (typeof beforeCreate === 'function') { + await beforeCreate(instance); + } + + if (typeof beforeSave === 'function') { + await beforeSave(instance); + } const query = table() .returning(primaryKey) @@ -346,179 +436,72 @@ class Model { configurable: false }); - await afterCreate(instance); - await afterSave(instance); + Object.freeze(instance); - return instance; - } - - static async count(where = {}): number { - const { table, store: { debug } } = this; - const query = table().count('* AS count').where(where); - - if (debug) { - const { logger } = this; - - query.on('query', () => { - setImmediate(() => logger.info(sql`${query.toString()}`)); - }); + if (typeof afterCreate === 'function') { + await afterCreate(instance); } - let [{ count }] = await query; - count = parseInt(count, 10); + if (typeof afterSave === 'function') { + await afterSave(instance); + } - return Number.isFinite(count) ? count : 0; + return instance; } - static async find(pk, options = {}): Model { - const { primaryKey, tableName } = this; - - return await this.findOne({ - ...options, - where: { - [`${tableName}.${primaryKey}`]: pk - } - }); + static all(): Query { + return new Query(this).all(); } - static async findAll(options: {} = {}, count: boolean = false): Collection { - const { - table, - tableName, - primaryKey, - - store: { - debug - } - } = this; - - let { - page, - order, - limit, - where, - select, - include = [] - } = options; - - if (!limit) { - limit = this.defaultPerPage; - } - - select = formatSelect(this, select); - - include = include - .map(included => { - let name, attrs; - - if (typeof included === 'string') { - name = included; - } else if (typeof included === 'object') { - [[name, attrs]] = entries(included); - } - - included = this.getRelationship(name); - - if (!included) { - return null; - } - - if (!attrs) { - attrs = included.model.attributeNames; - } - - return { - name, - attrs, - relationship: included - }; - }) - .filter(included => included); + static find(primaryKey: string | number): Query { + return new Query(this).find(primaryKey); + } - let total: ?number; + static page(num: number): Query { + return new Query(this).page(num); + } - let related = include.filter(({ relationship: { type } }) => { - return type === 'hasMany'; - }); + static limit(amount: number): Query { + return new Query(this).limit(amount); + } - let records = table() - .select(select) - .where(where) - .limit(limit) - .offset(getOffset(page, limit)); - - if (order) { - if (typeof order === 'string') { - const direction = order.charAt(0) === '-' ? 'desc' : 'asc'; - - records = records.orderBy( - `${tableName}.` + this.getColumnName( - direction === 'desc' ? order.substr(1) : order - ) || 'created_at', - direction - ); - } else if (Array.isArray(order)) { - records = records.orderBy(order[0], order[1]); - } - } + static offset(amount: number): Query { + return new Query(this).offset(amount); + } - include - .filter(({ relationship: { type } }) => type !== 'hasMany') - .forEach(({ name, attrs, relationship: { type, model, foreignKey } }) => { - records = records.select( - ...formatSelect(model, attrs, `${name}.`) - ); - - if (type === 'belongsTo') { - records = records.leftOuterJoin( - model.tableName, - `${tableName}.${foreignKey}`, - '=', - `${model.tableName}.${model.primaryKey}` - ); - } else if (type === 'hasOne') { - records = records.leftOuterJoin( - model.tableName, - `${tableName}.${primaryKey}`, - '=', - `${model.tableName}.${foreignKey}` - ); - } - }); + static count(): Query { + return new Query(this).count(); + } - if (debug) { - const { logger } = this; + static order(attr: string, direction?: string): Query { + return new Query(this).order(attr, direction); + } - records.on('query', () => { - setImmediate(() => logger.info(sql`${records.toString()}`)); - }); - } + static where(conditions: Object): Query { + return new Query(this).where(conditions); + } - [records, total] = await Promise.all([ - records, - count ? this.count() : K.call(null) - ]); + static not(conditions: Object): Query { + return new Query(this).not(conditions); + } - related = await fetchHasMany(this, related, records); + static select(...params: Array): Query { + return new Query(this).select(...params); + } - return new Collection({ - records, - related, - total, - model: this - }); + static include(...relationships: Array): Query { + return new Query(this).include(...relationships); } - static async findOne(options = {}): Model { - const [record] = await this.findAll({ - ...options, - limit: 1 - }); + static unscope(...scopes: Array): Query { + return new Query(this).unscope(...scopes); + } - return record ? record : null; + static hasScope(name: string): boolean { + return Boolean(this.scopes[name]); } - static getColumn(key): {} { + static columnFor(key): Object { const { attributes: { [key]: column @@ -528,15 +511,15 @@ class Model { return column; } - static getColumnName(key): string { - const column = this.getColumn(key); + static columnNameFor(key): string { + const column = this.columnFor(key); if (column) { return column.columnName; } } - static getRelationship(key): {} { + static relationshipFor(key): Object { const { relationships: { [key]: relationship diff --git a/src/packages/database/model/utils/fetch-has-many.js b/src/packages/database/model/utils/fetch-has-many.js deleted file mode 100644 index e345fcad..00000000 --- a/src/packages/database/model/utils/fetch-has-many.js +++ /dev/null @@ -1,55 +0,0 @@ -import Promise from 'bluebird'; -import formatSelect from './format-select'; -import { sql } from '../../../logger'; - -export default async function fetchHasMany(model, related, records) { - const { - tableName, - primaryKey, - - store: { - debug - } - } = model; - - related = related.reduce((hash, included) => { - const { - name, - attrs, - relationship: { - foreignKey, - model: relatedModel, - - model: { - table, - tableName: relatedTableName - } - } - } = included; - - const query = table() - .select( - ...formatSelect(relatedModel, attrs, `${name}.`), - `${relatedTableName}.${foreignKey} AS ${tableName}.${primaryKey}` - ) - .whereIn( - `${relatedTableName}.${foreignKey}`, - records.map(({ [primaryKey]: pk }) => pk) - ); - - if (debug) { - const { logger } = model; - - query.on('query', () => { - setImmediate(() => logger.info(sql`${query.toString()}`)); - }); - } - - return { - ...hash, - [name]: query - }; - }, {}); - - return await Promise.props(related); -} diff --git a/src/packages/database/model/utils/format-select.js b/src/packages/database/model/utils/format-select.js deleted file mode 100644 index 77fab5cc..00000000 --- a/src/packages/database/model/utils/format-select.js +++ /dev/null @@ -1,7 +0,0 @@ -export default function formatSelect(model, attrs = [], prefix = '') { - const { tableName } = model; - - return attrs.map(attr => { - return `${tableName}.${model.getColumnName(attr)} AS ${prefix + attr}`; - }); -} diff --git a/src/packages/database/model/utils/get-offset.js b/src/packages/database/model/utils/get-offset.js deleted file mode 100644 index 5fb0a5c0..00000000 --- a/src/packages/database/model/utils/get-offset.js +++ /dev/null @@ -1,11 +0,0 @@ -// @flow - -/** - * @private - */ -export default function getOffset( - page: number = 1, - limit: number = 25 -): number { - return Math.max(parseInt(page, 10) - 1 , 0) * limit; -} diff --git a/src/packages/database/model/utils/initialize.js b/src/packages/database/model/utils/initialize.js index 1f8b7609..108a0ffe 100644 --- a/src/packages/database/model/utils/initialize.js +++ b/src/packages/database/model/utils/initialize.js @@ -1,7 +1,5 @@ import { camelize, dasherize, singularize } from 'inflection'; -import Collection from '../../collection'; - import { line } from '../../../logger'; import entries from '../../../../utils/entries'; import underscore from '../../../../utils/underscore'; @@ -21,48 +19,6 @@ const VALID_HOOKS = [ 'beforeValidation' ]; -const DEFAULT_HOOKS = { - async afterCreate() { - return true; - }, - - async afterDestroy() { - return true; - }, - - async afterSave() { - return true; - }, - - async afterUpdate() { - return true; - }, - - async afterValidation() { - return true; - }, - - async beforeCreate() { - return true; - }, - - async beforeDestroy() { - return true; - }, - - async beforeSave() { - return true; - }, - - async beforeUpdate() { - return true; - }, - - async beforeValidation() { - return true; - } -}; - function refsFor(instance) { let table = REFS.get(instance); @@ -148,10 +104,7 @@ function initializeProps(prototype, attributes, relationships) { const refs = refsFor(this); if (Array.isArray(value)) { - refs[key] = new Collection({ - model, - records: value - }); + refs[key] = value; } } }; @@ -174,7 +127,7 @@ function initializeProps(prototype, attributes, relationships) { } function initializeHooks(model, hooks) { - return entries({ ...DEFAULT_HOOKS, ...hooks }) + hooks = entries({ ...hooks }) .filter(([key]) => { const isValid = VALID_HOOKS.indexOf(key) >= 0; @@ -193,12 +146,14 @@ function initializeHooks(model, hooks) { [key]: async (...args) => await hook.apply(model, args) }; }, Object.create(null)); + + return Object.freeze(hooks); } function initializeValidations(model, attributes, validations) { const attributeNames = Object.keys(attributes); - return entries(validations) + const validates = entries(validations) .filter(([key, value]) => { let isValid = attributeNames.indexOf(key) >= 0; @@ -226,10 +181,12 @@ function initializeValidations(model, attributes, validations) { [key]: value }; }, Object.create(null)); + + return Object.freeze(validates); } export default async function initialize(store, model, table) { - const { hooks, validates } = model; + const { hooks, scopes, validates } = model; const { logger } = store; const attributes = entries(await table().columnInfo()) @@ -247,51 +204,122 @@ export default async function initialize(store, model, table) { }, {}); const belongsTo = entries(model.belongsTo || {}) - .reduce((hash, [relatedName, value]) => { - return { - ...hash, + .reduce((hash, [relatedName, { inverse, model: relatedModel }]) => { + const relationship = {}; + + Object.defineProperties(relationship, { + model: { + value: store.modelFor(relatedModel || singularize(relatedName)), + writable: false, + enumerable: true, + configurable: false + }, - [relatedName]: { - foreignKey: `${underscore(relatedName)}_id`, + inverse: { + value: inverse, + writable: false, + enumerable: true, + configurable: false + }, - ...value, + type: { + value: 'belongsTo', + writable: false, + enumerable: false, + configurable: false + }, - type: 'belongsTo', - model: store.modelFor(value.model || relatedName) + foreignKey: { + value: `${underscore(relatedName)}_id`, + writable: false, + enumerable: false, + configurable: false } + }); + + return { + ...hash, + [relatedName]: relationship }; }, {}); const hasOne = entries(model.hasOne || {}) - .reduce((hash, [relatedName, value]) => { - return { - foreignKey: `${underscore(value.inverse)}_id`, - - ...hash, + .reduce((hash, [relatedName, { inverse, model: relatedModel }]) => { + const relationship = {}; + + Object.defineProperties(relationship, { + model: { + value: store.modelFor(relatedModel || singularize(relatedName)), + writable: false, + enumerable: true, + configurable: false + }, - [relatedName]: { + inverse: { + value: inverse, + writable: false, + enumerable: true, + configurable: false + }, - ...value, + type: { + value: 'hasOne', + writable: false, + enumerable: false, + configurable: false + }, - type: 'hasOne', - model: store.modelFor(value.model || relatedName) + foreignKey: { + value: `${underscore(inverse)}_id`, + writable: false, + enumerable: false, + configurable: false } + }); + + return { + ...hash, + [relatedName]: relationship }; }, {}); const hasMany = entries(model.hasMany || {}) - .reduce((hash, [relatedName, value]) => { - return { - ...hash, + .reduce((hash, [relatedName, { inverse, model: relatedModel }]) => { + const relationship = {}; + + Object.defineProperties(relationship, { + model: { + value: store.modelFor(relatedModel || singularize(relatedName)), + writable: false, + enumerable: true, + configurable: false + }, - [relatedName]: { - foreignKey: `${underscore(value.inverse)}_id`, + inverse: { + value: inverse, + writable: false, + enumerable: true, + configurable: false + }, - ...value, + type: { + value: 'hasMany', + writable: false, + enumerable: false, + configurable: false + }, - type: 'hasMany', - model: store.modelFor(value.model || singularize(relatedName)) + foreignKey: { + value: `${underscore(inverse)}_id`, + writable: false, + enumerable: false, + configurable: false } + }); + + return { + ...hash, + [relatedName]: relationship }; }, {}); @@ -318,52 +346,72 @@ export default async function initialize(store, model, table) { }, attributes: { - value: attributes, + value: Object.freeze(attributes), writable: false, enumerable: false, configurable: false }, - belongsTo: { - value: belongsTo, + hasOne: { + value: Object.freeze(hasOne), writable: false, - enumerable: false, + enumerable: Boolean(Object.keys(hasOne).length), configurable: false }, - hasOne: { - value: hasOne, + hasMany: { + value: Object.freeze(hasMany), writable: false, - enumerable: false, + enumerable: Boolean(Object.keys(hasMany).length), configurable: false }, - hasMany: { - value: hasMany, + belongsTo: { + value: Object.freeze(belongsTo), writable: false, - enumerable: false, + enumerable: Boolean(Object.keys(belongsTo).length), configurable: false }, hooks: { value: initializeHooks(model, hooks), writable: false, - enumerable: false, + enumerable: Boolean(Object.keys(hooks).length), + configurable: false + }, + + scopes: { + value: scopes, + writable: false, + enumerable: Boolean(Object.keys(scopes).length), configurable: false }, validates: { value: initializeValidations(model, attributes, validates), writable: false, - enumerable: false, + enumerable: Boolean(Object.keys(validates).length), configurable: false - } + }, + + ...Object.freeze(entries(scopes).reduce((hash, [name, scope]) => { + return { + ...hash, + + [name]: { + value: scope, + writable: false, + enumerable: false, + configurable: false + } + }; + }, {})) }); initializeProps(model.prototype, attributes, { - ...belongsTo, ...hasOne, - ...hasMany + ...hasMany, + ...belongsTo }); return model; diff --git a/src/packages/database/query/index.js b/src/packages/database/query/index.js new file mode 100644 index 00000000..a6edeff7 --- /dev/null +++ b/src/packages/database/query/index.js @@ -0,0 +1,437 @@ +import { camelize } from 'inflection'; + +import Model from '../model'; +import { sql } from '../../logger'; + +import entries from '../../../utils/entries'; +import tryCatch from '../../../utils/try-catch'; +import formatSelect from './utils/format-select'; +import buildResults from './utils/build-results'; + +/** + * @private + */ +class Query { + /** + * @private + */ + model: typeof Model; + + /** + * @private + */ + snapshots: Array<[string, mixed]>; + + /** + * @private + */ + collection: boolean; + + /** + * @private + */ + shouldCount: boolean; + + /** + * @private + */ + relationships: {}; + + constructor(model: typeof Model): Query { + Object.defineProperties(this, { + model: { + value: model, + writable: false, + enumerable: false, + configurable: false + }, + + collection: { + value: true, + writable: true, + enumerable: false, + configurable: false + }, + + snapshots: { + value: [], + writable: true, + enumerable: false, + configurable: false + }, + + shouldCount: { + value: false, + writable: true, + enumerable: false, + configurable: false + }, + + relationships: { + value: {}, + writable: true, + enumerable: false, + configurable: false + } + }); + + const proxy = new Proxy(this, { + get(instance: Query, key: string): ?mixed { + if (model.hasScope(key)) { + const scope = model.scopes[key]; + + return (...args) => { + let { snapshots } = scope.apply(model, args); + snapshots = snapshots.map(snapshot => [...snapshot, key]); + + instance.snapshots.push(...snapshots); + return proxy; + }; + } else { + return instance[key]; + } + } + }); + + return proxy; + } + + all(): Query { + return this; + } + + not(conditions: Object = {}): Query { + return this.where(conditions, true); + } + + find(primaryKey: string | number): Query { + this.collection = false; + + this.where({ + [this.model.primaryKey]: primaryKey + }); + + if (!this.shouldCount) { + this.limit(1); + } + + return this; + } + + page(num: number): Query { + if (this.shouldCount) { + return this; + } else { + let limit = this.snapshots.find(([name, params]) => name === 'limit'); + + if (limit) { + [, limit] = limit; + } + + if (typeof limit !== 'number') { + limit = 25; + } + + this.limit(limit); + + return this.offset(Math.max(parseInt(num, 10) - 1 , 0) * limit); + } + } + + limit(amount: number): Query { + if (!this.shouldCount) { + this.snapshots.push(['limit', amount]); + } + + return this; + } + + order(attr: string, direction: string = 'ASC'): Query { + if (!this.shouldCount) { + this.snapshots.push(['orderBy', [ + `${this.model.tableName}.${this.model.columnNameFor(attr)}`, + direction + ]]); + } + + return this; + } + + where(conditions: Object = {}, not: boolean = false): Query { + const { + model: { + tableName + } + } = this; + + const where = entries(conditions).reduce((hash, [key, value]) => { + key = `${tableName}.${this.model.columnNameFor(key)}`; + + if (typeof value === 'undefined') { + value = null; + } + + if (Array.isArray(value)) { + if (value.length > 1) { + this.snapshots.push([not ? 'whereNotIn' : 'whereIn', [key, value]]); + } else { + hash[key] = value[0]; + } + } else { + hash[key] = value; + } + + return hash; + }, {}); + + if (Object.keys(where).length) { + this.snapshots.push([not ? 'whereNot' : 'where', where]); + } + + return this; + } + + count(): Query { + const validName = /^(where(Not)?(In)?)$/g; + + Object.assign(this, { + shouldCount: true, + + snapshots: [ + ['count', '* as countAll'], + ...this.snapshots.filter(([name]) => validName.test(name)) + ] + }); + + return this; + } + + offset(amount: number): Query { + if (!this.shouldCount) { + this.snapshots.push(['offset', amount]); + } + + return this; + } + + select(...attrs: Array): Query { + if (!this.shouldCount) { + this.snapshots.push(['select', formatSelect(this.model, attrs)]); + } + + return this; + } + + include(...relationships: Array) { + let included; + + if (!this.shouldCount) { + if (relationships.length === 1 && typeof relationships[0] === 'object') { + included = entries(relationships[0]).map(([ + name, + attrs + ]: [ + string, + Array + ]) => { + const relationship = this.model.relationshipFor(name); + + if (!attrs.length) { + attrs = relationship.model.attributeNames; + } + + return { + name, + attrs, + relationship + }; + }); + } else { + included = relationships.map(name => { + const relationship = this.model.relationshipFor(name); + const attrs = relationship.model.attributeNames; + + if (typeof name !== 'string') { + name = name.toString(); + } + + return { + name, + attrs, + relationship + }; + }); + } + + included = included + .filter(Boolean) + .filter(({ + name, + attrs, + relationship + }: { + name: string, + attrs: Array, + + relationship: { + type: string, + model: Model, + foreignKey: string + } + }) => { + if (relationship.type === 'hasMany') { + this.relationships[name] = { + type: 'hasMany', + attrs: [...attrs, camelize(relationship.foreignKey, true)], + model: relationship.model, + foreignKey: relationship.foreignKey + }; + + return false; + } else { + return true; + } + }) + .reduce((arr, { name, attrs, relationship }) => { + arr.push([ + 'includeSelect', + formatSelect(relationship.model, attrs, `${name}.`) + ]); + + if (relationship.type === 'belongsTo') { + arr.push(['leftOuterJoin', [ + relationship.model.tableName, + `${this.model.tableName}.${relationship.foreignKey}`, + '=', + `${relationship.model.tableName}.` + + `${relationship.model.primaryKey}` + ]]); + } else if (relationship.type === 'hasOne') { + arr.push(['leftOuterJoin', [ + relationship.model.tableName, + `${this.model.tableName}.${this.model.primaryKey}`, + '=', + `${relationship.model.tableName}.${relationship.foreignKey}` + ]]); + } + + return arr; + }, []); + + this.snapshots.push(...included); + } + + return this; + } + + unscope(...scopes: Array): Query { + if (scopes.length) { + scopes = scopes.filter(scope => { + return scope === 'order' ? 'orderBy' : scope; + }); + + this.snapshots = this.snapshots.filter(([, , scope]) => { + return !scope || scopes.indexOf(scope) < 0; + }); + } else { + this.snapshots = this.snapshots.filter(([, , scope]) => !scope); + } + + return this; + } + + /** + * @private + */ + async run(): Promise|number> { + let records; + let results; + + const { + model, + snapshots, + collection, + shouldCount, + relationships + } = this; + + if (!shouldCount && !snapshots.some(([name]) => name === 'select')) { + this.select(...this.model.attributeNames); + } + + records = snapshots.reduce((query, [name, params]) => { + if (!shouldCount && name === 'includeSelect') { + name = 'select'; + } + + const method = query[name]; + + if (Array.isArray(params)) { + return method.apply(query, params); + } else { + return method.call(query, params); + } + }, model.table()); + + if (model.store.debug) { + records.on('query', () => { + setImmediate(() => model.logger.info(sql`${records.toString()}`)); + }); + } + + if (shouldCount) { + let [{ countAll: count }] = await records; + count = parseInt(count, 10); + + return Number.isFinite(count) ? count : 0; + } else { + results = await buildResults({ + model, + records, + relationships + }); + + return collection ? results : results[0]; + } + } + + then( + onData: ?(data: ?(Model | Array)) => void, + onError: ?(err: Error) => void + ): Promise> { + return tryCatch(async () => { + const data = await this.run(); + + if (typeof onData === 'function') { + onData(data); + } + }, (err) => { + if (typeof onError === 'function') { + onError(err); + } + }); + } + + static from(src: Query): Query { + const { + model, + snapshots, + collection, + shouldCount, + relationships + } = src; + + const dest = new this(model); + + Object.assign(dest, { + snapshots, + collection, + shouldCount, + relationships + }); + + return dest; + } +} + +export default Query; diff --git a/src/packages/database/query/utils/build-results.js b/src/packages/database/query/utils/build-results.js new file mode 100644 index 00000000..99fbf88f --- /dev/null +++ b/src/packages/database/query/utils/build-results.js @@ -0,0 +1,87 @@ +// @flow +import { camelize } from 'inflection'; + +import Model from '../../model'; + +import entries from '../../../../utils/entries'; +import promiseHash from '../../../../utils/promise-hash'; + +export default async function buildResults({ + model, + records, + relationships +}: { + model: typeof Model, + records: Promise>, + relationships: Object +}): Promise> { + let related; + + const pkPattern = new RegExp(`^.+\.${model.primaryKey}$`); + let results = await records; + + if (Object.keys(relationships).length) { + related = entries(relationships) + .reduce((hash, [name, relationship]) => { + const foreignKey = camelize(relationship.foreignKey, true); + + hash[name] = relationship.model + .select(...relationship.attrs) + .where({ + [foreignKey]: results.map(({ id }) => id) + }); + + return hash; + }, {}); + + related = await promiseHash(related); + } + + return results.map((record) => { + entries(related) + .forEach(([name, relatedresults]: [string, Array<{}>]) => { + const relationship = model.relationshipFor(name); + + if (relationship) { + let { foreignKey } = relationship; + foreignKey = camelize(foreignKey, true); + + const match = relatedresults.filter((relatedRecord): boolean => { + return record[model.primaryKey] === + relatedRecord[camelize(foreignKey, true)]; + }); + + if (match.length) { + record[name] = match; + } + } + }); + + record = entries(record) + .reduce((r, [key, value]) => { + if (!value && pkPattern.test(key)) { + return r; + } else if (key.indexOf('.') >= 0) { + const [a, b] = key.split('.'); + let parent: ?Object = r[a]; + + if (!parent) { + parent = {}; + } + + key = a; + value = { + ...parent, + [b]: value + }; + } + + return { + ...r, + [key]: value + }; + }, {}); + + return new model(record); + }); +} diff --git a/src/packages/database/query/utils/format-select.js b/src/packages/database/query/utils/format-select.js new file mode 100644 index 00000000..9f94cf9c --- /dev/null +++ b/src/packages/database/query/utils/format-select.js @@ -0,0 +1,14 @@ +// @flow +import typeof Model from '../../model'; + +export default function formatSelect( + model: Model, + attrs: Array = [], + prefix: string = '' +): Array { + const { tableName } = model; + + return attrs.map(attr => { + return `${tableName}.${model.columnNameFor(attr)} AS ${prefix + attr}`; + }); +} diff --git a/src/packages/route/index.js b/src/packages/route/index.js index 04aaa2be..8437a6b7 100644 --- a/src/packages/route/index.js +++ b/src/packages/route/index.js @@ -5,6 +5,8 @@ import getDynamicSegments from './utils/get-dynamic-segments'; import type { Controller } from '../controller'; +const resourcePattern = /^((?!\/)[a-z\-]+)/ig; + /** * @private */ @@ -36,7 +38,7 @@ class Route { controllers: Map, method: string }) { - const resource = path.replace(/^(.+)\/.+$/ig, '$1'); + const [resource] = path.match(resourcePattern) || [path]; const controller: ?Controller = controllers.get(resource); const dynamicSegments = getDynamicSegments(path); let handlers; diff --git a/src/packages/controller/middleware/sanitize-params.js b/src/packages/route/middleware/sanitize-params.js similarity index 82% rename from src/packages/controller/middleware/sanitize-params.js rename to src/packages/route/middleware/sanitize-params.js index dfce2477..4aaeb795 100644 --- a/src/packages/controller/middleware/sanitize-params.js +++ b/src/packages/route/middleware/sanitize-params.js @@ -15,6 +15,7 @@ export default function sanitizeParams( ): void { const { modelName, + model: { relationshipNames } @@ -27,6 +28,8 @@ export default function sanitizeParams( const params = { ...req.params }; + let sortDirection; + let { page, limit, @@ -49,19 +52,25 @@ export default function sanitizeParams( } if (!sort) { - sort = 'createdAt'; + sort = ['createdAt', 'ASC']; } else { - if (sort.charAt(0) === '-') { - sort = `-${sort.substr(1).replace(/\-/g, '_')}`; - } else { - sort = sort.replace(/\-/g, '_'); - } + if (!Array.isArray(sort)) { + if (sort.charAt(0) === '-') { + sort = sort.substr(1).replace(/\-/g, '_'); + sortDirection = 'DESC'; + } else { + sort = sort.replace(/\-/g, '_'); + sortDirection = 'ASC'; + } - sort = camelize(sort, true); + sort = camelize(sort, true); - if (this.sort.indexOf(sort) < 0 && - this.sort.indexOf(sort.substr(1)) < 0) { + if (this.sort.indexOf(sort) < 0) { sort = 'createdAt'; + sortDirection = 'ASC'; + } + + sort = [sort, sortDirection]; } } diff --git a/src/packages/route/middleware/set-fields.js b/src/packages/route/middleware/set-fields.js new file mode 100644 index 00000000..59b6ffd8 --- /dev/null +++ b/src/packages/route/middleware/set-fields.js @@ -0,0 +1,23 @@ +// @flow +import type { IncomingMessage, ServerResponse } from 'http'; + +/** + * @private + */ +export default function setFields( + req: IncomingMessage, + res: ServerResponse +): void { + const { route } = req; + + if (route && /^(index|show)$/g.test(route.action)) { + let { + params: { + fields + } + } = req; + + fields = fields[this.modelName]; + req.params.fields = fields ? fields : this.attributes; + } +} diff --git a/src/packages/route/middleware/set-include.js b/src/packages/route/middleware/set-include.js new file mode 100644 index 00000000..b20d7b6f --- /dev/null +++ b/src/packages/route/middleware/set-include.js @@ -0,0 +1,82 @@ +// @flow +import { singularize } from 'inflection'; + +import omit from '../../../utils/omit'; + +import type { IncomingMessage, ServerResponse } from 'http'; +import typeof { Model } from '../../database'; + +/** + * @private + */ +export default function setInclude( + req: IncomingMessage, + res: ServerResponse +): void { + const { route } = req; + + if (route && /^(index|show)$/g.test(route.action)) { + const { + model, + modelName, + relationships + }: { + model: Model, + modelName: string, + relationships: Array + } = this; + + const { + params: { + include = [] + } + } = req; + + let { + params: { + fields + } + } = req; + + fields = omit(fields, modelName); + + Object.assign(req.params, { + include: relationships.reduce((included, value) => { + const relationship = model.relationshipFor(value); + + if (!relationship) { + return included; + } + + if (include.indexOf(value) >= 0) { + const { + model: { + serializer: { + attributes: relatedAttrs + } + } + } = relationship; + + let fieldsForRelationship = fields[singularize(value)]; + + if (fieldsForRelationship) { + fieldsForRelationship = fieldsForRelationship.filter(attr => { + return relatedAttrs.indexOf(attr) >= 0; + }); + } else { + fieldsForRelationship = relatedAttrs; + } + + included[value] = [ + 'id', + ...fieldsForRelationship + ]; + } else { + included[value] = ['id']; + } + + return included; + }, {}) + }); + } +} diff --git a/src/packages/route/middleware/set-limit.js b/src/packages/route/middleware/set-limit.js new file mode 100644 index 00000000..1b675854 --- /dev/null +++ b/src/packages/route/middleware/set-limit.js @@ -0,0 +1,24 @@ +// @flow +import type { IncomingMessage, ServerResponse } from 'http'; + +/** + * @private + */ +export default function setLimit( + req: IncomingMessage, + res: ServerResponse +): void { + const { route } = req; + + if (route && route.action === 'index') { + let { + params: { + limit + } + } = req; + + if (!limit) { + req.params.limit = this.defaultPerPage; + } + } +} diff --git a/src/packages/route/utils/create-action.js b/src/packages/route/utils/create-action.js index 7e5ab1d8..12088125 100644 --- a/src/packages/route/utils/create-action.js +++ b/src/packages/route/utils/create-action.js @@ -1,8 +1,13 @@ // @flow -import { Collection, Model } from '../../database'; +import { Model, Query } from '../../database'; -import sanitizeParams from '../../controller/middleware/sanitize-params'; -import createPageLinks from '../../controller/utils/create-page-links'; +import sanitizeParams from '../middleware/sanitize-params'; +import setInclude from '../middleware/set-include'; +import setFields from '../middleware/set-fields'; +import setLimit from '../middleware/set-limit'; + +import insert from '../../../utils/insert'; +import createPageLinks from './create-page-links'; import type Controller from '../../controller'; import type { IncomingMessage, ServerResponse } from 'http'; @@ -14,42 +19,95 @@ export default function createAction( controller: Controller, action: () => Promise ): Array { - return [ + const { middleware } = controller; + + const builtIns = [ sanitizeParams, - ...controller.middleware, + setInclude, + setFields, + setLimit + ]; + + const handlers = new Array(builtIns.length + middleware.length + 1); + + insert(handlers, [ + ...builtIns, + ...middleware, + + async function actionHandler( + req: IncomingMessage, + res: ServerResponse + ): mixed { + const { defaultPerPage } = controller; + + const { + route, + headers, + + url: { + path, + query, + pathname + }, + + params: { + page, + limit, + include + } + } = req; + + const domain = `http://${headers.get('host')}`; - async function (req: IncomingMessage, res: ServerResponse) { - const { domain } = controller; - const { url: { pathname } } = req; + let total; + let { params: { fields } } = req; + let data = action.call(controller, req, res); let links = { self: domain + pathname }; - let data = await action.call(controller, req, res); - if (typeof data === 'object') { - const { - params, + if (route && route.action === 'index') { + [data, total] = await Promise.all([ + data, + Query.from(data).count() + ]); - params: { + if (Array.isArray(data)) { + links = { + self: domain + path, + + ...createPageLinks({ + page, + limit, + total, + query, + domain, + pathname, + defaultPerPage + }) + }; + + return controller.serializer.stream({ + data, + links, + domain, fields, include - }, + }); + } + } else { + data = await data; - url: { - path - } - } = req; - - if (data instanceof Collection || data instanceof Model) { - if (data instanceof Collection) { - links = { - self: domain + path, - ...createPageLinks(domain, pathname, params, data.total) - }; + if (data instanceof Model) { + if (!fields.length) { + fields = controller.attributes; } - data = controller.serializer.stream({ + return controller.serializer.stream({ data, - links - }, include, fields); + links, + domain, + fields, + include + }); } } @@ -59,5 +117,9 @@ export default function createAction( return (req: IncomingMessage, res: ServerResponse) => { return handler.call(controller, req, res); }; - }); + })); + + Object.freeze(handlers); + + return handlers; } diff --git a/src/packages/route/utils/create-page-links.js b/src/packages/route/utils/create-page-links.js new file mode 100644 index 00000000..ce35e687 --- /dev/null +++ b/src/packages/route/utils/create-page-links.js @@ -0,0 +1,81 @@ +// @flow +import querystring from 'querystring'; + +import omit from '../../../utils/omit'; + +export default function createPageLinks({ + page, + limit, + total, + query, + domain, + pathname, + defaultPerPage +}: { + page: number, + limit: number, + total: number, + query: Object, + domain: string, + pathname: string, + defaultPerPage: number +}): { + first: string, + last: string, + prev: ?string, + next: ?string +} { + const params = omit(query, 'limit', 'page'); + const lastPageNum = total > 0 ? Math.ceil(total / limit) : 1; + let base = domain + pathname; + let prev = null; + let next = null; + let last = null; + let first = null; + + if (limit !== defaultPerPage) { + params.limit = limit; + } + + if (Object.keys(params).length) { + base += '?'; + first = base + querystring.stringify(params); + } else { + first = base; + base += '?'; + } + + if (lastPageNum > 1) { + last = base + querystring.stringify({ + ...params, + page: lastPageNum + }); + } else { + last = first; + } + + if (page > 1) { + if (page === 2) { + prev = first; + } else { + prev = base + querystring.stringify({ + ...params, + page: page - 1 + }); + } + } + + if (page < lastPageNum) { + next = base + querystring.stringify({ + ...params, + page: page + 1 + }); + } + + return { + first, + last, + prev, + next + }; +} diff --git a/src/packages/route/utils/get-dynamic-segments.js b/src/packages/route/utils/get-dynamic-segments.js index bbd33f51..ca3c9432 100644 --- a/src/packages/route/utils/get-dynamic-segments.js +++ b/src/packages/route/utils/get-dynamic-segments.js @@ -1,9 +1,18 @@ // @flow +import insert from '../../../utils/insert'; + const pattern = /(:\w+)/g; /** * @private */ export default function getDynamicSegments(path: string): Array { - return (path.match(pattern) || []).map(part => part.substr(1)); + const matches = (path.match(pattern) || []).map(part => part.substr(1)); + const dynamicSegments = new Array(matches.length); + + insert(dynamicSegments, matches); + + Object.freeze(dynamicSegments); + + return dynamicSegments; } diff --git a/src/packages/router/index.js b/src/packages/router/index.js index f96f22b2..ae87ab7c 100644 --- a/src/packages/router/index.js +++ b/src/packages/router/index.js @@ -139,6 +139,7 @@ class Router { } } + req.route = route; this.visit(req, res, route); } else { this.notFound(req, res); @@ -194,20 +195,24 @@ class Router { if (message.indexOf('Validation failed') === 0) { res.statusCode = 403; this.serializer.stream({ - errors: [{ - title: 'Forbidden', - status: 403, - detail: message - }] + data: { + errors: [{ + title: 'Forbidden', + status: 403, + detail: message + }] + } }).pipe(res); } else { res.statusCode = 500; this.serializer.stream({ - errors: [{ - title: 'Internal Server Error', - status: 500, - detail: message - }] + data: { + errors: [{ + title: 'Internal Server Error', + status: 500, + detail: message + }] + } }).pipe(res); } } @@ -215,20 +220,24 @@ class Router { unauthorized(req, res) { res.statusCode = 401; this.serializer.stream({ - errors: [{ - title: 'Unauthorized', - status: 401 - }] + data: { + errors: [{ + title: 'Unauthorized', + status: 401 + }] + } }).pipe(res); } notFound(req, res) { res.statusCode = 404; this.serializer.stream({ - errors: [{ - title: 'Not Found', - status: 404 - }] + data: { + errors: [{ + title: 'Not Found', + status: 404 + }] + } }).pipe(res); } diff --git a/src/packages/serializer/content-stream/index.js b/src/packages/serializer/content-stream/index.js new file mode 100644 index 00000000..57ba3d84 --- /dev/null +++ b/src/packages/serializer/content-stream/index.js @@ -0,0 +1,26 @@ +import { Transform } from 'stream'; + +class ContentStream extends Transform { + constructor(): ContentStream { + super({ + encoding: 'utf8', + writableObjectMode: true + }); + + process.nextTick(() => { + this.emit('ready', this); + }); + + return this; + } + + _transform(chunk: ?Object, encoding: string, done: () => void): void { + if (chunk && typeof chunk === 'object') { + this.push(JSON.stringify(chunk)); + } + + done(null); + } +} + +export default ContentStream; diff --git a/src/packages/serializer/index.js b/src/packages/serializer/index.js index 289cf31d..0774cf83 100644 --- a/src/packages/serializer/index.js +++ b/src/packages/serializer/index.js @@ -1,11 +1,16 @@ // @flow -import { Readable } from 'stream'; -import { dasherize, pluralize, camelize } from 'inflection'; +import { pluralize } from 'inflection'; import { Model } from '../database'; -import tryCatch from '../../utils/try-catch'; -import underscore from '../../utils/underscore'; +import ContentStream from './content-stream'; + +import pick from '../../utils/pick'; +import insert from '../../utils/insert'; +import entries from '../../utils/entries'; +import { dasherizeKeys } from '../../utils/transform-keys'; + +const idRegExp = /^id$/; /** * The `Serializer` class is where you declare the specific attributes and @@ -27,19 +32,6 @@ class Serializer { */ model: typeof Model; - /** - * The public domain where an `Application` instance is located. This is - * defined in ./config/environments/{{NODE_ENV}.js} and is primarily used for - * creating `links` resource objects. - * - * @property domain - * @memberof Serializer - * @instance - * @readonly - * @private - */ - domain: ?string; - /** * A Map of all resolved serializers in a an `Application` instance. This is * used when a `Serializer` instance has to serialize an embedded @@ -53,168 +45,6 @@ class Serializer { */ serializers: Map; - /** - * An Array of the `hasOne` or `belongsTo` relationships on a `Serializer` - * instance's model to include in the `relationships` resource object of a - * serialized payload. - * - * @example - * class PostsSerializer extends Serializer { - * hasOne = [ - * 'author' - * ]; - * } - * - * // A request to `/posts` would result in the following payload: - * - * { - * "data": [ - * { - * "id": 1, - * "type": "posts", - * "attributes": {}, - * "relationships": [ - * { - * "data": { - * "id": 1, - * "type": "authors" - * }, - * "links": { - * "self": "http://localhost:4000/authors/1" - * } - * } - * ], - * "links": { - * "self": "http://localhost:4000/posts/1" - * } - * } - * ], - * "links": { - * "self": "http://localhost:4000/posts", - * "first": "http://localhost:4000/posts?page=1", - * "last": "http://localhost:4000/posts?page=1", - * "prev": null, - * "next": null - * } - * "jsonapi": { - * "version": "1.0" - * } - * } - * - * @property hasOne - * @memberof Serializer - * @instance - */ - hasOne: Array = []; - - /** - * An Array of the `hasMany` relationships on a `Serializer` instance's model - * to include in the `relationships` resource object of a serialized payload. - * - * @example - * class PostsSerializer extends Serializer { - * hasMany = [ - * 'comments' - * ]; - * } - * - * // A request to `/posts` would result in the following payload: - * - * { - * "data": [ - * { - * "id": 1, - * "type": "posts", - * "attributes": {}, - * "relationships": [ - * { - * "data": { - * "id": 1, - * "type": "comments" - * }, - * "links": { - * "self": "http://localhost:4000/comments/1" - * } - * }, - * { - * "data": { - * "id": 2, - * "type": "comments" - * }, - * "links": { - * "self": "http://localhost:4000/comments/2" - * } - * } - * ], - * "links": { - * "self": "http://localhost:4000/posts/1" - * } - * } - * ], - * "links": { - * "self": "http://localhost:4000/posts", - * "first": "http://localhost:4000/posts?page=1", - * "last": "http://localhost:4000/posts?page=1", - * "prev": null, - * "next": null - * } - * "jsonapi": { - * "version": "1.0" - * } - * } - * - * @property hasMany - * @memberof Serializer - * @instance - */ - hasMany: Array = []; - - /** - * An Array of the `attributes` on a `Serializer` instance's model to include - * in the `attributes` resource object of a serialized payload. - * - * @example - * class PostsSerializer extends Serializer { - * attributes = [ - * 'title', - * 'isPublic' - * ]; - * } - * - * // A request to `/posts` would result in the following payload: - * - * { - * "data": [ - * { - * "id": 1, - * "type": "posts", - * "attributes": { - * "title": "Not another Node.js framework...", - * "is-public": true - * }, - * "links": { - * "self": "http://localhost:4000/posts/1" - * } - * } - * ], - * "links": { - * "self": "http://localhost:4000/posts", - * "first": "http://localhost:4000/posts?page=1", - * "last": "http://localhost:4000/posts?page=1", - * "prev": null, - * "next": null - * } - * "jsonapi": { - * "version": "1.0" - * } - * } - * - * @property attributes - * @memberof Serializer - * @instance - */ - attributes: Array = []; - /** * Create an instance of `Serializer`. * @@ -227,11 +57,9 @@ class Serializer { */ constructor({ model, - domain, serializers }: { model: typeof Model, - domain: string, serializers: Map } = {}) { Object.defineProperties(this, { @@ -242,13 +70,6 @@ class Serializer { configurable: false }, - domain: { - value: domain, - writable: false, - enumerable: false, - configurable: false - }, - serializers: { value: serializers, writable: false, @@ -260,326 +81,479 @@ class Serializer { return this; } - /** - * @private - */ - formatKey(key: string): string { - return dasherize(underscore(key)); - } + /** + * An Array of the `hasOne` or `belongsTo` relationships on a `Serializer` + * instance's model to include in the `relationships` resource object of a + * serialized payload. + * + * @example + * class PostsSerializer extends Serializer { + * hasOne = [ + * 'author' + * ]; + * } + * + * // A request to `/posts` would result in the following payload: + * + * { + * "data": [ + * { + * "id": 1, + * "type": "posts", + * "attributes": {}, + * "relationships": [ + * { + * "data": { + * "id": 1, + * "type": "authors" + * }, + * "links": { + * "self": "http://localhost:4000/authors/1" + * } + * } + * ], + * "links": { + * "self": "http://localhost:4000/posts/1" + * } + * } + * ], + * "links": { + * "self": "http://localhost:4000/posts", + * "first": "http://localhost:4000/posts?page=1", + * "last": "http://localhost:4000/posts?page=1", + * "prev": null, + * "next": null + * } + * "jsonapi": { + * "version": "1.0" + * } + * } + * + * @property hasOne + * @memberof Serializer + * @instance + */ + get hasOne(): Array { + return Object.freeze([]); + } - /** - * @private - */ - fieldsFor(name: string, fields: Object = {}): Array { - const match: ?Array = fields[camelize(underscore(name), true)]; + set hasOne(value: Array): void { + if (value && value.length) { + const hasOne = new Array(value.length); - return match ? [...match] : []; - } + insert(hasOne, value); - /** - * @private - */ - attributesFor(item: Model, fields: Array = []): Object { - return (fields.length ? fields : this.attributes) - .reduce((hash, attr) => { - if (attr.indexOf('id') < 0) { - hash[this.formatKey(attr)] = item[attr]; - } - - return hash; - }, {}); + Object.defineProperty(this, 'hasOne', { + value: Object.freeze(hasOne), + writable: false, + enumerable: true, + configurable: false + }); + } } /** - * @private + * An Array of the `hasMany` relationships on a `Serializer` instance's model + * to include in the `relationships` resource object of a serialized payload. + * + * @example + * class PostsSerializer extends Serializer { + * hasMany = [ + * 'comments' + * ]; + * } + * + * // A request to `/posts` would result in the following payload: + * + * { + * "data": [ + * { + * "id": 1, + * "type": "posts", + * "attributes": {}, + * "relationships": [ + * { + * "data": { + * "id": 1, + * "type": "comments" + * }, + * "links": { + * "self": "http://localhost:4000/comments/1" + * } + * }, + * { + * "data": { + * "id": 2, + * "type": "comments" + * }, + * "links": { + * "self": "http://localhost:4000/comments/2" + * } + * } + * ], + * "links": { + * "self": "http://localhost:4000/posts/1" + * } + * } + * ], + * "links": { + * "self": "http://localhost:4000/posts", + * "first": "http://localhost:4000/posts?page=1", + * "last": "http://localhost:4000/posts?page=1", + * "prev": null, + * "next": null + * } + * "jsonapi": { + * "version": "1.0" + * } + * } + * + * @property hasMany + * @memberof Serializer + * @instance */ - relationshipsFor( - item: Model, - include: Array, - fields: Object - ): Object { - const { domain, hasOne, hasMany } = this; - const hash: Object = { data: {}, included: [] }; - - hash.data = { - ...hasOne.reduce((obj, key) => { - const related: ?Model = item[key]; - - if (related) { - const { id, modelName }: { id: number, modelName: string } = related; - const type: string = pluralize(modelName); - - obj[key] = { - data: { - id, - type - }, - - links: { - self: `${domain}/${type}/${id}` - } - }; + get hasMany(): Array { + return Object.freeze([]); + } - if (include.indexOf(key) >= 0) { - const { - constructor: { - serializer: relatedSerializer - } - } = related; - - if (relatedSerializer) { - hash.included.push( - relatedSerializer.serializeOne(related, [], fields) - ); - } - } - } + set hasMany(value: Array): void { + if (value && value.length) { + const hasMany = new Array(value.length); - return obj; - }, {}), + insert(hasMany, value); - ...hasMany.reduce((obj, key) => { - const records: ?Array = item[key]; + Object.defineProperty(this, 'hasMany', { + value: Object.freeze(hasMany), + writable: false, + enumerable: true, + configurable: false + }); + } + } - if (records && records.length) { - obj[key] = { - data: records.map(related => { - const { - id, - modelName - }: { - id: number, - modelName: string - } = related; - - const type: string = pluralize(modelName); - - if (include.indexOf(key) >= 0) { - const { - constructor: { - serializer: relatedSerializer - } - } = related; - - if (relatedSerializer) { - hash.included.push( - relatedSerializer.serializeOne(related, [], fields) - ); - } - } + /** + * An Array of the `attributes` on a `Serializer` instance's model to include + * in the `attributes` resource object of a serialized payload. + * + * @example + * class PostsSerializer extends Serializer { + * attributes = [ + * 'title', + * 'isPublic' + * ]; + * } + * + * // A request to `/posts` would result in the following payload: + * + * { + * "data": [ + * { + * "id": 1, + * "type": "posts", + * "attributes": { + * "title": "Not another Node.js framework...", + * "is-public": true + * }, + * "links": { + * "self": "http://localhost:4000/posts/1" + * } + * } + * ], + * "links": { + * "self": "http://localhost:4000/posts", + * "first": "http://localhost:4000/posts?page=1", + * "last": "http://localhost:4000/posts?page=1", + * "prev": null, + * "next": null + * } + * "jsonapi": { + * "version": "1.0" + * } + * } + * + * @property attributes + * @memberof Serializer + * @instance + */ + get attributes(): Array { + return Object.freeze([]); + } - return { - id, - type, + set attributes(value: Array): void { + if (value && value.length) { + const attributes = new Array(value.length); - links: { - self: `${domain}/${type}/${id}` - } - }; - }) - }; - } + insert(attributes, value); - return obj; - }, {}) - }; + Object.defineProperty(this, 'attributes', { + value: Object.freeze(attributes), + writable: false, + enumerable: true, + configurable: false + }); + } + } - return hash; + /** + * @private + */ + stream({ + data, + links = {}, + fields = [], + domain = '', + include = {} + }: { + data: ?(Model | Array), + links: Object, + fields: Array, + domain: string, + include: Object + }): ContentStream { + return new ContentStream().on('ready', (stream: ContentStream) => { + const serialized = this.serialize({ + data, + links, + fields, + domain, + include + }); + + stream.end(serialized); + }); } /** * @private */ - serializeGroup( - stream: Readable, - key: string, - data: Array | Model, - include: Array, - fields: Object - ): void { - stream.push(`"${this.formatKey(key)}":`); - - if (key === 'data') { - let included: Array = []; - let lastItemIndex: number; + serialize({ + data, + links, + fields, + domain, + include + }: { + data: ?(Object | Array | Model), + links: Object, + fields: Array, + domain: string, + include: Object + }): Object | Array { + let serialized = {}; + + if (data instanceof Model || Array.isArray(data)) { + const included = []; + + if (Array.isArray(fields)) { + fields = fields.filter(field => !idRegExp.test(field)); + } else { + fields = []; + } if (Array.isArray(data)) { - lastItemIndex = Math.max(data.length - 1, 0); - - stream.push('['); - - for (let i = 0; i < data.length; i++) { - let item = this.serializeOne(data[i], include, fields); - - if (item.included && item.included.length) { - included = item.included.reduce((value, record: Object) => { - const { id, type }: { id: number, type: string } = record; - const shouldInclude = !value.some(({ - id: vId, - type: vType - }: { - id: number, - type: string - }) => { - return vId === id && vType === type; - }); - - if (shouldInclude) { - value = [...value, record]; - } - - return value; - }, included); - - delete item.included; - } - - stream.push( - JSON.stringify(item) - ); - - if (i !== lastItemIndex) { - stream.push(','); - } - } - - stream.push(']'); + serialized = { + ...serialized, + + data: data.map(item => { + return this.serializeOne({ + item, + fields, + domain, + include, + included + }); + }) + }; } else { - if (data instanceof Object) { - data = this.serializeOne(data, include, fields, false); - - if (data.included && data.included.length) { - included = [...included, ...data.included]; - delete data.included; - } - - stream.push( - JSON.stringify(data) - ); - } + serialized = { + ...serialized, + + data: this.serializeOne({ + fields, + domain, + include, + included, + item: data, + links: false + }) + }; } if (included.length) { - lastItemIndex = Math.max(included.length - 1, 0); - - stream.push(',"included":['); + serialized = { + ...serialized, + included + }; + } - for (let i = 0; i < included.length; i++) { - stream.push( - JSON.stringify(included[i]) - ); + if (links) { + serialized = { + ...serialized, + links + }; + } + } else if (data instanceof Object) { + serialized = data; + } - if (i !== lastItemIndex) { - stream.push(','); - } - } + return { + ...serialized, - stream.push(']'); + jsonapi: { + version: '1.0' } - } else { - stream.push(JSON.stringify(data)); - } + }; } /** * @private */ - async serializePayload( - stream: Readable, - payload: Object, - include: Array, - fields: Object - ): Promise { - tryCatch(() => { - const payloadKeys: Array = Object.keys(payload); - let i: number; - - stream.push('{'); - - for (i = 0; i < payloadKeys.length; i++) { - const key: string = payloadKeys[i]; - const value: ?Object = payload[key]; - - if (value) { - this.serializeGroup(stream, key, value, include, fields); - stream.push(','); - } - } + serializeOne({ + item, + links, + fields, + domain, + include, + included + }: { + item: Model, + links?: boolean, + fields: Array, + domain: string, + include: Object, + included: Array + }): Object { + const { id } = item; + const attributes = dasherizeKeys(pick(item, ...fields)); + + let { modelName: type } = item; + type = pluralize(type); + + if (Array.isArray(attributes)) { + return {}; + } - stream.push('"jsonapi":{"version":"1.0"}}'); - }, (err: Error) => { - console.error(err); - }); + let serialized: { + id: number, + type: string, + links?: Object, + attributes: Object, + relationships?: Object + } = { + id, + type, + attributes + }; - stream.push(null); + if (Object.keys(include).length) { + const relationships = entries(include).reduce((hash, [name, attrs]) => { + const related = item[name]; + + attrs = attrs.filter(field => !idRegExp.test(field)); + + if (related instanceof Model) { + hash[name] = this.serializeRelationship({ + domain, + included, + item: related, + links: true, + fields: attrs + }); + } else if (Array.isArray(related)) { + hash[name] = { + data: related.map(relatedItem => { + const { + data: relatedData, + links: relatedLinks + } = this.serializeRelationship({ + domain, + included, + item: relatedItem, + links: true, + fields: attrs + }); - return stream; - } + return { + ...relatedData, + links: relatedLinks + }; + }) + }; + } - /** - * @private - */ - stream(payload: Object, include: Array, fields: Object): Readable { - const stream: Readable = new Readable({ - encoding: 'utf8' - }); + return hash; + }, {}); + + if (Object.keys(relationships).length) { + serialized = { + ...serialized, + relationships + }; + } + } - this.serializePayload(stream, payload, include, fields); + if (links || typeof links !== 'boolean') { + serialized.links = { + self: `${domain}/${type}/${id}` + }; + } - return stream; + return serialized; } /** * @private */ - serializeOne( + serializeRelationship({ + item, + fields, + domain, + included + }: { item: Model, - include: Array, - fields: Object, - links: boolean = true - ): Object { + fields: Array, + domain: string, + included: Array + }): Object { const { id, - modelName: name - }: { - id: number, - modelName: string - } = item; - - const type: string = pluralize(name); - - const data = { - id, - type, - attributes: this.attributesFor(item, this.fieldsFor(name, fields)), - relationships: null, - included: null, - links: {} - }; - const relationships = this.relationshipsFor(item, include, fields); + constructor: { + serializer + } + } = item; - if (Object.keys(relationships.data).length) { - data.relationships = relationships.data; - } else { - delete data.relationships; + let { modelName: type } = item; + type = pluralize(type); + + if (fields.length) { + const shouldInclude = !included.some((incl) => { + return id === incl.id && type === incl.type; + }); + + if (shouldInclude) { + included.push( + serializer.serializeOne({ + item, + domain, + fields, + include: {}, + included: [] + }) + ); + } } - if (relationships.included.length) { - data.included = relationships.included; - } else { - delete data.included; - } + return { + data: { + id, + type + }, - if (links) { - data.links = { - self: `${this.domain}/${type}/${id}` - }; - } else { - delete data.links; - } - - return data; + links: { + self: `${domain}/${type}/${id}` + } + }; } } diff --git a/src/packages/server/index.js b/src/packages/server/index.js index b67a2c00..0e3a4cbb 100644 --- a/src/packages/server/index.js +++ b/src/packages/server/index.js @@ -6,6 +6,7 @@ import chalk, { cyan } from 'chalk'; import { line } from '../logger'; +import entries from '../../utils/entries'; import formatParams from './utils/format-params'; import type { @@ -84,6 +85,7 @@ class Server { req.url = parseURL(req.url, true); req.params = await formatParams(req); + req.headers = new Map(entries(headers)); this.router.resolve(req, res); } diff --git a/src/packages/server/utils/format-params.js b/src/packages/server/utils/format-params.js index 1c377fe7..1c6541aa 100644 --- a/src/packages/server/utils/format-params.js +++ b/src/packages/server/utils/format-params.js @@ -4,7 +4,7 @@ import { camelize } from 'inflection'; import bodyParser from './body-parser'; import entries from '../../../utils/entries'; -import camelizeKeys from '../../../utils/camelize-keys'; +import { camelizeKeys } from '../../../utils/transform-keys'; const int = /^\d+$/g; const bool = /^(true|false)$/i; diff --git a/src/utils/camelize-keys.js b/src/utils/camelize-keys.js deleted file mode 100644 index edf91bf2..00000000 --- a/src/utils/camelize-keys.js +++ /dev/null @@ -1,31 +0,0 @@ -// @flow -import { camelize } from 'inflection'; - -import entries from './entries'; - -/** - * @private - */ -export default function camelizeKeys( - obj: {} | Array, - deep: boolean = false -): {} | Array { - if (Array.isArray(obj)) { - return obj.slice(0); - } else if (obj && typeof obj === 'object') { - return entries(obj) - .reduce((result, [key, value]) => { - if (deep && value && typeof value === 'object' - && !Array.isArray(value)) { - value = camelizeKeys(value, true); - } - - return { - ...result, - [camelize(key.replace(/-/g, '_'), true)]: value - }; - }, {}); - } else { - return {}; - } -} diff --git a/src/utils/insert.js b/src/utils/insert.js new file mode 100644 index 00000000..bc234b98 --- /dev/null +++ b/src/utils/insert.js @@ -0,0 +1,13 @@ +/** + * @private + */ +export default function insert( + target: Array, + items: Array +): Array { + for (let i = 0; i < items.length; i++) { + target[i] = items[i]; + } + + return target; +} diff --git a/src/utils/omit.js b/src/utils/omit.js index f859cb56..359b45c5 100644 --- a/src/utils/omit.js +++ b/src/utils/omit.js @@ -4,7 +4,7 @@ import entries from './entries'; /** * @private */ - export default function omit(source: {}, ...omitted: Array): {} { + export default function omit(source: {}, ...omitted: Array): Object { return entries(source) .filter(([key, value]: [string, mixed]): boolean => { return omitted.indexOf(key) < 0; diff --git a/src/utils/pick.js b/src/utils/pick.js index cb912e93..b434ceba 100644 --- a/src/utils/pick.js +++ b/src/utils/pick.js @@ -3,7 +3,7 @@ /** * @private */ -export default function pick(source: {}, ...keys: Array): {} { +export default function pick(source: {}, ...keys: Array): Object { return keys .map((key: string): [string, mixed] => [key, source[key]]) .filter(([key, value]: [string, mixed]): boolean => { diff --git a/src/utils/promise-hash.js b/src/utils/promise-hash.js new file mode 100644 index 00000000..0baccd09 --- /dev/null +++ b/src/utils/promise-hash.js @@ -0,0 +1,27 @@ +// @flow +import entries from './entries'; + +/** + * @private + */ +export default function promiseHash(promises: {}): Promise { + return new Promise((resolveHash, rejectHash) => { + if (Object.keys(promises).length) { + Promise.all( + entries(promises).map(([key, value]) => { + return new Promise((resolve, reject) => { + value.then(resolvedValue => { + resolve({ [key]: resolvedValue }); + }, reject); + }); + }) + ).then((objects) => { + resolveHash( + objects.reduce((hash, object) => Object.assign(hash, object)) + ); + }, rejectHash); + } else { + resolveHash({}); + } + }); +} diff --git a/src/utils/transform-keys.js b/src/utils/transform-keys.js new file mode 100644 index 00000000..2cac2add --- /dev/null +++ b/src/utils/transform-keys.js @@ -0,0 +1,63 @@ +// @flow +import { camelize, dasherize } from 'inflection'; + +import entries from './entries'; +import underscore from './underscore'; + +/** + * @private + */ +export default function transformKeys( + obj: Object | Array, + transformer: (key: string) => string, + deep: boolean = false +): Object | Array { + if (Array.isArray(obj)) { + return obj.slice(0); + } else if (obj && typeof obj === 'object') { + return entries(obj) + .reduce((result, [key, value]) => { + if (deep && value && typeof value === 'object' + && !Array.isArray(value)) { + value = transformKeys(value, transformer, true); + } + + return { + ...result, + [transformer(key)]: value + }; + }, {}); + } else { + return {}; + } +} + +/** + * @private + */ +export function camelizeKeys( + obj: Object | Array, + deep: boolean = false +): Object | Array { + return transformKeys(obj, (key) => camelize(underscore(key), true), deep); +} + +/** + * @private + */ +export function dasherizeKeys( + obj: Object | Array, + deep: boolean = false +): Object | Array { + return transformKeys(obj, (key) => dasherize(underscore(key), true), deep); +} + +/** + * @private + */ +export function underscoreKeys( + obj: Object | Array, + deep: boolean = false +): Object | Array { + return transformKeys(obj, (key) => underscore(key), deep); +} diff --git a/test/integration/controller.js b/test/integration/controller.js index 7f832cf7..f10c1043 100644 --- a/test/integration/controller.js +++ b/test/integration/controller.js @@ -487,18 +487,19 @@ describe('Integration: class Controller', () => { }); describe('Regression: #createPageLinks (https://github.com/postlight/lux/issues/102)', () => { - let subject, payload; + let url, subject, payload; before(async () => { - subject = await fetch(`${host}/tags`); + url = `${host}/tags`; + subject = await fetch(url); payload = await subject.json(); }); it('has the expected `links` value', () => { expect(payload.links).to.deep.equal({ - self: `${host}/tags`, - first: `${host}/tags?page=1`, - last: `${host}/tags?page=1`, + self: url, + first: url, + last: url, prev: null, next: null }); diff --git a/test/test-app/app/controllers/posts.js b/test/test-app/app/controllers/posts.js index fcc93693..ca14ae14 100644 --- a/test/test-app/app/controllers/posts.js +++ b/test/test-app/app/controllers/posts.js @@ -12,6 +12,10 @@ class PostsController extends Controller { res.setHeader('X-Controller', 'Posts'); } ]; + + index(req, res) { + return super.index(req, res).isPublic(); + } } export default PostsController; diff --git a/test/test-app/app/models/post.js b/test/test-app/app/models/post.js index eee9c89e..05d11307 100644 --- a/test/test-app/app/models/post.js +++ b/test/test-app/app/models/post.js @@ -6,6 +6,20 @@ class Post extends Model { inverse: 'posts' } }; + + static scopes = { + drafts() { + return this.where({ + isPublic: false + }); + }, + + isPublic() { + return this.where({ + isPublic: true + }); + } + }; } export default Post; diff --git a/test/test-app/config/environments/development.js b/test/test-app/config/environments/development.js index 030d4e62..9c1d9d46 100644 --- a/test/test-app/config/environments/development.js +++ b/test/test-app/config/environments/development.js @@ -1,4 +1,3 @@ export default { - log: true, - domain: 'http://localhost:4000' + log: true }; diff --git a/test/test-app/config/environments/production.js b/test/test-app/config/environments/production.js index 26014e2d..b9122bca 100644 --- a/test/test-app/config/environments/production.js +++ b/test/test-app/config/environments/production.js @@ -1,4 +1,3 @@ export default { - log: false, - domain: 'http://localhost:4000' + log: false }; diff --git a/test/test-app/config/environments/test.js b/test/test-app/config/environments/test.js index 26014e2d..b9122bca 100644 --- a/test/test-app/config/environments/test.js +++ b/test/test-app/config/environments/test.js @@ -1,4 +1,3 @@ export default { - log: false, - domain: 'http://localhost:4000' + log: false }; diff --git a/test/test-app/package.json b/test/test-app/package.json index d926cc35..2ad15c13 100644 --- a/test/test-app/package.json +++ b/test/test-app/package.json @@ -13,8 +13,8 @@ "babel-core": "6.9.1", "babel-preset-lux": "1.0.0", "knex": "0.11.5", - "mysql2": "1.0.0-rc.2", - "pg": "4.5.6", + "mysql2": "1.0.0-rc.3", + "pg": "5.0.0", "sqlite3": "3.1.4" } } diff --git a/test/unit/utils/camelize-keys.js b/test/unit/utils/camelize-keys.js index 14b74293..6b5ce060 100644 --- a/test/unit/utils/camelize-keys.js +++ b/test/unit/utils/camelize-keys.js @@ -1,6 +1,6 @@ import { expect } from 'chai'; -import camelizeKeys from '../../../src/utils/camelize-keys'; +import { camelizeKeys } from '../../../src/utils/transform-keys'; describe('Unit: util camelizeKeys', () => { const subject = {