orville-postgresql-1.1.0.0: src/Orville/PostgreSQL/Raw/PgTime.hs
{- |
Copyright : Flipstone Technology Partners 2023-2025
License : MIT
Stability : Stable
@since 1.0.0.0
-}
module Orville.PostgreSQL.Raw.PgTime
( dayToPostgreSQL
, day
, utcTimeToPostgreSQL
, utcTime
, localTimeToPostgreSQL
, localTime
)
where
import Control.Applicative ((<|>))
import Control.Monad (when)
import qualified Data.Attoparsec.ByteString as AttoBS
import qualified Data.Attoparsec.ByteString.Char8 as AttoB8
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as B8
import qualified Data.Char as Char
import qualified Data.Fixed as Fixed
import qualified Data.Time as Time
import qualified Data.Word as Word
import qualified Text.Printf as Printf
{- | Renders a 'Time.Day' value to a textual representation for PostgreSQL.
@since 1.0.0.0
-}
dayToPostgreSQL :: Time.Day -> B8.ByteString
dayToPostgreSQL date =
case mkCommonEraDay date of
(ce, ced) ->
B8.pack (renderCommonEraDay ced <> renderCommonEraSuffix ce)
{- | An Attoparsec parser for parsing 'Time.Day' from a string in PostgreSQL's ISO style
(YYYY-MM-DD[ BC]) format. Parsing fails if given an invalid 'Time.Day'.
@since 1.0.0.0
-}
day :: AttoB8.Parser Time.Day
day = do
ced <- commonEraDay
ce <- parseCommonEraSuffix
commonEraDayToISO8601Day ce ced
commonEraDay :: AttoB8.Parser CommonEraDay
commonEraDay = do
(y, yearCount) <- decimalWithCount <* AttoB8.char '-'
when (yearCount < 4) (fail "invalid date format")
m <- twoDigits <* AttoB8.char '-'
d <- twoDigits
pure $ CommonEraDay y m d
{- | Renders a 'Time.UTCTime' value to a textual representation for PostgreSQL.
@since 1.0.0.0
-}
utcTimeToPostgreSQL :: Time.UTCTime -> B8.ByteString
utcTimeToPostgreSQL time =
let
-- DiffTime lacks support for %Q (fractional seconds), so be sure to format
-- via a type that supports %Q.
formattedTime =
Time.formatTime Time.defaultTimeLocale "%0H:%0M:%0S%Q+00"
. Time.timeToTimeOfDay
$ Time.utctDayTime time
in
case mkCommonEraDay (Time.utctDay time) of
(ce, ced) ->
B8.pack $
renderCommonEraDay ced <> " " <> formattedTime <> renderCommonEraSuffix ce
{- | An Attoparsec parser for 'Time.UTCTime' from a PostgreSQL ISO style timestamptz format.
@since 1.0.0.0
-}
utcTime :: AttoB8.Parser Time.UTCTime
utcTime = do
ced <- commonEraDay <* AttoB8.char ' '
time <- timeOfDay
offset <- signedTimezoneOffset
ce <- parseCommonEraSuffix
validDay <- commonEraDayToISO8601Day ce ced
pure $ Time.addUTCTime offset (Time.UTCTime validDay (Time.timeOfDayToTime time))
signedTimezoneOffset :: AttoB8.Parser Time.NominalDiffTime
signedTimezoneOffset = do
sign <- AttoB8.satisfy (\char -> char == '+' || char == '-')
case sign of
'+' -> fmap negate timezoneOffset
_ -> timezoneOffset
timezoneOffset :: AttoB8.Parser Time.NominalDiffTime
timezoneOffset = do
h <- twoDigits
m <- AttoB8.option 0 (AttoB8.char ':' *> twoDigits)
s <- AttoB8.option 0 (AttoB8.char ':' *> twoDigits)
pure . fromIntegral $ (s :: Int) + m * 60 + h * 3600
{- | Renders a 'Time.LocalTime' value to a textual representation for PostgreSQL.
@since 1.0.0.0
-}
localTimeToPostgreSQL :: Time.LocalTime -> B8.ByteString
localTimeToPostgreSQL time =
let
formattedTime =
Time.formatTime Time.defaultTimeLocale "%0H:%0M:%0S%Q" $
Time.localTimeOfDay time
in
case mkCommonEraDay (Time.localDay time) of
(ce, ced) ->
B8.pack $
renderCommonEraDay ced <> " " <> formattedTime <> renderCommonEraSuffix ce
{- | An Attoparsec parser for 'Time.LocalTime' from PostgreSQL's ISO style timestamp format.
@since 1.0.0.0
-}
localTime :: AttoB8.Parser Time.LocalTime
localTime = do
ced <- commonEraDay <* AttoB8.char ' '
time <- timeOfDay
ce <- parseCommonEraSuffix
validDay <- commonEraDayToISO8601Day ce ced
pure $ Time.LocalTime validDay time
{- | An Attoparsec parser for 'Time.TimeOfDay' from PostgreSQL's ISO style time format.
@since 1.0.0.0
-}
timeOfDay :: AttoB8.Parser Time.TimeOfDay
timeOfDay = do
h <- twoDigits <* AttoB8.char ':'
m <- twoDigits <* AttoB8.char ':'
s <- seconds
case Time.makeTimeOfDayValid h m s of
Nothing -> fail "invalid time format"
Just validTime -> pure validTime
{- | An Attoparsec parser for parsing 'Fixed.Pico' from SS[.sss] format. This can
handle more resolution than PostgreSQL uses, and will truncate the seconds
fraction if more than 12 digits are present.
@since 1.0.0.0
-}
seconds :: AttoB8.Parser Fixed.Pico
seconds = do
s <- twoDigits
(dec, charCount) <- AttoB8.option (0, 0) (AttoB8.char '.' *> decimalWithCount)
if charCount >= 12
then pure . Fixed.MkFixed $ (s * 10 ^ (12 :: Int)) + (dec `div` 10 ^ (charCount - 12))
else pure . Fixed.MkFixed $ (s * 10 ^ (12 :: Int)) + (dec * 10 ^ (12 - charCount))
{- | An Attoparsec parser for parsing 2-digit integral numbers.
@since 1.0.0.0
-}
twoDigits :: Integral a => AttoB8.Parser a
twoDigits = do
tens <- fmap (fromIntegral . Char.digitToInt) AttoB8.digit
ones <- fmap (fromIntegral . Char.digitToInt) AttoB8.digit
pure $ tens * 10 + ones
{- | An Attoparsec parser for parsing a base-10 number. Returns the number of
digits consumed. Based off of 'AttoB8.decimal'.
@since 1.0.0.0
-}
decimalWithCount :: Integral a => AttoB8.Parser (a, a)
decimalWithCount = do
wrds <- AttoBS.takeWhile1 AttoB8.isDigit_w8
pure (BS.foldl' appendDigit 0 wrds, fromIntegral $ BS.length wrds)
appendDigit :: Integral a => a -> Word.Word8 -> a
appendDigit a w = a * 10 + fromIntegral (w - 48)
data CommonEra
= BCE
| CE
renderCommonEraSuffix :: CommonEra -> String
renderCommonEraSuffix ce =
case ce of
BCE -> " BC"
CE -> mempty
parseCommonEraSuffix :: AttoB8.Parser CommonEra
parseCommonEraSuffix =
(BCE <$ AttoB8.string (B8.pack " BC") <|> pure CE) <* AttoB8.endOfInput
data CommonEraDay = CommonEraDay
{ _commonEraDayYear :: Integer
, _commonEraDayMonth :: Int
, _commonEraDayDay :: Int
}
mkCommonEraDay :: Time.Day -> (CommonEra, CommonEraDay)
mkCommonEraDay date =
case Time.toGregorian date of
(y, m, d) ->
fmap
(\cey -> CommonEraDay cey m d)
(if y <= 0 then (BCE, 1 - y) else (CE, y))
renderCommonEraDay :: CommonEraDay -> String
renderCommonEraDay (CommonEraDay y m d) =
Printf.printf "%04d-%02d-%02d" y m d
commonEraDayToISO8601Day :: MonadFail m => CommonEra -> CommonEraDay -> m Time.Day
commonEraDayToISO8601Day ce (CommonEraDay y m d) =
let
iso8601Year = case ce of
BCE -> 1 - y
CE -> y
in
case Time.fromGregorianValid iso8601Year m d of
Nothing -> fail "invalid date"
Just valid -> pure valid