1 package com.randomnoun.common.jexl;
2
3 /* (c) 2013 randomnoun. All Rights Reserved. This work is licensed under a
4 * BSD Simplified License. (http://www.randomnoun.com/bsd-simplified.html)
5 */
6
7 import java.io.Serializable;
8 import java.text.*;
9 import java.util.*;
10
11 import com.randomnoun.common.Text;
12
13 /**
14 * Data object representing a point in time, or a 24-hour day period.
15 *
16 * <p>You should probably use something in joda or the java 8 time package these days
17 *
18 * <p>DateSpans may be <i>fixed</i>, which refer to dates that do not necessarily have timezones
19 * (e.g. a valueDate, represented with the fixed date "2004-11-07" is always the 7th of November,
20 * regardless of where in the world this may be interpreted). Note that
21 * Date objects are stored internally as milliseconds since the Unix epoch UTC, and therefore
22 * do *not* qualify as 'fixed' dates. Anything that isn't fixed I refer to as 'timezoned'
23 * in the documentation here (because they only make sense when interpretted in a
24 * specific timezone).
25 *
26 * <p>Examples of DateSpans:
27 * <pre>
28 * new DateSpan("2004-11-07") - fixed day span (representing November 7, 2004, 24hr period))
29 * new DateSpan("2004-11-07T20:00:00") - fixed timestamp (representing November 7, 2004, 8:00pm)
30 * new DateSpan(
31 * DateUtils.getStartOfDay(new Date(),
32 * TimeZone.getDefault()), true) - timezoned day span (representing today in local time (24hr period))
33 * new DateSpan(new Date()) - timezoned timestamp (representing right now in local time)
34 * </pre>
35 *
36 * <p>Note that only yyyy-MM-dd or yyyy-MM-dd'T'HH:mm:ss (without timezone) string formats are permitted;
37 * this is more strict than that allowed by ISO8901, so I do not use that class here.
38 *
39 * <p>Note that timestamps are only precise down to 1 second.
40 *
41 * <p>'fixed' times may also be assigned default timezones, which can be used when comparing
42 * timezoned values with fixed DateSpans; e.g. the RUNDATE for Market Center "New York" may be
43 * specified as "2004-11-07" (and compared with valueDates with that same fixed date),
44 * but when compared against a SystemArrivalTime, needs to be interpretted in a time zone (in
45 * this case, TimeZone.getTimezone("America/New_York")). This can be specified at construction
46 * time, e.g.
47 * <pre style="code">
48 * new DateSpan("2004-11-07", TimeZone.getTimezone("America/New_York"))
49 * </pre>
50 *
51 * <p>Note that if a timezone is not supplied for a 'fixed' time, then any call to {@link #getStartAsDate()}
52 * or {@link #getEndAsDate()} will return an IllegalStateException.
53 *
54 * <p>This class only supports the 'fixed day span', 'timezoned day span' or 'timezoned timestamp'
55 * examples above. Could extend this later on to 'fixed timestamp' values.
56 * Similarly, this class only supports 24hour spans, but could support more arbitrary durations
57 * if this was needed further down the track.
58 *
59 * <p>Only years between 1000 and 9999 will be handled correctly by this class.
60 *
61 * @author knoxg
62 */
63 public class DateSpan
64 implements Serializable
65 {
66 /** Generated serialVersionUID */
67 private static final long serialVersionUID = 4189707923685011603L;
68
69 /** Contains the string representation of a 'fixed' timestamp or duration (or null if this is a timezoned DateSpan) */
70 private String fixedTime;
71
72 /** Contains a 'timezoned' Date, or for fixed values, contains the Date of 'fixedTime' in the defaultTimeZone */
73 private Date timezonedTime;
74
75 /** Is true if this DateSpan represents a 24hour period, or false for a 1 second instant. */
76 boolean isDay;
77
78 /** For 'fixed' DateSpans, specifies a default timezone to interpret this in (if we need to) */
79 private TimeZone defaultTimezone;
80
81 /** A single timestamp instant, without timezone. Equivalent to DateSpan(Date, false, null) */
82 public DateSpan(Date timestamp) {
83 this(timestamp, false, null);
84 }
85
86 /** Create a 'timezoneless' date. A defaultTimezone may be supplied, which will be used
87 * when converting to Date objects.
88 *
89 * @param dateSpan A date in yyyy-MM-dd or yyyy-MM-dd'T'HH:mm:ss format (the former
90 * specifies a day period, the latter specifies a single instant
91 * @param defaultTimezone A default timezone to use when converting to Date objects.
92 * This parameter may be null.
93 *
94 * @throws NullPointerException if the dateSpan passed to this method is null
95 **/
96 public DateSpan(String dateSpan, TimeZone defaultTimezone)
97 {
98 // ensure this is a date we can accept
99 if (dateSpan == null) {
100 throw new NullPointerException("null dateSpan");
101 }
102
103 DateFormat timestampFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
104 DateFormat dayFormat = new SimpleDateFormat("yyyy-MM-dd");
105 TimeZone timezone;
106
107 if (defaultTimezone == null) {
108 timezone = TimeZone.getTimeZone("UTC"); // a simple, known timezone.
109 } else {
110 timezone = defaultTimezone;
111 }
112
113 // we want dates accepted here to be exactly right
114 timestampFormat.setLenient(false);
115 dayFormat.setLenient(false);
116 timestampFormat.setTimeZone(timezone);
117 dayFormat.setTimeZone(timezone);
118
119 Date date;
120
121 try {
122 date = timestampFormat.parse(dateSpan);
123
124 // parse succeeded, must be a timestamp
125 this.isDay = false;
126 this.fixedTime = dateSpan;
127 this.timezonedTime = date;
128 this.defaultTimezone = defaultTimezone;
129 } catch (ParseException pe) {
130 try {
131 // set timestamp component for this date to 00:00:00 in this timezone
132 date = timestampFormat.parse(dateSpan + "T00:00:00");
133 //date = dayFormat.parse(dateSpan);
134
135 // parse succeeded, must be a day span
136 this.isDay = true;
137 this.fixedTime = dateSpan;
138 this.timezonedTime = date;
139 this.defaultTimezone = defaultTimezone;
140 } catch (ParseException pe2) {
141 throw new IllegalArgumentException("dateSpan '" + dateSpan +
142 "' could not be parsed as a timestamp or day value");
143 }
144 }
145 }
146
147 /** Specify a 24-hour period, starting at point (e.g. "today" spans 24 hours).
148 *
149 * @param dateSpanStart the time of this DateSpan (or the beginning, if a period)
150 * @param isDay set this to true if this DateSpan represents a 24 hour day, false otherwise
151 * @param defaultTimezone the timezone to interpret this date in, if we need to convert
152 * it to string form.
153 *
154 * @throws NullPointerException if the dateSpanStart parameter passed to this method is null
155 */
156 public DateSpan(Date dateSpanStart, boolean isDay, TimeZone defaultTimezone)
157 {
158 if (dateSpanStart == null) {
159 throw new NullPointerException("null dateSpanStart");
160 }
161
162 this.timezonedTime = dateSpanStart;
163 this.isDay = isDay;
164 this.defaultTimezone = defaultTimezone;
165 }
166
167 /** Returns true for a 'fixed' DateSpan. See the class javadocs for details.
168 *
169 * @return true for a 'fixed' DateSpan
170 */
171 public boolean isFixed()
172 {
173 return (this.fixedTime != null);
174 }
175
176 /** Returns true for a 24hour period. See the class javadocs for details
177 *
178 * @return true if this DateSpan represents a 24 hour period, false otherwise
179 */
180 public boolean isDay()
181 {
182 return this.isDay;
183 }
184
185 /** Gets the 'fixed' representation of this DateSpan, if the DateSpan is fixed. This
186 * may return a day string (yyyy-MM-dd) or a day/time (yyyy-MM-dd'T'HH:mm:ss).
187 *
188 * @return the 'fixed' representation of this DateSpan, if the DateSpan is fixed
189 *
190 * @throws IllegalStateException if this DateSpan is not fixed.
191 */
192 public String getFixed()
193 {
194 if (!isFixed()) {
195 throw new IllegalStateException(
196 "getFixed() only allowed on fixed DateSpan objects");
197 }
198
199 return fixedTime;
200 }
201
202 /** Returns the start of this DateSpan in yyyyMMdd format (presumably for comparing
203 * against the value date column). If this is a timezoned DateSpan and no default
204 * timezone has been set, raises an IllegalStateException
205 *
206 * @return the start of this DateSpan in yyyyMMdd format
207 *
208 * @throws IllegalStateException if no defaultTimezone has been specified for a timezoned DateSpan
209 */
210 public String getStartAsYyyymmdd()
211 {
212 if (isFixed()) {
213 // yyyy-MM-ddTHH:mm:ss
214 // 0123456789012345678
215 return fixedTime.substring(0, 4) + fixedTime.substring(5, 7) +
216 fixedTime.substring(8, 10);
217 } else {
218 if (defaultTimezone == null) {
219 throw new IllegalStateException("Default timezone must be set");
220 }
221 SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
222 sdf.setTimeZone(defaultTimezone);
223 return sdf.format(timezonedTime);
224 }
225 }
226
227 /** Returns the start of this DateSpan as a Date (milliseconds from UTC epoch).
228 * If this is a fixed DateSpan and no default timezone has been set, raises
229 * an IllegalStateException
230 *
231 * @return the start of this DateSpan as a Date
232 *
233 * @throws IllegalStateException if no defaultTimezone has been specified for a fixed DateSpan
234 */
235 public Date getStartAsDate()
236 {
237 if (isFixed()) {
238 if (defaultTimezone == null) {
239 throw new IllegalStateException("Default timezone must be set");
240 }
241 return timezonedTime;
242 } else {
243 return timezonedTime;
244 }
245 }
246
247 /** Returns the end of this DateSpan in yyyyMMdd format (presumably for comparing
248 * against the value date column). If this is a timezoned DateSpan and no default
249 * timezone has been set, raises an IllegalStateException
250 *
251 * @return the end of this DateSpan in yyyyMMdd format
252 *
253 * @throws IllegalStateException if this DateSpan represents a point in time (not a duration),
254 * or if no defaultTimezone has been specified for a timezoned DateSpan
255 */
256 public String getEndAsYyyymmdd()
257 {
258 if (!isDay) {
259 throw new IllegalStateException(
260 "getEndAsString() only supported for time periods, not timestamps");
261 }
262
263 if (isFixed()) {
264 Calendar cal = new GregorianCalendar(Integer.parseInt(fixedTime.substring(0, 4)),
265 Integer.parseInt(fixedTime.substring(5, 7)),
266 Integer.parseInt(fixedTime.substring(8, 10)));
267
268 cal.add(Calendar.DAY_OF_YEAR, 1);
269
270 int day = cal.get(Calendar.DAY_OF_MONTH);
271 int month = cal.get(Calendar.MONTH) + 1;
272 int year = cal.get(Calendar.YEAR);
273
274 return ((day < 10 ? "0" : "") + day) + ((month < 10 ? "0" : "") + month) +
275 year;
276 } else {
277 if (defaultTimezone == null) {
278 throw new IllegalStateException("Default timezone must be set");
279 }
280
281 SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
282 sdf.setTimeZone(defaultTimezone);
283 return sdf.format(new Date(timezonedTime.getTime() + 86399999)); // 86400000ms in 1 day
284 }
285 }
286
287 /** Returns the end of this DateSpan as a Date (milliseconds from UTC epoch).
288 * If this is a fixed DateSpan and no default timezone has been set, raises
289 * an IllegalStateException
290 *
291 * @return the end of this DateSpan as a Date
292 *
293 * @throws IllegalStateException if no defaultTimezone has been specified for a fixed DateSpan
294 */
295 public Date getEndAsDate()
296 {
297 if (isFixed()) {
298 if (defaultTimezone == null) {
299 throw new IllegalStateException("Default timezone must be set");
300 }
301 return new Date(timezonedTime.getTime() + 86399999); // 86400000ms in 1 day
302 } else {
303 return new Date(timezonedTime.getTime() + 86399999); // 86400000ms in 1 day
304 }
305 }
306
307 /** A string representation of this object, useful for debugging.
308 *
309 * @return a string representation of this object, useful for debugging.
310 */
311 public String toString()
312 {
313 if (isFixed()) {
314 return "(\"" + fixedTime + "\", isDay=" + isDay + ", tz=" +
315 (defaultTimezone == null ? "(null)" : defaultTimezone.getID()) +
316 ")";
317 } else {
318 SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'+00:00'");
319 sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
320 return "(t=" + sdf.format(timezonedTime) + ", isDay=" + isDay + ", tz=" +
321 (defaultTimezone == null ? "(null)" : defaultTimezone.getID()) +
322 ")";
323 }
324 }
325
326 /** Inverse of toString(); required for {@link com.randomnoun.common.Text#parseStructOutput(String)}
327 * to do it's thing.
328 *
329 * @param string The text representation of this object (as returned by .toString())
330 *
331 * @return an instance of a DateSpan
332 */
333 public static DateSpan valueOf(String text) throws ParseException {
334 if (!(text.startsWith("(") && text.endsWith(")"))) {
335 throw new ParseException("DateSpan representation must begin and end with brackets", 0);
336 }
337 text = text.substring(1, text.length()-1);
338 List<String> list = Text.parseCsv(text);
339 if (list.size()!=3) { throw new ParseException("Expected 3 elements in DateSpan representation", 0); }
340 String time = (String) list.get(0);
341 String isDay = (String) list.get(1);
342 String tz = (String) list.get(2);
343 TimeZone timezone = null;
344 if (!isDay.startsWith("isDay=")) { throw new ParseException("Expected 'isDay=' in second element", 0); }
345 if (!tz.startsWith("tz=")) { throw new ParseException("Expected 'tz=' in third element", 0); }
346 if (!tz.equals("(null)")) { timezone = TimeZone.getTimeZone(tz.substring(3)); }
347
348 if (time.startsWith("t=")) {
349 SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'+00:00'");
350 sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
351 Date date = sdf.parse(time.substring(2));
352 return new DateSpan(date, isDay.substring(6).equals("true"), timezone);
353
354 } else {
355 return new DateSpan(time, timezone);
356
357 }
358 }
359
360
361 /** Returns true if the object passed to this method as an equivalent dateSpan instance
362 * to the current object. (Used in unit testing only)
363 *
364 * @param other the object we are comparing agianst
365 *
366 * @return true if the other object is identical to this one, false otherwise
367 */
368 public boolean equals(Object obj) {
369 if (!(obj instanceof DateSpan)) { return false; }
370 DateSpan other = (DateSpan) obj;
371 if ( ((fixedTime == null && other.fixedTime==null) ||
372 (fixedTime != null && fixedTime.equals(other.fixedTime))) &&
373 ((timezonedTime == null && other.timezonedTime==null) ||
374 (timezonedTime != null && timezonedTime.equals(other.timezonedTime))) &&
375 ((defaultTimezone == null && other.defaultTimezone==null) ||
376 (defaultTimezone != null && defaultTimezone.equals(other.defaultTimezone))) &&
377 isDay == other.isDay ) {
378 return true;
379 } else {
380 return false;
381 }
382 }
383
384
385 }