diff --git a/package.json b/package.json index 5fbbde9..52d44b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apollo-cursor-pagination", - "version": "0.3.0-alpha-3", + "version": "0.3.1", "description": "Relay's Connection implementation for Apollo Server GraphQL library", "main": "dist/index.js", "repository": "https://github.com/Terminal-Systems/apollo-cursor-pagination", diff --git a/src/builder/index.js b/src/builder/index.js index e85c473..388ccb9 100644 --- a/src/builder/index.js +++ b/src/builder/index.js @@ -14,15 +14,19 @@ const applyCursorsToNodes = ( removeNodesBeforeAndIncluding, removeNodesAfterAndIncluding, }, { - orderColumn, ascOrDesc, + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, }, ) => { let nodesAccessor = allNodesAccessor; if (after !== undefined) { - nodesAccessor = removeNodesBeforeAndIncluding(nodesAccessor, after, { orderColumn, ascOrDesc }); + nodesAccessor = removeNodesBeforeAndIncluding(nodesAccessor, after, { + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, + }); } if (before !== undefined) { - nodesAccessor = removeNodesAfterAndIncluding(nodesAccessor, before, { orderColumn, ascOrDesc }); + nodesAccessor = removeNodesAfterAndIncluding(nodesAccessor, before, { + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, + }); } return nodesAccessor; }; @@ -47,7 +51,7 @@ const nodesToReturn = async ( { before, after, first, last, }, { - orderColumn, ascOrDesc, + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, }, ) => { const orderedNodesAccessor = orderNodesBy(allNodesAccessor, orderColumn, ascOrDesc); @@ -58,7 +62,7 @@ const nodesToReturn = async ( removeNodesBeforeAndIncluding, removeNodesAfterAndIncluding, }, { - orderColumn, ascOrDesc, + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, }, ); @@ -90,12 +94,14 @@ const hasPreviousPage = async (allNodesAccessor, }, { before, after, first, last, }, { - orderColumn, ascOrDesc, + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, }) => { if (last) { const nodes = applyCursorsToNodes(allNodesAccessor, { before, after }, { removeNodesBeforeAndIncluding, removeNodesAfterAndIncluding, - }, { orderColumn, ascOrDesc }); + }, { + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, + }); const length = await getNodesLength(nodes); if (length > last) return true; } @@ -118,12 +124,14 @@ const hasNextPage = async (allNodesAccessor, }, { before, after, first, last, }, { - orderColumn, ascOrDesc, + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, }) => { if (first) { const nodes = applyCursorsToNodes(allNodesAccessor, { before, after }, { removeNodesBeforeAndIncluding, removeNodesAfterAndIncluding, - }, { orderColumn, ascOrDesc }); + }, { + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, + }); const length = await getNodesLength(nodes); if (length > first) return true; } @@ -133,18 +141,9 @@ const hasNextPage = async (allNodesAccessor, const totalCount = async (allNodesAccessor, { - removeNodesBeforeAndIncluding, - removeNodesAfterAndIncluding, getNodesLength, - }, { - before, after, - }, { - orderColumn, ascOrDesc, }) => { - const nodes = applyCursorsToNodes(allNodesAccessor, { before, after }, { - removeNodesBeforeAndIncluding, removeNodesAfterAndIncluding, - }, { orderColumn, ascOrDesc }); - const length = await getNodesLength(nodes); + const length = await getNodesLength(allNodesAccessor); return length; }; @@ -167,6 +166,7 @@ const apolloCursorPaginationBuilder = ({ }, opts = {}, ) => { + const { isAggregateFn, formatColumnFn } = opts; let { orderColumn, ascOrDesc, } = opts; @@ -189,13 +189,13 @@ const apolloCursorPaginationBuilder = ({ }, { before, after, first, last, }, { - orderColumn, ascOrDesc, + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, }, ); const edges = convertNodesToEdges(nodes, { before, after, first, last, }, { - orderColumn, + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, }); return { pageInfo: { @@ -204,22 +204,18 @@ const apolloCursorPaginationBuilder = ({ }, { before, after, first, last, }, { - orderColumn, ascOrDesc, + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, }), hasNextPage: hasNextPage(allNodesAccessor, { removeNodesBeforeAndIncluding, removeNodesAfterAndIncluding, getNodesLength, }, { before, after, first, last, }, { - orderColumn, ascOrDesc, + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, }), }, totalCount: totalCount(allNodesAccessor, { - removeNodesBeforeAndIncluding, removeNodesAfterAndIncluding, getNodesLength, - }, { - before, after, first, last, - }, { - orderColumn, ascOrDesc, + getNodesLength, }), edges, }; diff --git a/src/orm-connectors/knex/custom-pagination.js b/src/orm-connectors/knex/custom-pagination.js index 81c9b13..7c04f6f 100644 --- a/src/orm-connectors/knex/custom-pagination.js +++ b/src/orm-connectors/knex/custom-pagination.js @@ -32,7 +32,15 @@ const getDataFromCursor = (cursor) => { if (data[0] === undefined || data[1] === undefined) { throw new Error(`Could not find edge with cursor ${cursor}`); } - return data; + const values = data[1].split(ARRAY_DATA_SEPARATION_TOKEN).map(v => JSON.parse(v)); + return [data[0], values]; +}; + +const formatColumnIfAvailable = (column, formatColumnFn) => { + if (formatColumnFn) { + return formatColumnFn(column); + } + return column; }; const buildRemoveNodesFromBeforeOrAfer = (beforeOrAfter) => { @@ -40,34 +48,50 @@ const buildRemoveNodesFromBeforeOrAfer = (beforeOrAfter) => { if (beforeOrAfter === 'after') return orderDirection === 'asc' ? '<' : '>'; return orderDirection === 'asc' ? '>' : '<'; }; - return (nodesAccessor, cursorOfInitialNode, { orderColumn, ascOrDesc }) => { + return (nodesAccessor, cursorOfInitialNode, { + orderColumn, ascOrDesc, isAggregateFn, formatColumnFn, + }) => { const data = getDataFromCursor(cursorOfInitialNode); const [id, columnValue] = data; const initialValue = nodesAccessor.clone(); - const result = operateOverScalarOrArray(initialValue, orderColumn, (orderBy, index, prev) => { let orderDirection; - let value; - const values = columnValue.split(ARRAY_DATA_SEPARATION_TOKEN); + const values = columnValue; + let currValue; if (index !== null) { orderDirection = ascOrDesc[index].toLowerCase(); - value = values[index]; + currValue = values[index]; } else { orderDirection = ascOrDesc.toLowerCase(); - value = columnValue; + currValue = values[0]; } const comparator = getComparator(orderDirection); if (index > 0) { - const nested = prev.orWhere(function () { - this.where(orderColumn[index], `${comparator}=`, values[index]) - .andWhere(orderColumn[index - 1], '=', values[index - 1]); - }); + const operation = (isAggregateFn && isAggregateFn(orderColumn[index - 1])) ? 'orHavingRaw' : 'orWhereRaw'; + const nested = prev[operation]( + `(${formatColumnIfAvailable(orderColumn[index - 1], formatColumnFn)} = ? and ${formatColumnIfAvailable(orderBy, formatColumnFn)} ${comparator} ?)`, + [values[index - 1], values[index]], + ); + return nested; } - return prev.where(orderBy, index === null ? `${comparator}=` : comparator, value); - }, prev => prev.whereNot({ id })); + const operation = (isAggregateFn && isAggregateFn(orderBy)) ? 'havingRaw' : 'whereRaw'; + + return prev[operation](`(${formatColumnIfAvailable(orderBy, formatColumnFn)} ${comparator} ?)`, [currValue]); + }, (prev, isArray) => { + // Result is sorted by id as the last column + const comparator = getComparator(ascOrDesc); + const lastOrderColumn = isArray ? orderColumn.pop() : orderColumn; + const lastValue = columnValue.pop(); + const operation = (isAggregateFn && isAggregateFn(lastOrderColumn)) ? 'orHavingRaw' : 'orWhereRaw'; + const nested = prev[operation]( + `(${formatColumnIfAvailable(lastOrderColumn, formatColumnFn)} = ? and ${formatColumnIfAvailable('id', formatColumnFn)} ${comparator} ?)`, + [lastValue, id], + ); + return nested; + }); return result; }; }; @@ -126,7 +150,7 @@ const convertNodesToEdges = (nodes, _, { }) => nodes.map((node) => { const dataValue = operateOverScalarOrArray('', orderColumn, (orderBy, index, prev) => { const nodeValue = node[orderBy]; - const result = `${prev}${index ? ARRAY_DATA_SEPARATION_TOKEN : ''}${nodeValue}`; + const result = `${prev}${index ? ARRAY_DATA_SEPARATION_TOKEN : ''}${JSON.stringify(nodeValue)}`; return result; }); diff --git a/tests/test-app/compiled-dist/index.js b/tests/test-app/compiled-dist/index.js new file mode 100644 index 0000000..fa1d2cb --- /dev/null +++ b/tests/test-app/compiled-dist/index.js @@ -0,0 +1,28 @@ +"use strict"; + +var _apolloServer = require("apollo-server"); + +var _schema = _interopRequireDefault(require("./schema")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +// import createLoaders from './loaders'; +const server = new _apolloServer.ApolloServer({ + schema: _schema.default, + formatError: error => { + console.log(error); + return error; + }, + formatResponse: response => { + console.log(response); + return response; + }, + engine: false, + tracing: true, + cacheControl: true +}); +server.listen().then(({ + url +}) => { + console.log(`🚀 Cats server ready at ${url}`); +}); \ No newline at end of file diff --git a/tests/test-app/compiled-dist/models/Cat.js b/tests/test-app/compiled-dist/models/Cat.js new file mode 100644 index 0000000..9550ae8 --- /dev/null +++ b/tests/test-app/compiled-dist/models/Cat.js @@ -0,0 +1,16 @@ +"use strict"; + +const BaseModel = require('./base-model'); + +const knex = require('./db'); + +BaseModel.knex(knex); + +class Cat extends BaseModel { + static get tableName() { + return 'cats'; + } + +} + +module.exports = Cat; \ No newline at end of file diff --git a/tests/test-app/compiled-dist/models/base-model.js b/tests/test-app/compiled-dist/models/base-model.js new file mode 100644 index 0000000..7a317b8 --- /dev/null +++ b/tests/test-app/compiled-dist/models/base-model.js @@ -0,0 +1,23 @@ +"use strict"; + +const { + Model +} = require('objection'); // models/BaseModel.js + + +class BaseModel extends Model { + static get modelPaths() { + return [__dirname]; + } + + $beforeUpdate() { + this.updatedAt = new Date().toISOString(); + } + + $beforeInsert() { + this.createdAt = new Date().toISOString(); + } + +} + +module.exports = BaseModel; \ No newline at end of file diff --git a/tests/test-app/compiled-dist/models/db.js b/tests/test-app/compiled-dist/models/db.js new file mode 100644 index 0000000..90725aa --- /dev/null +++ b/tests/test-app/compiled-dist/models/db.js @@ -0,0 +1,9 @@ +"use strict"; + +const Knex = require('knex'); + +const knexOptions = require('../../knexfile'); + +const env = process.env.NODE_ENV || 'development'; +const knex = Knex(knexOptions[env]); +module.exports = knex; \ No newline at end of file diff --git a/tests/test-app/compiled-dist/queries/cat/index.js b/tests/test-app/compiled-dist/queries/cat/index.js new file mode 100644 index 0000000..6dc2341 --- /dev/null +++ b/tests/test-app/compiled-dist/queries/cat/index.js @@ -0,0 +1,12 @@ +"use strict"; + +var _catsConnection = _interopRequireDefault(require("./root/cats-connection")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +module.exports = { + Query: { + catsConnection: _catsConnection.default + }, + Cat: {} +}; \ No newline at end of file diff --git a/tests/test-app/compiled-dist/queries/cat/root/cats-connection.js b/tests/test-app/compiled-dist/queries/cat/root/cats-connection.js new file mode 100644 index 0000000..f34dd2e --- /dev/null +++ b/tests/test-app/compiled-dist/queries/cat/root/cats-connection.js @@ -0,0 +1,33 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _apolloCursorPagination = require("apollo-cursor-pagination"); + +var _Cat = _interopRequireDefault(require("../../../models/Cat")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var _default = async (_, args) => { + // const { + // first, last, before, after, orderBy, orderDirection, orderByMultiple, orderDirectionMultiple + // } = args; + const orderBy = args.orderBy || args.orderByMultiple; + const orderDirection = args.orderDirection || args.orderDirectionMultiple; + + const baseQuery = _Cat.default.query().sum('id as idsum').select('cats.*').groupBy('id'); + + const result = await (0, _apolloCursorPagination.knexPaginator)(baseQuery, { ...args, + orderBy, + orderDirection + }, { + isAggregateFn: column => column === 'idsum', + formatColumnFn: column => column === 'idsum' ? 'sum(id)' : column + }); + return result; +}; + +exports.default = _default; \ No newline at end of file diff --git a/tests/test-app/compiled-dist/schema.js b/tests/test-app/compiled-dist/schema.js new file mode 100644 index 0000000..374450f --- /dev/null +++ b/tests/test-app/compiled-dist/schema.js @@ -0,0 +1,61 @@ +"use strict"; + +const glob = require('glob'); + +const path = require('path'); + +const { + merge +} = require('lodash'); + +const { + makeExecutableSchema +} = require('graphql-tools'); + +const Root = +/* GraphQL */ +` + type Query { + dummy: String + } + type Mutation { + dummy: String + } + type Subscription { + dummy: String + } + schema { + query: Query + mutation: Mutation + subscription: Subscription + } +`; +const queriesToMerge = []; +const mutationsToMerge = []; // Iterate over each folder in the queries folder +// to then add the index file to the resolver list. + +glob.sync(path.join(__dirname, '/queries/*/index.js')).forEach(file => { + const query = require(path.resolve(file)); + + queriesToMerge.push(query); +}); // Iterate over each folder in the mutations folder +// to then add the index file to the resolver list. + +glob.sync(path.join(__dirname, '/mutations/*/index.js')).forEach(file => { + const mutation = require(path.resolve(file)); + + mutationsToMerge.push(mutation); +}); +const resolvers = merge({}, ...queriesToMerge, ...mutationsToMerge); // Iterate over each type file in the types folder, and load into the typeDefs array + +const typeDefs = [Root]; +glob.sync(path.join(__dirname, '/type-defs/*.js')).forEach(file => { + const type = require(path.resolve(file)); + + typeDefs.push(type); +}); +const schema = makeExecutableSchema({ + typeDefs, + resolvers +}); +module.exports = schema; \ No newline at end of file diff --git a/tests/test-app/compiled-dist/type-defs/cat.js b/tests/test-app/compiled-dist/type-defs/cat.js new file mode 100644 index 0000000..b1f2fb1 --- /dev/null +++ b/tests/test-app/compiled-dist/type-defs/cat.js @@ -0,0 +1,39 @@ +"use strict"; + +const Cat = ` + type Cat { + id: ID! + name: String + lastName: String + } + + type CatsConnection { + pageInfo: PageInfo! + edges: [CatEdge]! + totalCount: Int! + } + + type CatEdge { + cursor: String! + node: Cat! + } + + enum OrderDirection { + asc + desc + } + + extend type Query { + catsConnection( + first: Int + after: String + last: Int + before: String + orderBy: String + orderDirection: OrderDirection + orderDirectionMultiple: [OrderDirection] + orderByMultiple: [String!] + ): CatsConnection! + } +`; +module.exports = Cat; \ No newline at end of file diff --git a/tests/test-app/compiled-dist/type-defs/general.js b/tests/test-app/compiled-dist/type-defs/general.js new file mode 100644 index 0000000..9b1e15b --- /dev/null +++ b/tests/test-app/compiled-dist/type-defs/general.js @@ -0,0 +1,9 @@ +"use strict"; + +const general = ` + type PageInfo { + hasNextPage: Boolean + hasPreviousPage: Boolean + } +`; +module.exports = general; \ No newline at end of file diff --git a/tests/test-app/src/models/db.js b/tests/test-app/src/models/db.js index 9c9de44..1edee5b 100644 --- a/tests/test-app/src/models/db.js +++ b/tests/test-app/src/models/db.js @@ -4,4 +4,7 @@ const knexOptions = require('../../knexfile'); const env = process.env.NODE_ENV || 'development'; const knex = Knex(knexOptions[env]); +// for debugging +// knex.on('query', console.log); + module.exports = knex; diff --git a/tests/test-app/src/queries/cat/root/cats-connection.js b/tests/test-app/src/queries/cat/root/cats-connection.js index 0b35f08..ef2adb9 100644 --- a/tests/test-app/src/queries/cat/root/cats-connection.js +++ b/tests/test-app/src/queries/cat/root/cats-connection.js @@ -9,7 +9,7 @@ export default async (_, args) => { const orderBy = args.orderBy || args.orderByMultiple; const orderDirection = args.orderDirection || args.orderDirectionMultiple; - const baseQuery = Cat.query(); + const baseQuery = Cat.query().sum('id as idsum').select('cats.*').groupBy('id'); const result = await paginate( baseQuery, @@ -18,6 +18,10 @@ export default async (_, args) => { orderBy, orderDirection, }, + { + isAggregateFn: column => column === 'idsum', + formatColumnFn: column => (column === 'idsum' ? 'sum(id)' : column), + }, ); return result; }; diff --git a/tests/test-app/tests/apollo-cursor-pagination/knex-implementation.test.js b/tests/test-app/tests/apollo-cursor-pagination/knex-implementation.test.js index ac91090..079ab1e 100644 --- a/tests/test-app/tests/apollo-cursor-pagination/knex-implementation.test.js +++ b/tests/test-app/tests/apollo-cursor-pagination/knex-implementation.test.js @@ -252,6 +252,53 @@ describe('getCatsByOwner root query', () => { ); }); + it('can sort by aggregate value', async () => { + let cursor; + const query = ` + { + catsConnection(first: 2, orderBy: "idsum", orderDirection: asc) { + totalCount + edges { + cursor + node { + id + } + } + } + } + `; + const response = await graphqlQuery(app, query); + + expect(response.body.errors).not.toBeDefined(); + expect(response.body.data.catsConnection.totalCount).toEqual(3); + expect(response.body.data.catsConnection.edges.map(e => e.node.id)).toEqual( + [cat1, cat2].map(c => c.id).map(id => id.toString()), + ); + + cursor = response.body.data.catsConnection.edges[1].cursor; + + const query2 = ` + { + catsConnection(first: 1, after: "${cursor}", orderBy: "idsum", orderDirection: asc) { + totalCount + edges { + cursor + node { + id + } + } + } + } + `; + const response2 = await graphqlQuery(app, query2); + + expect(response2.body.errors).not.toBeDefined(); + expect(response.body.data.catsConnection.totalCount).toEqual(3); + expect(response2.body.data.catsConnection.edges.map(e => e.node.id)).toEqual( + [cat3].map(c => c.id.toString()), + ); + }); + it('sorts asc and desc correctly when result set is segmented', async () => { let cursor; const query = ` @@ -548,7 +595,7 @@ describe('getCatsByOwner root query', () => { expect(response.body.errors).not.toBeDefined(); expect(response.body.data.catsConnection.totalCount).toBeDefined(); - expect(response.body.data.catsConnection.totalCount).toEqual(2); + expect(response.body.data.catsConnection.totalCount).toEqual(3); }); }); });