diff --git a/README.md b/README.md index 3039ef0..a98a3fa 100644 --- a/README.md +++ b/README.md @@ -96,29 +96,46 @@ npm install -g json-graphql-server Based on your data, json-graphql-server will generate a schema with one type per entity, as well as 3 query types and 3 mutation types. For instance for the `Post` entity: ```graphql -type Post { - id: ID! - title: String! - views: Int - user_id: ID - User: User - Comments: [Comment] -} type Query { Post(id: ID!): Post - allPosts(page: Int, perPage: Int, sortField: String, sortOrder: String, filter: String): [Customer] - _allPostsMeta(page: Int, perPage: Int, sortField: String, sortOrder: String, filter: String): ListMetadata + allPosts(page: Int, perPage: Int, sortField: String, sortOrder: String, filter: PostFilter): [Post] + _allPostsMeta(page: Int, perPage: Int, sortField: String, sortOrder: String, filter: PostFilter): ListMetadata } type Mutation { createPost(data: String): Post updatePost(data: String): Post removePost(id: ID!): Boolean } +type Post { + id: ID! + title: String! + views: Int! + user_id: ID! + User: User + Comments: [Comment] +} +type PostFilter { + q: String + id: ID + title: String + views: Int + views_lt: Int + views_lte: Int + views_gt: Int + views_gte: Int + user_id: ID +} type ListMetadata { count: Int! } ``` +By convention, json-graphql-server expects all entities to have an `id` field that is unique for their type - it's the entity primary key. The type of every field is inferred from the values, so for instance, `Post.title` is a `String!`, and `Post.views` is an `Int!`. When all entities have a value for a field, json-graphql-server makes the field type non nullable (that's why `Post.views` type is `Int!` and not `Int`). + +For every field named `*_id`, json-graphql-server creates a two-way relationship, to let you fetch related entities from both sides. For instance, the presence of the `user_id` field in the `posts` entity leads to the ability to fetch the related `User` for a `Post` - and the related `Posts` for a `User`. + +The `all*` queries accept parameters to let you sort, paginate, and filter the list of results. You can filter by any field, not just the primary key. For instance, you can get the posts written by user `123`. Json-graphql-server also adds a full-text query field named `q`, and created range filter fields for numeric fields. The detail of all available filters can be seen in the generated `*Filter` type. + ## GraphQL Usage Here is how you can use the queries and mutations generated for your data, using `Post` as an example: @@ -131,6 +148,94 @@ Here is how you can use the queries and mutations generated for your data, using
+// get a single entity, by id
+{
+  Post(id: 1) {
+    id
+    title
+    views
+    user_id
+  }
+}
+            
+ + +
+{
+  "data": {
+    "Post": {
+        "id": 1,
+        "title": "Lorem Ipsum",
+        "views": 254,
+        "user_id": 123
+    } 
+  }
+}
+            
+ + + + +
+// include many-to-one relationships
+{
+  Post(id: 1) {
+    title
+    User {
+        name
+    }
+  }
+}
+            
+ + +
+{
+  "data": {
+    "Post": {
+        "title": "Lorem Ipsum",
+        "User": {
+            "name": "John Doe"
+        }
+    } 
+  }
+}
+            
+ + + + +
+// include one-to-many relationships
+{
+  Post(id: 1) {
+    title
+    Comments {
+        body
+    }
+  }
+}
+            
+ + +
+{
+  "data": {
+    "Post": {
+        "title": "Lorem Ipsum",
+        "Comments": [
+            { "body": "Consectetur adipiscing elit" },
+            { "body": "Nam molestie pellentesque dui" },
+        ]
+    } 
+  }
+}
+            
+ + + + +
 // get a list of entities for a type
 {
   allPosts {
@@ -156,13 +261,11 @@ Here is how you can use the queries and mutations generated for your data, using
     
         
             
-// get a single entity, by id
+// paginate the results
 {
-  Post(id: 1) {
-    id
+  allPosts(page: 0, perPage: 1) {
     title
     views
-    user_id
   }
 }
             
@@ -171,12 +274,107 @@ Here is how you can use the queries and mutations generated for your data, using
 {
   "data": {
-    "Post": {
-        "id": 1,
-        "title": "Lorem Ipsum",
-        "views": 254,
-        "user_id": 123
-    } 
+    "allPosts": [
+      { "title": "Lorem Ipsum", views: 254 },
+    ]
+  }
+}
+            
+ + + + +
+// sort the results by field
+{
+  allPosts(sortField: "title", sortOrder: "desc") {
+    title
+    views
+  }
+}
+            
+ + +
+{
+  "data": {
+    "allPosts": [
+      { "title": "Sic Dolor amet", views: 65 }
+      { "title": "Lorem Ipsum", views: 254 },
+    ]
+  }
+}
+            
+ + + + +
+// filter the results using the full-text filter
+{
+  allPosts({ filter: { q: "lorem" }}) {
+    title
+    views
+  }
+}
+            
+ + +
+{
+  "data": {
+    "allPosts": [
+      { "title": "Lorem Ipsum", views: 254 },
+    ]
+  }
+}
+            
+ + + + +
+// filter the result using any of the entity fields
+{
+  allPosts(views: 254) {
+    title
+    views
+  }
+}
+            
+ + +
+{
+  "data": {
+    "allPosts": [
+      { "title": "Lorem Ipsum", views: 254 },
+    ]
+  }
+}
+            
+ + + + +
+// number fields get range filters
+// -lt, _lte, -gt, and _gte
+{
+  allPosts(views_gte: 200) {
+    title
+    views
+  }
+}
+            
+ + +
+{
+  "data": {
+    "allPosts": [
+      { "title": "Lorem Ipsum", views: 254 },
+    ]
   }
 }
             
@@ -184,7 +382,6 @@ Here is how you can use the queries and mutations generated for your data, using - ## Usage with Node Install the module locally @@ -234,7 +431,6 @@ Deploy with Heroku or Next.js. ## Roadmap -* Filtering in the `all*` queries * Client-side mocking (à la [FakeRest](https://github.com/marmelab/FakeRest)) * CLI options (port, https, watch, delay, custom schema) diff --git a/package.json b/package.json index fb3fe09..3ac3a37 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "rollup-plugin-node-globals": "~1.1.0", "rollup-plugin-node-resolve": "~3.0.0", "rollup-watch": "~4.0.0", + "supertest": "^3.0.0", "webpack": "~3.2.0" }, "dependencies": { @@ -66,6 +67,7 @@ "express": "~4.15.3", "express-graphql": "^0.6.6", "graphql": "~0.10.3", + "graphql-errors": "^2.1.0", "graphql-tag": "~2.0.0", "graphql-tools": "~1.1.0", "inflection": "~1.12.0", diff --git a/src/introspection/getFieldsFromEntities.js b/src/introspection/getFieldsFromEntities.js index 7245568..45b4be4 100644 --- a/src/introspection/getFieldsFromEntities.js +++ b/src/introspection/getFieldsFromEntities.js @@ -15,19 +15,18 @@ import getValuesFromEntities from './getValuesFromEntities'; * { * "id": 2, * "title": "Sic Dolor amet", - * "views": 65, * "user_id": 456, * }, * ]; * const types = getFieldsFromEntities(entities); * // { - * // id: { type: graphql.GraphQLString }, - * // title: { type: graphql.GraphQLString }, - * // views: { type: graphql.GraphQLInt }, - * // user_id: { type: graphql.GraphQLString }, + * // id: { type: new GraphQLNonNull(GraphQLString) }, + * // title: { type: new GraphQLNonNull(GraphQLString) }, + * // views: { type: GraphQLInt }, + * // user_id: { type: new GraphQLNonNull(GraphQLString) }, * // }; */ -export default entities => { +export default (entities, checkRequired = true) => { const fieldValues = getValuesFromEntities(entities); const nbValues = entities.length; return Object.keys(fieldValues).reduce((fields, fieldName) => { @@ -35,7 +34,9 @@ export default entities => { type: getTypeFromValues( fieldName, fieldValues[fieldName], - fieldValues[fieldName].length === nbValues, + checkRequired + ? fieldValues[fieldName].length === nbValues + : false, ), }; return fields; diff --git a/src/introspection/getFilterTypesFromData.js b/src/introspection/getFilterTypesFromData.js new file mode 100644 index 0000000..c43ad8e --- /dev/null +++ b/src/introspection/getFilterTypesFromData.js @@ -0,0 +1,100 @@ +import { + GraphQLInputObjectType, + GraphQLString, + GraphQLInt, + GraphQLFloat, +} from 'graphql'; +import getFieldsFromEntities from './getFieldsFromEntities'; +import getValuesFromEntities from './getValuesFromEntities'; +import getTypeFromValues from './getTypeFromValues'; +import { getTypeFromKey } from '../nameConverter'; + +const getNumberFiltersFromEntities = entities => { + const fieldValues = getValuesFromEntities(entities); + return Object.keys(fieldValues).reduce((fields, fieldName) => { + const fieldType = getTypeFromValues( + fieldName, + fieldValues[fieldName], + false, + ); + if (fieldType == GraphQLInt || fieldType == GraphQLFloat) { + fields[`${fieldName}_lt`] = { type: fieldType }; + fields[`${fieldName}_lte`] = { type: fieldType }; + fields[`${fieldName}_gt`] = { type: fieldType }; + fields[`${fieldName}_gte`] = { type: fieldType }; + } + return fields; + }, {}); +}; + +/** + * Get a list of GraphQLObjectType for filtering data + * + * @example + * const data = { + * "posts": [ + * { + * "id": 1, + * "title": "Lorem Ipsum", + * "views": 254, + * "user_id": 123, + * }, + * { + * "id": 2, + * "title": "Sic Dolor amet", + * "views": 65, + * "user_id": 456, + * }, + * ], + * "users": [ + * { + * "id": 123, + * "name": "John Doe" + * }, + * { + * "id": 456, + * "name": "Jane Doe" + * } + * ], + * }; + * const types = getFilterTypesFromData(data); + * // { + * // posts: new GraphQLInputObjectType({ + * // name: "PostFilter", + * // fields: { + * // q: { type: GraphQLString }, + * // id: { type: GraphQLString }, + * // title: { type: GraphQLString }, + * // views: { type: GraphQLInt }, + * // views_lt: { type: GraphQLInt }, + * // views_lte: { type: GraphQLInt }, + * // views_gt: { type: GraphQLInt }, + * // views_gte: { type: GraphQLInt }, + * // user_id: { type: GraphQLString }, + * // } + * // }), + * // users: new GraphQLObjectType({ + * // name: "UserFilter", + * // fields: { + * // q: { type: GraphQLString }, + * // id: { type: GraphQLString }, + * // name: { type: GraphQLString }, + * // } + * // }), + * // } + */ +export default data => + Object.keys(data).reduce( + (types, key) => ({ + ...types, + [getTypeFromKey(key)]: new GraphQLInputObjectType({ + name: `${getTypeFromKey(key)}Filter`, + fields: { + q: { type: GraphQLString }, + ...getFieldsFromEntities(data[key], false), + ...getNumberFiltersFromEntities(data[key]), + }, + }), + }), + {}, + ); diff --git a/src/introspection/getFilterTypesFromData.spec.js b/src/introspection/getFilterTypesFromData.spec.js new file mode 100644 index 0000000..6254424 --- /dev/null +++ b/src/introspection/getFilterTypesFromData.spec.js @@ -0,0 +1,92 @@ +import getFilterTypesFromData from './getFilterTypesFromData'; + +const data = { + posts: [ + { + id: 1, + title: 'Lorem Ipsum', + views: 254, + user_id: 123, + }, + { + id: 2, + title: 'Sic Dolor amet', + views: 65, + user_id: 456, + }, + ], + users: [ + { + id: 123, + name: 'John Doe', + }, + { + id: 456, + name: 'Jane Doe', + }, + ], +}; + +/* +const PostType = new GraphQLObjectType({ + name: 'PostFilter', + fields: { + q: { type: GraphQLString }, + id: { type: GraphQLID }, + title: { type: GraphQLString }, + views: { type: GraphQLInt }, + user_id: { type: GraphQLID }, + }, +}); +const UsersType = new GraphQLObjectType({ + name: 'UserFilter', + fields: { + q: { type: GraphQLString }, + id: { type: GraphQLID }, + name: { type: GraphQLString }, + }, +}); +*/ + +test('creates one filter type per entity', () => { + const filterTypes = getFilterTypesFromData(data); + expect(Object.values(filterTypes).map(type => type.toString())).toEqual([ + 'PostFilter', + 'UserFilter', + ]); +}); + +test('creates one filter field per entity field', () => { + const filterTypes = getFilterTypesFromData(data); + const PostFilterFields = filterTypes.Post.getFields(); + expect(PostFilterFields.id.type.toString()).toEqual('ID'); + expect(PostFilterFields.title.type.toString()).toEqual('String'); + expect(PostFilterFields.views.type.toString()).toEqual('Int'); + expect(PostFilterFields.user_id.type.toString()).toEqual('ID'); + const CommentFilterFields = filterTypes.User.getFields(); + expect(CommentFilterFields.id.type.toString()).toEqual('ID'); + expect(CommentFilterFields.name.type.toString()).toEqual('String'); +}); + +test('creates one q field per entity field', () => { + const filterTypes = getFilterTypesFromData(data); + const PostFilterFields = filterTypes.Post.getFields(); + expect(PostFilterFields.q.type.toString()).toEqual('String'); + const CommentFilterFields = filterTypes.User.getFields(); + expect(CommentFilterFields.q.type.toString()).toEqual('String'); +}); + +test('creates 4 fields for number field for range filters', () => { + const filterTypes = getFilterTypesFromData(data); + const PostFilterFields = filterTypes.Post.getFields(); + expect(PostFilterFields.views_lt.type.toString()).toEqual('Int'); + expect(PostFilterFields.views_lte.type.toString()).toEqual('Int'); + expect(PostFilterFields.views_gt.type.toString()).toEqual('Int'); + expect(PostFilterFields.views_gte.type.toString()).toEqual('Int'); +}); + +test('does not create vomparison fiels for non-number fields', () => { + const filterTypes = getFilterTypesFromData(data); + const PostFilterFields = filterTypes.Post.getFields(); + expect(PostFilterFields.title_lte).toBeUndefined(); +}); diff --git a/src/introspection/getSchemaFromData.js b/src/introspection/getSchemaFromData.js index cb878ca..643d345 100644 --- a/src/introspection/getSchemaFromData.js +++ b/src/introspection/getSchemaFromData.js @@ -13,6 +13,7 @@ import { import { pluralize, camelize } from 'inflection'; import getTypesFromData from './getTypesFromData'; +import getFilterTypesFromData from './getFilterTypesFromData'; import { isRelationshipField } from '../relationships'; import { getRelatedType } from '../nameConverter'; @@ -82,6 +83,8 @@ export default data => { return types; }, {}); + const filterTypesByName = getFilterTypesFromData(data); + const listMetadataType = new GraphQLObjectType({ name: 'ListMetadata', fields: { @@ -105,7 +108,7 @@ export default data => { perPage: { type: GraphQLInt }, sortField: { type: GraphQLString }, sortOrder: { type: GraphQLString }, - filter: { type: GraphQLString }, + filter: { type: filterTypesByName[type.name] }, }, }; fields[`_all${camelize(pluralize(type.name))}Meta`] = { diff --git a/src/introspection/getSchemaFromData.spec.js b/src/introspection/getSchemaFromData.spec.js index 0c46562..b98acde 100644 --- a/src/introspection/getSchemaFromData.spec.js +++ b/src/introspection/getSchemaFromData.spec.js @@ -137,38 +137,16 @@ test('creates three query fields per data type', () => { }, ]); expect(queries['allPosts'].type.toString()).toEqual('[Post]'); - expect(queries['allPosts'].args).toEqual([ - { - defaultValue: undefined, - description: null, - name: 'page', - type: GraphQLInt, - }, - { - defaultValue: undefined, - description: null, - name: 'perPage', - type: GraphQLInt, - }, - { - defaultValue: undefined, - description: null, - name: 'sortField', - type: GraphQLString, - }, - { - defaultValue: undefined, - description: null, - name: 'sortOrder', - type: GraphQLString, - }, - { - defaultValue: undefined, - description: null, - name: 'filter', - type: GraphQLString, - }, - ]); + expect(queries['allPosts'].args[0].name).toEqual('page'); + expect(queries['allPosts'].args[0].type).toEqual(GraphQLInt); + expect(queries['allPosts'].args[1].name).toEqual('perPage'); + expect(queries['allPosts'].args[1].type).toEqual(GraphQLInt); + expect(queries['allPosts'].args[2].name).toEqual('sortField'); + expect(queries['allPosts'].args[2].type).toEqual(GraphQLString); + expect(queries['allPosts'].args[3].name).toEqual('sortOrder'); + expect(queries['allPosts'].args[3].type).toEqual(GraphQLString); + expect(queries['allPosts'].args[4].name).toEqual('filter'); + expect(queries['allPosts'].args[4].type.toString()).toEqual('PostFilter'); expect(queries['_allPostsMeta'].type.toString()).toEqual('ListMetadata'); expect(queries['User'].type.name).toEqual(UserType.name); @@ -181,38 +159,16 @@ test('creates three query fields per data type', () => { }, ]); expect(queries['allUsers'].type.toString()).toEqual('[User]'); - expect(queries['allUsers'].args).toEqual([ - { - defaultValue: undefined, - description: null, - name: 'page', - type: GraphQLInt, - }, - { - defaultValue: undefined, - description: null, - name: 'perPage', - type: GraphQLInt, - }, - { - defaultValue: undefined, - description: null, - name: 'sortField', - type: GraphQLString, - }, - { - defaultValue: undefined, - description: null, - name: 'sortOrder', - type: GraphQLString, - }, - { - defaultValue: undefined, - description: null, - name: 'filter', - type: GraphQLString, - }, - ]); + expect(queries['allUsers'].args[0].name).toEqual('page'); + expect(queries['allUsers'].args[0].type).toEqual(GraphQLInt); + expect(queries['allUsers'].args[1].name).toEqual('perPage'); + expect(queries['allUsers'].args[1].type).toEqual(GraphQLInt); + expect(queries['allUsers'].args[2].name).toEqual('sortField'); + expect(queries['allUsers'].args[2].type).toEqual(GraphQLString); + expect(queries['allUsers'].args[3].name).toEqual('sortOrder'); + expect(queries['allUsers'].args[3].type).toEqual(GraphQLString); + expect(queries['allUsers'].args[4].name).toEqual('filter'); + expect(queries['allUsers'].args[4].type.toString()).toEqual('UserFilter'); expect(queries['_allPostsMeta'].type.toString()).toEqual('ListMetadata'); }); diff --git a/src/jsonGraphqlExpress.js b/src/jsonGraphqlExpress.js index e07d0dc..df5c997 100644 --- a/src/jsonGraphqlExpress.js +++ b/src/jsonGraphqlExpress.js @@ -1,6 +1,7 @@ import graphqlHTTP from 'express-graphql'; import { printSchema } from 'graphql'; -const { makeExecutableSchema } = require('graphql-tools'); +import { maskErrors } from 'graphql-errors'; +import { makeExecutableSchema } from 'graphql-tools'; import getSchemaFromData from './introspection/getSchemaFromData'; import resolver from './resolver'; @@ -48,11 +49,14 @@ import resolver from './resolver'; * * app.listen(PORT); */ -export default data => - graphqlHTTP({ - schema: makeExecutableSchema({ - typeDefs: printSchema(getSchemaFromData(data)), - resolvers: resolver(data), - }), +export default data => { + const schema = makeExecutableSchema({ + typeDefs: printSchema(getSchemaFromData(data)), + resolvers: resolver(data), + }); + maskErrors(schema); + return graphqlHTTP({ + schema, graphiql: true, }); +}; diff --git a/src/jsonGraphqlExpress.spec.js b/src/jsonGraphqlExpress.spec.js new file mode 100644 index 0000000..eab5063 --- /dev/null +++ b/src/jsonGraphqlExpress.spec.js @@ -0,0 +1,275 @@ +import express from 'express'; +import request from 'supertest'; +import jsonGraphqlExpress from './jsonGraphqlExpress'; + +const data = { + posts: [ + { + id: 1, + title: 'Lorem Ipsum', + views: 254, + user_id: 123, + }, + { + id: 2, + title: 'Ut enim ad minim veniam', + views: 65, + user_id: 456, + }, + { + id: 3, + title: 'Sic Dolor amet', + views: 76, + user_id: 123, + }, + ], + users: [{ id: 123, name: 'John Doe' }, { id: 456, name: 'Jane Doe' }], + comments: [ + { id: 987, post_id: 1, body: 'Consectetur adipiscing elit' }, + { id: 995, post_id: 1, body: 'Nam molestie pellentesque dui' }, + { id: 998, post_id: 2, body: 'Sunt in culpa qui officia' }, + ], +}; + +let agent; + +beforeAll(() => { + const app = express(); + app.use('/', jsonGraphqlExpress(data)); + agent = request.agent(app); +}); + +const gqlAgent = (query, variables) => + agent.post('/').send({ + query, + variables, + }); + +describe('all* route', () => { + it('returns all entities by default', () => + gqlAgent('{ allPosts { id } }').expect({ + data: { + allPosts: [{ id: 1 }, { id: 2 }, { id: 3 }], + }, + })); + describe('pagination', () => { + it('does not paginate when page is not set', () => + gqlAgent('{ allPosts(perPage: 1) { id } }').expect({ + data: { + allPosts: [{ id: 1 }, { id: 2 }, { id: 3 }], + }, + })); + it('uses page to set page number', () => + Promise.all([ + gqlAgent('{ allPosts(page: 0) { id } }').expect({ + data: { + allPosts: [{ id: 1 }, { id: 2 }, { id: 3 }], + }, + }), + gqlAgent('{ allPosts(page: 1) { id } }').expect({ + data: { + allPosts: [], + }, + }), + ])); + it('uses perPage to set number of results per page', () => + Promise.all([ + gqlAgent('{ allPosts(page: 0, perPage: 1) { id } }').expect({ + data: { + allPosts: [{ id: 1 }], + }, + }), + gqlAgent('{ allPosts(page: 1, perPage: 1) { id } }').expect({ + data: { + allPosts: [{ id: 2 }], + }, + }), + gqlAgent('{ allPosts(page: 2, perPage: 1) { id } }').expect({ + data: { + allPosts: [{ id: 3 }], + }, + }), + gqlAgent('{ allPosts(page: 3, perPage: 1) { id } }').expect({ + data: { + allPosts: [], + }, + }), + gqlAgent('{ allPosts(page: 0, perPage: 2) { id } }').expect({ + data: { + allPosts: [{ id: 1 }, { id: 2 }], + }, + }), + gqlAgent('{ allPosts(page: 1, perPage: 2) { id } }').expect({ + data: { + allPosts: [{ id: 3 }], + }, + }), + gqlAgent('{ allPosts(page: 2, perPage: 2) { id } }').expect({ + data: { + allPosts: [], + }, + }), + ])); + }); + describe('sort', () => { + it('sorts data using sortField for the field', () => + Promise.all([ + gqlAgent('{ allPosts(sortField: "views") { id } }').expect({ + data: { + allPosts: [{ id: 2 }, { id: 3 }, { id: 1 }], + }, + }), + gqlAgent('{ allPosts(sortField: "title") { id } }').expect({ + data: { + allPosts: [{ id: 1 }, { id: 3 }, { id: 2 }], + }, + }), + ])); + it('sorts data using sortOrder for the sort direction', () => + Promise.all([ + gqlAgent( + '{ allPosts(sortField: "views", sortOrder: "asc") { id } }', + ).expect({ + data: { + allPosts: [{ id: 2 }, { id: 3 }, { id: 1 }], + }, + }), + gqlAgent( + '{ allPosts(sortField: "views", sortOrder: "desc") { id } }', + ).expect({ + data: { + allPosts: [{ id: 1 }, { id: 3 }, { id: 2 }], + }, + }), + ])); + }); + describe('filter', () => { + it('filters by string on all text fields using the q filter', () => + gqlAgent('{ allPosts(filter: { q: "Lorem" }) { id } }').expect({ + data: { + allPosts: [{ id: 1 }], + }, + })); + it('filters by string using the q filter in a case-insensitive way', () => + gqlAgent('{ allPosts(filter: { q: "lorem" }) { id } }').expect({ + data: { + allPosts: [{ id: 1 }], + }, + })); + it('filters by value on each field using the related filter', () => + Promise.all([ + gqlAgent('{ allPosts(filter: { id: 2 }) { id } }').expect({ + data: { + allPosts: [{ id: 2 }], + }, + }), + gqlAgent( + '{ allPosts(filter: { title: "Sic Dolor amet" }) { id } }', + ).expect({ + data: { + allPosts: [{ id: 3 }], + }, + }), + gqlAgent('{ allPosts(filter: { views: 65 }) { id } }').expect({ + data: { + allPosts: [{ id: 2 }], + }, + }), + gqlAgent( + '{ allPosts(filter: { user_id: 456 }) { id } }', + ).expect({ + data: { + allPosts: [{ id: 2 }], + }, + }), + ])); + it('filters by value range on each integer field using the related filters', () => + Promise.all([ + gqlAgent( + '{ allPosts(filter: { views_lt: 76 }) { id } }', + ).expect({ + data: { + allPosts: [{ id: 2 }], + }, + }), + gqlAgent( + '{ allPosts(filter: { views_lte: 76 }) { id } }', + ).expect({ + data: { + allPosts: [{ id: 2 }, { id: 3 }], + }, + }), + gqlAgent( + '{ allPosts(filter: { views_gt: 76 }) { id } }', + ).expect({ + data: { + allPosts: [{ id: 1 }], + }, + }), + gqlAgent( + '{ allPosts(filter: { views_gte: 76 }) { id } }', + ).expect({ + data: { + allPosts: [{ id: 1 }, { id: 3 }], + }, + }), + ])); + }); +}); + +describe('Entity route', () => { + it('gets an entity by id', () => + Promise.all([ + gqlAgent('{ Post(id: 1) { id } }').expect({ + data: { + Post: { id: 1 }, + }, + }), + gqlAgent('{ Post(id: 2) { id } }').expect({ + data: { + Post: { id: 2 }, + }, + }), + ])); + it('gets all the entity fields', () => + gqlAgent('{ Post(id: 1) { id title views user_id } }').expect({ + data: { + Post: { id: 1, title: 'Lorem Ipsum', views: 254, user_id: 123 }, + }, + })); + it('throws an error when asked for a non existent field', () => + gqlAgent('{ Post(id: 1) { foo } }').expect({ + errors: [ + { + message: 'Cannot query field "foo" on type "Post".', + locations: [{ line: 1, column: 17 }], + }, + ], + })); + it('gets one to many relationship fields', () => + gqlAgent('{ Post(id: 1) { User { name } } }').expect({ + data: { + Post: { User: { name: 'John Doe' } }, + }, + })); + it('gets many to one relationship fields', () => + Promise.all([ + gqlAgent('{ Post(id: 1) { Comments { body } } }').expect({ + data: { + Post: { + Comments: [ + { body: 'Consectetur adipiscing elit' }, + { body: 'Nam molestie pellentesque dui' }, + ], + }, + }, + }), + gqlAgent('{ Post(id: 2) { Comments { body } } }').expect({ + data: { + Post: { + Comments: [{ body: 'Sunt in culpa qui officia' }], + }, + }, + }), + ])); +}); diff --git a/src/resolver/Query/all.js b/src/resolver/Query/all.js index cc3bf0d..408848f 100644 --- a/src/resolver/Query/all.js +++ b/src/resolver/Query/all.js @@ -1,8 +1,7 @@ export default entityData => ( _, - { sortField, sortOrder = 'asc', page, perPage = 25, filter = '{}' }, + { sortField, sortOrder = 'asc', page, perPage = 25, filter = {} }, ) => { - const filters = JSON.parse(filter); let items = [...entityData]; if (sortField) { @@ -17,42 +16,47 @@ export default entityData => ( return 0; }); } - if (filters.ids) { - items = items.filter(d => filters.ids.includes(d.id.toString())); + if (filter.ids) { + items = items.filter(d => filter.ids.includes(d.id.toString())); } else { - Object.keys(filters).filter(key => key !== 'q').forEach(key => { + Object.keys(filter).filter(key => key !== 'q').forEach(key => { if (key.indexOf('_lte') !== -1) { // less than or equal const realKey = key.replace(/(_lte)$/, ''); - items = items.filter(d => d[realKey] <= filters[key]); + items = items.filter(d => d[realKey] <= filter[key]); return; } if (key.indexOf('_gte') !== -1) { // less than or equal const realKey = key.replace(/(_gte)$/, ''); - items = items.filter(d => d[realKey] >= filters[key]); + items = items.filter(d => d[realKey] >= filter[key]); return; } if (key.indexOf('_lt') !== -1) { // less than or equal const realKey = key.replace(/(_lt)$/, ''); - items = items.filter(d => d[realKey] < filters[key]); + items = items.filter(d => d[realKey] < filter[key]); return; } if (key.indexOf('_gt') !== -1) { // less than or equal const realKey = key.replace(/(_gt)$/, ''); - items = items.filter(d => d[realKey] > filters[key]); + items = items.filter(d => d[realKey] > filter[key]); return; } - items = items.filter(d => d[key] == filters[key]); + items = items.filter(d => d[key] == filter[key]); }); - if (filters.q) { + if (filter.q) { items = items.filter(d => - Object.keys(d).some(key => - d[key].toString().includes(filters.q), + Object.keys(d).some( + key => + d[key] && + d[key] + .toString() + .toLowerCase() + .includes(filter.q.toLowerCase()), ), ); } diff --git a/yarn.lock b/yarn.lock index 5887345..da1441d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -923,7 +923,7 @@ babel-register@^6.24.1: mkdirp "^0.5.1" source-map-support "^0.4.2" -babel-runtime@^6.18.0, babel-runtime@^6.22.0: +babel-runtime@^6.18.0, babel-runtime@^6.22.0, babel-runtime@^6.6.1: version "6.23.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.23.0.tgz#0a9489f144de70efb3ce4300accdb329e2fc543b" dependencies: @@ -1299,6 +1299,10 @@ commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" +component-emitter@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1353,6 +1357,10 @@ cookie@0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" +cookiejar@^2.0.6: + version "2.1.1" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.1.tgz#41ad57b1b555951ec171412a81942b1e8200d34a" + core-js@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e" @@ -1943,7 +1951,7 @@ express@~4.15.3: utils-merge "1.0.0" vary "~1.1.1" -extend@~3.0.0: +extend@^3.0.0, extend@~3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" @@ -2094,7 +2102,7 @@ forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" -form-data@~2.1.1: +form-data@^2.1.1, form-data@~2.1.1: version "2.1.4" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.4.tgz#33c183acf193276ecaa98143a69e94bfee1750d1" dependencies: @@ -2102,6 +2110,10 @@ form-data@~2.1.1: combined-stream "^1.0.5" mime-types "^2.1.12" +formidable@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.1.1.tgz#96b8886f7c3c3508b932d6bd70c4d3a88f35f1a9" + forwarded@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363" @@ -2230,6 +2242,13 @@ graphql-anywhere@^3.0.1: version "3.1.0" resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-3.1.0.tgz#3ea0d8e8646b5cee68035016a9a7557c15c21e96" +graphql-errors@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/graphql-errors/-/graphql-errors-2.1.0.tgz#831c8c491b354859ee7a0c07bff101af64731195" + dependencies: + babel-runtime "^6.6.1" + uuid "^2.0.2" + graphql-tag@^2.0.0, graphql-tag@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.0.0.tgz#f3efe3b4d64f33bfe8479ae06a461c9d72f2a6fe" @@ -3317,7 +3336,7 @@ merge@^1.1.3: version "1.2.0" resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.0.tgz#7531e39d4949c281a66b8c5a6e0265e8b05894da" -methods@~1.1.2: +methods@^1.1.1, methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -3356,7 +3375,7 @@ mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.7: dependencies: mime-db "~1.29.0" -mime@1.3.4: +mime@1.3.4, mime@^1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53" @@ -3862,7 +3881,7 @@ punycode@^1.2.4, punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" -qs@6.4.0, qs@~6.4.0: +qs@6.4.0, qs@^6.1.0, qs@~6.4.0: version "6.4.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" @@ -4503,6 +4522,28 @@ strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" +superagent@^3.0.0: + version "3.5.2" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.5.2.tgz#3361a3971567504c351063abeaae0faa23dbf3f8" + dependencies: + component-emitter "^1.2.0" + cookiejar "^2.0.6" + debug "^2.2.0" + extend "^3.0.0" + form-data "^2.1.1" + formidable "^1.1.1" + methods "^1.1.1" + mime "^1.3.4" + qs "^6.1.0" + readable-stream "^2.0.5" + +supertest@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-3.0.0.tgz#8d4bb68fd1830ee07033b1c5a5a9a4021c965296" + dependencies: + methods "~1.1.2" + superagent "^3.0.0" + supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" @@ -4716,6 +4757,10 @@ utils-merge@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.0.tgz#0294fb922bb9375153541c4f7096231f287c8af8" +uuid@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" + uuid@^3.0.0, uuid@^3.0.1: version "3.1.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04"