diff --git a/postgresql-simple.cabal b/postgresql-simple.cabal index 0598a18..509261d 100644 --- a/postgresql-simple.cabal +++ b/postgresql-simple.cabal @@ -48,6 +48,7 @@ library Database.PostgreSQL.Simple.HStore Database.PostgreSQL.Simple.HStore.Internal Database.PostgreSQL.Simple.Internal + Database.PostgreSQL.Simple.Interval Database.PostgreSQL.Simple.LargeObjects Database.PostgreSQL.Simple.Newtypes Database.PostgreSQL.Simple.Notification @@ -136,6 +137,7 @@ test-suite test Notify Serializable Time + Database.PostgreSQL.Simple.IntervalTest ghc-options: -threaded ghc-options: -Wall -fno-warn-name-shadowing -fno-warn-unused-do-bind @@ -149,6 +151,7 @@ test-suite test build-depends: aeson + , attoparsec , base , base16-bytestring , bytestring diff --git a/src/Database/PostgreSQL/Simple/Interval.hs b/src/Database/PostgreSQL/Simple/Interval.hs new file mode 100644 index 0000000..0b46802 --- /dev/null +++ b/src/Database/PostgreSQL/Simple/Interval.hs @@ -0,0 +1,241 @@ +{-# LANGUAGE LexicalNegation #-} +{-# LANGUAGE NumDecimals #-} + +module Database.PostgreSQL.Simple.Interval where + +import qualified Control.Applicative as Applicative +import qualified Control.Monad as Monad +import qualified Data.Attoparsec.ByteString.Char8 as A +import qualified Data.Bits as Bits +import qualified Data.ByteString as ByteString +import qualified Data.ByteString.Builder as Builder +import qualified Data.Function as Function +import qualified Data.Int as Int +import qualified Data.Scientific as Scientific +import qualified Database.PostgreSQL.Simple.FromField as Postgres +import qualified Database.PostgreSQL.Simple.ToField as Postgres +import qualified Database.PostgreSQL.Simple.TypeInfo.Static as Postgres + +data Interval = MkInterval + { months :: !Int.Int32, + days :: !Int.Int32, + microseconds :: !Int.Int64 + } + deriving (Eq, Show) + +instance Postgres.FromField Interval where + fromField = Postgres.attoFieldParser (== Postgres.intervalOid) parse + +instance Postgres.ToField Interval where + toField = Postgres.Plain . render + +zero :: Interval +zero = MkInterval 0 0 0 + +fromMicroseconds :: Int.Int64 -> Interval +fromMicroseconds x = zero {microseconds = x} + +fromMilliseconds :: Int.Int64 -> Maybe Interval +fromMilliseconds = + fmap fromMicroseconds + . Bits.toIntegralSized + . (*) 1e3 + . toInteger + +fromSeconds :: Int.Int64 -> Maybe Interval +fromSeconds = + fmap fromMicroseconds + . Bits.toIntegralSized + . (*) 1e6 + . toInteger + +fromMinutes :: Int.Int64 -> Maybe Interval +fromMinutes = + fmap fromMicroseconds + . Bits.toIntegralSized + . (*) 60e6 + . toInteger + +fromHours :: Int.Int64 -> Maybe Interval +fromHours = + fmap fromMicroseconds + . Bits.toIntegralSized + . (*) 3600e6 + . toInteger + +fromDays :: Int.Int32 -> Interval +fromDays x = zero {days = x} + +fromWeeks :: Int.Int32 -> Maybe Interval +fromWeeks = + fmap fromDays + . Bits.toIntegralSized + . (*) 7 + . toInteger + +fromMonths :: Int.Int32 -> Interval +fromMonths x = zero {months = x} + +fromYears :: Int.Int32 -> Maybe Interval +fromYears = + fmap fromMonths + . Bits.toIntegralSized + . (*) 12 + . toInteger + +add :: Interval -> Interval -> Maybe Interval +add x y = + let safeAdd :: (Bits.Bits a, Integral a) => a -> a -> Maybe a + safeAdd n = Bits.toIntegralSized . Function.on (+) toInteger n + in MkInterval + <$> Function.on safeAdd months x y + <*> Function.on safeAdd days x y + <*> Function.on safeAdd microseconds x y + +render :: Interval -> Builder.Builder +render x = + let signed :: (Num a, Ord a) => (a -> Builder.Builder) -> a -> Builder.Builder + signed f n = (if n > 0 then "+" else "") <> f n + in "interval '" + <> signed Builder.int32Dec (months x) + <> " months " + <> signed Builder.int32Dec (days x) + <> " days " + <> signed Builder.int64Dec (microseconds x) + <> " microseconds'" + +parse :: A.Parser Interval +parse = + -- Start with parsers that have non-empty prefixes, in order to avoid + -- ambiguity. Neither of the `postgres` nor `sql_standard` interval styles + -- have a prefix (or suffix), so whichever one is attempted first needs to + -- make sure it has consumed all of the input. + A.choice $ + parseInfinities + : fmap + (fromComponents =<<) + [ parseIso8601, + parsePostgresVerbose, + parsePostgres <* A.endOfInput, + parseSqlStandard + ] + +parseInfinities :: A.Parser Interval +parseInfinities = + -- Both `-infinity` and `infinity` are new as of PostgreSQL 17.0. + -- https://www.postgresql.org/message-id/E1r2rB1-005PHm-UL%40gemulon.postgresql.org + A.choice + [ MkInterval minBound minBound minBound <$ "-infinity", + MkInterval maxBound maxBound maxBound <$ "infinity" + ] + +parseIso8601 :: A.Parser [Component] +parseIso8601 = do + Monad.void "P" + dates <- + A.many' $ + A.choice + [ Years <$> A.signed A.decimal <* "Y", + Months <$> A.signed A.decimal <* "M", + Days <$> A.signed A.decimal <* "D" + ] + times <- A.option [] $ do + Monad.void "T" + A.many' $ + A.choice + [ Hours <$> A.signed A.decimal <* "H", + Minutes <$> A.signed A.decimal <* "M", + Microseconds <$> A.signed A.scientific <* "S" + ] + pure $ dates <> times + +parsePostgresVerbose :: A.Parser [Component] +parsePostgresVerbose = do + Monad.void "@ " + components <- + flip A.sepBy " " $ + A.choice + [ Years <$> A.signed A.decimal <* maybePlural " year", + Months <$> A.signed A.decimal <* maybePlural " mon", + Days <$> A.signed A.decimal <* maybePlural " day", + Hours <$> A.signed A.decimal <* maybePlural " hour", + Minutes <$> A.signed A.decimal <* maybePlural " min", + Microseconds <$> A.signed A.scientific <* A.option "" (maybePlural " sec") + ] + ago <- A.option "" " ago" + pure $ negateComponentsWhen (not $ ByteString.null ago) components + +parsePostgres :: A.Parser [Component] +parsePostgres = do + dates <- + flip A.sepBy " " $ + A.choice + [ Years <$> A.signed A.decimal <* maybePlural " year", + Months <$> A.signed A.decimal <* maybePlural " mon", + Days <$> A.signed A.decimal <* maybePlural " day" + ] + time <- A.option [] $ A.skipSpace *> parseTime + pure $ dates <> time + +parseSqlStandard :: A.Parser [Component] +parseSqlStandard = do + let parseYearsAndMonths = do + sign <- parseSign + years <- Years <$> A.decimal <* "-" + months_ <- Months <$> A.decimal + pure $ negateComponentsWhen (sign == "-") [years, months_] + let parseDays = (: []) . Days <$> A.signed A.decimal + let parsers = [parseYearsAndMonths, parseTime, parseDays] + mconcat <$> A.sepBy1 (A.choice parsers) " " + +parseTime :: A.Parser [Component] +parseTime = do + sign <- parseSign + hours <- Hours <$> A.decimal <* ":" + minutes <- Minutes <$> A.decimal <* ":" + micros <- Microseconds <$> A.scientific + pure $ negateComponentsWhen (sign == "-") [hours, minutes, micros] + +parseSign :: A.Parser ByteString.ByteString +parseSign = A.choice ["-", "+", ""] + +maybePlural :: ByteString.ByteString -> A.Parser ByteString.ByteString +maybePlural word = (<>) <$> A.string word <*> A.option "" "s" + +data Component + = Years !Integer + | Months !Integer + | Days !Integer + | Hours !Integer + | Minutes !Integer + | Microseconds !Scientific.Scientific + deriving (Eq, Show) + +fromComponent :: Component -> Maybe Interval +fromComponent c = case c of + Years y -> fromYears =<< Bits.toIntegralSized y + Months m -> fromMonths <$> Bits.toIntegralSized m + Days d -> fromDays <$> Bits.toIntegralSized d + Hours h -> fromHours =<< Bits.toIntegralSized h + Minutes m -> fromMinutes =<< Bits.toIntegralSized m + Microseconds u -> fromMicroseconds <$> Scientific.toBoundedInteger (u * 1e6) + +fromComponents :: + (Applicative.Alternative f, Traversable t) => + t Component -> + f Interval +fromComponents = + maybe Applicative.empty pure + . (Monad.foldM add zero Monad.<=< traverse fromComponent) + +negateComponent :: Component -> Component +negateComponent c = case c of + Years y -> Years -y + Months m -> Months -m + Days d -> Days -d + Hours h -> Hours -h + Minutes m -> Minutes -m + Microseconds u -> Microseconds -u + +negateComponentsWhen :: (Functor f) => Bool -> f Component -> f Component +negateComponentsWhen p = if p then fmap negateComponent else id diff --git a/test/Database/PostgreSQL/Simple/IntervalTest.hs b/test/Database/PostgreSQL/Simple/IntervalTest.hs new file mode 100644 index 0000000..c5c680a --- /dev/null +++ b/test/Database/PostgreSQL/Simple/IntervalTest.hs @@ -0,0 +1,231 @@ +{-# LANGUAGE LexicalNegation #-} +{-# LANGUAGE NumDecimals #-} + +module Database.PostgreSQL.Simple.IntervalTest where + +import Common +import qualified Data.Attoparsec.ByteString.Char8 as Attoparsec +import qualified Data.ByteString.Builder as Builder +import qualified Data.ByteString.Char8 as Ascii +import Data.Functor ((<&>)) +import qualified Data.Int as Int +import qualified Database.PostgreSQL.Simple.Interval as I +import Test.Tasty + +testTree :: TestEnv -> TestTree +testTree _ = + testGroup + "Database.PostgreSQL.Simple.Interval" + [ testGroup + "zero" + [ testCase "works" $ do + let actual = I.zero + actual @?= I.MkInterval 0 0 0 + ], + testGroup + "add" + [ testCase "succeeds with no overflow" $ do + let actual = I.add (I.MkInterval 1 2 3) (I.MkInterval 4 5 6) + actual @?= Just (I.MkInterval 5 7 9), + testCase "fails with positive month overflow" $ do + let actual = I.add (I.fromMonths maxBound) (I.fromMonths 1) + actual @?= Nothing, + testCase "fails with negative month overflow" $ do + let actual = I.add (I.fromMonths minBound) (I.fromMonths -1) + actual @?= Nothing, + testCase "fails with positive day overflow" $ do + let actual = I.add (I.fromDays maxBound) (I.fromDays 1) + actual @?= Nothing, + testCase "fails with negative day overflow" $ do + let actual = I.add (I.fromDays minBound) (I.fromDays -1) + actual @?= Nothing, + testCase "fails with positive microsecond overflow" $ do + let actual = I.add (I.fromMicroseconds maxBound) (I.fromMicroseconds 1) + actual @?= Nothing, + testCase "fails with negative microsecond overflow" $ do + let actual = I.add (I.fromMicroseconds minBound) (I.fromMicroseconds -1) + actual @?= Nothing + ], + testGroup + "fromMicroseconds" + [ testCase "works" $ do + let actual = I.fromMicroseconds 1 + actual @?= I.MkInterval 0 0 1 + ], + testGroup + "fromMilliseconds" + [ testCase "succeeds with no overflow" $ do + let actual = I.fromMilliseconds 1 + actual @?= Just (I.MkInterval 0 0 1e3), + testCase "fails with overflow" $ do + let actual = I.fromMilliseconds maxBound + actual @?= Nothing + ], + testGroup + "fromSeconds" + [ testCase "succeeds with no overflow" $ do + let actual = I.fromSeconds 1 + actual @?= Just (I.MkInterval 0 0 1e6), + testCase "fails with overflow" $ do + let actual = I.fromSeconds maxBound + actual @?= Nothing + ], + testGroup + "fromMinutes" + [ testCase "succeeds with no overflow" $ do + let actual = I.fromMinutes 1 + actual @?= Just (I.MkInterval 0 0 60e6), + testCase "fails with overflow" $ do + let actual = I.fromMinutes maxBound + actual @?= Nothing + ], + testGroup + "fromHours" + [ testCase "succeeds with no overflow" $ do + let actual = I.fromHours 1 + actual @?= Just (I.MkInterval 0 0 3600e6), + testCase "fails with overflow" $ do + let actual = I.fromHours maxBound + actual @?= Nothing + ], + testGroup + "fromDays" + [ testCase "works" $ do + let actual = I.fromDays 1 + actual @?= I.MkInterval 0 1 0 + ], + testGroup + "fromWeeks" + [ testCase "succeeds with no overflow" $ do + let actual = I.fromWeeks 1 + actual @?= Just (I.MkInterval 0 7 0), + testCase "fails with overflow" $ do + let actual = I.fromWeeks maxBound + actual @?= Nothing + ], + testGroup + "fromMonths" + [ testCase "works" $ do + let actual = I.fromMonths 1 + actual @?= I.MkInterval 1 0 0 + ], + testGroup + "fromYears" + [ testCase "succeeds with no overflow" $ do + let actual = I.fromYears 1 + actual @?= Just (I.MkInterval 12 0 0), + testCase "fails with overflow" $ do + let actual = I.fromYears maxBound + actual @?= Nothing + ], + testGroup + "render" + [ testCase "works with zero" $ do + let actual = Builder.toLazyByteString $ I.render I.zero + actual @?= "interval '0 months 0 days 0 microseconds'", + testCase "works with positive components" $ do + let actual = Builder.toLazyByteString . I.render $ I.MkInterval 1 2 3 + actual @?= "interval '+1 months +2 days +3 microseconds'", + testCase "works with negative components" $ do + let actual = Builder.toLazyByteString . I.render $ I.MkInterval -3 -2 -1 + actual @?= "interval '-3 months -2 days -1 microseconds'" + ], + testGroup + "parse" + [ testCase "fails with invalid input" $ do + let actual = Attoparsec.parseOnly I.parse "invalid" + actual @?= Left "Failed reading: empty", + testCase "succeeds with positive infinity" $ do + let actual = Attoparsec.parseOnly I.parse "infinity" + actual @?= Right (I.MkInterval maxBound maxBound maxBound), + testCase "succeeds with negative infinity" $ do + let actual = Attoparsec.parseOnly I.parse "-infinity" + actual @?= Right (I.MkInterval minBound minBound minBound), + testGroup "styles" $ + intervalStyles <&> \(style, field) -> + testGroup style $ + examples <&> \example -> + let input = field example + in testCase (Ascii.unpack input) $ do + let actual = Attoparsec.parseOnly I.parse input + actual @?= Right (exampleInterval example) + ] + ] + +data Example = MkExample + { exampleInterval :: I.Interval, + exampleIso8601 :: Ascii.ByteString, + examplePostgres :: Ascii.ByteString, + examplePostgresVerbose :: Ascii.ByteString, + exampleSqlStandard :: Ascii.ByteString + } + deriving (Eq, Show) + +intervalStyles :: [(String, Example -> Ascii.ByteString)] +intervalStyles = + [ ("iso_8601", exampleIso8601), + ("postgres", examplePostgres), + ("postgres_verbose", examplePostgresVerbose), + ("sql_standard", exampleSqlStandard) + ] + +mkExample :: + Int.Int32 -> + Int.Int32 -> + Int.Int64 -> + Ascii.ByteString -> + Ascii.ByteString -> + Ascii.ByteString -> + Ascii.ByteString -> + Example +mkExample m d s iso8601 postgres postgresVerbose sqlStandard = + MkExample + { exampleInterval = I.MkInterval {I.months = m, I.days = d, I.microseconds = s}, + exampleIso8601 = iso8601, + examplePostgres = postgres, + examplePostgresVerbose = postgresVerbose, + exampleSqlStandard = sqlStandard + } + +examples :: [Example] +examples = + [ mkExample 0 0 0 "PT0S" "00:00:00" "@ 0" "0", + mkExample 1 0 0 "P1M" "1 mon" "@ 1 mon" "0-1", + mkExample -1 0 0 "P-1M" "-1 mons" "@ 1 mon ago" "-0-1", + mkExample 3 0 0 "P3M" "3 mons" "@ 3 mons" "0-3", + mkExample 6 0 0 "P6M" "6 mons" "@ 6 mons" "0-6", + mkExample 12 0 0 "P1Y" "1 year" "@ 1 year" "1-0", + mkExample -12 0 0 "P-1Y" "-1 years" "@ 1 year ago" "-1-0", + mkExample 13 0 0 "P1Y1M" "1 year 1 mon" "@ 1 year 1 mon" "1-1", + mkExample -13 0 0 "P-1Y-1M" "-1 years -1 mons" "@ 1 year 1 mon ago" "-1-1", + mkExample 24 0 0 "P2Y" "2 years" "@ 2 years" "2-0", + mkExample 0 1 0 "P1D" "1 day" "@ 1 day" "1 0:00:00", + mkExample 0 -1 0 "P-1D" "-1 days" "@ 1 day ago" "-1 0:00:00", + mkExample 0 2 0 "P2D" "2 days" "@ 2 days" "2 0:00:00", + mkExample 0 7 0 "P7D" "7 days" "@ 7 days" "7 0:00:00", + mkExample 0 0 1 "PT0.000001S" "00:00:00.000001" "@ 0.000001 secs" "0:00:00.000001", + mkExample 0 0 -1 "PT-0.000001S" "-00:00:00.000001" "@ 0.000001 secs ago" "-0:00:00.000001", + mkExample 0 0 1e3 "PT0.001S" "00:00:00.001" "@ 0.001 secs" "0:00:00.001", + mkExample 0 0 1e6 "PT1S" "00:00:01" "@ 1 sec" "0:00:01", + mkExample 0 0 -1e6 "PT-1S" "-00:00:01" "@ 1 sec ago" "-0:00:01", + mkExample 0 0 2e6 "PT2S" "00:00:02" "@ 2 secs" "0:00:02", + mkExample 0 0 60e6 "PT1M" "00:01:00" "@ 1 min" "0:01:00", + mkExample 0 0 -60e6 "PT-1M" "-00:01:00" "@ 1 min ago" "-0:01:00", + mkExample 0 0 120e6 "PT2M" "00:02:00" "@ 2 mins" "0:02:00", + mkExample 0 0 3600e6 "PT1H" "01:00:00" "@ 1 hour" "01:00:00", + mkExample 0 0 -3600e6 "PT-1H" "-01:00:00" "@ 1 hour ago" "-01:00:00", + mkExample 0 0 7200e6 "PT2H" "02:00:00" "@ 2 hours" "02:00:00", + mkExample 0 0 86400e6 "PT24H" "24:00:00" "@ 24 hours" "24:00:00", + mkExample 1 1 1e6 "P1M1DT1S" "1 mon 1 day 00:00:01" "@ 1 mon 1 day 1 sec" "+0-1 +1 +0:00:01", + mkExample -1 -1 -1e6 "P-1M-1DT-1S" "-1 mons -1 days -00:00:01" "@ 1 mon 1 day 1 sec ago" "-0-1 -1 -0:00:01", + mkExample -1 1 1e6 "P-1M1DT1S" "-1 mons +1 day 00:00:01" "@ 1 mon -1 days -1 sec ago" "-0-1 +1 +0:00:01", + mkExample 1 -1 1e6 "P1M-1DT1S" "1 mon -1 days +00:00:01" "@ 1 mon -1 days 1 sec" "+0-1 -1 +0:00:01", + mkExample 1 1 -1e6 "P1M1DT-1S" "1 mon 1 day -00:00:01" "@ 1 mon 1 day -1 sec" "+0-1 +1 -0:00:01", + mkExample 14 3 14706000007 "P1Y2M3DT4H5M6.000007S" "1 year 2 mons 3 days 04:05:06.000007" "@ 1 year 2 mons 3 days 4 hours 5 mins 6.000007 secs" "+1-2 +3 +4:05:06.000007", + mkExample maxBound 0 0 "P178956970Y7M" "178956970 years 7 mons" "@ 178956970 years 7 mons" "178956970-7", + mkExample minBound 0 0 "P-178956970Y-8M" "-178956970 years -8 mons" "@ 178956970 years 8 mons ago" "-178956970-8", + mkExample 0 maxBound 0 "P2147483647D" "2147483647 days" "@ 2147483647 days" "2147483647 0:00:00", + mkExample 0 minBound 0 "P-2147483648D" "-2147483648 days" "@ 2147483648 days ago" "-2147483648 0:00:00", + mkExample 0 0 maxBound "PT2562047788H54.775807S" "2562047788:00:54.775807" "@ 2562047788 hours 54.775807 secs" "2562047788:00:54.775807", + mkExample 0 0 minBound "PT-2562047788H-54.775808S" "-2562047788:00:54.775808" "@ 2562047788 hours 54.775808 secs ago" "-2562047788:00:54.775808" + ] diff --git a/test/Main.hs b/test/Main.hs index 32eb230..41500cf 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -56,6 +56,7 @@ import Serializable import Time import Interval import Exception (testExceptions) +import qualified Database.PostgreSQL.Simple.IntervalTest as IntervalTest tests :: TestEnv -> TestTree tests env = testGroup "tests" @@ -86,6 +87,7 @@ tests env = testGroup "tests" , testCase "3-ary generic" . testGeneric3 , testCase "Timeout" . testTimeout , testCase "Exceptions" . testExceptions + , IntervalTest.testTree ] testBytea :: TestEnv -> TestTree