index.js

(c) Mathieu Bruyen - http://mais-h.eu/ License: MIT (http://www.opensource.org/licenses/mit-license.php)

var nano = require('nano');
var when = require('when');
var apply = require('when/apply');

CouchDB pagination

Connect middleware for paginated CouchDB views. Inspired from CouchDB guide, but left the dealbreaker part out (expects keys to be unique, either emitted by only one document or reduced).

Source repository.

Examples

Reduced values

View with both a map and a reduce function:

var paginate = require('couchdb-paginate');
app.get('/list/:start', paginate({
  couchURI: 'http://localhost:5984',
  database: 'dbname',
  design: 'mydesign',
  view: 'myview'
}), function (req, res, next) { /* display */ });

Indexed documents

View with only a map function like function (doc) { emit(doc.key, null); }:

var paginate = require('couchdb-paginate');
app.get('/list/:start', paginate({
  couchURI: 'http://localhost:5984',
  database: 'dbname',
  design: 'mydesign',
  view: 'myview',
  useDocuments: true
}), function (req, res, next) { /* display */ });

API access (JSON)

View with only a map function like function (doc) { emit(doc.key, null); }:

var paginate = require('couchdb-paginate');
app.get('/api/list/:start', paginate({
  couchURI: 'http://localhost:5984',
  database: 'dbname',
  design: 'mydesign',
  view: 'myview',
  asJson: true
}));

View displaying

View with only a map function like function (doc) { emit(doc.key, null); }:

var paginate = require('couchdb-paginate');
app.get('/list/:start', paginate({
  couchURI: 'http://localhost:5984',
  database: 'dbname',
  design: 'mydesign',
  view: 'myview',
  renderView: 'myview.jade'
}));

Complex keys

Blog posts example: comments attached to posts, organized by timestamp.

Document example:

{ postId: "abcdef", timestamp: 1234, author: "Foo", comment: "Bar" }

Map function (no reduce function):

function (doc) { emit([doc.postId, doc.timestamp], null); }

Pagination of comments for a given post exposed as an JSON API:

var paginate = require('couchdb-paginate');
app.get('/comments/:post/:startTimestamp', paginate({
  couchURI: 'http://localhost:5984',
  database: 'dbname',
  design: 'blogposts',
  view: 'comments_by_post',
  useDocuments: true
  asJson: true,
  getBounds: function (req) {
    return [
      [req.params.post, 0],
      [req.params.post, req.params.startTimestamp],
      [req.params.post, 9007199254740992]
    ];
  }
}));
module.exports = function (config) {

Configuration options

Connection to the database

  • couchURI: URI of the CouchDB store
  • database: CouchDB database name
  • design: design name in couchdb
  • view: view name in design
  if (typeof config.couchURI != 'string') {
    throw new TypeError('"couchURI" is not a string');
  }
  if (typeof config.database != 'string') {
    throw new TypeError('"database" is not a string');
  }
  var db = nano(config.couchURI).use(config.database);
  var design = config.design;
  if (typeof design != 'string') {
    throw new TypeError('"design" is not a string');
  }
  var view = config.view;
  if (typeof view != 'string') {
    throw new TypeError('"view" is not a string');
  }

Bounds and navigation

  • getStartKey (default: fetch from query parameter called start): how to get requested start key from the request
  • getBounds (default: no limits and start key from getStartKey): how to get navigation bounds from the request (if set then getStartKey will be ignored)

Get bounds is expected to return an array (or a promise that will resolve an array) with three elements: * the lowest key to display * the current page start key * the highest key to display By default it uses no limits and take start key from request parameter start. Can be used to have more complex keys.

  var getBounds;
  if (typeof config.getBounds == 'function') {
    getBounds = config.getBounds;
  } else if (typeof config.getBounds == 'undefined') {
    if (typeof config.getStartKey == 'function') {
      var getStartKey = config.getStartKey;
      getBounds = function (req) { return [undefined, getStartKey(req), undefined]; };
    } else if (typeof config.getBounds == 'undefined') {
      getBounds = function (req) { return [undefined, req.params.start, undefined]; };
    } else {
      throw new TypeError('"getStartKey" is not a function');
    }
  } else {
    throw new TypeError('"getBounds" is not a function');
  }

Content to display

  • allowEmpty (default: false): do not fail on empty list of documents
  • pageSize (default: 20): number of items per page
  • nextNumber (default: 1): number of next links to compute
  • prevNumber (default: the value of nextNumber): number of previous links to compute
  • useDocuments (default: false): use documents instead of reduced values
  • reduce (default: opposite of useDocuments): informs that there is a reduce function in the view

By default it assumes that the view is a complete one with a reduce function and uses the reduced value as content. By setting useDocuments to false, it works on indexing views with no emitted value (emit(doc.myKey, null)) and in that case it assumes there is no reduce function. By explicitely setting reduce to false, it allows to work with views that emit a value but do not use a reduce function.

  var allowEmpty = config.allowEmpty || false;
  if (typeof allowEmpty != 'boolean') {
    throw new TypeError('"allowEmpty" is not a boolean');
  }
  var pageSize = config.pageSize || 20;
  if (typeof pageSize != 'number' || isNaN(pageSize) || Math.floor(pageSize) !== pageSize || pageSize <= 0) {
    throw new TypeError('"pageSize" is not a strictly positive integer');
  }
  var nextNumber = config.nextNumber || 1;
  if (typeof nextNumber != 'number' || isNaN(nextNumber) || Math.floor(nextNumber) !== nextNumber || nextNumber < 0) {
    throw new TypeError('"nextNumber" is not a positive integer');
  }
  var prevNumber = config.prevNumber || nextNumber;
  if (typeof prevNumber != 'number' || isNaN(prevNumber) || Math.floor(prevNumber) !== prevNumber || prevNumber < 0) {
    throw new TypeError('"prevNumber" is not a positive integer');
  }
  var useDocuments = config.useDocuments || false;
  if (typeof useDocuments != 'boolean') {
    throw new TypeError('"useDocuments" is not a boolean');
  }
  var reduce;
  if (typeof config.reduce == 'undefined') {
    reduce = !useDocuments;
  } else if (typeof config.reduce == 'boolean') {
    reduce = config.reduce;
  }
  else {
    throw new TypeError('"reduce" is not a boolean');
  }

Way to display content

  • asJson (default: false): sends the content as JSON (if set to true then renderView will be ignored)
  • renderView: view to render with data (ignored if asJson is set to true)
  • documentsExportKey (default: documents): key in the content that holds the array of elements to display
  • nextExportKey (default: nextIds): key in the content that holds the array of next start identifiers
  • previousExportKey (default: previousIds): key in the content that holds the array of previous start identifiers

By default content is simply added to the request object at specified keys, and next middleware can handle it. However there are two possible shortcuts: directly send the JSON content, or render a view with content.

  var asJson = config.asJson || false;
  if (typeof asJson != 'boolean') {
    throw new TypeError('"asJson" is not a boolean');
  }
  var renderView = config.renderView || null;
  if (renderView !== null && typeof renderView != 'string') {
    throw new TypeError('"renderView" is not a string');
  }
  var documentsExportKey = config.documentsExportKey || 'documents';
  if (typeof documentsExportKey != 'string') {
    throw new TypeError('"documentsExportKey" is not a string');
  }
  var nextExportKey = config.nextExportKey || 'nextIds';
  if (typeof nextExportKey != 'string') {
    throw new TypeError('"nextExportKey" is not a string');
  }
  var previousExportKey = config.previousExportKey || 'previousIds';
  if (typeof previousExportKey != 'string') {
    throw new TypeError('"previousExportKey" is not a string');
  }

Helper to query the database

  function query(startkey, endkey, limit, include_docs, descending) {

General data, allowing not to specify include_docs or descending.

    var obj = {
      limit: limit
    };
    if (reduce) {
      obj.group = true;
    }
    if (include_docs) {
      obj.include_docs = true;
    }
    if (descending) {
      obj.descending = true;
    }

In case the start key is undefined (start page with no lowest key), do not include it in the request.

    if (startkey !== undefined) {
      obj.startkey = startkey;
    }

In case the end key is undefined (no lowest key or no uppermost key), do not include it in the request.

    if (endkey !== undefined) {
      obj.endkey = endkey;
    }

Do the query and return a promise that holds the body.

    var deferred = when.defer();
    db.view(design, view, obj, function (err, body) {
      if (err) {
        deferred.reject(err);
      } else {
        deferred.resolve(body);
      }
    });
    return deferred.promise;
  }
  var fetchPrev;

Prepare the method used to fetch current and next pages.

  var fetchCurrent = null;
  var fetchNext = null;
  var fetchCurrentAndNext = null;

When fetching documents, use different requests to get next pages start keys and documents to limit response size. Otherzise the content would be fetched anyway so get documents and next pages start keys in one request.

  if (useDocuments) {
    fetchCurrent = function (startKey, uppermostKey) {
      var documentsDef = when.defer();
      query(startKey, uppermostKey, pageSize, true).then(function (body) {

Prevent empty pages.

        if ((!allowEmpty) && (body.rows.length === 0)) {
          documentsDef.reject('No document found');
        } else {

Select documents.

          documentsDef.resolve(body.rows.map(function (item) { return item.doc; }));
        }
      }, function (err) {
        documentsDef.reject(err);
      });
      return documentsDef.promise;
    };
    fetchNext = function (startKey, uppermostKey) {
      var nextDef = when.defer();
      query(startKey, uppermostKey, (pageSize * nextNumber) + 1).then(function (body) {
        var pages = [];
        for (var i = pageSize; i < body.rows.length; i += pageSize) {
          pages.push(body.rows[i].key);
        }
        nextDef.resolve(pages);
      }, function (err) {
        nextDef.reject(err);
      });
      return nextDef.promise;
    };

If no next pages are requested no need to query extra documents at all.

  } else if (nextNumber === 0) {
    fetchNext = function () {
      return [];
    };
    fetchCurrent = function (startKey, uppermostKey) {
      var documentsDef = when.defer();
      query(startKey, uppermostKey, pageSize).then(function (body) {

Prevent empty pages.

        if ((!allowEmpty) && (body.rows.length === 0)) {
          documentsDef.reject('No document found');
        } else {

Select reduced values.

          documentsDef.resolve(body.rows.map(function (item) { return item.value; }));
        }
      }, function (err) {
        documentsDef.reject(err);
      });
      return documentsDef.promise;
    };
  } else {

When not using documents, content is fetched anyway thus only one request is issued to get both at once.

    fetchCurrentAndNext = function (startKey, uppermostKey) {
      var documentsDef = when.defer();
      var nextDef = when.defer();
      query(startKey, uppermostKey, (pageSize * nextNumber) + 1).then(function (body) {

Prevent empty pages.

        if ((!allowEmpty) && (body.rows.length === 0)) {
          documentsDef.reject('No document found');
          nextDef.reject('No document found');
        } else {
          var documents = [];
          var pages = [];
          var i;

Select values only for the page range.

          for (i = 0; i < pageSize && i < body.rows.length; i++) {
            documents.push(body.rows[i].value);
          }

Select start page indexes.

          for (i = pageSize; i < body.rows.length; i += pageSize) {
            pages.push(body.rows[i].key);
          }
          documentsDef.resolve(documents);
          nextDef.resolve(pages);
        }
      }, function(err) {
        documentsDef.reject(err);
        nextDef.reject(err);
      });
      return [documentsDef.promise, nextDef.promise];
    };
  }

Prepare the method used to fetch previous pages.

No need to query for previous pages if no page requested.

  if (prevNumber === 0) {
    fetchPrev = function () {
      return [];
    };
  } else {
    fetchPrev = function (startKey, lowestKey) {

No need to query for previous pages if on the start page.

      if (startKey === lowestKey) {
        return [];

In general case fetch the number of elements in previous pages plus two (one because the start document is included in the results and one to detect if the last fetched page is actually the first one) and record the keys of start keys, with additional start page detection.

      } else {
        var deferred = when.defer();
        query(startKey, lowestKey, (prevNumber * pageSize) + 2, false, true).then(function (body) {
          var pages = [];
          for (var i = pageSize; i < body.rows.length && pages.length < prevNumber; i += pageSize) {
            pages.push(body.rows[i].key);
          }

If the response contains less items than expected then start page is included.

          if (body.rows.length > 1 && body.rows.length !== (prevNumber * pageSize) + 2) {

Start page is actually the last one recorded (do not test for equality with lowestKey as the later may not exist at all).

            if (body.rows[body.rows.length - 1].key === pages[pages.length - 1]) {
              pages[pages.length - 1] = null;

Start page is an additional one if there is more than this page first element in the response.

            } else {
              pages.push(null);
            }
          }
          deferred.resolve(pages);
        }, function (err) {
          deferred.reject(err);
        });
        return deferred.promise;
      }
    };
  }

Retrieves the array of promises

  var getAllPromises;
  if (fetchCurrentAndNext !== null) {
    getAllPromises = function (lowestKey, startKey, uppermostKey) {
      var tmp = fetchCurrentAndNext(startKey, uppermostKey);
      return when.all([fetchPrev(startKey, lowestKey), tmp[0], tmp[1]]);
    };
  } else {
    getAllPromises = function (lowestKey, startKey, uppermostKey) {
      return when.all([fetchPrev(startKey, lowestKey), fetchCurrent(startKey, uppermostKey), fetchNext(startKey, uppermostKey)]);
    };
  }

Actual middleware

  return function (req, res, next) {
    when(getBounds(req)).then(apply(function (lowestKey, startKey, uppermostKey) {
      var nextDef = when.defer();
      var documentsDef = when.defer();

If start key is not provided, then use the lowermost one to get start page.

      if (startKey === undefined) {
        startKey = lowestKey;
      }
      return getAllPromises(lowestKey, startKey, uppermostKey).then(function (resolved) {

Build the output.

        var result;
        if (asJson || renderView) {
          result = {};
        } else {
          result = req;
        }
        result[previousExportKey] = resolved[0];
        result[documentsExportKey] = resolved[1];
        result[nextExportKey] = resolved[2];

Terminate middleware working.

        if (asJson) {
          res.json(result);
        } else if (renderView) {
          res.render(renderView, result);
        } else {
          next();
        }
      });
    })).otherwise(function (error) {
      next(error);
    });
  };
};