001package com.randomnoun.common.jexl;
002
003/* (c) 2013 randomnoun. All Rights Reserved. This work is licensed under a
004 * BSD Simplified License. (http://www.randomnoun.com/bsd-simplified.html)
005 */
006
007import java.io.Serializable;
008import java.text.*;
009import java.util.*;
010
011import com.randomnoun.common.Text;
012
013/**
014 * Data object representing a point in time, or a 24-hour day period.
015 * 
016 * <p>You should probably use something in joda or the java 8 time package these days
017 *
018 * <p>DateSpans may be <i>fixed</i>, which refer to dates that do not necessarily have timezones
019 * (e.g. a valueDate, represented with the fixed date "2004-11-07" is always the 7th of November,
020 * regardless of where in the world this may be interpreted). Note that
021 * Date objects are stored internally as milliseconds since the Unix epoch UTC, and therefore
022 * do *not* qualify as 'fixed' dates. Anything that isn't fixed I refer to as 'timezoned'
023 * in the documentation here (because they only make sense when interpretted in a
024 * specific timezone).
025 *
026 * <p>Examples of DateSpans:
027 * <pre>
028 * new DateSpan("2004-11-07")          - fixed day span       (representing November 7, 2004, 24hr period))
029 * new DateSpan("2004-11-07T20:00:00") - fixed timestamp      (representing November 7, 2004, 8:00pm)
030 * new DateSpan(
031 *   DateUtils.getStartOfDay(new Date(),
032 *   TimeZone.getDefault()), true)     - timezoned day span   (representing today in local time (24hr period))
033 * new DateSpan(new Date())            - timezoned timestamp  (representing right now in local time)
034 * </pre>
035 *
036 * <p>Note that only yyyy-MM-dd or yyyy-MM-dd'T'HH:mm:ss (without timezone) string formats are permitted;
037 * this is more strict than that allowed by ISO8901, so I do not use that class here.
038 *
039 * <p>Note that timestamps are only precise down to 1 second.
040 *
041 * <p>'fixed' times may also be assigned default timezones, which can be used when comparing
042 * timezoned values with fixed DateSpans; e.g. the RUNDATE for Market Center "New York" may be
043 * specified as "2004-11-07" (and compared with valueDates with that same fixed date),
044 * but when compared against a SystemArrivalTime, needs to be interpretted in a time zone (in
045 * this case, TimeZone.getTimezone("America/New_York")). This can be specified at construction
046 * time, e.g.
047 * <pre style="code">
048 *   new DateSpan("2004-11-07", TimeZone.getTimezone("America/New_York"))
049 * </pre>
050 *
051 * <p>Note that if a timezone is not supplied for a 'fixed' time, then any call to {@link #getStartAsDate()}
052 * or {@link #getEndAsDate()} will return an IllegalStateException.
053 *
054 * <p>This class only supports the 'fixed day span', 'timezoned day span' or 'timezoned timestamp'
055 * examples above. Could extend this later on to 'fixed timestamp' values. 
056 * Similarly, this class only supports 24hour spans, but could support more arbitrary durations 
057 * if this was needed further down the track.
058 *
059 * <p>Only years between 1000 and 9999 will be handled correctly by this class.
060 * 
061 * @author knoxg
062 */
063public class DateSpan
064    implements Serializable
065{
066    /** Generated serialVersionUID */
067        private static final long serialVersionUID = 4189707923685011603L;
068
069        /** Contains the string representation of a 'fixed' timestamp or duration (or null if this is a timezoned DateSpan) */
070    private String fixedTime;
071
072    /** Contains a 'timezoned' Date, or for fixed values, contains the Date of 'fixedTime' in the defaultTimeZone */
073    private Date timezonedTime;
074
075    /** Is true if this DateSpan represents a 24hour period, or false for a 1 second instant. */
076    boolean isDay;
077
078    /** For 'fixed' DateSpans, specifies a default timezone to interpret this in (if we need to) */
079    private TimeZone defaultTimezone;
080    
081    /** A single timestamp instant, without timezone. Equivalent to DateSpan(Date, false, null) */
082    public DateSpan(Date timestamp) {
083        this(timestamp, false, null);
084    }
085
086    /** Create a 'timezoneless' date. A defaultTimezone may be supplied, which will be used
087     *  when converting to Date objects.
088     *
089     * @param dateSpan A date in yyyy-MM-dd or yyyy-MM-dd'T'HH:mm:ss format (the former
090     *    specifies a day period, the latter specifies a single instant
091     * @param defaultTimezone A default timezone to use when converting to Date objects.
092     *    This parameter may be null.
093     *
094     * @throws NullPointerException if the dateSpan passed to this method is null
095     **/
096    public DateSpan(String dateSpan, TimeZone defaultTimezone)
097    {
098        // ensure this is a date we can accept
099        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}