Skip to content

Commit b62df08

Browse files
minerStuart Sierra
authored andcommitted
CLJ-928 instant literals for Date and Timestamp should print in UTC
Signed-off-by: Stuart Sierra <mail@stuartsierra.com>
1 parent 4c0722a commit b62df08

2 files changed

Lines changed: 125 additions & 58 deletions

File tree

src/clj/clojure/instant.clj

Lines changed: 80 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@
2222
([test msg] `(when-not ~test (fail ~msg)))
2323
([test] `(verify ~test ~(str "failed: " (pr-str test)))))
2424

25-
(defmacro ^:private divisible?
25+
(defn- divisible?
2626
[num div]
27-
`(zero? (mod ~num ~div)))
27+
(zero? (mod num div)))
2828

29-
(defmacro ^:private indivisible?
29+
(defn- indivisible?
3030
[num div]
31-
`(not (divisible? ~num ~div)))
31+
(not (divisible? num div)))
3232

3333

3434
;;; ------------------------------------------------------------------------
@@ -52,13 +52,13 @@ The function new-instant is called with the following arguments.
5252
5353
min max default
5454
--- ------------ -------
55-
years 0 9'999 N/A (s must provide years)
55+
years 0 9999 N/A (s must provide years)
5656
months 1 12 1
5757
days 1 31 1 (actual max days depends
5858
hours 0 23 0 on month and year)
5959
minutes 0 59 0
6060
seconds 0 60 0 (though 60 is only valid
61-
nanoseconds 0 999'999'999 0 when minutes is 59)
61+
nanoseconds 0 999999999 0 when minutes is 59)
6262
offset-sign -1 1 0
6363
offset-hours 0 23 0
6464
offset-minutes 0 59 0
@@ -88,10 +88,9 @@ Grammar (of s):
8888
8989
Unlike RFC3339:
9090
91-
- we only consdier timestamp (was 'date-time')
92-
(removed: 'full-time', 'full-date')
91+
- we only parse the timestamp format
9392
- timestamp can elide trailing components
94-
- time-offset is optional
93+
- time-offset is optional (defaults to +00:00)
9594
9695
Though time-offset is syntactically optional, a missing time-offset
9796
will be treated as if the time-offset zero (+00:00) had been
@@ -158,52 +157,78 @@ with invalid arguments."
158157
;;; ------------------------------------------------------------------------
159158
;;; print integration
160159

161-
(defn- fixup-offset
162-
[^String s]
163-
(let [x (- (count s) 3)]
164-
(str (.substring s 0 x) ":" (.substring s x))))
165-
166-
(defn- caldate->rfc3339
167-
"format java.util.Date or java.util.Calendar as RFC3339 timestamp."
168-
[d]
169-
(fixup-offset (format "#inst \"%1$tFT%1$tT.%1$tL%1$tz\"" d)))
160+
(def ^:private thread-local-utc-date-format
161+
;; SimpleDateFormat is not thread-safe, so we use a ThreadLocal proxy for access.
162+
;; http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4228335
163+
(proxy [ThreadLocal] []
164+
(initialValue []
165+
(doto (java.text.SimpleDateFormat. "yyyy-MM-dd'T'HH:mm:ss.SSS-00:00")
166+
;; RFC3339 says to use -00:00 when the timezone is unknown (+00:00 implies a known GMT)
167+
(.setTimeZone (java.util.TimeZone/getTimeZone "GMT"))))))
168+
169+
(defn- print-date
170+
"Print a java.util.Date as RFC3339 timestamp, always in UTC."
171+
[^java.util.Date d, ^java.io.Writer w]
172+
(let [utc-format (.get thread-local-utc-date-format)]
173+
(.write w "#inst \"")
174+
(.write w (.format utc-format d))
175+
(.write w "\"")))
170176

171177
(defmethod print-method java.util.Date
172178
[^java.util.Date d, ^java.io.Writer w]
173-
(.write w (caldate->rfc3339 d)))
179+
(print-date d w))
174180

175181
(defmethod print-dup java.util.Date
176182
[^java.util.Date d, ^java.io.Writer w]
177-
(.write w (caldate->rfc3339 d)))
183+
(print-date d w))
184+
185+
(defn- print-calendar
186+
"Print a java.util.Calendar as RFC3339 timestamp, preserving timezone."
187+
[^java.util.Calendar c, ^java.io.Writer w]
188+
(let [calstr (format "%1$tFT%1$tT.%1$tL%1$tz" c)
189+
offset-minutes (- (.length calstr) 2)]
190+
;; calstr is almost right, but is missing the colon in the offset
191+
(.write w "#inst \"")
192+
(.write w calstr 0 offset-minutes)
193+
(.write w ":")
194+
(.write w calstr offset-minutes 2)
195+
(.write w "\"")))
178196

179197
(defmethod print-method java.util.Calendar
180198
[^java.util.Calendar c, ^java.io.Writer w]
181-
(.write w (caldate->rfc3339 c)))
199+
(print-calendar c w))
182200

183201
(defmethod print-dup java.util.Calendar
184202
[^java.util.Calendar c, ^java.io.Writer w]
185-
(.write w (caldate->rfc3339 c)))
186-
187-
(defn- fixup-nanos ; 0123456789012345678901234567890123456
188-
[^long nanos ^String s] ; #@2011-01-01T01:00:00.000000000+01:00
189-
(str (.substring s 0 22)
190-
(format "%09d" nanos)
191-
(.substring s 31)))
192-
193-
(defn- timestamp->rfc3339
194-
[^java.sql.Timestamp ts]
195-
(->> ts
196-
(format "#inst \"%1$tFT%1$tT.%1$tN%1$tz\"") ; %1$tN prints 9 digits for frac.
197-
fixup-offset ; second, but last 6 are always
198-
(fixup-nanos (.getNanos ts)))) ; 0 though timestamp has getNanos
203+
(print-calendar c w))
204+
205+
206+
(def ^:private thread-local-utc-timestamp-format
207+
;; SimpleDateFormat is not thread-safe, so we use a ThreadLocal proxy for access.
208+
;; http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4228335
209+
(proxy [ThreadLocal] []
210+
(initialValue []
211+
(doto (java.text.SimpleDateFormat. "yyyy-MM-dd'T'HH:mm:ss")
212+
(.setTimeZone (java.util.TimeZone/getTimeZone "GMT"))))))
213+
214+
(defn- print-timestamp
215+
"Print a java.sql.Timestamp as RFC3339 timestamp, always in UTC."
216+
[^java.sql.Timestamp ts, ^java.io.Writer w]
217+
(let [utc-format (.get thread-local-utc-timestamp-format)]
218+
(.write w "#inst \"")
219+
(.write w (.format utc-format ts))
220+
;; add on nanos and offset
221+
;; RFC3339 says to use -00:00 when the timezone is unknown (+00:00 implies a known GMT)
222+
(.write w (format ".%09d-00:00" (.getNanos ts)))
223+
(.write w "\"")))
199224

200225
(defmethod print-method java.sql.Timestamp
201-
[^java.sql.Timestamp t, ^java.io.Writer w]
202-
(.write w (timestamp->rfc3339 t)))
226+
[^java.sql.Timestamp ts, ^java.io.Writer w]
227+
(print-timestamp ts w))
203228

204229
(defmethod print-dup java.sql.Timestamp
205-
[^java.sql.Timestamp t, ^java.io.Writer w]
206-
(.write w (timestamp->rfc3339 t)))
230+
[^java.sql.Timestamp ts, ^java.io.Writer w]
231+
(print-timestamp ts w))
207232

208233

209234
;;; ------------------------------------------------------------------------
@@ -216,7 +241,7 @@ offset, but truncating the subsecond fraction to milliseconds."
216241
[years months days hours minutes seconds nanoseconds
217242
offset-sign offset-hours offset-minutes]
218243
(doto (GregorianCalendar. years (dec months) days hours minutes seconds)
219-
(.set Calendar/MILLISECOND (/ nanoseconds 1000000))
244+
(.set Calendar/MILLISECOND (quot nanoseconds 1000000))
220245
(.setTimeZone (TimeZone/getTimeZone
221246
(format "GMT%s%02d:%02d"
222247
(if (neg? offset-sign) "-" "+")
@@ -238,23 +263,27 @@ milliseconds since the epoch, GMT."
238263
(doto (Timestamp.
239264
(.getTimeInMillis
240265
(construct-calendar years months days
241-
hours minutes seconds nanoseconds
266+
hours minutes seconds 0
242267
offset-sign offset-hours offset-minutes)))
268+
;; nanos must be set separately, pass 0 above for the base calendar
243269
(.setNanos nanoseconds)))
244270

245271
(def read-instant-date
246-
"Bind this to *instant-reader* to read instants as java.util.Date."
247-
(partial parse-timestamp (validated construct-date)))
272+
"To read an instant as a java.util.Date, bind *data-readers* to a map with
273+
this var as the value for the 'inst key. The timezone offset will be used
274+
to convert into UTC."
275+
(partial parse-timestamp (validated construct-date)))
248276

249277
(def read-instant-calendar
250-
"Bind this to *instant-reader* to read instants as java.util.Calendar.
251-
Calendar preserves the timezone offset originally used in the date
252-
literal as written."
253-
(partial parse-timestamp (validated construct-calendar)))
278+
"To read an instant as a java.util.Calendar, bind *data-readers* to a map with
279+
this var as the value for the 'inst key. Calendar preserves the timezone
280+
offset."
281+
(partial parse-timestamp (validated construct-calendar)))
254282

255283
(def read-instant-timestamp
256-
"Bind this to *instant-reader* to read instants as
257-
java.sql.Timestamp. Timestamp preserves fractional seconds with
258-
nanosecond precision."
259-
(partial parse-timestamp (validated construct-timestamp)))
284+
"To read an instant as a java.sql.Timestamp, bind *data-readers* to a
285+
map with this var as the value for the 'inst key. Timestamp preserves
286+
fractional seconds with nanosecond precision. The timezone offset will
287+
be used to convert into UTC."
288+
(partial parse-timestamp (validated construct-timestamp)))
260289

test/clojure/test_clojure/reader.clj

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
(:use [clojure.instant :only [read-instant-date
2222
read-instant-calendar
2323
read-instant-timestamp]])
24-
(:import clojure.lang.BigInt))
24+
(:import clojure.lang.BigInt
25+
java.util.TimeZone))
2526

2627
;; Symbols
2728

@@ -319,7 +320,6 @@
319320

320321
(deftest t-read)
321322

322-
323323
(deftest Instants
324324
(testing "Instants are read as java.util.Date by default"
325325
(is (= java.util.Date (class #inst "2010-11-12T13:14:15.666"))))
@@ -329,7 +329,25 @@
329329
(is (= java.util.Date (class (read-string s)))))
330330
(testing "java.util.Date instants round-trips"
331331
(is (= (-> s read-string)
332-
(-> s read-string pr-str read-string)))))
332+
(-> s read-string pr-str read-string))))
333+
(testing "java.util.Date instants round-trip throughout the year"
334+
(doseq [month (range 1 13) day (range 1 29) hour (range 1 23)]
335+
(let [s (format "#inst \"2010-%02d-%02dT%02d:14:15.666-06:00\"" month day hour)]
336+
(is (= (-> s read-string)
337+
(-> s read-string pr-str read-string))))))
338+
(testing "java.util.Date handling DST in time zones"
339+
(let [dtz (TimeZone/getDefault)]
340+
(try
341+
;; A timezone with DST in effect during 2010-11-12
342+
(TimeZone/setDefault (TimeZone/getTimeZone "Australia/Sydney"))
343+
(is (= (-> s read-string)
344+
(-> s read-string pr-str read-string)))
345+
(finally (TimeZone/setDefault dtz)))))
346+
(testing "java.util.Date should always print in UTC"
347+
(let [d (read-string s)
348+
pstr (print-str d)
349+
len (.length pstr)]
350+
(is (= (subs pstr (- len 7)) "-00:00\"")))))
333351
(binding [*data-readers* {'inst read-instant-calendar}]
334352
(testing "read-instant-calendar produces java.util.Calendar"
335353
(is (instance? java.util.Calendar (read-string s))))
@@ -344,15 +362,35 @@
344362
(testing "java.util.Calendar preserves milliseconds"
345363
(is (= 666 (-> s read-string
346364
(.get java.util.Calendar/MILLISECOND)))))))
347-
(let [s "#inst \"2010-11-12T13:14:15.123456789\""]
365+
(let [s "#inst \"2010-11-12T13:14:15.123456789\""
366+
s2 "#inst \"2010-11-12T13:14:15.123\""
367+
s3 "#inst \"2010-11-12T13:14:15.123456789123\""]
348368
(binding [*data-readers* {'inst read-instant-timestamp}]
349369
(testing "read-instant-timestamp produces java.sql.Timestamp"
350370
(is (= java.sql.Timestamp (class (read-string s)))))
351371
(testing "java.sql.Timestamp preserves nanoseconds"
352372
(is (= 123456789 (-> s read-string .getNanos)))
353-
;; bad ATM
354-
#_(is (= 123456789 (-> s read-string pr-str read-string .getNanos)))))))
355-
373+
(is (= 123456789 (-> s read-string pr-str read-string .getNanos)))
374+
;; truncate at nanos for s3
375+
(is (= 123456789 (-> s3 read-string pr-str read-string .getNanos))))
376+
(testing "java.sql.Timestamp should compare nanos"
377+
(is (= (read-string s) (read-string s3)))
378+
(is (not= (read-string s) (read-string s2)))))
379+
(binding [*data-readers* {'inst read-instant-date}]
380+
(testing "read-instant-date should truncate at milliseconds"
381+
(is (= (read-string s) (read-string s2)) (read-string s3)))))
382+
(let [s "#inst \"2010-11-12T03:14:15.123+05:00\""
383+
s2 "#inst \"2010-11-11T22:14:15.123Z\""]
384+
(binding [*data-readers* {'inst read-instant-date}]
385+
(testing "read-instant-date should convert to UTC"
386+
(is (= (read-string s) (read-string s2)))))
387+
(binding [*data-readers* {'inst read-instant-timestamp}]
388+
(testing "read-instant-timestamp should convert to UTC"
389+
(is (= (read-string s) (read-string s2)))))
390+
(binding [*data-readers* {'inst read-instant-calendar}]
391+
(testing "read-instant-calendar should preserve timezone"
392+
(is (not= (read-string s) (read-string s2)))))))
393+
356394
;; UUID Literals
357395
;; #uuid "550e8400-e29b-41d4-a716-446655440000"
358396

0 commit comments

Comments
 (0)