Mikael's blog

A developers sixth time trying to maintain a blog

Double linked paging in CouchDB

Yesterday I said that I would look into paging, as my post count had reached 10+, so that's what I did today.

Paging in CouchDB isn't all that straight forward for a bunch of reasons that I'll try to explain.

Firstly, to be able to query anything in Couch you need a View. A Couch View is basically a really fast hash of your documents that is constructed with a little piece of javascript (like everything in Couch).

In my case it looks like this:

1
2
3
4
5
6
function(doc)
{
  if(doc.type === "blogpost" && doc.published) {
    emit(doc.dateTime, doc);
  }
}

The execution of this code is what makes Couch so fast for reads. The magic can be read about here and if you're interested in how Couch maintains its indexes, then it's a good read. It is however not the point of this blog post.

The important thing to know about the above piece of code is that it returns all published blogposts with the dateTime-attribute as the Key and the whole document as the Value.

The reason for using a date and time as the key in this view is that Couch always sorts its views on the key and I wan't my blog posts to be returned to me in reversed chronologic order.

CouchDB has a few interesting arguments that you can set when querying a view:

  • key - If set, the query will only return documents with that key.
  • descending - Is used to set the sort order.
  • startkey - The start key of a range of keys to query for.
  • endkey - The end key of a range of keys to query for.
  • limit - Select only a set number of documents.
  • skip - Skip a number of documents before yielding results.

Easy paging

Now the easy way of doing this would be to set limit = page size and skip = page size * page number (zero-indexed of course).

The problem with that is that skip is an expensive operation in Couch. In my case with 10-20 blog posts by now it wouldn't even matter but if it's worth doing, it's worth doing it right.

Double linked (awesome) paging

Single linked paging wasn't that hard either but I wanted more then an "Older posts" button. I (for some weird reason) was hell bent on also having a "Newer posts" button so that I could step freely forwards and backwards through my posts.

The trick relies on using the startkey-, limit- and descending-arguments.

On the front page I use an "A" as my startkey. I also set descending = true so that I will get my results in reverse order. When querying with descending = true I needed a value for startkey that was greater than any I could have as keys in my view. Since my keys were timestamps starting with the year number I knew that a letter would be considered "greater". (remember the reverse part)

I then set limit = page size + 1. The +1 document is never displayed on the website but I use its key as my startkey for the next page. And if the result size is less than page size + 1 then I have reached the last page.

The "previous page"-button was a bit trickier to figure out but the solution was quite simple.

The same way I use the +1 document as the startkey to get to the next page I use the first post in the result as the startkey to get to the previous page, only this time I query with descending = false and limit = page size + 2.

If the result size is less then page size +2 then I'm at the first page, and all I have to do then is reverse the result manually and then do all the same stuff as before.

Code

Here is the code to help you figure out what I just said. I'm not really that good at explaining this stuff in the middle of the night. =)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
function page(request, response) {
  var key = url.parse(request.url, true).query.key;
 
  var startKey = 'A';
  var pageSize = 8; // 7 for show and 1 for reference
  var desc = true;
  var firstPage = true;
 
  if(key) {
    var prefix = key.substring(0, 1);
    desc = (prefix != 'p');
    startKey = key.substring(1);
    firstPage = false;
  }
 
  var nextPageKey = null;
  var prevPageKey = null;
 
  var queryargs = { descending: desc, limit: (desc ? pageSize : pageSize + 1), startkey: startKey};
  db.view('posts/publishedByDateTime', queryargs, function (err, doc) {
    if (err) {
      console.log("CouchDB: DB Read error");
      error(request, response, 404);
      return;
    }
 
    if (!desc) {
      if (doc.length < pageSize + 1) {
        firstPage = true;
      } else {
        doc.pop();
      }
 
      // reverse doc if we came from a "prev page" click
      // since we then selected with descending: false;
      doc.reverse();
    }
 
    // if result size = page size then remove the last item
    // in the array but store its key for the next page button
    if (doc.length == pageSize) {
      nextPageKey = doc.pop().key;
    }
 
    // if there are results and we are not on the first
    // page then set the key for the previous page button
    if (doc.length > 0 && !firstPage ) {
      prevPageKey = doc[0].key;
    }
 
    // create a list of posts and keys for use in the html
    var postList = {
      posts : doc,
      olderPosts: (nextPageKey != null),
      nextPageKey: 'n' + nextPageKey,
      newerPosts: (prevPageKey != null),
      prevPageKey: 'p' + prevPageKey,
      renderPageNav: ((prevPageKey != null) || (nextPageKey != null))
    };
 
    // send JSON to Mustache
    var html = mu.to_html(templates['list.mu'], postList);
 
    // write HTML to response stream
    response.writeHead(200, { "Content-Type" : "text/html" });
    response.end(html);
    return;
  });
}
by Mikael Lofjärd
Comments
There are no comments for this post yet.

Name:

Home page:

Comment: