packages feed

postgresql-types-0.1: src/library/PostgresqlTypes/Timetz.hs

module PostgresqlTypes.Timetz
  ( Timetz,

    -- * Accessors
    toTimeInMicroseconds,
    toTimeZoneInSeconds,
    toTimeOfDay,
    normalizeToTimeZone,
    refineToTimeZone,

    -- * Constructors
    normalizeFromTimeInMicrosecondsAndOffsetInSeconds,
    normalizeFromTimeOfDayAndTimeZone,
    refineFromTimeInMicrosecondsAndOffsetInSeconds,
    refineFromTimeOfDayAndTimeZone,
  )
where

import qualified Data.Attoparsec.Text as Attoparsec
import qualified Data.Text as Text
import qualified Data.Time as TimeLib
import PostgresqlTypes.Algebra
import PostgresqlTypes.Prelude
import qualified PostgresqlTypes.Timetz.Offset as Offset
import qualified PostgresqlTypes.Timetz.Time as Time
import PostgresqlTypes.Via
import qualified PtrPeeker

-- | PostgreSQL @timetz@ type. Time of day with time zone.
--
-- Stored as microseconds since midnight and time zone offset in seconds.
--
-- Low value: @00:00:00+1559@. High value: @24:00:00-1559@.
--
-- [PostgreSQL docs](https://www.postgresql.org/docs/18/datatype-datetime.html#DATATYPE-TIMEZONES).
data Timetz
  = Timetz
      -- | Time as microseconds since midnight (00:00:00)
      Time.TimetzTime
      -- | Timezone offset in seconds (positive is east of UTC, negative is west of UTC)
      Offset.TimetzOffset
  deriving stock (Eq, Ord)
  deriving (Show, Read, IsString) via (ViaIsScalar Timetz)

instance Arbitrary Timetz where
  arbitrary = do
    time <- arbitrary
    offset <- arbitrary
    pure (Timetz time offset)

instance Hashable Timetz where
  hashWithSalt salt (Timetz time offset) =
    salt `hashWithSalt` Time.toMicroseconds time `hashWithSalt` Offset.toSeconds offset

instance IsScalar Timetz where
  schemaName = Tagged Nothing
  typeName = Tagged "timetz"
  baseOid = Tagged (Just 1266)
  arrayOid = Tagged (Just 1270)
  typeParams = Tagged []

  binaryEncoder (Timetz time offset) =
    mconcat
      [ Time.binaryEncoder time,
        Offset.binaryEncoder offset
      ]

  binaryDecoder =
    PtrPeeker.fixed do
      time <- Time.binaryDecoder
      offset <- Offset.binaryDecoder
      pure (Timetz <$> time <*> offset)

  -- Format:
  -- 23:59:59-15:59:59
  -- 24:00:00-15:59:59
  -- 00:00:00+15:59:59
  textualEncoder (Timetz time offset) =
    Time.renderInTextFormat time <> Offset.renderInTextFormat offset
  textualDecoder = do
    -- Parse time part: HH:MM:SS[.microseconds]
    h <- twoDigits
    _ <- Attoparsec.char ':'
    m <- twoDigits
    _ <- Attoparsec.char ':'
    s <- twoDigits
    micros <- Attoparsec.option 0 parseFraction
    -- Parse time zone offset: [+-]HH[:MM[:SS]]
    -- PostgreSQL omits minutes and seconds when they are zero
    sign <- (1 <$ Attoparsec.char '+') <|> ((-1) <$ Attoparsec.char '-')
    tzH <- twoDigits
    tzM <- Attoparsec.option 0 (Attoparsec.char ':' *> twoDigits)
    tzS <- Attoparsec.option 0 (Attoparsec.char ':' *> twoDigits)
    -- Build time and offset
    let timeMicros = fromIntegral h * 3600_000_000 + fromIntegral m * 60_000_000 + fromIntegral s * 1_000_000 + micros
        -- Note: PostgreSQL stores offset with inverted sign (positive means west of UTC)
        offsetSeconds = negate sign * (fromIntegral tzH * 3600 + fromIntegral tzM * 60 + fromIntegral tzS)
    case (Time.refineFromMicroseconds timeMicros, Offset.refineFromSeconds offsetSeconds) of
      (Just time, Just offset) -> pure (Timetz time offset)
      _ -> fail "Invalid timetz value"
    where
      twoDigits = do
        a <- Attoparsec.digit
        b <- Attoparsec.digit
        pure (digitToInt a * 10 + digitToInt b)
      parseFraction = do
        _ <- Attoparsec.char '.'
        digits <- Attoparsec.takeWhile1 isDigit
        let paddedDigits = take 6 (Text.unpack digits ++ repeat '0')
            micros = foldl' (\acc c -> acc * 10 + fromIntegral (digitToInt c)) 0 paddedDigits
        pure micros

-- * Accessors

-- | Extract time in microseconds since midnight.
toTimeInMicroseconds :: Timetz -> Int64
toTimeInMicroseconds (Timetz time _) = Time.toMicroseconds time

-- | Extract time zone offset in seconds.
toTimeZoneInSeconds :: Timetz -> Int32
toTimeZoneInSeconds (Timetz _ offset) = Offset.toSeconds offset

-- | Extract time of day.
toTimeOfDay :: Timetz -> TimeLib.TimeOfDay
toTimeOfDay (Timetz time _) = Time.toTimeOfDay time

-- | Extract time zone rounding the offset in seconds to the nearest minute, because that's the precision supported by 'TimeLib.TimeZone'.
normalizeToTimeZone :: Timetz -> TimeLib.TimeZone
normalizeToTimeZone (Timetz _ offset) = Offset.normalizeToTimeZone offset

-- | Try to extract time zone, failing if the offset in seconds is not a multiple of 60.
refineToTimeZone :: Timetz -> Maybe TimeLib.TimeZone
refineToTimeZone (Timetz _ offset) = Offset.refineToTimeZone offset

-- * Constructors

-- | Construct 'Timetz' from time in microseconds since midnight and time zone offset in seconds, clamping the out of range values.
normalizeFromTimeInMicrosecondsAndOffsetInSeconds :: Int64 -> Int32 -> Timetz
normalizeFromTimeInMicrosecondsAndOffsetInSeconds microseconds offset =
  Timetz (Time.normalizeFromMicroseconds microseconds) (Offset.normalizeFromSeconds offset)

-- | Construct 'Timetz' from 'TimeLib.TimeOfDay' and 'TimeLib.TimeZone', clamping the out of range values.
normalizeFromTimeOfDayAndTimeZone :: TimeLib.TimeOfDay -> TimeLib.TimeZone -> Timetz
normalizeFromTimeOfDayAndTimeZone timeOfDay timeZone =
  let time = Time.normalizeFromTimeOfDay timeOfDay
      offset = Offset.normalizeFromTimeZone timeZone
   in Timetz time offset

-- | Try to construct 'Timetz' from time in microseconds since midnight and time zone offset in seconds, failing if out of range.
refineFromTimeInMicrosecondsAndOffsetInSeconds :: Int64 -> Int32 -> Maybe Timetz
refineFromTimeInMicrosecondsAndOffsetInSeconds microseconds offset = do
  time <- Time.refineFromMicroseconds microseconds
  offset <- Offset.refineFromSeconds offset
  pure (Timetz time offset)

-- | Try to construct 'Timetz' from 'TimeLib.TimeOfDay' and 'TimeLib.TimeZone', failing if out of range.
refineFromTimeOfDayAndTimeZone :: TimeLib.TimeOfDay -> TimeLib.TimeZone -> Maybe Timetz
refineFromTimeOfDayAndTimeZone timeOfDay timeZone = do
  time <- Time.refineFromTimeOfDay timeOfDay
  offset <- Offset.refineFromTimeZone timeZone
  pure (Timetz time offset)