Skip to content

Commit 5b3676c

Browse files
amarquesleephadej
authored andcommitted
Implement support for postgresql 'interval' type
1 parent cb1a4ea commit 5b3676c

File tree

9 files changed

+233
-3
lines changed

9 files changed

+233
-3
lines changed

postgresql-simple.cabal

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ library
8282
, containers >=0.5.0.0 && <0.7
8383
, template-haskell >=2.8.0.0 && <2.17
8484
, text >=1.2.3.0 && <1.3
85-
, time >=1.4.0.1 && <1.12
85+
, time >=1.9.0.0 && <1.12
8686
, transformers >=0.3.0.0 && <0.6
8787

8888
-- Other dependencies
@@ -150,6 +150,7 @@ test-suite test
150150
Notify
151151
Serializable
152152
Time
153+
Interval
153154

154155
ghc-options: -threaded
155156
ghc-options: -Wall -fno-warn-name-shadowing -fno-warn-unused-do-bind

src/Database/PostgreSQL/Simple/FromField.hs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ import Data.Functor.Identity (Identity(Identity))
129129
import Data.Int (Int16, Int32, Int64)
130130
import Data.IORef (IORef, newIORef)
131131
import Data.Ratio (Ratio)
132-
import Data.Time ( UTCTime, ZonedTime, LocalTime, Day, TimeOfDay )
132+
import Data.Time ( UTCTime, ZonedTime, LocalTime, Day, TimeOfDay, CalendarDiffTime )
133133
import Data.Typeable (Typeable, typeOf)
134134
import Data.Vector (Vector)
135135
import Data.Vector.Mutable (IOVector)
@@ -487,6 +487,15 @@ instance FromField LocalTimestamp where
487487
instance FromField Date where
488488
fromField = ff TI.dateOid "Date" parseDate
489489

490+
-- | interval. Requires you to configure intervalstyle as @iso_8601@.
491+
--
492+
-- You can configure intervalstyle on every connection with a @SET@ command,
493+
-- but for better performance you may want to configure it permanently in the
494+
-- file found with @SHOW config_file;@ .
495+
--
496+
instance FromField CalendarDiffTime where
497+
fromField = ff TI.intervalOid "CalendarDiffTime" parseCalendarDiffTime
498+
490499
ff :: PQ.Oid -> String -> (B8.ByteString -> Either String a)
491500
-> Field -> Maybe B8.ByteString -> Conversion a
492501
ff compatOid hsType parseBS f mstr =

src/Database/PostgreSQL/Simple/Time.hs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ module Database.PostgreSQL.Simple.Time
227227
, parseUTCTimestamp
228228
, parseZonedTimestamp
229229
, parseLocalTimestamp
230+
, parseCalendarDiffTime
230231
, dayToBuilder
231232
, utcTimeToBuilder
232233
, zonedTimeToBuilder
@@ -239,6 +240,7 @@ module Database.PostgreSQL.Simple.Time
239240
, localTimestampToBuilder
240241
, unboundedToBuilder
241242
, nominalDiffTimeToBuilder
243+
, calendarDiffTimeToBuilder
242244
) where
243245

244246
import Database.PostgreSQL.Simple.Time.Implementation

src/Database/PostgreSQL/Simple/Time/Implementation.hs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import Control.Arrow((***))
1919
import Control.Applicative
2020
import qualified Data.ByteString as B
2121
import Data.Time hiding (getTimeZone, getZonedTime)
22+
import Data.Time.LocalTime (CalendarDiffTime)
2223
import Data.Typeable
2324
import Data.Maybe (fromMaybe)
2425
import qualified Data.Attoparsec.ByteString.Char8 as A
@@ -77,6 +78,9 @@ parseLocalTimestamp = A.parseOnly (getLocalTimestamp <* A.endOfInput)
7778
parseDate :: B.ByteString -> Either String Date
7879
parseDate = A.parseOnly (getDate <* A.endOfInput)
7980

81+
parseCalendarDiffTime :: B.ByteString -> Either String CalendarDiffTime
82+
parseCalendarDiffTime = A.parseOnly (getCalendarDiffTime <* A.endOfInput)
83+
8084
getUnbounded :: A.Parser a -> A.Parser (Unbounded a)
8185
getUnbounded getFinite
8286
= (pure NegInfinity <* A.string "-infinity")
@@ -125,6 +129,9 @@ getUTCTime = TP.utcTime
125129
getUTCTimestamp :: A.Parser UTCTimestamp
126130
getUTCTimestamp = getUnbounded getUTCTime
127131

132+
getCalendarDiffTime :: A.Parser CalendarDiffTime
133+
getCalendarDiffTime = TP.calendarDiffTime
134+
128135
dayToBuilder :: Day -> Builder
129136
dayToBuilder = primBounded TPP.day
130137

@@ -164,3 +171,6 @@ dateToBuilder = unboundedToBuilder dayToBuilder
164171

165172
nominalDiffTimeToBuilder :: NominalDiffTime -> Builder
166173
nominalDiffTimeToBuilder = TPP.nominalDiffTime
174+
175+
calendarDiffTimeToBuilder :: CalendarDiffTime -> Builder
176+
calendarDiffTimeToBuilder = TPP.calendarDiffTime

src/Database/PostgreSQL/Simple/Time/Internal/Parser.hs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,22 @@ module Database.PostgreSQL.Simple.Time.Internal.Parser
2121
, localToUTCTimeOfDayHMS
2222
, utcTime
2323
, zonedTime
24+
, calendarDiffTime
2425
) where
2526

2627
import Control.Applicative ((<$>), (<*>), (<*), (*>))
2728
import Database.PostgreSQL.Simple.Compat (toPico)
2829
import Data.Attoparsec.ByteString.Char8 as A
2930
import Data.Bits ((.&.))
31+
import Data.ByteString (ByteString)
3032
import Data.Char (ord)
3133
import Data.Fixed (Pico)
3234
import Data.Int (Int64)
3335
import Data.Maybe (fromMaybe)
3436
import Data.Time.Calendar (Day, fromGregorianValid, addDays)
3537
import Data.Time.Clock (UTCTime(..))
38+
import Data.Time.Format.ISO8601 (iso8601ParseM)
39+
import Data.Time.LocalTime (CalendarDiffTime)
3640
import qualified Data.ByteString.Char8 as B8
3741
import qualified Data.Time.LocalTime as Local
3842

@@ -193,3 +197,8 @@ zonedTime = Local.ZonedTime <$> localTime <*> (fromMaybe utc <$> timeZone)
193197

194198
utc :: Local.TimeZone
195199
utc = Local.TimeZone 0 False ""
200+
201+
calendarDiffTime :: Parser CalendarDiffTime
202+
calendarDiffTime = do
203+
contents <- takeByteString
204+
iso8601ParseM $ B8.unpack contents

src/Database/PostgreSQL/Simple/Time/Internal/Printer.hs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,23 @@ module Database.PostgreSQL.Simple.Time.Internal.Printer
1717
, localTime
1818
, zonedTime
1919
, nominalDiffTime
20+
, calendarDiffTime
2021
) where
2122

2223
import Control.Arrow ((>>>))
23-
import Data.ByteString.Builder (Builder, integerDec)
24+
import Data.ByteString.Builder (Builder, byteString, integerDec)
2425
import Data.ByteString.Builder.Prim
2526
( liftFixedToBounded, (>$<), (>*<)
2627
, BoundedPrim, primBounded, condB, emptyB, FixedPrim, char8, int32Dec)
2728
import Data.Char ( chr )
2829
import Data.Int ( Int32, Int64 )
30+
import Data.String (fromString)
2931
import Data.Time
3032
( UTCTime(..), ZonedTime(..), LocalTime(..), NominalDiffTime
3133
, Day, toGregorian, TimeOfDay(..), timeToTimeOfDay
3234
, TimeZone, timeZoneMinutes )
35+
import Data.Time.Format.ISO8601 (iso8601Show)
36+
import Data.Time.LocalTime (CalendarDiffTime)
3337
import Database.PostgreSQL.Simple.Compat ((<>), fromPico)
3438
import Unsafe.Coerce (unsafeCoerce)
3539

@@ -121,3 +125,11 @@ nominalDiffTime :: NominalDiffTime -> Builder
121125
nominalDiffTime xy = integerDec x <> primBounded frac (abs (fromIntegral y))
122126
where
123127
(x,y) = fromPico (unsafeCoerce xy) `quotRem` 1000000000000
128+
129+
calendarDiffTime :: CalendarDiffTime -> Builder
130+
calendarDiffTime = byteString
131+
. fromString
132+
-- from the docs: "Beware: fromString truncates multi-byte characters to octets".
133+
-- However, I think this is a safe usage, because ISO8601-encoding seems restricted
134+
-- to ASCII output.
135+
. iso8601Show

src/Database/PostgreSQL/Simple/ToField.hs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import Data.Int (Int8, Int16, Int32, Int64)
3737
import Data.List (intersperse)
3838
import Data.Monoid (mappend)
3939
import Data.Time (Day, TimeOfDay, LocalTime, UTCTime, ZonedTime, NominalDiffTime)
40+
import Data.Time.LocalTime (CalendarDiffTime)
4041
import Data.Typeable (Typeable)
4142
import Data.Word (Word, Word8, Word16, Word32, Word64)
4243
import {-# SOURCE #-} Database.PostgreSQL.Simple.ToRow
@@ -293,6 +294,10 @@ instance ToField NominalDiffTime where
293294
toField = Plain . inQuotes . nominalDiffTimeToBuilder
294295
{-# INLINE toField #-}
295296

297+
instance ToField CalendarDiffTime where
298+
toField = Plain . inQuotes . calendarDiffTimeToBuilder
299+
{-# INLINE toField #-}
300+
296301
instance (ToField a) => ToField (PGArray a) where
297302
toField pgArray =
298303
case fromPGArray pgArray of

test/Interval.hs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
{-# LANGUAGE QuasiQuotes #-}
2+
3+
{-
4+
5+
Testing strategies:
6+
7+
fromString . toString == id ** Todo?
8+
9+
toString . fromString == almost id ** Todo?
10+
11+
postgresql -> haskell -> postgresql * Done
12+
13+
haskell -> postgresql -> haskell ** Todo?
14+
15+
But still, what we really want to establish is that the two values
16+
correspond; for example, a conversion that consistently added hour
17+
when printed to a string and subtracted an hour when parsed from string
18+
would still pass these tests.
19+
20+
21+
Right now, we are checking that 1400+ timestamps in the range of 1860 to
22+
2060 round trip from postgresql to haskell and back in 5 different timezones.
23+
In addition to UTC, the four timezones were selected so that 2 have a positive
24+
offset, and 2 have a negative offset, and that 2 have an offset of a
25+
whole number of hours, while the other two do not.
26+
27+
It may be worth adding a few more timezones to ensure better test coverage.
28+
29+
We are checking a handful of selected timestamps to ensure we hit
30+
various corner-cases in the code, in addition to 1400 timestamps randomly
31+
generated with granularity of seconds down to microseconds in powers of ten.
32+
33+
-}
34+
35+
module Interval (testInterval) where
36+
37+
import Common
38+
import Control.Monad(forM_, replicateM_)
39+
import Data.Time
40+
import Data.Time.LocalTime (CalendarDiffTime(..))
41+
import Data.Time.LocalTime (CalendarDiffTime(..))
42+
import Data.ByteString(ByteString)
43+
import Database.PostgreSQL.Simple.SqlQQ
44+
45+
data IntervalTestCase = IntervalTestCase
46+
{ label :: String
47+
, inputMonths :: Integer
48+
, inputSeconds :: NominalDiffTime
49+
, asText :: String
50+
}
51+
deriving (Eq, Show)
52+
53+
testInterval :: TestEnv -> Assertion
54+
testInterval env@TestEnv{..} = do
55+
56+
initializeTable env
57+
58+
-- currently required for interval to work
59+
execute_ conn "SET intervalstyle TO 'iso_8601'"
60+
61+
let milliseconds = 0.001
62+
seconds = 1
63+
minutes = 60 * seconds
64+
hours = 60 * minutes
65+
days = 24 * hours
66+
weeks = 7 * days
67+
months = 1
68+
years = 12 * months
69+
70+
mapM (checkRoundTrip env)
71+
[ IntervalTestCase
72+
{ label = "zero"
73+
, inputMonths = 0
74+
, inputSeconds = 0
75+
, asText = "PT0"
76+
}
77+
, IntervalTestCase
78+
{ label = "1 year"
79+
, inputMonths = 1 * years
80+
, inputSeconds = 0
81+
, asText = "P1Y"
82+
}
83+
, IntervalTestCase
84+
{ label = "2 months"
85+
, inputMonths = 2 * months
86+
, inputSeconds = 0
87+
, asText = "P2M"
88+
}
89+
, IntervalTestCase
90+
{ label = "3 weeks"
91+
, inputMonths = 0
92+
, inputSeconds = 3 * weeks
93+
, asText = "P3W"
94+
}
95+
, IntervalTestCase
96+
{ label = "4 days"
97+
, inputMonths = 0
98+
, inputSeconds = 4 * days
99+
, asText = "P4D"
100+
}
101+
, IntervalTestCase
102+
{ label = "5 hours"
103+
, inputMonths = 0
104+
, inputSeconds = 5 * hours
105+
, asText = "PT5H"
106+
}
107+
, IntervalTestCase
108+
{ label = "6 minutes"
109+
, inputMonths = 0
110+
, inputSeconds = 6 * minutes
111+
, asText = "PT6M"
112+
}
113+
, IntervalTestCase
114+
{ label = "7 seconds"
115+
, inputMonths = 0
116+
, inputSeconds = 7 * seconds
117+
, asText = "PT7S"
118+
}
119+
, IntervalTestCase
120+
{ label = "8 milliseconds"
121+
, inputMonths = 0
122+
, inputSeconds = 8 * milliseconds
123+
, asText = "PT0.008S"
124+
}
125+
, IntervalTestCase
126+
{ label = "combination of intervals (day-size or bigger)"
127+
, inputMonths = 2 * years + 4 * months
128+
, inputSeconds = 3 * weeks + 5 * days
129+
, asText = "P2Y4M3W5D"
130+
}
131+
, IntervalTestCase
132+
{ label = "combination of intervals (smaller than day-size)"
133+
, inputMonths = 0
134+
, inputSeconds = 18 * hours + 56 * minutes + 23 * seconds + 563 * milliseconds
135+
, asText = "PT18H56M23.563S"
136+
}
137+
, IntervalTestCase
138+
{ label = "full combination of intervals"
139+
, inputMonths = 2 * years + 4 * months
140+
, inputSeconds = 3 * weeks + 5 * days + 18 * hours + 56 * minutes + 23 * seconds + 563 * milliseconds
141+
, asText = "P2Y4M3W5DT18H56M23.563S"
142+
}
143+
]
144+
145+
return ()
146+
147+
initializeTable :: TestEnv -> IO ()
148+
initializeTable TestEnv{..} = withTransaction conn $ do
149+
execute_ conn
150+
[sql| CREATE TEMPORARY TABLE testinterval
151+
( id serial, sample interval, PRIMARY KEY(id) ) |]
152+
153+
return ()
154+
155+
checkRoundTrip :: TestEnv -> IntervalTestCase -> IO ()
156+
checkRoundTrip TestEnv{..} IntervalTestCase{..} = do
157+
158+
let input = CalendarDiffTime
159+
{ ctMonths = inputMonths
160+
, ctTime = inputSeconds
161+
}
162+
163+
[(returnedId :: Int, output :: CalendarDiffTime)] <- query conn [sql|
164+
INSERT INTO testinterval (sample)
165+
VALUES (?)
166+
RETURNING id, sample
167+
|] (Only input)
168+
169+
assertBool ("CalendarDiffTime did not round-trip from Haskell to SQL and back (" ++ label ++ ")") $
170+
output == input
171+
172+
[(Only isExpectedIso)] <- query conn [sql|
173+
SELECT sample = (?)::interval
174+
FROM testinterval
175+
WHERE id = ?
176+
|] (asText, returnedId)
177+
178+
assertBool ("CalendarDiffTime inserted did not match ISO8601 equivalent \"" ++ asText ++ "\". (" ++ label ++ ")")
179+
isExpectedIso
180+

test/Main.hs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import Test.Tasty.Golden
5454
import Notify
5555
import Serializable
5656
import Time
57+
import Interval
5758

5859
tests :: TestEnv -> TestTree
5960
tests env = testGroup "tests"
@@ -64,6 +65,7 @@ tests env = testGroup "tests"
6465
, testCase "Notify" . testNotify
6566
, testCase "Serializable" . testSerializable
6667
, testCase "Time" . testTime
68+
, testCase "Interval" . testInterval
6769
, testCase "Array" . testArray
6870
, testCase "Array of nullables" . testNullableArray
6971
, testCase "HStore" . testHStore

0 commit comments

Comments
 (0)