diff --git a/README.md b/README.md index e7f8523..4dde252 100644 --- a/README.md +++ b/README.md @@ -337,4 +337,50 @@ expressApp.get('/graphql', (req, res, next) => { ## setBuilderOptions Allows you to customize **Objection** query builder behavior. For instance, you can pass `{ skipUndefined: true }` as an options argument. So, each time the builder is called, it will be called with **skipUndefined** enabled. -This can be useful when you use [graphql-tools](https://github.com/apollographql/graphql-tools) schema stitching. \ No newline at end of file +This can be useful when you use [graphql-tools](https://github.com/apollographql/graphql-tools) schema stitching. + +## Pagination + +In many cases it is useful to have a total record count to use with pagination. +If you pass `{ paginated: true }` to the `setBuilderOptions` function all list queries will be +structured with a `collection` and a `totalCount` field. For example: +```js +const graphQlSchema = graphQlBuilder() + .model(Movie) + .model(Person) + .model(Review) + .setBuilderOptions({ paginated: true }) + .build(); +``` +allows you to do: +```js +// Execute a GraphQL query. +graphql(graphQlSchema, `{ + movies { + collection(nameLike: "%erminato%", range: [0, 2], orderBy: releaseDate) { + name, + releaseDate, + + actors(gender: Male, ageLte: 100, orderBy: firstName) { + id + firstName, + age + } + + reviews(starsIn: [3, 4, 5], orderByDesc: stars) { + title, + text, + stars, + + reviewer { + firstName + } + } + }, + totalCount +}`).then(result => { + console.log(result.data.movies); +}); +``` +Note the addition of the `collection` and ``totalCount` fields in the +GraphQL query. diff --git a/lib/SchemaBuilder.js b/lib/SchemaBuilder.js index 32ac3af..4aac09f 100644 --- a/lib/SchemaBuilder.js +++ b/lib/SchemaBuilder.js @@ -6,7 +6,7 @@ const graphqlRoot = require('graphql'); const jsonSchemaUtils = require('./jsonSchema'); const defaultArgFactories = require('./argFactories'); -const { GraphQLObjectType, GraphQLSchema, GraphQLList } = graphqlRoot; +const { GraphQLInt, GraphQLObjectType, GraphQLSchema, GraphQLList } = graphqlRoot; // Default arguments that are excluded from the relation arguments. @@ -144,7 +144,9 @@ class SchemaBuilder { const listFieldName = modelData.opt.listFieldName || (`${defaultFieldName}s`); fields[singleFieldName] = this._rootSingleField(modelData); - fields[listFieldName] = this._rootListField(modelData); + fields[listFieldName] = this.builderOptions && this.builderOptions.paginated + ? this._rootPaginatedType(modelData) + : this._rootListField(modelData); }); return fields; @@ -194,6 +196,24 @@ class SchemaBuilder { }; } + _rootPaginatedType(modelData) { + const defaultFieldName = fieldNameForModel(modelData.modelClass); + const listFieldName = modelData.opt.listFieldName || (`${defaultFieldName}s`); + return { + type: new GraphQLObjectType({ + name: `Paginated${_.upperFirst(listFieldName)}Type`, + fields: { + collection: { + type: new GraphQLList(this._typeForModel(modelData)), + }, + totalCount: { type: GraphQLInt }, + }, + }), + args: modelData.args, + resolve: this._middlewareResolver(Object.assign({}, modelData, { withPagination: true })), + }; + } + _rootListField(modelData) { return { type: new GraphQLList(this._typeForModel(modelData)), @@ -265,9 +285,22 @@ class SchemaBuilder { const { modelClass } = modelData; const ast = (data.fieldASTs || data.fieldNodes)[0]; - const eager = this._buildEager(ast, modelClass, data); - const argFilter = this._filterForArgs(ast, modelClass, data.variableValues); - const selectFilter = this._filterForSelects(ast, modelClass, data); + + const clonedAst = Object.assign({}, ast ); + if (modelData.withPagination) { + // unroll the AST for the nested collection so that existing query logic works + const [ innerSelection ] = ast.selectionSet.selections; + const newSelections = innerSelection.selectionSet.selections; + + clonedAst.selectionSet = Object.assign( + {}, + ast.selectionSet, + { selections: newSelections } + ); + } + const eager = this._buildEager(clonedAst, modelClass, data); + const argFilter = this._filterForArgs(clonedAst, modelClass, data.variableValues); + const selectFilter = this._filterForSelects(clonedAst, modelClass, data); const builder = modelClass.query(ctx.knex); if (this.builderOptions && this.builderOptions.skipUndefined) { @@ -294,7 +327,22 @@ class SchemaBuilder { builder.eager(eager.expression, eager.filters); } - return builder.then(toJson); + return builder.then(function (res) { + return new Promise(function(resolve) { + const result = toJson(res); + + if (modelData.withPagination) { + builder.resultSize().then(function(totalCount) { + resolve({ + collection: result, + totalCount + }); + }); + } else { + resolve(result); + } + }) + }); }; } diff --git a/package-lock.json b/package-lock.json index f519d9f..12c86bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "objection-graphql", - "version": "0.4.2", + "version": "0.4.5", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -56,6 +56,7 @@ "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", "dev": true, + "optional": true, "requires": { "kind-of": "^3.0.2", "longest": "^1.0.1", @@ -2489,7 +2490,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", - "dev": true + "dev": true, + "optional": true }, "lru-cache": { "version": "4.1.1", diff --git a/tests/integration.js b/tests/integration.js index afccbe8..6021b37 100644 --- a/tests/integration.js +++ b/tests/integration.js @@ -265,7 +265,7 @@ describe('integration tests', () => { it('`people` field should have all properties defined in the Person model\'s jsonSchema, plus virtual properties', () => graphql(schema, '{ people { age, birthYear, gender, firstName, lastName, parentId, addresses { street, city, zipCode } } }').then((res) => { - console.log(res); + // console.log(res); const { data: { people } } = res; people.sort(sortByFirstName); @@ -855,6 +855,45 @@ describe('integration tests', () => { }); }); + describe('list fields with pagination', () => { + let schema; + + beforeEach(() => { + schema = mainModule + .builder() + .model(session.models.Person, {listFieldName: 'people'}) + .model(session.models.Movie) + .model(session.models.Review) + .setBuilderOptions({ paginated: true }) + .build(); + }); + + it('root should have `totalCount` field', () => graphql(schema, '{ people { collection { firstName }, totalCount } }').then((res) => { + const { data: { people: { totalCount } } } = res; + expect(totalCount).to.eql(4); + })); + + it('root should have `people` field', () => graphql(schema, '{ people { collection { firstName } }}').then((res) => { + const { data: { people: { collection } } } = res; + collection.sort(sortByFirstName); + + expect(collection).to.eql([ + { + firstName: 'Arnold', + }, + { + firstName: 'Gustav', + }, + { + firstName: 'Michael', + }, + { + firstName: 'Some', + }, + ]); + })); + }); + describe('single fields', () => { let schema; @@ -1271,7 +1310,7 @@ describe('integration tests', () => { if (modelClass.needAuth) { // You can define in model somethig like roles and check it here if (!context) { // check your own context property throw new Error('Access denied'); - } + } } return callback(obj, args, context, info); };