servant-util-0.1.0: src/Servant/Util/Combinators/Sorting/Base.hs
module Servant.Util.Combinators.Sorting.Base
( SortingParams
, SortParamsExpanded
, SortingSpec (..)
, ssBase
, ssAll
, SortingOrder (..)
, NullsSortingOrder (..)
, SortingItemTagged (..)
, SortingItem (..)
, TaggedSortingItemsList
, SortingOrderType (..)
, ReifySortingItems (..)
, BaseSortingToParam
, AllSortingParams
, SortingParamProvidedOf
, SortingParamBaseOf
, SortingParamsOf
, SortingSpecOf
) where
import Universum
import Data.List (nubBy)
import Fmt (Buildable (..))
import GHC.TypeLits (KnownSymbol)
import Servant (QueryParam, (:>))
import Servant.API (NoContent)
import Servant.Server (Tagged (..))
import Servant.Util.Common
import qualified Text.Show
{- | Servant API combinator which allows to accept sorting parameters as a query parameter.
Example: with the following combinator
@
SortingParams ["time" ?: Timestamp, "name" ?: Text] '[]
@
the endpoint can parse "sortBy=-time,+name" or "sortBy=desc(time),asc(name)" formats,
which would mean sorting by mentioned fields lexicographically. All sorting
subparameters are optional, as well as entire "sortBy" parameter.
The second type-level list stands for the base sorting order, it will be applied
in the end disregard the user's input.
It is highly recommended to specify the base sorting that unambigously orders the
result(for example - by the primary key of the database), otherwise pagination
may behave unexpectedly for the client when it specifies no sorting.
If you want the base sorting order to be overridable by the user, you can
put the respective fields in both lists. For example, this combinator:
@
SortingParams
'["time" ?: Timestamp]
["id" ?: '(Id, 'Descendant), "time" ?: '(Timestamp, 'Ascendant)]
@
will sort results lexicographically by @(Down id, time)@, but if the client
specifies sorting by time, you will get sorting by @(time, Down id)@ as the
trailing @"time"@ will not affect anything.
It is preferred to put a base sorting at least by @ID@, this way results will be
more deterministic.
Your handler will be provided with 'SortingSpec' argument which can later be passed
in an appropriate function to perform sorting.
-}
data SortingParams
(provided :: [TyNamedParam *])
(base :: [TyNamedParam (SortingOrderType *)])
-- | How servant sees 'SortParams' under the hood.
type SortParamsExpanded allowed subApi =
QueryParam "sortBy" (TaggedSortingItemsList allowed) :> subApi
-- | Order of sorting.
data SortingOrder
= Descendant
| Ascendant
deriving (Show, Eq, Enum)
-- | Where to place null fields.
data NullsSortingOrder
= NullsFirst
| NullsLast
deriving (Show, Eq, Enum)
-- | For a given field, user-supplied order of sorting.
-- This type is primarly for internal use, see also 'SortingItemTagged'.
data SortingItem = SortingItem
{ siName :: Text
-- ^ Name of parameter.
-- Always matches one in @param@, but we keep it at term-level as well for convenience.
, siOrder :: SortingOrder
-- ^ Sorting order on the given parameter.
-- , siNullsOrder :: Maybe NullsSortingOrder
---- ^ Order of null fields.
---- Present only when the second element in @param@ tuple is 'Maybe'.
---- TODO [DSCP-425] add support for this
} deriving (Show)
-- | Version 'SortingItem' which remembers its name and parameter type at type level.
-- In functions which belong to public API you will most probably want to use this datatype
-- as a safer variant of 'SortingItem'.
newtype SortingItemTagged (provided :: TyNamedParam *) = SortingItemTagged
{ untagSortingItem :: SortingItem
} deriving (Show)
instance Buildable SortingItem where
build SortingItem{..} =
let order = case siOrder of { Ascendant -> "⯅ "; Descendant -> "⯆ " }
in order <> build siName
deriving instance Buildable (SortingItemTagged param)
-- | Tagged, because we want to retain list of allowed fields for parsing
-- (in @instance FromHttpApiData@).
type TaggedSortingItemsList provided = Tagged (provided :: [TyNamedParam *]) [SortingItem]
-- | Order of sorting for type-level.
--
-- Its constructors accept the type of thing we order by, e.g. @Asc Id@.
data SortingOrderType k
= Desc k
| Asc k
-- | What is passed to an endpoint, contains all sorting parameters provided by a user.
{- Following properties hold:
1. Each entry in the underlying list has a unique name ('siName' field).
2. Entries correspond to @params@ type, i.e. any 'SortingItem' entry of the underlying
list with name "N" will be present in @params@.
Not all parameters specified by @params@ phantom type can be present, e.g. the underlying
list will be empty if user didn't pass "sortBy" query parameter at all. However,
entries from the base sorting are always present.
-}
data SortingSpec
(provided :: [TyNamedParam *])
(base :: [TyNamedParam (SortingOrderType *)]) =
ReifySortingItems base =>
SortingSpec
{ ssProvided :: [SortingItem]
-- ^ Sorting items provided by the user (lexicographical order).
}
instance Show (SortingSpec provided base) where
show s =
"SortingSpec {ssProvided = " <> show (ssProvided s) <>
", ssBase = " <> show (ssBase s) <> "}"
-- | Base sorting items, that are present disregard the client's input
-- (lexicographical order).
--
-- This is a sort of virtual field, so such naming.
ssBase :: forall base provided. SortingSpec provided base -> [SortingItem]
ssBase SortingSpec{} = reifySortingItems @base
-- | Requires given type-level items to be valid specification of sorting.
class ReifySortingItems (items :: [TyNamedParam (SortingOrderType *)]) where
reifySortingItems :: [SortingItem]
instance ReifySortingItems '[] where
reifySortingItems = []
instance ( ReifySortingOrder order, KnownSymbol name
, ReifySortingItems items
) => ReifySortingItems ('TyNamedParam name (order field) ': items) where
reifySortingItems =
SortingItem
{ siName = symbolValT @name
, siOrder = reifySortingOrder @order
} : reifySortingItems @items
class ReifySortingOrder (order :: * -> SortingOrderType *) where
reifySortingOrder :: SortingOrder
instance ReifySortingOrder 'Asc where
reifySortingOrder = Ascendant
instance ReifySortingOrder 'Desc where
reifySortingOrder = Descendant
-- | All sorting items with duplicates removed (lexicographical order).
ssAll :: SortingSpec provided base -> [SortingItem]
ssAll s = nubBy ((==) `on` siName) (ssProvided s <> ssBase s)
-- | Maps @base@ params to the form common for @provided@ and @base@.
type family BaseSortingToParam (base :: [TyNamedParam (SortingOrderType *)])
:: [TyNamedParam *] where
BaseSortingToParam '[] = '[]
BaseSortingToParam ('TyNamedParam name (order field) ': xs) =
'TyNamedParam name field ': BaseSortingToParam xs
-- | All sorting params, provided + base.
--
-- This does not yet remove duplicates from @provided@ and @base@ sets,
-- we wait for specific use cases to decide how to handle this better.
type family AllSortingParams
(provided :: [TyNamedParam *])
(base :: [TyNamedParam (SortingOrderType *)])
:: [TyNamedParam *] where
AllSortingParams provided base = provided ++ BaseSortingToParam base
-- | For a given return type of an endpoint get corresponding sorting params
-- that can be specified by user.
-- This mapping is sensible, since we usually allow to sort only on fields appearing in
-- endpoint's response.
type family SortingParamProvidedOf a :: [TyNamedParam *]
-- | For a given return type of an endpoint get corresponding base sorting params.
type family SortingParamBaseOf a :: [TyNamedParam (SortingOrderType *)]
-- | This you will most probably want to specify in API.
type SortingParamsOf a = SortingParams (SortingParamProvidedOf a) (SortingParamBaseOf a)
-- | This you will most probably want to specify in an endpoint implementation.
type SortingSpecOf a = SortingSpec (SortingParamProvidedOf a) (SortingParamBaseOf a)
type instance SortingParamBaseOf NoContent = '[]
type instance SortingParamProvidedOf NoContent = '[]