Pagination in Yesod - from naive to monads
Posted on Oct 10, 2016 by Alexej Bondarenko
There are some examples for Yesod apps out there. Mostly very complete but not very beginner friendly. One common use case (for me) is pagination. I was very surprised to see that this is covered nowhere at all. Just the simpliest "Building my own blog"-example is missing such useful administration features like "paginate through all blog posts".
Sure, there are two plugin solutions out there: yesod-pagination and yesod-paginator (YES, different things!). Both libraries try to cover pagination (but have only one basic, simple example and no documentation at all. Plus, I'm pretty sure nobody maintains this anymore).
Hence, I went all the way on my own (at least you learn much more this way) and integrate it into this blog. What do we need for pagination? We need a GET query parameter, let's call it "page". Furthermore we need the overall amount of valid (active, published) entries to calculate our offsets, nextPage and previousPage. To pull the query parameter inside the do notaion we can use lookupGetParam in our Handler like this:
getBlogR :: Handler Html getBlogR = do pageParam <- lookupGetParam "page" defaultLayout $ do $(widgetFile "sometemplate")
This will give us a Maybe Text, a Just in case the parameter is present and a Nothing if the parameter is missing. Since we need an Int instead of Text we need to transform this into Int by using Text.Read.readMaybe:
parseInt :: Maybe Text -> Maybe Int parseInt maybeText = case maybeText of Just text -> readMaybe $ unpack text Nothing -> Nothing
Great! We have a simple, type-safe way to determine the page parameter. readMaybe works on Strings, so we have to unpack it first. Furthermore, by defining the result type as Maybe Int we are able to tell Haskell what we expect by calling the polymorphic readMaybe function.
Let's get our overall count for all (visible) blog posts:
getBlogR :: Handler Html getBlogR = do pageParam <- lookupGetParam "page" entriesCount <- runDB $ selectCount $ \entry -> do E.where_ (entry ^. BlogPostPublishedAt E.<=. E.val now) defaultLayout $ do $(widgetFile "sometemplate")
The selectCount function is taken from StackOverflow. I don't understand why this is not built-in in Esqueleto. Any other query - following the examples on the Esqueleto page regarding E.countRows - just does not work (for me)!
With the overall count and an optional page parameter we can now go on and define our previousPage and nextPage functions:
previousPage :: Maybe Int -> Maybe Int previousPage maybePage = case maybePage of Just value -> -- calculating previous page depending on current page if value > 0 then Just $ value - 1 else Nothing Nothing -> -- if we have no page param, we can not have a previous page Nothing
nextPage :: Maybe Int -> Int -> Int -> Maybe Int nextPage maybePage entries pageSize = case maybePage of Just currentPage -> -- check if we have a next page if (currentPage + 1) * pageSize > entries then Nothing else Just $ currentPage + 1 Nothing -> if entries > pageSize then Just 1 else Nothing
Our nextPage function takes two additional parameters to calculate the next page because we don't want to show a next-page link if we don't have any further entries.
With the definition of both functions we can now start to use our pagination inside the handler. Please pay attention that we are working only with the Maybe monad here and can not use it directly in the do block of the Handler. Instead we will use let:
getBlogR :: Handler Html getBlogR = do pageParam <- lookupGetParam "page" let maybePage = parseInt pageParam entriesCount <- runDB $ selectCount $ \entry -> do E.where_ (entry ^. BlogPostPublishedAt E.<=. E.val now) let next = nextPage maybePage entries 15 previous = previousPage maybePage defaultLayout $ do $(widgetFile "sometemplate")
We can now use maybePage for offset calculation, next to display a next link and previous to display a previous link. This is - what I call - a naive (coming from imperative world) implementation. If you have a closer look at all function, we made use of the switch/case notation a lot. This is often an indication to use monads. Furthermore we have to stick to our let definitions inside the do block of our handler function.
How can we improve this code ? By having a look at the type definition of our getBlogR function you can see that the do block locks us down in the Handler monad. Hence, we need to lift our computations of parseInt, nextPage and previousPage into it. Here, the liftM function comes in (a -> m a). Let's have a look if we can refactor the pageParam part. Wouldn't it be nice to just say "page <- getCurrentPage" so that page is an Int we can continue to work with?
getCurrentPage :: Yesod m => HandlerT m IO Int getCurrentPage = liftM (fromMaybe 0 . getIntParam) $ lookupGetParam "page" getIntParam :: Maybe Text -> Maybe Int getIntParam maybeText = do text <- maybeText parseInt text parseInt :: Text -> Maybe Int parseInt text = do readMaybe $ unpack text
Whoa, wait a minute! What happens here? First of all, we defined the monadic result type so we can use our function inside the do block of our Handler. As you can see, we still use lookupGetParam "page" which will give us a Maybe Text. We apply getIntParam which is our parseInt function, using do notation instead of . On the result (Maybe Int) we apply fromMaybe with a default value 0 if the outcome is Nothing. This will help us to calculate the offset by assuming the default page is always 0. And as the last step we lift our result (Int) to our monad. Let's do this with nextPage and previousPage as well:
previousPage :: Yesod m => HandlerT m IO (Maybe Int) previousPage = liftM (calculatePreviousPage . fromMaybe 0 . getIntParam) $ lookupGetParam "page" nextPage :: Yesod m => Int -> Int -> HandlerT m IO (Maybe Int) nextPage entries pageSize = liftM (calculate . fromMaybe 0 . getIntParam) $ lookupGetParam "page" where calculate = calculateNextPage entries pageSize calculatePreviousPage :: Int -> Maybe Int calculatePreviousPage currentPage = if currentPage <= 0 then Nothing else Just $ currentPage - 1 calculateNextPage :: Int -> Int -> Int -> Maybe Int calculateNextPage entries pageSize currentPage = if (currentPage + 1) * pageSize > entries then Nothing else Just $ currentPage + 1
Looks much nicer as having to write the switch/case over and over again. Did you notice how we stick functions together to implement our nextPage and previousPage examples? Now, there is a small little and nasty bug inside this code. In our case a page can also be -1,-2,-3,-4,... Let's manually fix this by adding a new function:
ensurePositive :: Int -> Int ensurePositive value = if value < 0 then 0 else value
Easy, right? Now just stick this one to our getCurrentPage method:
getCurrentPage :: Yesod m => HandlerT m IO Int getCurrentPage = liftM (ensurePositive . fromMaybe 0 . getIntParam) $ lookupGetParam "page"
We don't need to apply ensurePositive to nextPage and previousPage because the constrains in calculateNextPage and calculatePreviousPage already take care of it. As the last step, let's refactor our Handler:
getBlogR :: Handler Html getBlogR = do page <- getCurrentPage entriesCount <- runDB $ selectCount $ \entry -> do E.where_ (entry ^. BlogPostPublishedAt E.<=. E.val now) next <- nextPage entries 15 previous <- previousPage defaultLayout $ do $(widgetFile "sometemplate")
You can now use all three (page :: Int, next :: Maybe Int and previous :: Maybe Int) in your template or inside the offset calculation of your query.
If you have any comments, suggestions or questions please use the section below.