├── .editorconfig
├── .gitignore
├── .travis.yml
├── LICENCE
├── README.md
├── demo
├── .babelrc
├── .gitignore
├── .sequelizerc
├── package-lock.json
├── package.json
└── server
│ ├── config
│ └── database.js
│ ├── graphql
│ ├── queryLoaderConfig.js
│ ├── resolvers
│ │ ├── article.js
│ │ ├── category.js
│ │ ├── index.js
│ │ └── user.js
│ └── schemas
│ │ ├── article.js
│ │ ├── category.js
│ │ ├── comment.js
│ │ ├── index.js
│ │ └── user.js
│ ├── index.js
│ ├── migrations
│ ├── 20190204140700-create-user.js
│ ├── 20190204140701-create-category.js
│ ├── 20190204140742-create-article.js
│ └── 20190216132118-create-comment.js
│ ├── models
│ ├── article.js
│ ├── category.js
│ ├── comment.js
│ ├── index.js
│ └── user.js
│ └── seeders
│ └── dbSeed.js
├── jestconfig.json
├── package-lock.json
├── package.json
├── src
├── __mocks__
│ ├── getArticlesArgsInfo.ts
│ ├── getArticlesInfo.ts
│ ├── getArticlesWithIncludesInfo.ts
│ ├── getCategoryDeepInfo.ts
│ ├── getCategoryInfo.ts
│ └── models
│ │ ├── articleModel.ts
│ │ ├── categoryModel.ts
│ │ ├── commentModel.ts
│ │ └── userModel.ts
├── __tests__
│ └── queryLoader.spec.ts
├── index.ts
├── queryLoader.ts
└── types.ts
├── tsconfig.json
└── tslint.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 | # General settings for whole project
3 | [*]
4 | indent_style = space
5 | end_of_line = lf
6 | indent_size = 2
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | # Format specific overrides
10 | [*.md]
11 | max_line_length = 0
12 | trim_trailing_whitespace = false
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | /lib
3 | /coverage
4 | .vscode
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: required
2 | language: node_js
3 | node_js:
4 | - "10.15.0"
5 | before_script:
6 | - npm install
7 | script:
8 | - npm test
9 | - npm run coverage
--------------------------------------------------------------------------------
/LICENCE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019-present Samuel Osuh and all collaborators
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # graphql-sequelize-query-loader
2 |
3 | Convert GraphQL Query to query options for Sequelize models facilitating eagerloading of associated resources.
4 |
5 | [](https://codecov.io/gh/jsamchineme/graphql-sequelize-query-loader)
6 | [](https://travis-ci.com/jsamchineme/graphql-sequelize-query-loader)
7 | [](https://codebeat.co/projects/github-com-jsamchineme-graphql-sequelize-query-loader-master)
8 | [](https://github.com/jsamchineme/graphql-sequelize-query-loader/blob/master/LICENCE)
9 |
10 | ### Overview
11 | Essentially, the tool expects that all sequelize models have set on them the appropriate associations to all related resources. Then, when given a GraphQL query, it parses the info object made available as a parameter in GraphQL Query Resolvers, producing an object which includes ONLY the selected resources and with ONLY the specified attributes.
12 |
13 | This solves for the `N + 1` problem with database querying, as well as the issue of `over-fetching` of table attributes in result sets.
14 |
15 | ## Installation
16 | `$ npm install --save graphql-sequelize-query-loader`
17 |
18 | ## Pre-requisites
19 | In order to use the helper to eagerload related entities, you need to have setup the associations on the Sequelize Models.
20 |
21 | ## Features
22 | - maps the selected fields (in all models found in a GraphQL query) to the `attributes` option for Sequelize `Model.Find
23 | - maps included models found in the GraphQL query to `include` option properties
24 | - converts `scope` argument in GraphQL query and turns them to `where` option properties
25 |
26 | ## Usage
27 | ```js
28 | import queryLoader from 'graphql-sequelize-query-loader';
29 | import models from 'path/to/sequelize/models';
30 |
31 | /**
32 | * dictionary of what sequelize models respectively match the named resources
33 | * captured on the graphql schema
34 | */
35 | const includeModels = {
36 | articles: models.Article,
37 | article: models.Article,
38 | owner: models.User,
39 | category: models.category,
40 | comments: models.Comment,
41 | };
42 |
43 | /*
44 | * Initiliase the loader with "includeModels",
45 | * a map of all models referenced in GraphQL with their respective Sequelize Model
46 | */
47 | queryLoader.init({ includeModels });
48 |
49 | /**
50 | * GraphQL
51 | */
52 | Query: {
53 | articles: async (parent, args, { models }, info) => {
54 | const { Article } = models;
55 | const queryOptions = queryLoader.getFindOptions({ model: Article, info });
56 | const articles = await Article.findAll(queryOptions);
57 | return articles;
58 | },
59 | },
60 | ```
61 |
62 | ## Examples
63 | You can find examples in the [demo](https://github.com/jsamchineme/graphql-sequelize-query-loader/tree/add-example-project/demo) directory.
64 | It contains migrations, models and seeds setup for testing out the app.
65 |
It also contains graphql schemas and resolvers with examples of how the `queryLoader` utility is used
66 |
67 | ## Testing the Demo
68 | On the [git repo](https://github.com/jsamchineme/graphql-sequelize-query-loader/tree/add-example-project/demo)
69 | You can quickly test the demo and play with the code using `Gitpod`.
70 |
Gitpod is a full blown IDE created on the fly from a git repository.
71 |
It allows us to test a github project with just the browser.
72 | Learn [more about gitpod](https://www.gitpod.io/) OR [install the chrome extension](https://chrome.google.com/webstore/detail/gitpod-online-ide/dodmmooeoklaejobgleioelladacbeki)
73 |
74 | - clone the repo, if you want to test locally
75 | - check into the `demo` directory. `$cd demo`
76 | - environment should be set to `development`. This is already setup in the package.json scripts
77 | - setup `DATABASE_URL`: You can use this database created with elephantsql.
78 | `postgres://cigjzbzt:krzc-48qlH4hfj0HM5Oid_rfxN9uLbuf@raja.db.elephantsql.com:5432/cigjzbzt`
79 | - `npm install`
80 | - run migrations and seeds - `npm run db:seed` (optional): This will create tables and seed them with dummy data. If you are testing the project locally, you will definitely need to this the first time, but if you are testing it online with gitpod, the database has already been migrated and seeded, so you don't have to do this step.
81 | - start the development server: `npm start`
82 | - send GraphQL queries to the `/graphql` endpoint.
83 |
84 | Examples queries
85 | ```js
86 | {
87 | articles(scope: 'id|gt|2') {
88 | id
89 | title
90 | }
91 | }
92 |
93 | {
94 | articles {
95 | id
96 | title
97 | owner {
98 | firstname
99 | lastname
100 | }
101 | comments {
102 | id
103 | body
104 | }
105 | }
106 | }
107 |
108 | {
109 | categories {
110 | name
111 | articles {
112 | id
113 | title
114 | owner {
115 | firstname
116 | lastname
117 | }
118 | comments {
119 | id
120 | body
121 | }
122 | }
123 | }
124 | }
125 | ```
126 | **To get a better view of this utility's value**, check the terminal to see the number of queries executed by Sequelize.
127 | You will find that all the queries above execute JUST ONE query. This could have been over tens of queries given very large tables, and with code not so well written.
128 |
129 |
130 |
131 |
132 | ## Unit Tests
133 |
134 | `npm test`
135 |
136 | You can find the test cases in the [`/src/__tests__`](https://github.com/jsamchineme/graphql-sequelize-query-loader/blob/add-example-project/src/__tests__/queryLoader.spec.ts) directory
137 |
138 |
139 | ## Scope Argument
140 |
141 | This allows us to query models and return records matching certain conditions.
142 | Currently, we support a small set of Sequelize operators with the `scope` argument
143 |
144 | ### Usage
145 |
146 | In a GraphQL query, provide `scope` argument with the following pattern
147 | `field|operator|value`
148 |
149 | Example
150 | ```js
151 | articles(scope: 'title|like|%graphql%') {
152 | id
153 | title
154 | }
155 | ```
156 |
157 | ### Supported Scope Operators
158 | eq, gt, gte, like, lt, lte, ne
159 |
160 |
161 |
162 | ## Author
163 | Samuel Osuh @jsamchineme
164 |
--------------------------------------------------------------------------------
/demo/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env"]
3 | }
--------------------------------------------------------------------------------
/demo/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env
--------------------------------------------------------------------------------
/demo/.sequelizerc:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | 'config': path.resolve('server/config', 'database.js'),
5 | 'models-path': path.resolve('server', 'models'),
6 | 'seeders-path': path.resolve('server', 'seeders'),
7 | 'migrations-path': path.resolve('server', 'migrations')
8 | };
9 |
--------------------------------------------------------------------------------
/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sequelize-pro",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "cross-env NODE_ENV=development babel-node server --preset-env",
8 | "migrate": "cross-env NODE_ENV=development sequelize db:migrate",
9 | "db:seed": "npm run migration && cross-env NODE_ENV=development babel-node server/seeders/dbSeed.js --preset-env",
10 | "migrate:reset": "sequelize db:migrate:undo:all",
11 | "migration": "npm run migrate:reset && npm run migrate"
12 | },
13 | "author": "",
14 | "license": "ISC",
15 | "devDependencies": {
16 | "@babel/cli": "^7.2.3",
17 | "@babel/core": "^7.2.2",
18 | "@babel/node": "^7.2.2",
19 | "@babel/preset-env": "^7.3.1",
20 | "cross-env": "^5.2.0",
21 | "sequelize-cli": "^5.5.0"
22 | },
23 | "dependencies": {
24 | "apollo-boost": "^0.1.28",
25 | "apollo-server-express": "^2.4.2",
26 | "body-parser": "^1.18.3",
27 | "cors": "^2.8.5",
28 | "dotenv": "^6.2.0",
29 | "express": "^4.16.4",
30 | "faker": "^4.1.0",
31 | "graphql": "^14.1.1",
32 | "graphql-sequelize-query-loader": "^1.0.5",
33 | "graphql-tag": "^2.10.1",
34 | "morgan": "^1.9.1",
35 | "pg": "^7.8.0",
36 | "pg-hstore": "^2.3.2",
37 | "sequelize": "^5.8.10"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/demo/server/config/database.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 |
3 | module.exports = {
4 | development: {
5 | url: process.env.DATABASE_URL,
6 | dialect: 'postgres'
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/demo/server/graphql/queryLoaderConfig.js:
--------------------------------------------------------------------------------
1 | import queryLoader from 'graphql-sequelize-query-loader';
2 | import models from '../models';
3 |
4 | /**
5 | * dictionary of what sequelize models respectively match the named resources
6 | * captured on the graphql schema
7 | */
8 | const includeModels = {
9 | articles: models.Article,
10 | article: models.Article,
11 | owner: models.User,
12 | category: models.category,
13 | comments: models.Comment,
14 | me: models.User
15 | };
16 |
17 | queryLoader.init({ includeModels });
18 |
19 |
20 | export default queryLoader;
21 |
--------------------------------------------------------------------------------
/demo/server/graphql/resolvers/article.js:
--------------------------------------------------------------------------------
1 | import queryLoader from '../queryLoaderConfig';
2 |
3 |
4 | export default {
5 | Query: {
6 | articles: async (parent, args, { models }, info) => {
7 | const { Article } = models;
8 | // query options prepare attributes and associated model includes
9 | const queryOptions = queryLoader.getFindOptions({ model: Article, info });
10 |
11 | const articles = await Article.findAll(queryOptions);
12 | return articles;
13 | },
14 | article: async (parent, { id }, { models }, info) => {
15 | const { Article } = models;
16 | // query options prepare attributes and associated model includes
17 | const queryOptions = queryLoader.getFindOptions({ model: Article, info });
18 |
19 | const article = await Article.findByPk(id, queryOptions);
20 | return article;
21 | },
22 | },
23 | Article: {
24 | owner: (parent) => {
25 | const { owner } = parent;
26 | return owner;
27 | },
28 | category: (parent) => {
29 | const { category } = parent;
30 | return category;
31 | },
32 | comments: (parent) => {
33 | const { comments } = parent;
34 | return comments;
35 | }
36 | },
37 | };
38 |
--------------------------------------------------------------------------------
/demo/server/graphql/resolvers/category.js:
--------------------------------------------------------------------------------
1 | import queryLoader from '../queryLoaderConfig';
2 |
3 | export default {
4 | Query: {
5 | categories: async (parent, args, { models }, info) => {
6 | const { Category } = models;
7 | const queryOptions = queryLoader.getFindOptions({ model: Category, info });
8 |
9 | const categories = await Category.findAll(queryOptions);
10 | return categories;
11 | },
12 | category: async (parent, { id }, { models }, info) => {
13 | const { Category } = models;
14 | const queryOptions = queryLoader.getFindOptions({ model: Category, info });
15 |
16 | const category = await Category.findByPk(id, queryOptions);
17 | return category;
18 | }
19 | },
20 | Category: {
21 | articles: async (parent) => {
22 | const { articles } = parent;
23 | return articles;
24 | }
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/demo/server/graphql/resolvers/index.js:
--------------------------------------------------------------------------------
1 | import userResolvers from './user';
2 | import articleResolvers from './article';
3 | import categoryResolvers from './category';
4 |
5 | export default [userResolvers, articleResolvers, categoryResolvers];
6 |
--------------------------------------------------------------------------------
/demo/server/graphql/resolvers/user.js:
--------------------------------------------------------------------------------
1 | import queryLoader from '../queryLoaderConfig';
2 |
3 | export default {
4 | Query: {
5 | user: (parent, { id }, { models }, info) => {
6 | const { User } = models;
7 | // query options prepare attributes and associated model includes
8 | const queryOptions = queryLoader.getFindOptions({ model: User, info });
9 |
10 | const user = User.findByPk(id, queryOptions);
11 | return user;
12 | },
13 | users: (parent, args, { models }, info) => {
14 | const { User } = models;
15 | // query options prepare attributes and associated model includes
16 | const queryOptions = queryLoader.getFindOptions({ model: User, info });
17 |
18 | const users = User.findAll(queryOptions);
19 | return users;
20 | },
21 | },
22 | User: {
23 | articles: async (parent) => {
24 | const { articles } = parent;
25 |
26 | return articles;
27 | }
28 | },
29 | };
30 |
--------------------------------------------------------------------------------
/demo/server/graphql/schemas/article.js:
--------------------------------------------------------------------------------
1 | import { gql } from 'apollo-server-express';
2 |
3 | export default gql`
4 | extend type Query {
5 | article(id: ID!): Article!
6 | articles(scope: String): [Article!]!
7 | }
8 |
9 | type Article {
10 | id: ID!
11 | body: String
12 | title: String
13 | slug: String
14 | description: String
15 | authorId: String
16 | categoryId: Int!
17 | owner: User!
18 | category: Category!
19 | comments(scope: String): [Comment!]!
20 | }
21 |
22 | extend type Mutation {
23 | createArticle(
24 | title: String!
25 | description: String!
26 | body: String!
27 | categoryId: Int!
28 | ): Article!
29 |
30 | deleteArticle(id: ID!): Boolean!
31 | }
32 | `;
33 |
--------------------------------------------------------------------------------
/demo/server/graphql/schemas/category.js:
--------------------------------------------------------------------------------
1 | import { gql } from 'apollo-server-express';
2 |
3 | export default gql`
4 | extend type Query {
5 | category(id: ID!): Category!
6 | categories: [Category!]!
7 | }
8 |
9 | type Category {
10 | id: ID!
11 | name: String
12 | createdAt: String
13 | updatedAt: String
14 | articles(scope: String): [Article!]!
15 | }
16 |
17 | extend type Mutation {
18 | createCategory(
19 | name: String!
20 | ): Category!
21 |
22 | deleteCategory(id: ID!): Boolean!
23 | }
24 | `;
25 |
--------------------------------------------------------------------------------
/demo/server/graphql/schemas/comment.js:
--------------------------------------------------------------------------------
1 | import { gql } from 'apollo-server-express';
2 |
3 | export default gql`
4 | extend type Query {
5 | comment(id: ID!): Comment!
6 | comments(scope: String): [Comment!]!
7 | }
8 |
9 | type Comment {
10 | id: ID!
11 | name: String
12 | userId: String
13 | articleId: Int
14 | body: String
15 | owner: User
16 | createdAt: String
17 | updatedAt: String
18 | articles: [Article!]
19 | }
20 |
21 | extend type Mutation {
22 | createComment(
23 | articleId: Int!
24 | body: String!
25 | ): Comment!
26 |
27 | deleteComment(id: ID!): Boolean!
28 | }
29 | `;
30 |
--------------------------------------------------------------------------------
/demo/server/graphql/schemas/index.js:
--------------------------------------------------------------------------------
1 | import { gql } from 'apollo-server-express';
2 |
3 | import userSchema from './user';
4 | import articleSchema from './article';
5 | import commentSchema from './comment';
6 | import categorySchema from './category';
7 |
8 | const linkSchema = gql`
9 | type Query {
10 | _: Boolean
11 | }
12 |
13 | type Mutation {
14 | _: Boolean
15 | }
16 |
17 | type Subscription {
18 | _: Boolean
19 | }
20 | `;
21 |
22 | export default [linkSchema, userSchema, articleSchema, commentSchema, categorySchema];
23 |
--------------------------------------------------------------------------------
/demo/server/graphql/schemas/user.js:
--------------------------------------------------------------------------------
1 | import { gql } from 'apollo-server-express';
2 |
3 | export default gql`
4 | extend type Query {
5 | me: User
6 | user(id: ID!): User
7 | users: [User!]
8 | }
9 |
10 | type User {
11 | id: ID!
12 | userId: ID
13 | firstname: String
14 | lastname: String
15 | username: String!
16 | articles(id: ID, scope: String): [Article!]!
17 | }
18 |
19 | extend type Mutation {
20 | createUser(
21 | firstname: String
22 | lastname: String
23 | username: String
24 | ): User!
25 | }
26 | `;
27 |
--------------------------------------------------------------------------------
/demo/server/index.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { ApolloServer } from 'apollo-server-express';
3 | import cors from 'cors';
4 | import schema from './graphql/schemas';
5 | import resolvers from './graphql/resolvers';
6 | import models from './models';
7 |
8 | const app = express();
9 | app.use(cors());
10 | app.use(express.urlencoded({ extended: true }));
11 |
12 | const start = async () => {
13 | const server = new ApolloServer({
14 | typeDefs: schema,
15 | resolvers,
16 | context: {
17 | models,
18 | }
19 | });
20 |
21 | server.applyMiddleware({ app, path: '/graphql' });
22 |
23 | const port = 8001;
24 | app.listen({ port }, () => {
25 | console.log(`🚀 Apollo Server on http://localhost:${port}${server.graphqlPath}`);
26 | });
27 | };
28 |
29 | start();
30 |
--------------------------------------------------------------------------------
/demo/server/migrations/20190204140700-create-user.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => {
3 | return queryInterface.createTable('Users', {
4 | id: {
5 | allowNull: false,
6 | autoIncrement: true,
7 | primaryKey: true,
8 | type: Sequelize.INTEGER,
9 | },
10 | firstname: {
11 | type: Sequelize.STRING
12 | },
13 | lastname: {
14 | type: Sequelize.STRING
15 | },
16 | username: {
17 | allowNull: false,
18 | type: Sequelize.STRING,
19 | unique: true
20 | },
21 | createdAt: {
22 | allowNull: false,
23 | type: Sequelize.DATE
24 | },
25 | updatedAt: {
26 | allowNull: false,
27 | type: Sequelize.DATE
28 | }
29 | });
30 | },
31 | down: queryInterface => queryInterface.dropTable('Users')
32 | };
33 |
--------------------------------------------------------------------------------
/demo/server/migrations/20190204140701-create-category.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => {
3 | return queryInterface.createTable('Categories', {
4 | id: {
5 | allowNull: false,
6 | autoIncrement: true,
7 | primaryKey: true,
8 | type: Sequelize.INTEGER
9 | },
10 | name: {
11 | allowNull: false,
12 | type: Sequelize.STRING
13 | },
14 | createdAt: {
15 | allowNull: false,
16 | type: Sequelize.DATE
17 | },
18 | updatedAt: {
19 | allowNull: false,
20 | type: Sequelize.DATE
21 | }
22 | });
23 | },
24 | down: (queryInterface, Sequelize) => queryInterface.dropTable('Categories')
25 | };
--------------------------------------------------------------------------------
/demo/server/migrations/20190204140742-create-article.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => {
3 | return queryInterface.createTable('Articles', {
4 | id: {
5 | allowNull: false,
6 | autoIncrement: true,
7 | primaryKey: true,
8 | type: Sequelize.INTEGER
9 | },
10 | title: {
11 | allowNull: false,
12 | type: Sequelize.STRING
13 | },
14 | slug: {
15 | allowNull: false,
16 | type: Sequelize.STRING
17 | },
18 | description: {
19 | type: Sequelize.STRING
20 | },
21 | body: {
22 | allowNull: false,
23 | type: Sequelize.STRING
24 | },
25 | authorId: {
26 | allowNull: false,
27 | type: Sequelize.INTEGER,
28 | onDelete: 'CASCADE',
29 | references: {
30 | model: 'Users',
31 | key: 'id'
32 | }
33 | },
34 | categoryId: {
35 | allowNull: false,
36 | type: Sequelize.INTEGER,
37 | onDelete: 'CASCADE',
38 | references: {
39 | model: 'Categories',
40 | key: 'id'
41 | }
42 | },
43 | createdAt: {
44 | allowNull: false,
45 | type: Sequelize.DATE
46 | },
47 | updatedAt: {
48 | allowNull: false,
49 | type: Sequelize.DATE
50 | }
51 | });
52 | },
53 | down: (queryInterface) => queryInterface.dropTable('Articles')
54 | };
--------------------------------------------------------------------------------
/demo/server/migrations/20190216132118-create-comment.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => {
3 | return queryInterface.createTable('Comments', {
4 | id: {
5 | allowNull: false,
6 | autoIncrement: true,
7 | primaryKey: true,
8 | type: Sequelize.INTEGER
9 | },
10 | userId: {
11 | type: Sequelize.INTEGER,
12 | onDelete: 'CASCADE',
13 | references: {
14 | model: 'Users',
15 | key: 'id'
16 | }
17 | },
18 | articleId: {
19 | type: Sequelize.INTEGER,
20 | onDelete: 'CASCADE',
21 | references: {
22 | model: 'Articles',
23 | key: 'id',
24 | }
25 | },
26 | body: {
27 | allowNull: false,
28 | type: Sequelize.STRING
29 | },
30 | createdAt: {
31 | allowNull: false,
32 | type: Sequelize.DATE
33 | },
34 | updatedAt: {
35 | allowNull: false,
36 | type: Sequelize.DATE
37 | }
38 | });
39 | },
40 | down: queryInterface => queryInterface.dropTable('Comments')
41 | };
42 |
--------------------------------------------------------------------------------
/demo/server/models/article.js:
--------------------------------------------------------------------------------
1 | module.exports = (sequelize, DataTypes) => {
2 | const Article = sequelize.define('Article', {
3 | title: {
4 | allowNull: false,
5 | type: DataTypes.STRING
6 | },
7 | slug: {
8 | allowNull: false,
9 | type: DataTypes.STRING
10 | },
11 | description: {
12 | type: DataTypes.STRING
13 | },
14 | body: {
15 | allowNull: false,
16 | type: DataTypes.TEXT
17 | },
18 | authorId: DataTypes.UUID,
19 | categoryId: {
20 | allowNull: false,
21 | type: DataTypes.INTEGER,
22 | },
23 | }, {});
24 | Article.associate = (models) => {
25 | Article.belongsTo(models.User, {
26 | foreignKey: 'authorId',
27 | as: 'owner'
28 | });
29 | Article.hasMany(models.Comment, {
30 | foreignKey: 'articleId',
31 | as: 'comments'
32 | });
33 | };
34 | return Article;
35 | };
36 |
--------------------------------------------------------------------------------
/demo/server/models/category.js:
--------------------------------------------------------------------------------
1 | module.exports = (sequelize, DataTypes) => {
2 | const Category = sequelize.define('Category', {
3 | name: DataTypes.STRING
4 | }, {});
5 | Category.associate = function (models) {
6 | Category.hasMany(models.Article, {
7 | foreignKey: 'categoryId',
8 | as: 'articles'
9 | });
10 | };
11 | return Category;
12 | };
13 |
--------------------------------------------------------------------------------
/demo/server/models/comment.js:
--------------------------------------------------------------------------------
1 | module.exports = (sequelize, DataTypes) => {
2 | const Comment = sequelize.define('Comment', {
3 | userId: {
4 | allowNull: false,
5 | type: DataTypes.INTEGER,
6 | references: {
7 | model: 'User',
8 | key: 'id',
9 | as: 'userId'
10 | }
11 | },
12 | articleId: DataTypes.INTEGER,
13 | body: DataTypes.TEXT
14 | }, {});
15 | Comment.associate = (models) => {
16 | Comment.belongsTo(models.User, {
17 | foreignKey: 'userId',
18 | as: 'owner'
19 | });
20 | };
21 | return Comment;
22 | };
23 |
--------------------------------------------------------------------------------
/demo/server/models/index.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const Sequelize = require('sequelize');
4 |
5 | const basename = path.basename(__filename);
6 | const env = process.env.NODE_ENV || 'development';
7 | const config = require(`${__dirname}/../config/database.js`)[env];
8 | const db = {};
9 |
10 | const sequelize = new Sequelize(config.url, config);
11 |
12 | fs
13 | .readdirSync(__dirname)
14 | .filter((file) => {
15 | return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
16 | })
17 | .forEach((file) => {
18 | const model = sequelize.import(path.join(__dirname, file));
19 | db[model.name] = model;
20 | });
21 |
22 | Object.keys(db).forEach((modelName) => {
23 | if (db[modelName].associate) {
24 | db[modelName].associate(db);
25 | }
26 | });
27 |
28 | db.sequelize = sequelize;
29 | db.Sequelize = Sequelize;
30 |
31 | module.exports = db;
32 |
--------------------------------------------------------------------------------
/demo/server/models/user.js:
--------------------------------------------------------------------------------
1 | module.exports = (sequelize, DataTypes) => {
2 | const User = sequelize.define('User', {
3 | firstname: DataTypes.STRING,
4 | lastname: DataTypes.STRING,
5 | username: {
6 | type: DataTypes.STRING,
7 | allowNull: false,
8 | unique: true,
9 | }
10 | }, {});
11 | User.associate = (models) => {
12 | User.hasMany(models.Article, {
13 | foreignKey: 'authorId',
14 | as: 'articles',
15 | onDelete: 'CASCADE'
16 | });
17 | };
18 |
19 | return User;
20 | };
21 |
--------------------------------------------------------------------------------
/demo/server/seeders/dbSeed.js:
--------------------------------------------------------------------------------
1 | import models from '../models';
2 | import faker from 'faker';
3 |
4 | const { User, Category, Article, Comment } = models;
5 |
6 | let categories;
7 | let articles;
8 | let users;
9 | let comments;
10 |
11 |
12 | const seedCategories = async () => {
13 | const dummyCategories = [
14 | { name: 'technology' },
15 | { name: 'fashion' },
16 | ];
17 |
18 | const categoriesPromise = dummyCategories.map(async (data) => {
19 | return await Category.create(data);
20 | });
21 | categories = await Promise.all(categoriesPromise);
22 | };
23 |
24 | const seedUsers = async () => {
25 | const createdUsers = [];
26 | for(let i=1; i <= 2; i++) {
27 | const firstname = faker.name.firstName();
28 | const lastname = faker.name.lastName();
29 | const username = firstname.toLowerCase() + '.' + lastname.toLowerCase();
30 | const userData = { firstname, lastname, username };
31 | const newUser = await User.create(userData);
32 | createdUsers.push(newUser);
33 | }
34 | users = await Promise.all(createdUsers);
35 | }
36 |
37 | const seedArticles = async () => {
38 | const articleSeedsPromise = [];
39 |
40 | // create 20 articles
41 | for(let a=1; a <= 20; a++) {
42 | const user = faker.random.arrayElement(users);
43 | const category = faker.random.arrayElement(categories);
44 | const title = faker.random.words(10);
45 | const slug = title.replace(/\s+/g, '-').toLowerCase();
46 |
47 | const newArticle = await Article.create({
48 | title,
49 | slug,
50 | description: faker.random.words(10),
51 | body: faker.random.words(10),
52 | authorId: user.id,
53 | categoryId: category.id
54 | });
55 |
56 | articleSeedsPromise.push(newArticle);
57 | }
58 | articles = await Promise.all(articleSeedsPromise);
59 | };
60 |
61 |
62 | const seedComments = async () => {
63 | // create 30 random comments
64 | for(let i=1; i <= 30; i++) {
65 | const user = faker.random.arrayElement(users);
66 | const article = faker.random.arrayElement(articles);
67 | await Comment.create({
68 | userId: user.id,
69 | articleId: article.id,
70 | body: faker.random.words(10)
71 | });
72 | }
73 | }
74 |
75 |
76 | const seedDatabase = async () => {
77 | await seedCategories();
78 | await seedUsers();
79 | await seedArticles();
80 | await seedComments();
81 | }
82 |
83 | seedDatabase();
--------------------------------------------------------------------------------
/jestconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "transform": {
3 | "^.+\\.(t|j)sx?$": "ts-jest"
4 | },
5 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
6 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"],
7 | "collectCoverage": true,
8 | "globals": {
9 | "ts-jest": {
10 | "diagnostics": false
11 | }
12 | }
13 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "graphql-sequelize-query-loader",
3 | "version": "1.0.6",
4 | "description": "Convert GraphQL Query to query options for Sequelize models facilitating eagerloading of associated resources.",
5 | "main": "lib/index.js",
6 | "types": "lib/index.d.ts",
7 | "scripts": {
8 | "test": "jest --config jestconfig.json",
9 | "build": "tsc",
10 | "format": "prettier --write \"src/**/*.ts\" \"src/**/*.js\"",
11 | "lint": "tslint -p tsconfig.json",
12 | "prepublishOnly": "npm test && npm run lint",
13 | "preversion": "npm run lint",
14 | "version": "npm run format && git add -A src",
15 | "coverage": "codecov",
16 | "postversion": "git push -f && git push --tags"
17 | },
18 | "files": [
19 | "lib/**/*"
20 | ],
21 | "repository": {
22 | "type": "git",
23 | "url": "git+https://github.com/jsamchineme/graphql-sequelize-query-loader"
24 | },
25 | "bugs": {
26 | "url": "https://github.com/jsamchineme/graphql-sequelize-query-loader"
27 | },
28 | "homepage": "https://github.com/jsamchineme/graphql-sequelize-query-loader",
29 | "license": "MIT",
30 | "keywords": [
31 | "sequelize",
32 | "graphql",
33 | "graphql-sequelize",
34 | "graphql utils",
35 | "query-loader"
36 | ],
37 | "author": "jsamchineme",
38 | "devDependencies": {
39 | "@types/bluebird": "^3.5.27",
40 | "@types/graphql": "^14.2.1",
41 | "@types/jest": "^24.0.15",
42 | "@types/validator": "^10.11.1",
43 | "codecov": "^3.5.0",
44 | "jest": "^24.8.0",
45 | "prettier": "^1.18.2",
46 | "ts-jest": "^24.0.2",
47 | "tslint": "^5.17.0",
48 | "tslint-config-prettier": "^1.18.0",
49 | "typescript": "^3.5.2"
50 | },
51 | "dependencies": {
52 | "graphql": "^14.3.1",
53 | "sequelize": "^5.8.10"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/__mocks__/getArticlesArgsInfo.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GraphQLResolveInfo
3 | } from 'graphql';
4 |
5 | /**
6 | * A mock of the structure of the info object produced when a query
7 | * like this is sent from graphql
8 | * ```js
9 | * articles(scope: "id|gt|2") {
10 | * id
11 | * title
12 | * }
13 | */
14 |
15 | const getArticlesArgsInfo: GraphQLResolveInfo | any = {
16 | fieldName: 'articles',
17 | fieldNodes: [{
18 | alias: undefined,
19 | arguments: [{
20 | kind: 'Argument',
21 | name: {
22 | kind: 'Name',
23 | value: 'scope',
24 | },
25 | value: {
26 | block: false,
27 | kind: 'StringValue',
28 | value: 'id|gt|2',
29 | },
30 | }],
31 | directives: [],
32 | kind: 'Field',
33 | selectionSet: {
34 | kind: 'SelectionSet',
35 | selections: [{
36 | alias: undefined,
37 | arguments: [],
38 | directives: [],
39 | kind: 'Field',
40 | name: {
41 | kind: 'Name',
42 | value: 'id',
43 | },
44 | selectionSet: undefined,
45 | },
46 | {
47 | alias: undefined,
48 | arguments: [],
49 | directives: [],
50 | kind: 'Field',
51 | name: {
52 | kind: 'Name',
53 | value: 'title',
54 | },
55 | selectionSet: undefined,
56 | }
57 | ],
58 | },
59 | }],
60 | };
61 |
62 | export default getArticlesArgsInfo;
63 |
64 |
--------------------------------------------------------------------------------
/src/__mocks__/getArticlesInfo.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GraphQLResolveInfo
3 | } from 'graphql';
4 |
5 | /**
6 | * A mock of the structure of the info object produced when a query
7 | * like this is sent from graphql
8 | * ```js
9 | * articles {
10 | * id
11 | * title
12 | * }
13 | */
14 |
15 | const getArticlesInfo: GraphQLResolveInfo | any = {
16 | fieldName: 'articles',
17 | fieldNodes: [{
18 | alias: undefined,
19 | arguments: [],
20 | directives: [],
21 | kind: 'Field',
22 | selectionSet: {
23 | kind: 'SelectionSet',
24 | selections: [{
25 | alias: undefined,
26 | arguments: [],
27 | directives: [],
28 | kind: 'Field',
29 | name: {
30 | kind: 'Name',
31 | value: 'id',
32 | },
33 | selectionSet: undefined,
34 | },
35 | {
36 | alias: undefined,
37 | arguments: [],
38 | directives: [],
39 | kind: 'Field',
40 | name: {
41 | kind: 'Name',
42 | value: 'title',
43 | },
44 | selectionSet: undefined,
45 | }
46 | ],
47 | },
48 | }],
49 | };
50 |
51 | export default getArticlesInfo;
--------------------------------------------------------------------------------
/src/__mocks__/getArticlesWithIncludesInfo.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GraphQLResolveInfo
3 | } from 'graphql';
4 |
5 | /**
6 | * A mock of the structure of the info object produced when a query
7 | * like this is sent from graphql
8 | * ```js
9 | * articles(scope: "id|gt|2") {
10 | * id
11 | * title
12 | * owner {
13 | * firstname
14 | * lastname
15 | * }
16 | * comments {
17 | * id
18 | * body
19 | * }
20 | * }
21 | */
22 |
23 | const getArticlesWithIncludesInfo: GraphQLResolveInfo | any = {
24 | fieldName: 'articles',
25 | fieldNodes: [{
26 | alias: undefined,
27 | arguments: [{
28 | kind: 'Argument',
29 | name: {
30 | kind: 'Name',
31 | value: 'scope',
32 | },
33 | value: {
34 | block: false,
35 | kind: 'StringValue',
36 | value: 'id|gt|2',
37 | },
38 | }],
39 | directives: [],
40 | kind: 'Field',
41 | selectionSet: {
42 | kind: 'SelectionSet',
43 | selections: [{
44 | arguments: [],
45 | kind: 'Field',
46 | name: {
47 | kind: 'Name',
48 | value: 'id',
49 | },
50 | selectionSet: undefined,
51 | },
52 | {
53 | arguments: [],
54 | kind: 'Field',
55 | name: {
56 | kind: 'Name',
57 | value: 'title',
58 | },
59 | selectionSet: undefined,
60 | },
61 | {
62 | arguments: [],
63 | kind: 'Field',
64 | name: {
65 | kind: 'Name',
66 | value: 'owner',
67 | },
68 | selectionSet: {
69 | kind: 'SelectionSet',
70 | selections: [{
71 | arguments: [],
72 | kind: 'Field',
73 | name: {
74 | kind: 'Name',
75 | value: 'firstname',
76 | },
77 | selectionSet: undefined,
78 | },
79 | {
80 | arguments: [],
81 | kind: 'Field',
82 | name: {
83 | kind: 'Name',
84 | value: 'lastname',
85 | },
86 | selectionSet: undefined,
87 | }
88 | ],
89 | },
90 | },
91 | {
92 | arguments: [],
93 | kind: 'Field',
94 | name: {
95 | kind: 'Name',
96 | value: 'comments',
97 | },
98 | selectionSet: {
99 | kind: 'SelectionSet',
100 | selections: [{
101 | arguments: [],
102 | kind: 'Field',
103 | name: {
104 | kind: 'Name',
105 | value: 'id',
106 | },
107 | selectionSet: undefined,
108 | },
109 | {
110 | arguments: [],
111 | kind: 'Field',
112 | name: {
113 | kind: 'Name',
114 | value: 'body',
115 | },
116 | selectionSet: undefined,
117 | }
118 | ],
119 | },
120 | }
121 | ],
122 | },
123 | }],
124 | };
125 |
126 | export default getArticlesWithIncludesInfo;
--------------------------------------------------------------------------------
/src/__mocks__/getCategoryDeepInfo.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GraphQLResolveInfo
3 | } from 'graphql';
4 |
5 | /**
6 | * A mock of the structure of the info object produced when a query
7 | * like this is sent from graphql
8 | * ```js
9 | * categories {
10 | * id
11 | * name
12 | * articles(scope: 'id|gt|2 && body|like|%dummy%') {
13 | * id
14 | * title
15 | * owner {
16 | * firstname
17 | * lastname
18 | * }
19 | * comments {
20 | * id
21 | * body
22 | * }
23 | * }
24 | * }
25 | */
26 |
27 | const getCategoryDeeperInfo: GraphQLResolveInfo | any = {
28 | fieldName: 'categories',
29 | fieldNodes: [{
30 | alias: undefined,
31 | arguments: [],
32 | directives: [],
33 | kind: 'Field',
34 | selectionSet: {
35 | kind: 'SelectionSet',
36 | selections: [{
37 | alias: undefined,
38 | arguments: [],
39 | directives: [],
40 | kind: 'Field',
41 | name: {
42 | kind: 'Name',
43 | value: 'id',
44 | },
45 | selectionSet: undefined,
46 | },
47 | {
48 | alias: undefined,
49 | arguments: [],
50 | directives: [],
51 | kind: 'Field',
52 | name: {
53 | kind: 'Name',
54 | value: 'name',
55 | },
56 | selectionSet: undefined,
57 | },
58 | {
59 | alias: undefined,
60 | arguments: [{
61 | kind: 'Argument',
62 | name: {
63 | kind: 'Name',
64 | value: 'scope',
65 | },
66 | value: {
67 | block: false,
68 | kind: 'StringValue',
69 | value: 'id|gt|2 && body|like|%dummy%',
70 | },
71 | }],
72 | directives: [],
73 | kind: 'Field',
74 | name: {
75 | kind: 'Name',
76 | value: 'articles',
77 | },
78 | selectionSet: {
79 | kind: 'SelectionSet',
80 | selections: [{
81 | arguments: [],
82 | kind: 'Field',
83 | name: {
84 | kind: 'Name',
85 | value: 'id',
86 | },
87 | selectionSet: undefined,
88 | },
89 | {
90 | arguments: [],
91 | kind: 'Field',
92 | name: {
93 | kind: 'Name',
94 | value: 'title',
95 | },
96 | selectionSet: undefined,
97 | },
98 | {
99 | arguments: [],
100 | kind: 'Field',
101 | name: {
102 | kind: 'Name',
103 | value: 'owner',
104 | },
105 | selectionSet: {
106 | kind: 'SelectionSet',
107 | selections: [{
108 | arguments: [],
109 | kind: 'Field',
110 | name: {
111 | kind: 'Name',
112 | value: 'firstname',
113 | },
114 | selectionSet: undefined,
115 | },
116 | {
117 | arguments: [],
118 | kind: 'Field',
119 | name: {
120 | kind: 'Name',
121 | value: 'lastname',
122 | },
123 | selectionSet: undefined,
124 | }
125 | ],
126 | },
127 | },
128 | {
129 | arguments: [],
130 | kind: 'Field',
131 | name: {
132 | kind: 'Name',
133 | value: 'comments',
134 | },
135 | selectionSet: {
136 | kind: 'SelectionSet',
137 | selections: [{
138 | arguments: [],
139 | kind: 'Field',
140 | name: {
141 | kind: 'Name',
142 | value: 'id',
143 | },
144 | selectionSet: undefined,
145 | },
146 | {
147 | arguments: [],
148 | kind: 'Field',
149 | name: {
150 | kind: 'Name',
151 | value: 'body',
152 | },
153 | selectionSet: undefined,
154 | }
155 | ],
156 | },
157 | }
158 | ],
159 | },
160 | }
161 | ],
162 | },
163 | }],
164 | };
165 |
166 | export default getCategoryDeeperInfo;
--------------------------------------------------------------------------------
/src/__mocks__/getCategoryInfo.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GraphQLResolveInfo
3 | } from 'graphql';
4 |
5 | /**
6 | * A mock of the structure of the info object produced when a query
7 | * like this is sent from graphql
8 | * ```js
9 | * categories {
10 | * id
11 | * name
12 | * articles {
13 | * id
14 | * title
15 | * owner {
16 | * firstname
17 | * lastname
18 | * }
19 | * comments {
20 | * id
21 | * body
22 | * }
23 | * }
24 | * }
25 | */
26 |
27 | const getCategoryInfo: GraphQLResolveInfo | any = {
28 | fieldName: 'categories',
29 | fieldNodes: [{
30 | alias: undefined,
31 | arguments: [],
32 | directives: [],
33 | kind: 'Field',
34 | selectionSet: {
35 | kind: 'SelectionSet',
36 | selections: [{
37 | alias: undefined,
38 | arguments: [],
39 | directives: [],
40 | kind: 'Field',
41 | name: {
42 | kind: 'Name',
43 | value: 'id',
44 | },
45 | selectionSet: undefined,
46 | },
47 | {
48 | alias: undefined,
49 | arguments: [],
50 | directives: [],
51 | kind: 'Field',
52 | name: {
53 | kind: 'Name',
54 | value: 'name',
55 | },
56 | selectionSet: undefined,
57 | },
58 | {
59 | alias: undefined,
60 | arguments: [],
61 | directives: [],
62 | kind: 'Field',
63 | name: {
64 | kind: 'Name',
65 | value: 'articles',
66 | },
67 | selectionSet: {
68 | kind: 'SelectionSet',
69 | selections: [{
70 | arguments: [],
71 | kind: 'Field',
72 | name: {
73 | kind: 'Name',
74 | value: 'id',
75 | },
76 | selectionSet: undefined,
77 | },
78 | {
79 | arguments: [],
80 | kind: 'Field',
81 | name: {
82 | kind: 'Name',
83 | value: 'title',
84 | },
85 | selectionSet: undefined,
86 | },
87 | {
88 | arguments: [],
89 | kind: 'Field',
90 | name: {
91 | kind: 'Name',
92 | value: 'owner',
93 | },
94 | selectionSet: {
95 | kind: 'SelectionSet',
96 | selections: [{
97 | arguments: [],
98 | kind: 'Field',
99 | name: {
100 | kind: 'Name',
101 | value: 'firstname',
102 | },
103 | selectionSet: undefined,
104 | },
105 | {
106 | arguments: [],
107 | kind: 'Field',
108 | name: {
109 | kind: 'Name',
110 | value: 'lastname',
111 | },
112 | selectionSet: undefined,
113 | }
114 | ],
115 | },
116 | },
117 | {
118 | arguments: [],
119 | kind: 'Field',
120 | name: {
121 | kind: 'Name',
122 | value: 'comments',
123 | },
124 | selectionSet: {
125 | kind: 'SelectionSet',
126 | selections: [{
127 | arguments: [],
128 | kind: 'Field',
129 | name: {
130 | kind: 'Name',
131 | value: 'id',
132 | },
133 | selectionSet: undefined,
134 | },
135 | {
136 | arguments: [],
137 | kind: 'Field',
138 | name: {
139 | kind: 'Name',
140 | value: 'body',
141 | },
142 | selectionSet: undefined,
143 | }
144 | ],
145 | },
146 | }
147 | ],
148 | },
149 | }
150 | ],
151 | },
152 | }],
153 | };
154 |
155 | export default getCategoryInfo;
--------------------------------------------------------------------------------
/src/__mocks__/models/articleModel.ts:
--------------------------------------------------------------------------------
1 | import { Model } from "sequelize/types";
2 |
3 | const articleModel: Model | any = {
4 | rawAttributes: {
5 | authorId: {
6 | type: 'string'
7 | },
8 | body: {
9 | type: 'string'
10 | },
11 | categoryId: {
12 | type: 'integer',
13 | },
14 | description: {
15 | type: 'string'
16 | },
17 | id: {
18 | type: 'integer'
19 | },
20 | slug: {
21 | type: 'string'
22 | },
23 | title: {
24 | type: 'string'
25 | },
26 | }
27 | };
28 |
29 | export default articleModel;
30 |
--------------------------------------------------------------------------------
/src/__mocks__/models/categoryModel.ts:
--------------------------------------------------------------------------------
1 | import { Model } from "sequelize/types";
2 |
3 | const categoryModel: Model | any = {
4 | rawAttributes: {
5 | id: {
6 | type: 'integer'
7 | },
8 | name: {
9 | type: 'string'
10 | },
11 | }
12 | };
13 |
14 | export default categoryModel;
15 |
--------------------------------------------------------------------------------
/src/__mocks__/models/commentModel.ts:
--------------------------------------------------------------------------------
1 | import { Model } from "sequelize/types";
2 |
3 | const commentModel: Model | any = {
4 | rawAttributes: {
5 | articleId: {
6 | type: 'integer'
7 | },
8 | body: {
9 | type: 'string'
10 | },
11 | id: {
12 | type: 'integer'
13 | },
14 | }
15 | };
16 |
17 | export default commentModel;
18 |
--------------------------------------------------------------------------------
/src/__mocks__/models/userModel.ts:
--------------------------------------------------------------------------------
1 | import { Model } from "sequelize/types";
2 |
3 | const userModel: Model | any = {
4 | rawAttributes: {
5 | firstname: {
6 | type: 'string'
7 | },
8 | id: {
9 | type: 'integer'
10 | },
11 | lastname: {
12 | type: 'string'
13 | },
14 | }
15 | };
16 |
17 | export default userModel;
18 |
--------------------------------------------------------------------------------
/src/__tests__/queryLoader.spec.ts:
--------------------------------------------------------------------------------
1 | import { GraphQLResolveInfo } from 'graphql';
2 | import { Op } from 'sequelize';
3 | import getArticlesArgsInfoMock from '../__mocks__/getArticlesArgsInfo';
4 | import getArticlesInfoMock from '../__mocks__/getArticlesInfo';
5 | import getArticlesWithIncludesInfoMock from '../__mocks__/getArticlesWithIncludesInfo';
6 | import getCategoryDeepInfoMock from '../__mocks__/getCategoryDeepInfo';
7 | import getCategoryInfoMock from '../__mocks__/getCategoryInfo';
8 | import articleModelMock from '../__mocks__/models/articleModel';
9 | import commentModelMock from '../__mocks__/models/commentModel';
10 | import userModelMock from '../__mocks__/models/userModel';
11 | import queryLoader from '../index';
12 | import { IWhereConstraints } from '../types';
13 |
14 | const includeModels = {
15 | article: articleModelMock,
16 | articles: articleModelMock,
17 | category: userModelMock,
18 | comments: commentModelMock,
19 | owner: userModelMock,
20 | };
21 |
22 | describe('queryLoader', () => {
23 | describe('queryLoader.init()', () => {
24 | it('initialises loader with include models', () => {
25 | queryLoader.init({ includeModels });
26 | expect(queryLoader.includeModels.article).toEqual(articleModelMock);
27 | expect(typeof queryLoader === 'object').toBe(true);
28 | });
29 |
30 | it('throws an error when include models are not supplied', () => {
31 | try {
32 | (queryLoader as any).init({});
33 | } catch(error) {
34 | expect(error.message).toEqual('Please supply parameter property :includeModels. Check the docs');
35 | }
36 | });
37 | });
38 |
39 | describe('queryLoader.getSelectedAttributes()', () => {
40 | it('returns the selected attributes', () => {
41 | const info = getArticlesInfoMock as GraphQLResolveInfo
42 | const selectedAttributes = queryLoader.getSelectedAttributes({
43 | model: articleModelMock,
44 | selections: info.fieldNodes[0].selectionSet.selections
45 | });
46 | const expectedAttributes = ['id', 'title']
47 |
48 | expect(selectedAttributes).toEqual(expectedAttributes);
49 | });
50 | });
51 |
52 | describe('queryLoader.turnArgsToWhere()', () => {
53 | it('returns "undefined" when query lacks the "scope" argument', () => {
54 | const info = getArticlesInfoMock as GraphQLResolveInfo
55 | const whereConstraints = queryLoader.turnArgsToWhere(info.fieldNodes[0].arguments);
56 | const expectedWhereConstraints = {}
57 | expect(whereConstraints).toEqual(expectedWhereConstraints);
58 | });
59 |
60 | it('returns objects with properties corresponding to query when "scope" argument is provided', () => {
61 | const info = getArticlesArgsInfoMock as GraphQLResolveInfo
62 | const whereConstraints = queryLoader.turnArgsToWhere(info.fieldNodes[0].arguments);
63 | const expectedWhereConstraints: IWhereConstraints = {
64 | id: {
65 | [Op.gt]: '2'
66 | }
67 | }
68 | expect(whereConstraints).toEqual(expectedWhereConstraints);
69 | });
70 | });
71 |
72 | describe('queryLoader.getValidScopeString()', () => {
73 | it('throws error when incorrect parts are supplied', () => {
74 | let scopeString = 'id|gt| ';
75 | try {
76 | queryLoader.getValidScopeString(scopeString);
77 | } catch(error) {
78 | expect(error.message).toEqual(`Incorrect Parts supplied for scope: ${scopeString}`);
79 | }
80 |
81 | scopeString = 'id|gt';
82 | try {
83 | queryLoader.getValidScopeString(scopeString);
84 | } catch(error) {
85 | expect(error.message).toEqual(`Incorrect Parts supplied for scope: ${scopeString}`);
86 | }
87 | });
88 |
89 | it('returns appropriate array value when correct parts are supplied', () => {
90 | const scopeString = 'id|gt|1';
91 | expect(queryLoader.getValidScopeString(scopeString)).toEqual(['id', 'gt', '1']);
92 | });
93 | });
94 |
95 | describe('queryLoader.getSelectedIncludes()', () => {
96 | it('returns an empty array when query has no included models', () => {
97 | const info = getArticlesArgsInfoMock as GraphQLResolveInfo
98 | const includes = queryLoader.getSelectedIncludes({
99 | model: articleModelMock,
100 | selections: info.fieldNodes[0].selectionSet.selections
101 | });
102 | expect(includes).toEqual([]);
103 | });
104 |
105 | it('returns a non-empty array when query has an/some included model(s)', () => {
106 | const info = getArticlesWithIncludesInfoMock as GraphQLResolveInfo
107 | const includes = queryLoader.getSelectedIncludes({
108 | model: articleModelMock,
109 | selections: info.fieldNodes[0].selectionSet.selections
110 | });
111 | expect(includes.length).toEqual(2);
112 | });
113 |
114 | it('handles cases where included models next/include other related models', () => {
115 | const info = getCategoryInfoMock as GraphQLResolveInfo
116 | const includes = queryLoader.getSelectedIncludes({
117 | model: articleModelMock,
118 | selections: info.fieldNodes[0].selectionSet.selections
119 | });
120 | /**
121 | * structures expected
122 | * ```js
123 | * [{
124 | * model: Article,
125 | * as: 'articles',
126 | * attributes: ['id', 'title'],
127 | * required: false,
128 | * include: [{
129 | * model: User,
130 | * as: 'owner',
131 | * attributes: ['firstname', 'lastname'],
132 | * required: false,
133 | * include: []
134 | * },
135 | * {
136 | * model: Comment,
137 | * as: 'comments',
138 | * attributes: ['id', 'body'],
139 | * required: false,
140 | * include: []
141 | * }]
142 | * }]
143 | */
144 | expect(includes.length).toEqual(1);
145 | expect(includes[0].include.length).toEqual(2);
146 | });
147 | });
148 |
149 | describe('queryLoader.getFindOptions()', () => {
150 | it('returns object without "include" property, when graphql query lacks included selections', () => {
151 | const info = getArticlesInfoMock as GraphQLResolveInfo
152 | const options = queryLoader.getFindOptions({
153 | info,
154 | model: articleModelMock,
155 | });
156 | expect(options.include).toEqual(undefined);
157 | });
158 |
159 | it('returns object without "where" when graphql query lacks "scope" argument', () => {
160 | const info = getArticlesInfoMock as GraphQLResolveInfo
161 | const options = queryLoader.getFindOptions({
162 | info,
163 | model: articleModelMock,
164 | });
165 | expect(options.where).toBe(undefined);
166 | });
167 |
168 | it('returns object with "include" property, when graphql query has included selections', () => {
169 | const info = getArticlesWithIncludesInfoMock as GraphQLResolveInfo
170 | const options = queryLoader.getFindOptions({
171 | info,
172 | model: articleModelMock,
173 | });
174 | expect(options.include.length).toEqual(2);
175 | });
176 |
177 | it('returns object with "where" property, when graphql query has "scope" argument', () => {
178 | const info = getArticlesArgsInfoMock as GraphQLResolveInfo
179 | const options = queryLoader.getFindOptions({
180 | info,
181 | model: articleModelMock,
182 | });
183 | const expectedWhereConstraints: IWhereConstraints = {
184 | id: {
185 | [Op.gt]: '2'
186 | }
187 | };
188 | expect(options.where).toEqual(expectedWhereConstraints);
189 | });
190 |
191 | it('gets findOptions and whereConstraints for deeper shapes', () => {
192 | const info = getCategoryDeepInfoMock as GraphQLResolveInfo
193 | const options = queryLoader.getFindOptions({
194 | info,
195 | model: articleModelMock,
196 | });
197 |
198 | const expectedStructure = {
199 | attributes: ['id'],
200 | include: [{
201 | as: 'articles',
202 | attributes: ['id', 'title'],
203 | include: [{
204 | as: 'owner',
205 | attributes: ['firstname', 'lastname'],
206 | model: userModelMock,
207 | required: false,
208 | },
209 | {
210 | as: 'comments',
211 | attributes: ['id', 'body'],
212 | model: commentModelMock,
213 | required: false,
214 | }],
215 | model: articleModelMock,
216 | required: false,
217 | where: {
218 | body: {
219 | [Op.like]: '%dummy%'
220 | },
221 | id: {
222 | [Op.gt]: '2'
223 | },
224 | },
225 | }]
226 | }
227 |
228 | expect(options).toEqual(expectedStructure);
229 | });
230 | });
231 | });
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import * as queryLoader from './queryLoader';
2 |
3 | export default queryLoader;
--------------------------------------------------------------------------------
/src/queryLoader.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FieldNode,
3 | SelectionNode,
4 | SelectionSetNode,
5 | StringValueNode,
6 | } from 'graphql';
7 |
8 | import {
9 | FindOptions,
10 | IncludeOptions,
11 | Model as SequelizeModel,
12 | Op,
13 | } from 'sequelize';
14 |
15 | import {
16 | IQueryLoader,
17 | ISequelizeOperators,
18 | IWhereConstraints,
19 | SelectedAttributes,
20 | SelectedIncludes,
21 | } from './types';
22 |
23 |
24 | /**
25 | * Dictionary of available query scope operators
26 | * and their equivalent sequelize operators
27 | */
28 | const sequelizeOperators: ISequelizeOperators = {
29 | eq: Op.eq,
30 | gt: Op.gt,
31 | gte: Op.gte,
32 | like: Op.like,
33 | lt: Op.lt,
34 | lte: Op.lte,
35 | ne: Op.ne,
36 | };
37 |
38 |
39 | const queryLoader: IQueryLoader = {
40 | includeModels: {},
41 |
42 | /**
43 | * Initialise the queryLoader utility
44 | *
45 | * @param options - configuration options used for the initializing utility
46 | * @param options.includeModels - object containing included Models as pairs of `modelName`: `SequelizeModel`
47 | */
48 | init({ includeModels }) {
49 | if(includeModels === undefined) {
50 | throw new Error('Please supply parameter property :includeModels. Check the docs');
51 | }
52 | this.includeModels = includeModels;
53 | },
54 |
55 | /**
56 | * Returns the options that should be supplied to
57 | * a Sequelize `Find` or `FindAll` method call
58 | *
59 | * @remarks
60 | * A GraphQL Query with this structure
61 | * ```js
62 | * categories {
63 | * name
64 | * articles(scope: "id|gt|2") {
65 | * id
66 | * title
67 | * owner {
68 | * id
69 | * lastname
70 | * }
71 | * comments {
72 | * id
73 | * body
74 | * }
75 | * }
76 | * }
77 | * ```
78 | * is converted to a `findOptions` object, in this structure
79 | * forming ONE SINGLE QUERY that will be executed against the database
80 | * with sequelize
81 | * ```js
82 | * {
83 | * attributes: ['name'],
84 | * include: [{
85 | * model: Article,
86 | * as: 'articles',
87 | * attributes: ['id', 'title'],
88 | * required: false,
89 | * where: {
90 | * id: {
91 | * [Symbol(gt)]: '2'
92 | * }
93 | * },
94 | * include: [{
95 | * model: User,
96 | * as: 'owner',
97 | * attributes: ['id', 'lastname'],
98 | * required: false,
99 | * include: []
100 | * },
101 | * {
102 | * model: Comment,
103 | * as: 'comments',
104 | * attributes: ['id', 'body'],
105 | * required: false,
106 | * include: []
107 | * }]
108 | * }]
109 | * }
110 | * ```
111 | * @param model - model
112 | * @param info - the info meta property passed from graphql.
113 | * It has a structure that we can parse or analyse, to determine the attributes to be selected from the database
114 | * as well as the associated models to be included using sequelize include
115 | * @returns the query options to be applied to the Find method call
116 | *
117 | */
118 | getFindOptions({ model, info }): object {
119 | const selections = (info.fieldNodes[0].selectionSet as SelectionSetNode).selections;
120 | const selectedAttributes = this.getSelectedAttributes({ model, selections });
121 | const queryIncludes = this.getSelectedIncludes({ model, selections });
122 |
123 | const findOptions: FindOptions = {
124 | attributes: selectedAttributes,
125 | };
126 |
127 | const selectionArguments = info.fieldNodes[0].arguments || [];
128 | const whereAttributes = this.turnArgsToWhere(selectionArguments);
129 |
130 | if (queryIncludes.length) {
131 | findOptions.include = queryIncludes;
132 | }
133 | if (Object.keys(whereAttributes).length) {
134 | findOptions.where = whereAttributes;
135 | }
136 |
137 | return findOptions;
138 | },
139 |
140 | /**
141 | * Return an array of all the includes to be carried out
142 | * based on the schema sent in the request from graphql
143 | *
144 | * @param selections - an array of the selection nodes for each field in the schema.
145 | * @returns the array that should contain all model and association-model includes
146 | */
147 | prepareIncludes({ selections = [] }): SelectedIncludes {
148 | const includes: SelectedIncludes = [];
149 | const includedModelSelections: SelectionNode[] =
150 | selections.filter((selection) => (selection as FieldNode).selectionSet !== undefined);
151 |
152 | const hasFieldWithSelectionSet = includedModelSelections !== undefined;
153 |
154 | includedModelSelections.forEach((item) => {
155 | const selection = item as FieldNode;
156 | const fieldName: string = selection.name.value;
157 | const includedModel: SequelizeModel | any = this.getIncludeModel(fieldName);
158 | const selectionSet = selection.selectionSet || { selections: undefined };
159 |
160 | const selectedAttributes: SelectedAttributes = this.getSelectedAttributes({
161 | model: includedModel,
162 | selections: selectionSet.selections
163 | });
164 |
165 | let queryIncludes : IncludeOptions[] = [];
166 | if (hasFieldWithSelectionSet) {
167 | const fieldSelectionSet = selection.selectionSet || { selections: undefined };
168 | const currentSelections = fieldSelectionSet.selections;
169 | queryIncludes = this.getSelectedIncludes({ model: includedModel, selections: currentSelections });
170 | }
171 |
172 | const selectionArguments = selection.arguments || [];
173 | const whereAttributes = this.turnArgsToWhere(selectionArguments);
174 |
175 | const includeOption: IncludeOptions = {
176 | as: fieldName,
177 | attributes: selectedAttributes,
178 | model: includedModel,
179 | required: false
180 | };
181 |
182 | if (Object.keys(whereAttributes).length) {
183 | includeOption.where = whereAttributes;
184 | }
185 | if (queryIncludes.length) {
186 | includeOption.include = queryIncludes;
187 | }
188 |
189 | includes.push(includeOption);
190 | });
191 | return includes;
192 | },
193 |
194 | /**
195 | * Return an array of all the includes to be carried out
196 | * based on the schema sent in the request from graphql
197 | *
198 | * @remarks
199 | * This method is called `recursively` to prepare the included models
200 | * for all nodes with nested or associated resource(s)
201 | *
202 | * @param model - a specific model that should be checked for selected includes
203 | * @param selections - an array of the selection nodes for each field in the schema.
204 | * @returns the array that should contain all model and association-model includes
205 | */
206 | getSelectedIncludes({ model, selections = [] }) {
207 | let includes: SelectedIncludes = [];
208 | const includedModelSections = selections.filter(item => (item as FieldNode).selectionSet !== undefined);
209 |
210 | /**
211 | * hasFieldWithSelectionSet is used to assert that the top level resource
212 | * in the selectionSet has a child field which is a model
213 | */
214 | const hasFieldWithSelectionSet = includedModelSections !== undefined;
215 |
216 | if (hasFieldWithSelectionSet) {
217 | includes = this.prepareIncludes({ model, selections });
218 | }
219 | return includes
220 | },
221 |
222 | getIncludeModel(modelKeyName: string) {
223 | return this.includeModels[modelKeyName];
224 | },
225 |
226 | getSelectedAttributes({ model, selections = [] }) {
227 | /**
228 | * Request schema can sometimes have fields that do not exist in the table for the Model requested.
229 | * Here, we get all model attributes and check the request schema for fields that exist as
230 | * attributes for that model.
231 | * Those are the attributes that should be passed to the sequelise "select" query
232 | */
233 |
234 | // Initialise the list of selected attributes
235 | const selectedAttributes: SelectedAttributes = [];
236 |
237 | // Get the field names for the model
238 | const modelAttributes = Object.keys((model).rawAttributes);
239 |
240 | selections.forEach((item) => {
241 | const selection = item as FieldNode;
242 | const fieldName = selection.name.value;
243 | const isModelAttribute = modelAttributes.find(attr => attr === fieldName);
244 | const hasSubSelection = selection.selectionSet !== undefined;
245 |
246 | if (isModelAttribute && !hasSubSelection) {
247 | selectedAttributes.push(fieldName);
248 | }
249 | });
250 |
251 | return selectedAttributes;
252 | },
253 |
254 | turnArgsToWhere(fieldArguments) {
255 | /**
256 | * With any of the included models, the query can have arguments,
257 | * Here we convert the arguments into a data structure which will
258 | * serve as the WHERE property to be supplied to the sequelize query
259 | */
260 |
261 | let whereConstraints: IWhereConstraints = {};
262 | if (fieldArguments.length) {
263 | whereConstraints = this.getWhereConstraints(fieldArguments) as IWhereConstraints;
264 | }
265 | return whereConstraints;
266 | },
267 |
268 | getWhereConstraints(fieldArguments) {
269 | const whereOption: IWhereConstraints = {};
270 | const scopeFieldArgument = fieldArguments.find(arg => arg.name.value === 'scope');
271 |
272 | if(scopeFieldArgument !== undefined) {
273 | const argumentValueNode = scopeFieldArgument.value as StringValueNode;
274 | const argumentString = argumentValueNode.value;
275 | /**
276 | * we split with `&&` because we can multiple constraints
277 | * for example we can the scope argument as a string like the following
278 | * `id|like|%introduction% && published|eq|true`
279 | *
280 | * This would be the case for a GraphQL query like the one below
281 | * ```js
282 | * articles(scope: "id|like|%introduction% && published|eq|true") {
283 | * id
284 | * body
285 | * }
286 | */
287 | const whereComparisons = argumentString.split('&&');
288 |
289 | whereComparisons.forEach((fieldConditionString) => {
290 | const splitString = this.getValidScopeString(fieldConditionString);
291 | const field = splitString[0].trim();
292 | const operation = splitString[1].trim();
293 | const value = splitString[2].trim();
294 | const sequelizeOperator = sequelizeOperators[operation];
295 | whereOption[field] = { [sequelizeOperator]: value };
296 | });
297 | }
298 |
299 | return whereOption;
300 | },
301 |
302 | /**
303 | * Validate the scope argument string to be sure
304 | * there are no errors.
305 | * @param splitString - scope string to be checked
306 | */
307 | getValidScopeString(fieldConditionString) {
308 | const splitString = fieldConditionString.split('|');
309 | if(splitString.length < 3) {
310 | throw Error(`Incorrect Parts supplied for scope: ${fieldConditionString}`);
311 | }
312 | const field = splitString[0].trim();
313 | const operation = splitString[1].trim();
314 | const value = splitString[2].trim();
315 |
316 | if(field === "" || operation === "" || value === "") {
317 | throw Error(`Incorrect Parts supplied for scope: ${fieldConditionString}`);
318 | }
319 | return splitString;
320 | }
321 | }
322 |
323 | export = queryLoader;
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { ArgumentNode, GraphQLResolveInfo, SelectionNode } from 'graphql';
2 | import { FindOptions, IncludeOptions, Model as SequelizeModel } from "sequelize/types";
3 |
4 | /**
5 | * Object containing all loaded models
6 | * from sequelize
7 | *
8 | * eg
9 | * ```js
10 | * {
11 | * Article: SequelizeModel,
12 | * Category: SequelizeModel,
13 | * Comment: SequelizeModel,
14 | * User: SequelizeModel,
15 | * }
16 | */
17 | export interface IIncludeModels {
18 | [modelName: string]: SequelizeModel;
19 | }
20 |
21 | /**
22 | * model fields to be selected
23 | *
24 | * Example
25 | * ```js
26 | * ['id', 'firstname', 'lastname'],
27 | */
28 | export type SelectedAttributes = string[];
29 |
30 | /**
31 | * Array of include options
32 | * for all models to be included
33 | *
34 | * Example
35 | * ```js
36 | * [
37 | * {
38 | * model: User,
39 | * as: 'owner',
40 | * attributes: ['firstname', 'lastname'],
41 | * required: false,
42 | * },
43 | * {
44 | * model: Comment,
45 | * as: 'comments',
46 | * attributes: ['id', 'content'],
47 | * required: false,
48 | * }
49 | * ]
50 | */
51 | export type SelectedIncludes = IncludeOptions[];
52 |
53 | /**
54 | * Object containing the selected fields and the
55 | * where constraints applied on them
56 | *
57 | * Example
58 | * ```js
59 | * { id: { [Symbol(gt)]: '5' } }
60 | */
61 | export interface IWhereConstraints {
62 | [fieldName: string]: {
63 | [OperatorSymbol: string]: string
64 | }
65 | }
66 |
67 | export interface ISequelizeOperators {
68 | [operatorName: string]: symbol
69 | }
70 |
71 | export interface IQueryLoader {
72 |
73 | init: (object: {
74 | includeModels: IIncludeModels,
75 | }) => void;
76 |
77 | includeModels: IIncludeModels;
78 |
79 | getFindOptions: (object: {
80 | model: SequelizeModel,
81 | info: GraphQLResolveInfo
82 | }) => FindOptions;
83 |
84 | getSelectedAttributes: (object: {
85 | model: SequelizeModel | any,
86 | selections: ReadonlyArray | undefined
87 | }) => SelectedAttributes;
88 |
89 | getSelectedIncludes: (object: {
90 | model: SequelizeModel,
91 | selections: ReadonlyArray | undefined
92 | }) => SelectedIncludes;
93 |
94 | prepareIncludes: (object: {
95 | model: SequelizeModel,
96 | selections: ReadonlyArray | undefined
97 | }) => SelectedIncludes;
98 |
99 | getIncludeModel: (fieldName: string) => SequelizeModel
100 |
101 | turnArgsToWhere: (fieldArguments: ReadonlyArray) => IWhereConstraints | {}
102 |
103 | getWhereConstraints: (fieldArguments: ReadonlyArray) => IWhereConstraints | {}
104 |
105 | getValidScopeString: (fieldConditionString: string) => string[]
106 | }
107 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2015",
4 | "module": "commonjs",
5 | "declaration": true,
6 | "outDir": "./lib",
7 | "strict": true,
8 | "watch": true,
9 | },
10 | "include": ["src"],
11 | "exclude": ["node_modules", "**/__tests__/*", "**/__mocks__/*"]
12 | }
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["tslint:recommended", "tslint-config-prettier"]
3 | }
--------------------------------------------------------------------------------