packages feed

ihp-1.4.0: IHP/Pagination/ControllerFunctions.hs

{-|
Module: IHP.Pagination.ControllerFunctions
Description: Paginate results in your actions
Copyright: (c) digitally induced GmbH, 2021
-}
module IHP.Pagination.ControllerFunctions
(   paginate,
    paginateWithOptions,
    filterList,
    defaultPaginationOptions,
    paginatedSqlQuery,
    paginatedSqlQueryWithOptions,
) where

import IHP.Prelude
import IHP.Controller.Context
import IHP.Controller.Param ( paramOrDefault, paramOrNothing )
import IHP.Pagination.Types ( Options(..), Pagination(..) )
import IHP.QueryBuilder ( HasQueryBuilder, filterWhereILike, limit, offset )
import IHP.Fetch (fetchCount)
import IHP.ModelSupport (GetModelByTableName, sqlQuery, sqlQueryScalar, Table)

import Database.PostgreSQL.Simple (FromRow, ToRow, Query(..), Only(Only), (:.)(..))

-- | Paginate a query, with the following default options:
--
-- 1. Maximum items per page: 50. Each page will show at most 50 items.
-- 2. Selector window size: 5. The selector will show the current page, and 5 pages before and after it,
--    if they exist.
--
-- This function should be used inside your controller action. It will do two things:
--
--     1. Using the 'page' (current page number to display) and 'maxItems' (which overrides the set maximum
--        items per page) request parameters, this applies the the needed limit and offset to display the
--        correct page. For instance, page 3 with a maxItems of 50 would produce a limit of 50 and an offset
--        of 100 to display results 100 through 150.
--     2. Returns a 'Pagination' state which should be passed through to your view and then,
--        in turn, 'renderPagination'.
--
-- Example:
--
-- > action UsersAction = do
-- >    (userQ, pagination) <- query @User
-- >        |> orderBy #email
-- >        |> paginate
-- >    user <- userQ |> fetch
-- >    render IndexView { .. }
paginate :: forall controller table queryBuilderProvider joinRegister .
    (?context::ControllerContext
    , ?modelContext :: ModelContext
    , ?theAction :: controller
    , KnownSymbol table
    , HasQueryBuilder queryBuilderProvider joinRegister) =>
    queryBuilderProvider table
    -> IO (queryBuilderProvider table, Pagination)
paginate = paginateWithOptions defaultPaginationOptions

-- | Paginate with ability to override the default options for maximum items per page and selector window size.
--
-- This function should be used inside your controller action. It will do two things:
--
--     1. Using the 'page' (current page number to display) and 'maxItems' (which overrides the set maximum
--        items per page) request parameters, this applies the the needed limit and offset to display the
--        correct page. For instance, page 3 with a maxItems of 50 would produce a limit of 50 and an offset
--        of 100 to display results 100 through 150.
--     2. Returns a 'Pagination' state which should be passed through to your view and then,
--        in turn, 'renderPagination'.
--
-- Example:
--
-- > action UsersAction = do
-- >    (userQ, pagination) <- query @User
-- >        |> orderBy #email
-- >        |> paginateWithOptions
-- >            (defaultPaginationOptions
-- >                |> set #maxItems 10)
-- >    user <- userQ |> fetch
-- >    render IndexView { .. }
paginateWithOptions :: forall controller table queryBuilderProvider joinRegister .
    (?context::ControllerContext
    , ?modelContext :: ModelContext
    , ?theAction :: controller
    , KnownSymbol table
    , HasQueryBuilder queryBuilderProvider joinRegister) =>
    Options
    -> queryBuilderProvider table
    -> IO (queryBuilderProvider table, Pagination)
paginateWithOptions options query = do
    count <- query
        |> fetchCount

    let pageSize = pageSize' options
        pagination = Pagination
            { currentPage = page
            , totalItems = fromIntegral count
            , pageSize = pageSize
            , window = windowSize options
            }

    let results = query
            |> limit pageSize
            |> offset (offset' pageSize page)

    pure
        ( results
        , pagination
        )

-- | Reading from the 'filter' query parameter, filters a query according to the string entered in the
--   filter box by the user (if any), on a given text-based field. Will return any results containing the
--   string in a case-insensitive fashion.
--
-- Example:
--
-- > action UsersAction = do
-- >    (userQ, pagination) <- query @User
-- >        |> orderBy #email
-- >        |> paginate
-- >        |> filterList #email
-- >    user <- userQ |> fetch
-- >    render IndexView { .. }
filterList :: forall name table model queryBuilderProvider joinRegister .
    (?context::ControllerContext
    , KnownSymbol name
    , HasField name model Text
    , model ~ GetModelByTableName table
    , KnownSymbol table
    , HasQueryBuilder queryBuilderProvider joinRegister
    , Table model
    ) =>
    Proxy name
    -> queryBuilderProvider table
    -> queryBuilderProvider table
filterList field =
    case paramOrNothing @Text "filter" of
       Just uf -> filterWhereILike (field, "%" <> uf <> "%")
       Nothing -> id

-- | Default options for a pagination. Can be passed into 'paginateOptions'. The defaults are as follows:
--
-- 1. Maximum items per page: 50. Each page will show at most 50 items.
-- 2. Selector window size: 5. The selector will show the current page, and up to 5 pages before and after it,
--    if they exist.
defaultPaginationOptions :: Options
defaultPaginationOptions =
    Options
        { maxItems = 50
        , windowSize = 5
        }

-- | Runs a raw sql query and adds pagination to it.
--
-- By default, the pagination uses the following options:
-- 1. Maximum items per page: 50. Each page will show at most 50 items.
-- 2. Selector window size: 5. The selector will show the current page, and 5 pages before and after it,
--    if they exist.
--
-- This function should be used inside your controller action. It will do three things:
--
--     1. Using the 'page' (current page number to display) and 'maxItems' (which overrides the set maximum
--        items per page) request parameters, this applies the the needed limit and offset to display the
--        correct page. For instance, page 3 with a maxItems of 50 would produce a limit of 50 and an offset
--        of 100 to display results 100 through 150.
--     2. Returns a 'Pagination' state which should be passed through to your view and then,
--        in turn, 'renderPagination'.
--     3. Actually run the query and return the result.
--
-- __Example:__
--
-- > (users, pagination) <- paginatedSqlQuery "SELECT id, firstname, lastname FROM users" ()
--
-- Take a look at "IHP.QueryBuilder" for a typesafe approach on building simple queries.
--
-- *AutoRefresh:* When using 'paginatedSqlQuery' with AutoRefresh, you need to use 'trackTableRead' to let AutoRefresh know that you have accessed a certain table. Otherwise AutoRefresh will not watch table of your custom sql query.
paginatedSqlQuery
  :: ( FromRow model
     , ToRow parameters
     , ?context :: ControllerContext
     , ?modelContext :: ModelContext
     )
  => Query -> parameters -> IO ([model], Pagination)
paginatedSqlQuery = paginatedSqlQueryWithOptions defaultPaginationOptions

-- | Runs a raw sql query and adds pagination to it.
--
-- This function accepts the same Options as 'paginateWithOptions', but otherwise behaves like 'paginatedSqlQuery'.
--
-- __Example:__
--
-- > (users, pagination) <- paginatedSqlQueryWithOptions
-- >     (defaultPaginationOptions |> set #maxItems 10)
-- >     "SELECT id, firstname, lastname FROM users"
-- >     ()
--
-- Take a look at "IHP.QueryBuilder" for a typesafe approach on building simple queries.
--
-- *AutoRefresh:* When using 'paginatedSqlQuery' with AutoRefresh, you need to use 'trackTableRead' to let AutoRefresh know that you have accessed a certain table. Otherwise AutoRefresh will not watch table of your custom sql query.
paginatedSqlQueryWithOptions
  :: ( FromRow model
     , ToRow parameters
     , ?context :: ControllerContext
     , ?modelContext :: ModelContext
     )
  => Options -> Query -> parameters -> IO ([model], Pagination)
paginatedSqlQueryWithOptions options sql placeholders = do
    count :: Int <- sqlQueryScalar ("SELECT count(subquery.*) FROM (" <> sql <> ") as subquery") placeholders

    let pageSize = pageSize' options
        pagination = Pagination
            { pageSize = pageSize
            , totalItems = fromIntegral count
            , currentPage = fromIntegral page
            , window = windowSize options
            }

    results :: [model] <- sqlQuery
        ("SELECT subquery.* FROM (" <> sql <> ") as subquery LIMIT ? OFFSET ?")
        (placeholders :. Only pageSize :. Only (offset' pageSize page))

    pure (results, pagination)

-- We limit the page size to a maximum of 200, to prevent users from
-- passing in query params with a value that could overload the
-- database (e.g. maxItems=100000)
pageSize' :: (?context::ControllerContext) => Options -> Int
pageSize' options = min (max 1 $ paramOrDefault @Int (maxItems options) "maxItems") 200

-- Page and page size shouldn't be lower than 1.
page :: (?context::ControllerContext) => Int
page = max 1 $ paramOrDefault @Int 1 "page"

offset' :: Int -> Int -> Int
offset' pageSize page = (page - 1) * pageSize