From 4f39966da263952605c6aada3e373379f646841e Mon Sep 17 00:00:00 2001 From: Paul Brinkmeier Date: Wed, 4 Oct 2023 13:30:51 +0200 Subject: [PATCH] Add comment about difference between Postgres and Haskell dates --- lib/Database/PostgreSQL/Opium/FromField.hs | 24 ++++++++------- .../PostgreSQL/Opium/FromFieldSpec.hs | 30 +++++++++---------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/lib/Database/PostgreSQL/Opium/FromField.hs b/lib/Database/PostgreSQL/Opium/FromField.hs index f1c3074..143ae1b 100644 --- a/lib/Database/PostgreSQL/Opium/FromField.hs +++ b/lib/Database/PostgreSQL/Opium/FromField.hs @@ -19,13 +19,12 @@ import Data.Proxy (Proxy (..)) import Data.Time ( Day (..) , DiffTime + , LocalTime (..) , TimeOfDay - , UTCTime (..) , addDays , fromGregorian , picosecondsToDiffTime , timeToTimeOfDay - , toGregorian ) import Data.Text (Text) import Data.Word (Word32, Word64) @@ -131,15 +130,13 @@ postgresEpoch :: Day postgresEpoch = fromGregorian 2000 1 1 fromPostgresJulian :: Integer -> Day -fromPostgresJulian x - | year <= 0 = fromGregorian (year - 1) month dayOfMonth - | otherwise = day - where - day = addDays (fromIntegral x) postgresEpoch - (year, month, dayOfMonth) = toGregorian day +fromPostgresJulian x = addDays x postgresEpoch -- | See https://www.postgresql.org/docs/current/datatype-datetime.html. -- Relevant as well: https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/utils/adt/datetime.c;h=267dfd37b2e8b9bc63797c69b9ca2e45e6bfde61;hb=HEAD#l267. +-- Note that Postgres uses the proleptic Gregorian calendar, whereas @Show Day@ and @fromGregorian@ use an astronomical calendar. +-- In short, Postgres treats 1 BC as a leap year and doesn't have a year zero. +-- This means that working with negative dates will be different in Postgres and your application code. instance FromField Day where validOid Proxy = Oid.date parseField = fromPostgresJulian . fromIntegral <$> intParser @Int32 @@ -161,18 +158,23 @@ instance FromField TimeOfDay where validOid Proxy = Oid.time parseField = timeToTimeOfDay <$> parseField @DiffTime -instance FromField UTCTime where +-- | See https://www.postgresql.org/docs/current/datatype-datetime.html. +-- | Accepts @timestamp@. +-- Note that Postgres uses the proleptic Gregorian calendar, whereas @Show Day@ and @fromGregorian@ use an astronomical calendar. +-- In short, Postgres treats 1 BC as a leap year and doesn't have a year zero. +-- This means that working with negative dates will be different in Postgres and your application code. +instance FromField LocalTime where validOid Proxy = Oid.timestamp parseField = fromPostgresTimestamp <$> intParser @Int where - fromPostgresTimestamp :: Int -> UTCTime + fromPostgresTimestamp :: Int -> LocalTime fromPostgresTimestamp ts = let (days, microseconds) = ts `divMod` (86400 * 1000000) day = fromPostgresJulian $ fromIntegral days time = picosecondsToDiffTime $ fromIntegral microseconds * 1000000 in - UTCTime day time + LocalTime day (timeToTimeOfDay time) newtype RawField a = RawField a deriving (Eq, Show) diff --git a/test/Database/PostgreSQL/Opium/FromFieldSpec.hs b/test/Database/PostgreSQL/Opium/FromFieldSpec.hs index a09a733..0f69afa 100644 --- a/test/Database/PostgreSQL/Opium/FromFieldSpec.hs +++ b/test/Database/PostgreSQL/Opium/FromFieldSpec.hs @@ -7,11 +7,10 @@ import Data.ByteString (ByteString) import Data.Time ( Day (..) , DiffTime + , LocalTime (..) , TimeOfDay (..) - , UTCTime (..) , fromGregorian , secondsToDiffTime - , timeOfDayToTime ) import Data.Text (Text) import Database.PostgreSQL.LibPQ (Connection) @@ -101,11 +100,11 @@ newtype ATimeOfDay = ATimeOfDay instance FromRow ATimeOfDay where -newtype AUTCTime = AUTCTime - { utctime :: UTCTime +newtype ALocalTime = ALocalTime + { localtime :: LocalTime } deriving (Eq, Generic, Show) -instance FromRow AUTCTime where +instance FromRow ALocalTime where newtype ARawField = ARawField { raw :: Opium.RawField ByteString @@ -252,7 +251,7 @@ spec = do -- BC -- See https://www.postgresql.org/docs/current/datetime-input-rules.html: -- "If BC has been specified, negate the year and add one for internal storage. (There is no year zero in the Gregorian calendar, so numerically 1 BC becomes year zero.)" - shouldFetch conn "SELECT date 'January 1, 4710 BC' AS day" [ADay $ fromGregorian (-4710) 1 1] + shouldFetch conn "SELECT date '0001-02-29 BC' AS day" [ADay $ fromGregorian 0 2 29] describe "FromField DiffTime" $ do it "Decodes the time" $ \conn -> do @@ -266,19 +265,20 @@ spec = do shouldFetch conn "SELECT time '00:01:00' AS timeofday" [ATimeOfDay $ TimeOfDay 0 1 0] shouldFetch conn "SELECT time '13:07:43' AS timeofday" [ATimeOfDay $ TimeOfDay 13 7 43] - describe "FromField UTCTime" $ do + describe "FromField LocalTime" $ do it "Decodes timestamp" $ \conn -> do - let ts0 = UTCTime (fromGregorian 2023 10 2) (timeOfDayToTime $ TimeOfDay 12 42 23) - shouldFetch conn "SELECT timestamp '2023-10-02 12:42:23' AS utctime" [AUTCTime ts0] + let ts0 = LocalTime (fromGregorian 2023 10 2) (TimeOfDay 12 42 23) + shouldFetch conn "SELECT timestamp '2023-10-02 12:42:23' AS localtime" [ALocalTime ts0] - let ts1 = UTCTime (fromGregorian 294275 12 31) (timeOfDayToTime $ TimeOfDay 23 59 59) - shouldFetch conn "SELECT timestamp '294275-12-31 23:59:59' AS utctime" [AUTCTime ts1] + let ts1 = LocalTime (fromGregorian 294275 12 31) (TimeOfDay 23 59 59) + shouldFetch conn "SELECT timestamp '294275-12-31 23:59:59' AS localtime" [ALocalTime ts1] - let ts2 = UTCTime (fromGregorian 1 1 1) (timeOfDayToTime $ TimeOfDay 0 0 0) - shouldFetch conn "SELECT timestamp '0001-01-01 00:00:00' AS utctime" [AUTCTime ts2] + let ts2 = LocalTime (fromGregorian 1 1 1) (TimeOfDay 0 0 0) + shouldFetch conn "SELECT timestamp '0001-01-01 00:00:00' AS localtime" [ALocalTime ts2] - let ts3 = UTCTime (fromGregorian (-4710) 1 1) (timeOfDayToTime $ TimeOfDay 0 0 0) - shouldFetch conn "SELECT timestamp 'January 1, 4710 BC 00:00:00' AS utctime" [AUTCTime ts3] + -- See note at the FromField Day instance. + let ts3 = LocalTime (fromGregorian 0 2 29) (TimeOfDay 0 0 0) + shouldFetch conn "SELECT timestamp '0001-02-29 BC 00:00:00' AS localtime" [ALocalTime ts3] describe "FromField RawField" $ do it "Simply returns the bytestring without decoding it" $ \conn -> do