packages feed

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

module PostgresqlTypes.Date
  ( Date,

    -- * Accessors
    toDay,

    -- * Constructors
    refineFromDay,
    normalizeFromDay,
  )
where

import qualified Data.Attoparsec.Text as Attoparsec
import qualified Data.Time as Time
import PostgresqlTypes.Algebra
import PostgresqlTypes.Prelude
import PostgresqlTypes.Via
import qualified PtrPeeker
import qualified PtrPoker.Write as Write
import qualified Test.QuickCheck as QuickCheck
import qualified TextBuilder

-- | PostgreSQL @date@ type. Calendar date (year, month, day).
--
-- Range: @4713 BC@ to @5874897 AD@.
--
-- [PostgreSQL docs](https://www.postgresql.org/docs/18/datatype-datetime.html#DATATYPE-DATE).
newtype Date
  = -- | Days since PostgreSQL epoch (2000-01-01).
    Date Int32
  deriving newtype (Eq, Ord, Hashable)
  deriving (Show, Read, IsString) via (ViaIsScalar Date)

-- | PostgreSQL date range: 4713 BC to 5874897 AD.
--
-- Minimum date: 4713 BC January 1 = -4712-01-01 in ISO format.
--
-- Maximum date: 5874897 AD December 31 = 5874897-12-31 in ISO format.
instance Bounded Date where
  minBound = unsafeFromDay minDay
  maxBound = unsafeFromDay maxDay

-- | Custom Arbitrary instance that generates dates within PostgreSQL's supported range.
-- PostgreSQL supports dates from 4713 BC to 5874897 AD.
instance Arbitrary Date where
  arbitrary =
    QuickCheck.frequency
      [ ( 99,
          let minBase = coerce (minBound @Date)
              maxBase = coerce (maxBound @Date)
           in Date <$> QuickCheck.choose (minBase, maxBase)
        ),
        ( 1,
          do
            y <- QuickCheck.choose (-1, 1)
            m <- QuickCheck.choose (1, 12)
            d <- QuickCheck.choose (1, 31)
            pure (unsafeFromDay (Time.fromGregorian y m d))
        )
      ]

instance IsScalar Date where
  schemaName = Tagged Nothing
  typeName = Tagged "date"
  baseOid = Tagged (Just 1082)
  arrayOid = Tagged (Just 1182)
  typeParams = Tagged []
  binaryEncoder (Date days) = Write.bInt32 days
  binaryDecoder = do
    days <- PtrPeeker.fixed PtrPeeker.beSignedInt4
    pure (Right (Date days))
  textualEncoder date =
    let day = toDay date
        (y, m, d) = Time.toGregorian day
        (y', bc) =
          if y <= 0
            then (negate y + 1, True)
            else (y, False)
     in mconcat
          [ if y' > 9999
              then TextBuilder.decimal y'
              else TextBuilder.fixedLengthDecimal 4 y',
            "-",
            TextBuilder.fixedLengthDecimal 2 m,
            "-",
            TextBuilder.fixedLengthDecimal 2 d,
            if bc then " BC" else ""
          ]
  textualDecoder = do
    -- Parse year (may have more than 4 digits for extreme dates)
    y <- Attoparsec.decimal
    _ <- Attoparsec.char '-'
    m <- twoDigits
    _ <- Attoparsec.char '-'
    d <- twoDigits
    -- Check for BC suffix
    bc <- Attoparsec.option False (True <$ (Attoparsec.skipSpace *> Attoparsec.string "BC"))
    let year = if bc then negate y + 1 else y
    -- Try to use fromGregorianValid for normal dates, fallback to custom calculation for extreme dates
    case Time.fromGregorianValid year m d of
      Just day -> pure (unsafeFromDay day)
      Nothing ->
        -- For extreme dates outside time library's range, compute days directly
        -- This is a simplified calculation that may not be perfectly accurate for all historical dates
        -- but matches PostgreSQL's handling of extreme dates
        let yearsSinceEpoch = year - 2000
            daysFromYears = fromIntegral yearsSinceEpoch * 365 + fromIntegral (yearsSinceEpoch `div` 4)
            getDaysInMonth mon =
              case mon of
                1 -> (31 :: Int)
                2 -> if isLeapYear year then (29 :: Int) else (28 :: Int)
                3 -> (31 :: Int)
                4 -> (30 :: Int)
                5 -> (31 :: Int)
                6 -> (30 :: Int)
                7 -> (31 :: Int)
                8 -> (31 :: Int)
                9 -> (30 :: Int)
                10 -> (31 :: Int)
                11 -> (30 :: Int)
                12 -> (31 :: Int)
                _ -> (0 :: Int)
            monthDays = foldl' (+) 0 [if mon < m then getDaysInMonth mon else 0 | mon <- [1 .. 12]]
         in pure (Date (daysFromYears + fromIntegral monthDays + fromIntegral d - 1))
    where
      twoDigits = do
        a <- Attoparsec.digit
        b <- Attoparsec.digit
        pure (digitToInt a * 10 + digitToInt b)
      isLeapYear y = (y `mod` 4 == 0 && y `mod` 100 /= 0) || (y `mod` 400 == 0)

-- | Mapping to @daterange@ type.
instance IsRangeElement Date where
  rangeTypeName = Tagged "daterange"
  rangeBaseOid = Tagged (Just 3912)
  rangeArrayOid = Tagged (Just 3913)

-- | Mapping to @datemultirange@ type.
instance IsMultirangeElement Date where
  multirangeTypeName = Tagged "datemultirange"
  multirangeBaseOid = Tagged (Just 4535)
  multirangeArrayOid = Tagged (Just 6155)

-- | PostgreSQL epoch is 2000-01-01
postgresEpoch :: Time.Day
postgresEpoch = Time.fromGregorian 2000 1 1

-- * Accessors

-- | Convert Date to Day
toDay :: Date -> Time.Day
toDay (Date days) = Time.addDays (fromIntegral days) postgresEpoch

-- * Constructors

refineFromDay :: Time.Day -> Maybe Date
refineFromDay day
  | day < minDay || day > maxDay = Nothing
  | otherwise = Just (unsafeFromDay day)

normalizeFromDay :: Time.Day -> Date
normalizeFromDay day
  | day < minDay = minBound
  | day > maxDay = maxBound
  | otherwise = unsafeFromDay day

unsafeFromDay :: Time.Day -> Date
unsafeFromDay day = Date (fromIntegral (Time.diffDays day postgresEpoch))

minDay :: Time.Day
minDay = Time.fromGregorian (-4712) 1 1

maxDay :: Time.Day
maxDay = Time.fromGregorian 5874897 12 31