From 76d1a3e3979de276a717ee9541586b5ae34873f0 Mon Sep 17 00:00:00 2001 From: Daniel Merrill Date: Mon, 12 Aug 2019 16:27:59 -0300 Subject: [PATCH 1/2] feat: support nulls in knex pagination --- package.json | 2 +- src/orm-connectors/knex/custom-pagination.js | 44 +++++++++- .../knex-implementation.test.js | 87 ++++++++++++++++++- 3 files changed, 124 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index bf73410..b8d25de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apollo-cursor-pagination", - "version": "0.4.5", + "version": "0.5.0-alpha-5", "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/orm-connectors/knex/custom-pagination.js b/src/orm-connectors/knex/custom-pagination.js index 1aad545..1e4a7bb 100644 --- a/src/orm-connectors/knex/custom-pagination.js +++ b/src/orm-connectors/knex/custom-pagination.js @@ -43,6 +43,21 @@ const formatColumnIfAvailable = (column, formatColumnFn) => { return column; }; +const getOpossiteComparator = (comparator) => { + switch (comparator) { + case '<': + return '>='; + case '>': + return '<='; + case '<=': + return '>'; + case '>=': + return '<'; + default: + return '<>'; + } +}; + const buildRemoveNodesFromBeforeOrAfter = (beforeOrAfter) => { const getComparator = (orderDirection) => { if (beforeOrAfter === 'after') return orderDirection === 'asc' ? '<' : '>'; @@ -53,8 +68,9 @@ const buildRemoveNodesFromBeforeOrAfter = (beforeOrAfter) => { }) => { const data = getDataFromCursor(cursorOfInitialNode); const [id, columnValue] = data; + const initialValue = nodesAccessor.clone(); - const result = operateOverScalarOrArray(initialValue, orderColumn, (orderBy, index, prev) => { + const executeFilterQuery = query => operateOverScalarOrArray(query, orderColumn, (orderBy, index, prev) => { let orderDirection; const values = columnValue; let currValue; @@ -67,6 +83,7 @@ const buildRemoveNodesFromBeforeOrAfter = (beforeOrAfter) => { } const comparator = getComparator(orderDirection); + if (index > 0) { const operation = (isAggregateFn && isAggregateFn(orderColumn[index - 1])) ? 'orHavingRaw' : 'orWhereRaw'; const nested = prev[operation]( @@ -77,21 +94,40 @@ const buildRemoveNodesFromBeforeOrAfter = (beforeOrAfter) => { return nested; } - const operation = (isAggregateFn && isAggregateFn(orderBy)) ? 'havingRaw' : 'whereRaw'; + if (currValue === null || currValue === undefined) { + return prev; + } + 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(); + + // If value is null, we are forced to filter by id instead const operation = (isAggregateFn && isAggregateFn(lastOrderColumn)) ? 'orHavingRaw' : 'orWhereRaw'; - const nested = prev[operation]( + if (lastValue === null || lastValue === undefined) { + return prev[operation]( + `(${formatColumnIfAvailable('id', formatColumnFn)} ${comparator} ?) or (${formatColumnIfAvailable(lastOrderColumn, formatColumnFn)} IS NOT NULL)`, + [id], + ); + } + + return prev[operation]( `(${formatColumnIfAvailable(lastOrderColumn, formatColumnFn)} = ? and ${formatColumnIfAvailable('id', formatColumnFn)} ${comparator} ?)`, [lastValue, id], ); - return nested; }); + let result; + + if ((isAggregateFn && Array.isArray(orderColumn) && isAggregateFn(orderColumn[0])) + || (isAggregateFn && !Array.isArray(orderColumn) && isAggregateFn(orderColumn))) { + result = executeFilterQuery(initialValue); + } else { + result = initialValue.andWhere(query => executeFilterQuery(query)); + } 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 e052092..f73ebae 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 @@ -581,12 +581,18 @@ describe('getCatsByOwner root query', () => { cursor = response.body.data.catsConnection.edges[0].cursor; }); - it('brings the correct amount for a segmented query', async () => { + it('paginates segmentating by null values', async () => { + const [unnamedCat] = await catFactory.model.query().limit(1); + await catFactory.model.query().where({ id: unnamedCat.id }).patch({ lastName: null }); const query = ` { - catsConnection(first: 2, after: "${cursor}") { + catsConnection(first: 1, orderBy: "lastName", orderDirection: asc) { edges { cursor + node { + id + lastName + } } totalCount } @@ -595,8 +601,81 @@ describe('getCatsByOwner root query', () => { const response = await graphqlQuery(app, query); expect(response.body.errors).not.toBeDefined(); - expect(response.body.data.catsConnection.totalCount).toBeDefined(); - expect(response.body.data.catsConnection.totalCount).toEqual(3); + // In SQLITE nulls come first. + expect(response.body.data.catsConnection.edges[0].node.lastName).toEqual(null); + + const { cursor } = response.body.data.catsConnection.edges[0]; + + const query2 = ` + { + catsConnection( + first: 2, orderBy: "lastName", orderDirection: asc, after: "${cursor}" + ) { + edges { + cursor + node { + id + lastName + } + } + } + } + `; + const response2 = await graphqlQuery(app, query2); + + expect(response2.body.errors).not.toBeDefined(); + expect(response2.body.data.catsConnection.edges).toHaveLength(2); + expect(response2.body.data.catsConnection.edges[0].node.lastName).not.toEqual(null); + expect(response2.body.data.catsConnection.edges[1].node.lastName).not.toEqual(null); + }); + + it('paginates segmentating in the middle of null values', async () => { + const [unnamedCat1, unnamedCat2] = await catFactory.model.query().limit(2); + await catFactory.model.query().where({ id: unnamedCat1.id }).patch({ lastName: null }); + await catFactory.model.query().where({ id: unnamedCat2.id }).patch({ lastName: null }); + const query = ` + { + catsConnection(first: 1, orderBy: "lastName", orderDirection: asc) { + edges { + cursor + node { + id + lastName + } + } + totalCount + } + } + `; + const response = await graphqlQuery(app, query); + + expect(response.body.errors).not.toEqual(null); + // In SQLITE nulls come first. + expect(response.body.data.catsConnection.edges[0].node.lastName).toEqual(null); + + const { cursor } = response.body.data.catsConnection.edges[0]; + + const query2 = ` + { + catsConnection( + first: 2, orderBy: "lastName", orderDirection: asc, after: "${cursor}" + ) { + edges { + cursor + node { + id + lastName + } + } + } + } + `; + const response2 = await graphqlQuery(app, query2); + + expect(response2.body.errors).not.toEqual(null); + expect(response2.body.data.catsConnection.edges).toHaveLength(2); + expect(response2.body.data.catsConnection.edges[0].node.lastName).toEqual(null); + expect(response2.body.data.catsConnection.edges[1].node.lastName).not.toEqual(null); }); }); }); From b7faeee29084dfbc93db1a5f0dc5a3663df58f81 Mon Sep 17 00:00:00 2001 From: Daniel Merrill Date: Mon, 12 Aug 2019 16:30:09 -0300 Subject: [PATCH 2/2] chore: update version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b8d25de..95063e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apollo-cursor-pagination", - "version": "0.5.0-alpha-5", + "version": "0.5.0", "description": "Relay's Connection implementation for Apollo Server GraphQL library", "main": "dist/index.js", "repository": "https://github.com/Terminal-Systems/apollo-cursor-pagination",