UNPKG

objection-cursor

Version:

Cursor based pagination plugin for Objection.js

228 lines (189 loc) 7.12 kB
const {get, castArray} = require('lodash'); const {getOperations, hasOperation, clearOperations} = require('./utils'); const Operation = require('./Operation'); const OrderByOperation = require('./OrderByOperation'); const {serializeCursor, deserializeCursor} = require('../serialize'); const {columnToProperty} = require('../convert'); class CursorPageOperation extends Operation { constructor(options) { super('cursorPage'); this.options = options; this.originalBuilder = null; } onAdd(builder, args) { if (hasOperation(builder, CursorPageOperation)) { return false; } const [cursor = null, before = false] = args; return super.onAdd(builder, [cursor, before]); } onBefore(builder, result) { const orderByOps = getOperations(builder, OrderByOperation); if (this.args[0] && orderByOps.length !== this.keyset.length) { // Cursor was given, but keyset length does not match the number of orderBy operations throw new Error('Cursor does not match ordering'); } if (this.before) { // Reverse order by operation directions for (const op of orderByOps) { op.args[1] = op.order === 'asc' ? 'desc' : 'asc'; } } return result; } onBuild(builder) { // Save copy of builder without modifications made by this operation this.originalBuilder = clearOperations(builder.clone(), CursorPageOperation); whereMore(builder, this.keyset); // Add default limit if (!builder.has(/limit/)) { builder.limit(this.options.limit); } } onAfter(builder, data) { // We want to always return results in the same order, as if turning pages in a book if (this.before) { data.reverse(); } // Get more results before the first result, or after the last result const firstResult = data[0]; const lastResult = data[data.length - 1]; // If we didn't get results, use the last known keyset as a fallback const fallbackPreviousKeyset = this.before ? this.keyset : null; const fallbackNextKeyset = this.before ? null : this.keyset; /* When we reach end while going forward, save the last element of the last page, but discard * first element of last page. If we try to go forward, we get an empty result, because * there are no elements after the last one. If we go back from there, we get results for * the last page. The opposite is true when going backward from the first page. */ const previousKeyset = firstResult ? toKeyset(builder, firstResult) : fallbackPreviousKeyset; const nextKeyset = lastResult ? toKeyset(builder, lastResult) : fallbackNextKeyset; let results; let nodes; if (this.options.results) { results = data; } if (this.options.nodes) { nodes = data.map(data => ({ data, cursor: serializeCursor(toKeyset(builder, data)) })); } return this._getAdditionalPageInfo(data) .then(additionalPageInfo => ({ results, nodes, pageInfo: Object.assign(additionalPageInfo, { next: serializeCursor(nextKeyset), previous: serializeCursor(previousKeyset) }) })); } get keyset() { return deserializeCursor(this.args[0]); } get before() { return this.args[1] || false; } clone() { const op = super.clone(); op.options = Object.assign({}, this.options); op.originalBuilder = this.originalBuilder && this.originalBuilder.clone(); return op; } _getAdditionalPageInfo(result) { const pageInfo = {}; // Check if at least one given option is enabled const isEnabled = opts => castArray(opts).some(key => this.options.pageInfo[key]); const setIfEnabled = (key, val) => { pageInfo[key] = this.options.pageInfo[key] ? val : undefined; }; let total; return Promise.resolve() .then(() => { if (isEnabled(['total', 'hasNext', 'hasPrevious', 'remainingBefore', 'remainingAfter'])) { // Count number of rows without where statements or limits return this.originalBuilder.clone().resultSize().then(rs => { total = parseInt(rs, 10); setIfEnabled('total', total); }); } }) .then(() => { if (isEnabled(['hasMore', 'hasNext', 'hasPrevious', 'remaining', 'remainingBefore', 'remainingAfter'])) { const builder = this.originalBuilder.clone(); whereMore(builder, this.keyset); /* Count number of rows without limits, but retain where statements to count rows * only in one direction. I.e. get number of rows before/after current results. */ return builder.resultSize().then(rs => { const remaining = rs - result.length; setIfEnabled('remaining', remaining); setIfEnabled('remainingBefore', this.before ? remaining : total - rs); setIfEnabled('remainingAfter', this.before ? total - rs : remaining); setIfEnabled('hasMore', remaining > 0); setIfEnabled('hasPrevious', (this.before && remaining > 0) || (!this.before && total - rs > 0)); setIfEnabled('hasNext', (!this.before && remaining > 0) || (this.before && total - rs > 0)); }); } }) .then(() => pageInfo); } } /** * Returns array of object values, ordered by `orderBy` operations. */ function toKeyset(builder, obj) { const databaseJson = obj.$toDatabaseJson(); return getOperations(builder, OrderByOperation).map(op => { const property = op.property || columnToProperty(builder.modelClass(), op.column); const value = get(databaseJson, property, null); const fallbackValue = get(obj, property, null); // `$toDatabaseJson` removes joined data, so we also check the original model return (value !== null && value !== undefined) ? value : fallbackValue; }); } function whereMore(builder, keyset) { if (keyset.length > 0) { const comparisons = getOperations(builder, OrderByOperation).map((op, idx) => ({ column: op.column, order: op.order, value: op.compareValue ? op.compareValue(keyset[idx]) : keyset[idx] })); _whereMore(builder, comparisons, []); } } /** * Recursive procedure to build where statements needed to get rows before/after some given row. * * Let's say we want to order by columns [c1, c2, c3], all in ascending order for simplicity. * The resulting structure looks like this: * * - If c1 > value, return row * - Otherwise, if c1 = value and c2 > value, return row * - Otherwise, if c1 = value and c2 = value and c3 > value, return row * - Otherwise, do not return row * * Comparisons are simply flipped if order is 'desc', and Objection (usually) knows to compare * columns to nulls correctly with "column IS NULL" instead of "column = NULL". */ function _whereMore(builder, comparisons, composites) { const comparison = comparisons[0]; composites = [comparison, ...composites]; const op = comparison.order === 'asc' ? '>' : '<'; builder.andWhere(function () { this.where(comparison.column, op, comparison.value); if (comparisons.length > 1) { this.orWhere(function () { for (const composite of composites) { this.andWhere(composite.column, composite.value); } this.andWhere(function () { // Add where statements recursively _whereMore(this, comparisons.slice(1), composites); }); }); } }); } module.exports = CursorPageOperation;