packages feed

quibble-core-0.0.0.1: src/lib/Database/Quibble.hs

{-# LANGUAGE AllowAmbiguousTypes    #-}
{-# LANGUAGE DataKinds              #-}
{-# LANGUAGE FlexibleInstances      #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE KindSignatures         #-}
{-# LANGUAGE MultiParamTypeClasses  #-}
{-# LANGUAGE OverloadedStrings      #-}
{-# LANGUAGE RankNTypes             #-}
{-# LANGUAGE ScopedTypeVariables    #-}
{-# LANGUAGE TypeApplications       #-}
{-# LANGUAGE TypeFamilies           #-}
{-# LANGUAGE UndecidableInstances   #-}

module Database.Quibble
  ( Query
  , RowsOf
  , DBWhere(..), DBRender(..)
  , Expr(..)
  , SortExpr(..)
  , HasTable, HasColumn
  , Inline(..)
  , query, rowsOf
  , where_, sortBy, limit, offset
  , asc, desc
  , (.&&), (.||), (.==), (./=), true, false
  , isNull, isNotNull
  , module Reexport
  )
where

import Data.ByteString ( ByteString )
import Data.ByteString.Builder ( Builder )
import Data.Foldable ( fold )
import Data.Int ( Int16, Int32, Int64 )
import Data.Kind ( Type )
import Data.MonoTraversable ( MonoFoldable, Element, ofoldMap )
import Data.Proxy ( Proxy(..) )
import Data.Sequence ( Seq )
import Data.String ( IsString(..) )
import Data.Time ( UTCTime )
import Data.Time.Format.ISO8601 ( iso8601Show )
import Data.Text ( Text )
import Data.Text.Conversions ( convertText, UTF8(..) )
import Data.UUID ( UUID )
import Data.Word ( Word16, Word32, Word64 )
import Optics.Lens ( Lens', lens )
import Optics.Setter ( over )
import GHC.OverloadedLabels ( IsLabel(..) )
import GHC.TypeLits ( KnownSymbol, Symbol, symbolVal )

import qualified Data.ByteString.Builder as Builder
import qualified Data.ByteString.Lazy as BL
import qualified Data.Sequence as Seq
import qualified Data.Text.Lazy as TL
import qualified Data.UUID as UUID

import Data.Function as Reexport ( (&) )

-- | The constructor is exposed in case you need to unsafely construct
-- | expressions. But you shouldn't rely on it too much.
newtype Expr (ctx :: Type) (ty :: Type) =
  Expr { unExpr :: Builder }

-- | You can think of this as a wrapper around `Expr' that adds sorting direction.
-- | Unfortunately, we can't allow @ASC NULLS LAST@ or friends here because that's
-- | not portable across database.
data SortExpr (ctx :: Type)
  = Asc Builder
  | Desc Builder
  | CustomSort ByteString Builder

-- | Allows us to lookup table name by type.
class KnownSymbol tbl => HasTable (ctx :: Type) (tbl :: Symbol) | ctx -> tbl

-- | Allows us to infer the column type from the table type and column name.
class KnownSymbol col => HasColumn (ctx :: Type) (col :: Symbol) (ty :: Type) | ctx col -> ty

data Query (ctx :: Type) = Query
  { qryWhereCond :: Maybe (Expr ctx Bool)
  , qryLimit :: Maybe Word64
  , qryOffset :: Maybe Word64
  , qrySort :: Seq (SortExpr ctx)
  }

data RowsOf (ctx :: Type) = RowsOf
  { rowsWhereCond :: Maybe (Expr ctx Bool) }

sqlSortExpr :: SortExpr ctx -> Builder
sqlSortExpr sort = case sort of
  Asc expr -> expr <> " ASC"
  Desc expr -> expr <> " DESC"
  CustomSort sortDir expr -> expr <> " " <> Builder.byteString sortDir

sqlQuery :: Query ctx -> ByteString
sqlQuery qry = BL.toStrict $ Builder.toLazyByteString $ mconcat
  [ case qryWhereCond qry of
      Nothing -> mempty
      Just cond -> "\nWHERE " <> unExpr cond
  , if Seq.null (qrySort qry)
      then mempty
      else "\nSORT BY " <> fold (Seq.intersperse ", " $ fmap sqlSortExpr $ qrySort qry)
  , case qryLimit qry of
      Nothing -> mempty
      Just limit -> "\nLIMIT " <> Builder.word64Dec limit
  , case qryOffset qry of
      Nothing -> mempty
      Just offset -> "\nOFFSET " <> Builder.word64Dec offset
  ]

sqlRowsOf :: RowsOf ctx -> ByteString
sqlRowsOf rows = case rowsWhereCond rows of
  Nothing ->
    ""
  Just cond ->
    BL.toStrict $ Builder.toLazyByteString $
      "WHERE " <> unExpr cond

asc :: Expr ctx ty -> SortExpr ctx
asc (Expr bldr) = Asc bldr

desc :: Expr ctx ty -> SortExpr ctx
desc (Expr bldr) = Desc bldr

-- | A `Query' is meant to represent everything in a @SELECT@ statement, other
-- | than the columns selection and the joined tables.
-- | This is meant to be used with @-XTypeApplications@, like @query \@Foo@.
query :: forall ctx. Query ctx
query = Query
  { qryWhereCond = Nothing
  , qryLimit = Nothing
  , qryOffset = Nothing
  , qrySort = Seq.empty
  }

-- | A `RowsOf' is meant to represent the conditions of an @UPDATE@ or @DELETE@.
-- | This is meant to be used with @-XTypeApplications@, like @rowsOf \@Foo@.
rowsOf :: forall ctx. RowsOf ctx
rowsOf = RowsOf
  { rowsWhereCond = Nothing
  }

class DBWhere qry ctx where
  whereCond :: Lens' qry (Maybe (Expr ctx Bool))

  -- | A convenience function for when the user doesn't want to specify any conditions.
  allRows :: qry

instance (ctx ~ ctx') => DBWhere (Query ctx) ctx' where
  whereCond = lens qryWhereCond (\qry cond -> qry { qryWhereCond = cond })
  allRows = query @ctx

instance (ctx ~ ctx') => DBWhere (RowsOf ctx) ctx' where
  whereCond = lens rowsWhereCond (\rows cond -> rows { rowsWhereCond = cond })
  allRows = rowsOf @ctx

class DBRender a where
  renderSQL :: a -> ByteString

instance DBRender (Query ctx) where
  renderSQL = sqlQuery

instance DBRender (RowsOf ctx) where
  renderSQL = sqlRowsOf

-- | Add a condition to the `Query' or `RowsOf'. If @where_@ is used multiple times,
-- | the condition will be @AND@ed together.
-- |
-- | > rowsOf @Foo
-- | >   & where_ (...)
where_ :: DBWhere qry ctx => Expr ctx Bool -> qry -> qry
where_ cond qry =
  over whereCond
    (\existing -> case existing of
      Nothing -> Just cond
      Just cond' -> Just (cond' .&& cond))
    qry

-- | Set the number of rows specified by a @LIMIT@. If @limit@ is used multiple
-- | times, only the result of the last call matters.
-- |
-- | > query @Foo
-- | >   & limit 50
limit :: Word64 -> Query ctx -> Query ctx
limit rows qry = qry
  { qryLimit = Just rows
  }

-- | Set the number of rows specified by an @OFFSET@. If @offset@ is used
-- | multiple times, only the result of the last call matters.
-- |
-- | > query @Foo
-- | >   & offset 100
-- |
-- | Note that in the general case, your query will still pay the cost of
-- | looking up all the rows that were skipped! Be careful when using @OFFSET@.
offset :: Word64 -> Query ctx -> Query ctx
offset rows qry = qry
  { qryOffset = Just rows
  }

-- | Specify how the output should be sorted. If @sortBy@ is used multiple times,
-- | all sort expressions are concatenated together, with later calls having
-- | lower sort precedence.
-- |
-- | @-XOverloadedLists@ can help make specifying the list of sort expressions
-- | simpler.
-- |
-- | > query @Foo
-- | >   & sortBy [asc #col1, desc #col2]
sortBy :: Seq (SortExpr ctx) -> Query ctx -> Query ctx
sortBy sorts qry = qry
  { qrySort = qrySort qry <> sorts
  }

escapeCharSeq :: (MonoFoldable t, Element t ~ Char) => t -> Builder
escapeCharSeq t =
  mconcat ["E'", ofoldMap escapeChar t, "'"]
  where
    escapeChar :: Char -> Builder
    escapeChar '\\' = "\\\\"
    escapeChar '\'' = "''"
    escapeChar '?'  = "\\x3F"
    escapeChar c | fromEnum c > 127 = Builder.int32HexFixed (fromIntegral $ fromEnum c)
    escapeChar other = Builder.char7 other

escapeByteString :: ByteString -> Builder
escapeByteString bs =
  mconcat ["'\\x", Builder.byteStringHex bs, "'"]

instance IsString (Expr ctx ByteString) where
  fromString s =
    let UTF8 bs = convertText s
    in Expr (escapeByteString bs)

instance IsString (Expr ctx BL.ByteString) where
  fromString s =
    let UTF8 bs = convertText s
    in Expr (escapeByteString bs)

instance IsString (Expr ctx Text) where
  fromString s = Expr (escapeCharSeq s)

instance IsString (Expr ctx TL.Text) where
  fromString s = Expr (escapeCharSeq s)

instance IsString (Expr ctx String) where
  fromString s = Expr (escapeCharSeq s)

instance (Show a, Num a) => Num (Expr ctx a) where
  (+) (Expr l) (Expr r) = Expr (mconcat ["(", l, " + ", r, ")"])
  (-) (Expr l) (Expr r) = Expr (mconcat ["(", l, " - ", r, ")"])
  (*) (Expr l) (Expr r) = Expr (mconcat ["(", l, " * ", r, ")"])
  negate (Expr l) = Expr (mconcat ["-", l])
  abs (Expr x) = Expr (mconcat ["ABS(", x, ")"])
  signum (Expr x) = Expr (mconcat ["SIGN(", x, ")"])
  fromInteger i =
    let UTF8 bs = convertText $ show (fromInteger i :: a)
    in Expr (Builder.byteString bs)

instance (Show a, Fractional a) => Fractional (Expr ctx a) where
  (/) (Expr l) (Expr r) = Expr (mconcat ["(", l, " / ", r, ")"])
  fromRational r =
    let UTF8 bs = convertText $ show (fromRational r :: a)
    in Expr (Builder.byteString bs)

-- This instance is not currently Unicode-aware, because we don't use
-- the quoted syntax for identifiers.
instance
  ( KnownSymbol tbl
  , HasTable ctx tbl
  , HasColumn ctx col ty
  ) => IsLabel col (Expr ctx ty) where
  fromLabel = Expr $ mconcat
    [ Builder.string7 $ symbolVal $ Proxy @tbl
    , "."
    , Builder.string7 $ symbolVal $ Proxy @col
    ]

true :: Expr ctx Bool
true = Expr "TRUE"

false :: Expr ctx Bool
false = Expr "FALSE"

isNull :: Expr ctx a -> Expr ctx Bool
isNull (Expr x) = Expr (mconcat [x, " IS NULL"])

isNotNull :: Expr ctx a -> Expr ctx Bool
isNotNull (Expr x) = Expr (mconcat [x, " IS NOT NULL"])

infixl 0 .&&
(.&&) :: Expr ctx Bool -> Expr ctx Bool -> Expr ctx Bool
(.&&) (Expr l) (Expr r) = Expr (mconcat ["(", l, " AND ", r, ")"])

infixl 1 .||
(.||) :: Expr ctx Bool -> Expr ctx Bool -> Expr ctx Bool
(.||) (Expr l) (Expr r) = Expr (mconcat ["(", l, " OR ", r, ")"])

(./=) :: Expr ctx a -> Expr ctx a -> Expr ctx Bool
(./=) (Expr l) (Expr r) = Expr (mconcat [l, " <> ", r])

(.==) :: Expr ctx a -> Expr ctx a -> Expr ctx Bool
(.==) (Expr l) (Expr r) = Expr (mconcat [l, " = ", r])

class Inline ty where
  inline :: ty -> Expr ctx ty

instance Inline ByteString where
  inline s = Expr (escapeByteString s)
instance Inline BL.ByteString where
  inline s = Expr (escapeByteString $ BL.toStrict s)
instance Inline Text where
  inline s = Expr (escapeCharSeq s)
instance Inline TL.Text where
  inline s = Expr (escapeCharSeq s)
instance Inline String where
  inline s = Expr (escapeCharSeq s)

instance Inline Bool where
  inline b = if b then true else false

instance Inline Int16 where
  inline n = Expr (Builder.int16Dec n)
instance Inline Int32 where
  inline n = Expr (Builder.int32Dec n)
instance Inline Int64 where
  inline n = Expr (Builder.int64Dec n)

instance Inline Word16 where
  inline n = Expr (Builder.word16Dec n)
instance Inline Word32 where
  inline n = Expr (Builder.word32Dec n)
instance Inline Word64 where
  inline n = Expr (Builder.word64Dec n)

instance Inline UUID where
  inline uuid = Expr $ mconcat
    ["'", Builder.byteString $ UUID.toASCIIBytes uuid, "'"]

instance Inline UTCTime where
  inline t = Expr $ mconcat
    ["'", Builder.string7 $ iso8601Show t, "'"]