From 8e1aea57af5bd87d27ca632b7cc6474552ae6b4a Mon Sep 17 00:00:00 2001 From: Francois Zaninotto Date: Thu, 27 Jul 2017 16:36:52 +0200 Subject: [PATCH 1/6] Make filters work --- package.json | 1 + src/introspection/getFieldsFromEntities.js | 15 ++-- src/introspection/getFilterTypesFromData.js | 70 +++++++++++++++++ .../getFilterTypesFromData.spec.js | 77 +++++++++++++++++++ src/introspection/getSchemaFromData.js | 5 +- src/jsonGraphqlExpress.js | 18 +++-- src/resolver/Query/all.js | 25 +++--- yarn.lock | 13 +++- 8 files changed, 195 insertions(+), 29 deletions(-) create mode 100644 src/introspection/getFilterTypesFromData.js create mode 100644 src/introspection/getFilterTypesFromData.spec.js diff --git a/package.json b/package.json index fb3fe09..abcc0e6 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,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..c70191e --- /dev/null +++ b/src/introspection/getFilterTypesFromData.js @@ -0,0 +1,70 @@ +import { GraphQLInputObjectType, GraphQLString } from 'graphql'; +import getFieldsFromEntities from './getFieldsFromEntities'; +import { getTypeFromKey } from '../nameConverter'; + +/** + * 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 }, + * // 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), + }, + }), + }), + {}, + ); diff --git a/src/introspection/getFilterTypesFromData.spec.js b/src/introspection/getFilterTypesFromData.spec.js new file mode 100644 index 0000000..d2b25e6 --- /dev/null +++ b/src/introspection/getFilterTypesFromData.spec.js @@ -0,0 +1,77 @@ +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'); +}); 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/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/resolver/Query/all.js b/src/resolver/Query/all.js index cc3bf0d..826cd89 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,42 @@ 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().includes(filter.q), ), ); } diff --git a/yarn.lock b/yarn.lock index 5887345..9d6b655 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: @@ -2230,6 +2230,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" @@ -4716,6 +4723,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" From 1010c538b5566835a39ba8d31a818fe0d693f00c Mon Sep 17 00:00:00 2001 From: Francois Zaninotto Date: Sun, 30 Jul 2017 14:00:48 +0200 Subject: [PATCH 2/6] Add superagent to allow functional tests of graphql server --- package.json | 1 + yarn.lock | 44 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index abcc0e6..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": { diff --git a/yarn.lock b/yarn.lock index 9d6b655..da1441d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -3324,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" @@ -3363,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" @@ -3869,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" @@ -4510,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" From 5b29803af064418b01ba0ef64074e6d541927ea6 Mon Sep 17 00:00:00 2001 From: Francois Zaninotto Date: Sun, 30 Jul 2017 14:01:30 +0200 Subject: [PATCH 3/6] Make q fiter case insensitive --- src/jsonGraphqlExpress.spec.js | 160 +++++++++++++++++++++++++++++++++ src/resolver/Query/all.js | 7 +- 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 src/jsonGraphqlExpress.spec.js diff --git a/src/jsonGraphqlExpress.spec.js b/src/jsonGraphqlExpress.spec.js new file mode 100644 index 0000000..884f4b3 --- /dev/null +++ b/src/jsonGraphqlExpress.spec.js @@ -0,0 +1,160 @@ +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: 'Sic Dolor amet', + views: 65, + user_id: 456, + }, + ], + users: [ + { + id: 123, + name: 'John Doe', + }, + { + id: 456, + name: 'Jane Doe', + }, + ], +}; + +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 }], + }, + })); + describe('pagination', () => { + it('does not paginate when page is not set', () => + gqlAgent('{ allPosts(perPage: 1) { id } }').expect({ + data: { + allPosts: [{ id: 1 }, { id: 2 }], + }, + })); + it('uses page to set page number', () => + Promise.all([ + gqlAgent('{ allPosts(page: 0) { id } }').expect({ + data: { + allPosts: [{ id: 1 }, { id: 2 }], + }, + }), + 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 }], + }, + }), + ])); + }); + describe('sort', () => { + it('sorts data using sortField for the field', () => + Promise.all([ + gqlAgent('{ allPosts(sortField: "views") { id } }').expect({ + data: { + allPosts: [{ id: 2 }, { id: 1 }], + }, + }), + gqlAgent('{ allPosts(sortField: "title") { id } }').expect({ + data: { + allPosts: [{ id: 1 }, { 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: 1 }], + }, + }), + gqlAgent( + '{ allPosts(sortField: "views", sortOrder: "desc") { id } }', + ).expect({ + data: { + allPosts: [{ id: 1 }, { id: 2 }], + }, + }), + ])); + }); + describe('filters', () => { + 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: 2 }], + }, + }), + gqlAgent('{ allPosts(filter: { views: 65 }) { id } }').expect({ + data: { + allPosts: [{ id: 2 }], + }, + }), + gqlAgent( + '{ allPosts(filter: { user_id: 456 }) { id } }', + ).expect({ + data: { + allPosts: [{ id: 2 }], + }, + }), + ])); + }); +}); diff --git a/src/resolver/Query/all.js b/src/resolver/Query/all.js index 826cd89..408848f 100644 --- a/src/resolver/Query/all.js +++ b/src/resolver/Query/all.js @@ -51,7 +51,12 @@ export default entityData => ( if (filter.q) { items = items.filter(d => Object.keys(d).some( - key => d[key] && d[key].toString().includes(filter.q), + key => + d[key] && + d[key] + .toString() + .toLowerCase() + .includes(filter.q.toLowerCase()), ), ); } From e84265d98c6598a2c8bd18e431172972d38437dc Mon Sep 17 00:00:00 2001 From: Francois Zaninotto Date: Sun, 30 Jul 2017 14:26:15 +0200 Subject: [PATCH 4/6] add entity route tests --- src/jsonGraphqlExpress.spec.js | 120 ++++++++++++++++++++++++++++----- 1 file changed, 102 insertions(+), 18 deletions(-) diff --git a/src/jsonGraphqlExpress.spec.js b/src/jsonGraphqlExpress.spec.js index 884f4b3..784db1e 100644 --- a/src/jsonGraphqlExpress.spec.js +++ b/src/jsonGraphqlExpress.spec.js @@ -12,21 +12,23 @@ const data = { }, { id: 2, - title: 'Sic Dolor amet', + title: 'Ut enim ad minim veniam', views: 65, user_id: 456, }, - ], - users: [ - { - id: 123, - name: 'John Doe', - }, { - id: 456, - name: 'Jane Doe', + 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; @@ -47,21 +49,21 @@ describe('all* route', () => { it('returns all entities by default', () => gqlAgent('{ allPosts { id } }').expect({ data: { - allPosts: [{ id: 1 }, { id: 2 }], + 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 }], + 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 }], + allPosts: [{ id: 1 }, { id: 2 }, { id: 3 }], }, }), gqlAgent('{ allPosts(page: 1) { id } }').expect({ @@ -82,6 +84,31 @@ describe('all* route', () => { 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', () => { @@ -89,12 +116,12 @@ describe('all* route', () => { Promise.all([ gqlAgent('{ allPosts(sortField: "views") { id } }').expect({ data: { - allPosts: [{ id: 2 }, { id: 1 }], + allPosts: [{ id: 2 }, { id: 3 }, { id: 1 }], }, }), gqlAgent('{ allPosts(sortField: "title") { id } }').expect({ data: { - allPosts: [{ id: 1 }, { id: 2 }], + allPosts: [{ id: 1 }, { id: 3 }, { id: 2 }], }, }), ])); @@ -104,19 +131,19 @@ describe('all* route', () => { '{ allPosts(sortField: "views", sortOrder: "asc") { id } }', ).expect({ data: { - allPosts: [{ id: 2 }, { id: 1 }], + allPosts: [{ id: 2 }, { id: 3 }, { id: 1 }], }, }), gqlAgent( '{ allPosts(sortField: "views", sortOrder: "desc") { id } }', ).expect({ data: { - allPosts: [{ id: 1 }, { id: 2 }], + allPosts: [{ id: 1 }, { id: 3 }, { id: 2 }], }, }), ])); }); - describe('filters', () => { + describe('filter', () => { it('filters by string on all text fields using the q filter', () => gqlAgent('{ allPosts(filter: { q: "Lorem" }) { id } }').expect({ data: { @@ -140,7 +167,7 @@ describe('all* route', () => { '{ allPosts(filter: { title: "Sic Dolor amet" }) { id } }', ).expect({ data: { - allPosts: [{ id: 2 }], + allPosts: [{ id: 3 }], }, }), gqlAgent('{ allPosts(filter: { views: 65 }) { id } }').expect({ @@ -158,3 +185,60 @@ describe('all* route', () => { ])); }); }); + +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' }], + }, + }, + }), + ])); +}); From 73862fb0cc41bf135056d21c0b7595b7327d094f Mon Sep 17 00:00:00 2001 From: Francois Zaninotto Date: Sun, 30 Jul 2017 15:26:36 +0200 Subject: [PATCH 5/6] Add range filters --- src/introspection/getFilterTypesFromData.js | 32 ++++++- .../getFilterTypesFromData.spec.js | 15 ++++ src/introspection/getSchemaFromData.spec.js | 84 +++++-------------- src/jsonGraphqlExpress.spec.js | 31 +++++++ 4 files changed, 97 insertions(+), 65 deletions(-) diff --git a/src/introspection/getFilterTypesFromData.js b/src/introspection/getFilterTypesFromData.js index c70191e..c43ad8e 100644 --- a/src/introspection/getFilterTypesFromData.js +++ b/src/introspection/getFilterTypesFromData.js @@ -1,7 +1,32 @@ -import { GraphQLInputObjectType, GraphQLString } from 'graphql'; +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 * @@ -41,6 +66,10 @@ import { getTypeFromKey } from '../nameConverter'; * // 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 }, * // } * // }), @@ -63,6 +92,7 @@ export default data => 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 index d2b25e6..6254424 100644 --- a/src/introspection/getFilterTypesFromData.spec.js +++ b/src/introspection/getFilterTypesFromData.spec.js @@ -75,3 +75,18 @@ test('creates one q field per entity field', () => { 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.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.spec.js b/src/jsonGraphqlExpress.spec.js index 784db1e..eab5063 100644 --- a/src/jsonGraphqlExpress.spec.js +++ b/src/jsonGraphqlExpress.spec.js @@ -183,6 +183,37 @@ describe('all* route', () => { }, }), ])); + 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 }], + }, + }), + ])); }); }); From 59387b9432baf9f4feada62dc66c83c8eeccceeb Mon Sep 17 00:00:00 2001 From: Francois Zaninotto Date: Sun, 30 Jul 2017 15:54:28 +0200 Subject: [PATCH 6/6] Document filters --- README.md | 240 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 218 insertions(+), 22 deletions(-) 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)