Skip to content

Commit 09a5bab

Browse files
authored
Merge pull request #64 from haskellari/pr-60-interval
Pr 60 interval
2 parents cb1a4ea + 289acd2 commit 09a5bab

File tree

11 files changed

+245
-15
lines changed

11 files changed

+245
-15
lines changed

postgresql-simple.cabal

Lines changed: 4 additions & 3 deletions
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-compat >=1.9.5 && <1.12
8686
, transformers >=0.3.0.0 && <0.6
8787

8888
-- Other dependencies
@@ -93,7 +93,7 @@ library
9393
, case-insensitive >=1.2.0.11 && <1.3
9494
, hashable >=1.2.7.0 && <1.4
9595
, Only >=0.1 && <0.1.1
96-
, postgresql-libpq >=0.9.4.2 && <0.10
96+
, postgresql-libpq >=0.9.4.3 && <0.10
9797
, scientific >=0.3.6.2 && <0.4
9898
, uuid-types >=1.0.3 && <1.1
9999
, vector >=0.12.0.1 && <0.13
@@ -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
@@ -176,7 +177,7 @@ test-suite test
176177
, tasty-golden
177178
, tasty-hunit
178179
, text
179-
, time
180+
, time-compat
180181
, vector
181182

182183
if !impl(ghc >=7.6)

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.Compat ( 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/Range.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import Data.Monoid (mempty)
3838
import Data.Scientific (Scientific)
3939
import qualified Data.Text.Lazy.Builder as LT
4040
import qualified Data.Text.Lazy.Encoding as LT
41-
import Data.Time (Day, LocalTime,
41+
import Data.Time.Compat (Day, LocalTime,
4242
NominalDiffTime,
4343
TimeOfDay, UTCTime,
4444
ZonedTime,

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: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import Data.ByteString.Builder.Prim(primBounded)
1818
import Control.Arrow((***))
1919
import Control.Applicative
2020
import qualified Data.ByteString as B
21-
import Data.Time hiding (getTimeZone, getZonedTime)
21+
import Data.Time.Compat (LocalTime, UTCTime, ZonedTime, Day, TimeOfDay, TimeZone, NominalDiffTime, utc)
22+
import Data.Time.LocalTime.Compat (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: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,24 @@ 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)
34-
import Data.Time.Calendar (Day, fromGregorianValid, addDays)
35-
import Data.Time.Clock (UTCTime(..))
36+
import Data.Time.Calendar.Compat (Day, fromGregorianValid, addDays)
37+
import Data.Time.Clock.Compat (UTCTime(..))
38+
import Data.Time.Format.ISO8601.Compat (iso8601ParseM)
39+
import Data.Time.LocalTime.Compat (CalendarDiffTime)
3640
import qualified Data.ByteString.Char8 as B8
37-
import qualified Data.Time.LocalTime as Local
41+
import qualified Data.Time.LocalTime.Compat as Local
3842

3943
-- | Parse a date of the form @YYYY-MM-DD@.
4044
day :: Parser Day
@@ -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: 14 additions & 2 deletions
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 )
29-
import Data.Time
30+
import Data.String (fromString)
31+
import Data.Time.Compat
3032
( UTCTime(..), ZonedTime(..), LocalTime(..), NominalDiffTime
3133
, Day, toGregorian, TimeOfDay(..), timeToTimeOfDay
3234
, TimeZone, timeZoneMinutes )
35+
import Data.Time.Format.ISO8601.Compat (iso8601Show)
36+
import Data.Time.LocalTime.Compat (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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ import Data.Functor.Identity (Identity(Identity))
3636
import Data.Int (Int8, Int16, Int32, Int64)
3737
import Data.List (intersperse)
3838
import Data.Monoid (mappend)
39-
import Data.Time (Day, TimeOfDay, LocalTime, UTCTime, ZonedTime, NominalDiffTime)
39+
import Data.Time.Compat (Day, TimeOfDay, LocalTime, UTCTime, ZonedTime, NominalDiffTime)
40+
import Data.Time.LocalTime.Compat (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: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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.Compat
40+
import Data.Time.LocalTime.Compat (CalendarDiffTime(..))
41+
import Data.ByteString(ByteString)
42+
import Database.PostgreSQL.Simple.SqlQQ
43+
44+
data IntervalTestCase = IntervalTestCase
45+
{ label :: String
46+
, inputMonths :: Integer
47+
, inputSeconds :: NominalDiffTime
48+
, asText :: String
49+
}
50+
deriving (Eq, Show)
51+
52+
testInterval :: TestEnv -> Assertion
53+
testInterval env@TestEnv{..} = do
54+
55+
initializeTable env
56+
57+
let milliseconds = 0.001
58+
seconds = 1
59+
minutes = 60 * seconds
60+
hours = 60 * minutes
61+
days = 24 * hours
62+
weeks = 7 * days
63+
months = 1
64+
years = 12 * months
65+
66+
mapM (checkRoundTrip env)
67+
[ IntervalTestCase
68+
{ label = "zero"
69+
, inputMonths = 0
70+
, inputSeconds = 0
71+
, asText = "PT0"
72+
}
73+
, IntervalTestCase
74+
{ label = "1 year"
75+
, inputMonths = 1 * years
76+
, inputSeconds = 0
77+
, asText = "P1Y"
78+
}
79+
, IntervalTestCase
80+
{ label = "2 months"
81+
, inputMonths = 2 * months
82+
, inputSeconds = 0
83+
, asText = "P2M"
84+
}
85+
, IntervalTestCase
86+
{ label = "3 weeks"
87+
, inputMonths = 0
88+
, inputSeconds = 3 * weeks
89+
, asText = "P3W"
90+
}
91+
, IntervalTestCase
92+
{ label = "4 days"
93+
, inputMonths = 0
94+
, inputSeconds = 4 * days
95+
, asText = "P4D"
96+
}
97+
, IntervalTestCase
98+
{ label = "5 hours"
99+
, inputMonths = 0
100+
, inputSeconds = 5 * hours
101+
, asText = "PT5H"
102+
}
103+
, IntervalTestCase
104+
{ label = "6 minutes"
105+
, inputMonths = 0
106+
, inputSeconds = 6 * minutes
107+
, asText = "PT6M"
108+
}
109+
, IntervalTestCase
110+
{ label = "7 seconds"
111+
, inputMonths = 0
112+
, inputSeconds = 7 * seconds
113+
, asText = "PT7S"
114+
}
115+
, IntervalTestCase
116+
{ label = "8 milliseconds"
117+
, inputMonths = 0
118+
, inputSeconds = 8 * milliseconds
119+
, asText = "PT0.008S"
120+
}
121+
, IntervalTestCase
122+
{ label = "combination of intervals (day-size or bigger)"
123+
, inputMonths = 2 * years + 4 * months
124+
, inputSeconds = 3 * weeks + 5 * days
125+
, asText = "P2Y4M3W5D"
126+
}
127+
, IntervalTestCase
128+
{ label = "combination of intervals (smaller than day-size)"
129+
, inputMonths = 0
130+
, inputSeconds = 18 * hours + 56 * minutes + 23 * seconds + 563 * milliseconds
131+
, asText = "PT18H56M23.563S"
132+
}
133+
, IntervalTestCase
134+
{ label = "full combination of intervals"
135+
, inputMonths = 2 * years + 4 * months
136+
, inputSeconds = 3 * weeks + 5 * days + 18 * hours + 56 * minutes + 23 * seconds + 563 * milliseconds
137+
, asText = "P2Y4M3W5DT18H56M23.563S"
138+
}
139+
]
140+
141+
return ()
142+
143+
initializeTable :: TestEnv -> IO ()
144+
initializeTable TestEnv{..} = withTransaction conn $ do
145+
execute_ conn
146+
[sql| CREATE TEMPORARY TABLE testinterval
147+
( id serial, sample interval, PRIMARY KEY(id) ) |]
148+
149+
return ()
150+
151+
checkRoundTrip :: TestEnv -> IntervalTestCase -> IO ()
152+
checkRoundTrip TestEnv{..} IntervalTestCase{..} = do
153+
154+
let input = CalendarDiffTime
155+
{ ctMonths = inputMonths
156+
, ctTime = inputSeconds
157+
}
158+
159+
[(returnedId :: Int, output :: CalendarDiffTime)] <- query conn [sql|
160+
INSERT INTO testinterval (sample)
161+
VALUES (?)
162+
RETURNING id, sample
163+
|] (Only input)
164+
165+
assertBool ("CalendarDiffTime did not round-trip from Haskell to SQL and back (" ++ label ++ ")") $
166+
output == input
167+
168+
[(Only isExpectedIso)] <- query conn [sql|
169+
SELECT sample = (?)::interval
170+
FROM testinterval
171+
WHERE id = ?
172+
|] (asText, returnedId)
173+
174+
assertBool ("CalendarDiffTime inserted did not match ISO8601 equivalent \"" ++ asText ++ "\". (" ++ label ++ ")")
175+
isExpectedIso
176+

0 commit comments

Comments
 (0)