/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.apache.commons.lang3.time; import java.io.IOException; import java.io.ObjectInputStream; import java.io.Serializable; import java.text.DateFormatSymbols; import java.text.ParseException; import java.text.ParsePosition; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.SortedMap; import java.util.TimeZone; import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * <p>FastDateParser is a fast and thread-safe version of * {@link java.text.SimpleDateFormat}.</p> * * <p>This class can be used as a direct replacement for * <code>SimpleDateFormat</code> in most parsing situations. * This class is especially useful in multi-threaded server environments. * <code>SimpleDateFormat</code> is not thread-safe in any JDK version, * nor will it be as Sun have closed the * <a href="http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4228335">bug</a>/RFE. * </p> * * <p>Only parsing is supported, but all patterns are compatible with * SimpleDateFormat.</p> * * <p>Timing tests indicate this class is as about as fast as SimpleDateFormat * in single thread applications and about 25% faster in multi-thread applications.</p> * * <p>Note that the code only handles Gregorian calendars. The following non-Gregorian * calendars use SimpleDateFormat internally, and so will be slower: * <ul> * <li>ja_JP_TH - Japanese Imperial</li> * <li>th_TH (any variant) - Thai Buddhist</li> * </ul> * </p> * @since 3.2 */ public class FastDateParser implements DateParser, Serializable { /** * Required for serialization support. * * @see java.io.Serializable */ private static final long serialVersionUID = 1L; private static final ConcurrentMap<Locale,TimeZoneStrategy> tzsCache= new ConcurrentHashMap<Locale,TimeZoneStrategy>(3); static final Locale JAPANESE_IMPERIAL = new Locale("ja","JP","JP"); // defining fields private final String pattern; private final TimeZone timeZone; private final Locale locale; // derived fields private transient Pattern parsePattern; private transient Strategy[] strategies; private transient int thisYear; private transient ConcurrentMap<Integer, KeyValue[]> nameValues; // dynamic fields to communicate with Strategy private transient String currentFormatField; private transient Strategy nextStrategy; /** * <p>Constructs a new FastDateParser.</p> * * @param pattern non-null {@link java.text.SimpleDateFormat} compatible * pattern * @param timeZone non-null time zone to use * @param locale non-null locale */ protected FastDateParser(String pattern, TimeZone timeZone, Locale locale) { this.pattern = pattern; this.timeZone = timeZone; this.locale = locale; init(); } /** * Initialize derived fields from defining fields. * This is called from constructor and from readObject (de-serialization) */ private void init() { thisYear= Calendar.getInstance(timeZone, locale).get(Calendar.YEAR); nameValues= new ConcurrentHashMap<Integer, KeyValue[]>(); StringBuilder regex= new StringBuilder(); List<Strategy> collector = new ArrayList<Strategy>(); Matcher patternMatcher= formatPattern.matcher(pattern); if(!patternMatcher.lookingAt()) { throw new IllegalArgumentException("Invalid pattern"); } currentFormatField= patternMatcher.group(); Strategy currentStrategy= getStrategy(currentFormatField); for(;;) { patternMatcher.region(patternMatcher.end(), patternMatcher.regionEnd()); if(!patternMatcher.lookingAt()) { nextStrategy = null; break; } String nextFormatField= patternMatcher.group(); nextStrategy = getStrategy(nextFormatField); if(currentStrategy.addRegex(this, regex)) { collector.add(currentStrategy); } currentFormatField= nextFormatField; currentStrategy= nextStrategy; } if(currentStrategy.addRegex(this, regex)) { collector.add(currentStrategy); } currentFormatField= null; strategies= collector.toArray(new Strategy[collector.size()]); parsePattern= Pattern.compile(regex.toString()); } // Accessors //----------------------------------------------------------------------- /* (non-Javadoc) * @see org.apache.commons.lang3.time.DateParser#getPattern() */ @Override public String getPattern() { return pattern; } /* (non-Javadoc) * @see org.apache.commons.lang3.time.DateParser#getTimeZone() */ @Override public TimeZone getTimeZone() { return timeZone; } /* (non-Javadoc) * @see org.apache.commons.lang3.time.DateParser#getLocale() */ @Override public Locale getLocale() { return locale; } // Give access to generated pattern for test code Pattern getParsePattern() { return parsePattern; } // Basics //----------------------------------------------------------------------- /** * <p>Compare another object for equality with this object.</p> * * @param obj the object to compare to * @return <code>true</code>if equal to this instance */ @Override public boolean equals(Object obj) { if (! (obj instanceof FastDateParser) ) { return false; } FastDateParser other = (FastDateParser) obj; return pattern.equals(other.pattern) && timeZone.equals(other.timeZone) && locale.equals(other.locale); } /** * <p>Return a hashcode compatible with equals.</p> * * @return a hashcode compatible with equals */ @Override public int hashCode() { return pattern.hashCode() + 13 * (timeZone.hashCode() + 13 * locale.hashCode()); } /** * <p>Get a string version of this formatter.</p> * * @return a debugging string */ @Override public String toString() { return "FastDateParser[" + pattern + "," + locale + "," + timeZone.getID() + "]"; } // Serializing //----------------------------------------------------------------------- /** * Create the object after serialization. This implementation reinitializes the * transient properties. * * @param in ObjectInputStream from which the object is being deserialized. * @throws IOException if there is an IO issue. * @throws ClassNotFoundException if a class cannot be found. */ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); init(); } /* (non-Javadoc) * @see org.apache.commons.lang3.time.DateParser#parseObject(java.lang.String) */ @Override public Object parseObject(String source) throws ParseException { return parse(source); } /* (non-Javadoc) * @see org.apache.commons.lang3.time.DateParser#parse(java.lang.String) */ @Override public Date parse(String source) throws ParseException { Date date= parse(source, new ParsePosition(0)); if(date==null) { // Add a note re supported date range if (locale.equals(JAPANESE_IMPERIAL)) { throw new ParseException( "(The " +locale + " locale does not support dates before 1868 AD)\n" + "Unparseable date: \""+source+"\" does not match "+parsePattern.pattern(), 0); } throw new ParseException("Unparseable date: \""+source+"\" does not match "+parsePattern.pattern(), 0); } return date; } /* (non-Javadoc) * @see org.apache.commons.lang3.time.DateParser#parseObject(java.lang.String, java.text.ParsePosition) */ @Override public Object parseObject(String source, ParsePosition pos) { return parse(source, pos); } /* (non-Javadoc) * @see org.apache.commons.lang3.time.DateParser#parse(java.lang.String, java.text.ParsePosition) */ @Override public Date parse(String source, ParsePosition pos) { int offset= pos.getIndex(); Matcher matcher= parsePattern.matcher(source.substring(offset)); if(!matcher.lookingAt()) { return null; } // timing tests indicate getting new instance is 19% faster than cloning Calendar cal= Calendar.getInstance(timeZone, locale); cal.clear(); for(int i=0; i<strategies.length;) { Strategy strategy= strategies[i++]; strategy.setCalendar(this, cal, matcher.group(i)); } pos.setIndex(offset+matcher.end()); return cal.getTime(); } // Support for strategies //----------------------------------------------------------------------- /** * Escape constant fields into regular expression * @param regex The destination regex * @param value The source field * @param unquote If true, replace two success quotes ('') with single quote (') * @return The <code>StringBuilder</code> */ private static StringBuilder escapeRegex(StringBuilder regex, String value, boolean unquote) { boolean wasWhite= false; for(int i= 0; i<value.length(); ++i) { // start of generated patch char c=value.charAt(i); if(Character.isHighSurrogate(c)){ if(!wasWhite){ wasWhite=true; regex.append("\\s*+"); } continue; } // end of generated patch /* start of original code char c= value.charAt(i); if(Character.isWhitespace(c)) { if(!wasWhite) { wasWhite= true; regex.append("\\s*+"); } continue; } end of original code*/ wasWhite= false; switch(c) { case '\'': if(unquote) { if(++i==value.length()) { return regex; } c= value.charAt(i); } break; case '?': case '[': case ']': case '(': case ')': case '{': case '}': case '\\': case '|': case '*': case '+': case '^': case '$': case '.': regex.append('\\'); } regex.append(c); } return regex; } /** * A class to store Key / Value pairs */ private static class KeyValue { public String key; public int value; /** * Construct a Key / Value pair * @param key The key * @param value The value */ public KeyValue(String key, int value) { this.key= key; this.value= value; } } /** * ignore case comparison of keys */ private static final Comparator<KeyValue> IGNORE_CASE_COMPARATOR = new Comparator<KeyValue> () { @Override public int compare(KeyValue left, KeyValue right) { return left.key.compareToIgnoreCase(right.key); } }; /** * Get the short and long values displayed for a field * @param field The field of interest * @return A sorted array of the field key / value pairs */ KeyValue[] getDisplayNames(int field) { Integer fieldInt = Integer.valueOf(field); KeyValue[] fieldKeyValues= nameValues.get(fieldInt); if(fieldKeyValues==null) { DateFormatSymbols symbols= DateFormatSymbols.getInstance(locale); switch(field) { case Calendar.ERA: // DateFormatSymbols#getEras() only returns AD/BC or translations // It does not work for the Thai Buddhist or Japanese Imperial calendars. // see: https://issues.apache.org/jira/browse/TRINIDAD-2126 Calendar c = Calendar.getInstance(locale); // N.B. Some calendars have different short and long symbols, e.g. ja_JP_JP String[] shortEras = toArray(c.getDisplayNames(Calendar.ERA, Calendar.SHORT, locale)); String[] longEras = toArray(c.getDisplayNames(Calendar.ERA, Calendar.LONG, locale)); fieldKeyValues= createKeyValues(longEras, shortEras); break; case Calendar.DAY_OF_WEEK: fieldKeyValues= createKeyValues(symbols.getWeekdays(), symbols.getShortWeekdays()); break; case Calendar.AM_PM: fieldKeyValues= createKeyValues(symbols.getAmPmStrings(), null); break; case Calendar.MONTH: fieldKeyValues= createKeyValues(symbols.getMonths(), symbols.getShortMonths()); break; default: throw new IllegalArgumentException("Invalid field value "+field); } KeyValue[] prior = nameValues.putIfAbsent(fieldInt, fieldKeyValues); if(prior!=null) { fieldKeyValues= prior; } } return fieldKeyValues; } private String[] toArray(Map<String, Integer> era) { String[] eras = new String[era.size()]; // assume no gaps in entry values for(Map.Entry<String, Integer> me : era.entrySet()) { int idx = me.getValue().intValue(); final String key = me.getKey(); if (key == null) { throw new IllegalArgumentException(); } eras[idx] = key; } return eras; } /** * Create key / value pairs from keys * @param longValues The allowable long names for a field * @param shortValues The optional allowable short names for a field * @return The sorted name / value pairs for the field */ private static KeyValue[] createKeyValues(String[] longValues, String[] shortValues) { KeyValue[] fieldKeyValues= new KeyValue[count(longValues)+count(shortValues)]; copy(fieldKeyValues, copy(fieldKeyValues, 0, longValues), shortValues); Arrays.sort(fieldKeyValues, IGNORE_CASE_COMPARATOR); return fieldKeyValues; } /** * Get a count of valid values in array. A valid value is of non-zero length. * @param values The values to check. This parameter may be null * @return The number of valid values */ private static int count(String[] values) { int count= 0; if(values!=null) { for(String value : values) { if(value.length()>0) { ++count; } } } return count; } /** * Create key / value pairs from values * @param fieldKeyValues The destination array * @param offset The offset into the destination array * @param values The values to use to create key / value pairs. This parameter may be null. * @return The offset into the destination array */ private static int copy(KeyValue[] fieldKeyValues, int offset, String[] values) { if(values!=null) { for(int i= 0; i<values.length; ++i) { String value= values[i]; if(value.length()>0) { fieldKeyValues[offset++]= new KeyValue(value, i); } } } return offset; } /** * Adjust dates to be within 80 years before and 20 years after instantiation * @param twoDigitYear The year to adjust * @return A value within -80 and +20 years from instantiation of this instance */ int adjustYear(int twoDigitYear) { int trial= twoDigitYear + thisYear - thisYear%100; if(trial < thisYear+20) { return trial; } return trial-100; } /** * Is the next field a number? * @return true, if next field will be a number */ boolean isNextNumber() { return nextStrategy!=null && nextStrategy.isNumber(); } /** * What is the width of the current field? * @return The number of characters in the current format field */ int getFieldWidth() { return currentFormatField.length(); } /** * A strategy to parse a single field from the parsing pattern */ private interface Strategy { /** * Is this field a number? * @return true, if field is a number */ boolean isNumber(); /** * Set the Calendar with the parsed field * @param parser The parser calling this strategy * @param cal The <code>Calendar</code> to set * @param value The parsed field to translate and set in cal */ void setCalendar(FastDateParser parser, Calendar cal, String value); /** * Generate a <code>Pattern</code> regular expression to the <code>StringBuilder</code> * which will accept this field * @param parser The parser calling this strategy * @param regex The <code>StringBuilder</code> to append to * @return true, if this field will set the calendar; * false, if this field is a constant value */ boolean addRegex(FastDateParser parser, StringBuilder regex); } /** * A <code>Pattern</code> to parse the user supplied SimpleDateFormat pattern */ private static final Pattern formatPattern= Pattern.compile( "D+|E+|F+|G+|H+|K+|M+|S+|W+|Z+|a+|d+|h+|k+|m+|s+|w+|y+|z+|''|'[^']++(''[^']*+)*+'|[^'A-Za-z]++"); /** * Obtain a Strategy given a field from a SimpleDateFormat pattern * @param formatField A sub-sequence of the SimpleDateFormat pattern * @return The Strategy that will handle parsing for the field */ private Strategy getStrategy(String formatField) { switch(formatField.charAt(0)) { case '\'': if(formatField.length()>2) { formatField= formatField.substring(1, formatField.length()-1); } //$FALL-THROUGH$ default: return new CopyQuotedStrategy(formatField); case 'D': return DAY_OF_YEAR_STRATEGY; case 'E': return DAY_OF_WEEK_STRATEGY; case 'F': return DAY_OF_WEEK_IN_MONTH_STRATEGY; case 'G': return ERA_STRATEGY; case 'H': return MODULO_HOUR_OF_DAY_STRATEGY; case 'K': return HOUR_STRATEGY; case 'M': return formatField.length()>=3 ?TEXT_MONTH_STRATEGY :NUMBER_MONTH_STRATEGY; case 'S': return MILLISECOND_STRATEGY; case 'W': return WEEK_OF_MONTH_STRATEGY; case 'Z': break; case 'a': return AM_PM_STRATEGY; case 'd': return DAY_OF_MONTH_STRATEGY; case 'h': return MODULO_HOUR_STRATEGY; case 'k': return HOUR_OF_DAY_STRATEGY; case 'm': return MINUTE_STRATEGY; case 's': return SECOND_STRATEGY; case 'w': return WEEK_OF_YEAR_STRATEGY; case 'y': return formatField.length()>2 ?LITERAL_YEAR_STRATEGY :ABBREVIATED_YEAR_STRATEGY; case 'z': break; } TimeZoneStrategy tzs= tzsCache.get(locale); if(tzs==null) { tzs= new TimeZoneStrategy(locale); TimeZoneStrategy inCache= tzsCache.putIfAbsent(locale, tzs); if(inCache!=null) { return inCache; } } return tzs; } /** * A strategy that copies the static or quoted field in the parsing pattern */ private static class CopyQuotedStrategy implements Strategy { private final String formatField; /** * Construct a Strategy that ensures the formatField has literal text * @param formatField The literal text to match */ CopyQuotedStrategy(String formatField) { this.formatField= formatField; } /** * {@inheritDoc} */ @Override public boolean isNumber() { char c= formatField.charAt(0); if(c=='\'') { c= formatField.charAt(1); } return Character.isDigit(c); } /** * {@inheritDoc} */ @Override public boolean addRegex(FastDateParser parser, StringBuilder regex) { escapeRegex(regex, formatField, true); return false; } /** * {@inheritDoc} */ @Override public void setCalendar(FastDateParser parser, Calendar cal, String value) { } } /** * A strategy that handles a text field in the parsing pattern */ private static class TextStrategy implements Strategy { private final int field; /** * Construct a Strategy that parses a Text field * @param field The Calendar field */ TextStrategy(int field) { this.field= field; } /** * {@inheritDoc} */ @Override public boolean isNumber() { return false; } /** * {@inheritDoc} */ @Override public boolean addRegex(FastDateParser parser, StringBuilder regex) { regex.append('('); for(KeyValue textKeyValue : parser.getDisplayNames(field)) { escapeRegex(regex, textKeyValue.key, false).append('|'); } regex.setCharAt(regex.length()-1, ')'); return true; } /** * {@inheritDoc} */ @Override public void setCalendar(FastDateParser parser, Calendar cal, String value) { KeyValue[] textKeyValues= parser.getDisplayNames(field); int idx= Arrays.binarySearch(textKeyValues, new KeyValue(value, -1), IGNORE_CASE_COMPARATOR); if(idx<0) { StringBuilder sb= new StringBuilder(value); sb.append(" not in ("); for(KeyValue textKeyValue : textKeyValues) { sb.append(textKeyValue.key).append(' '); } sb.setCharAt(sb.length()-1, ')'); throw new IllegalArgumentException(sb.toString()); } cal.set(field, textKeyValues[idx].value); } } /** * A strategy that handles a number field in the parsing pattern */ private static class NumberStrategy implements Strategy { protected final int field; /** * Construct a Strategy that parses a Number field * @param field The Calendar field */ NumberStrategy(int field) { this.field= field; } /** * {@inheritDoc} */ @Override public boolean isNumber() { return true; } /** * {@inheritDoc} */ @Override public boolean addRegex(FastDateParser parser, StringBuilder regex) { if(parser.isNextNumber()) { regex.append("(\\p{IsNd}{").append(parser.getFieldWidth()).append("}+)"); } else { regex.append("(\\p{IsNd}++)"); } return true; } /** * {@inheritDoc} */ @Override public void setCalendar(FastDateParser parser, Calendar cal, String value) { cal.set(field, modify(Integer.parseInt(value))); } /** * Make any modifications to parsed integer * @param iValue The parsed integer * @return The modified value */ public int modify(int iValue) { return iValue; } } private static final Strategy ABBREVIATED_YEAR_STRATEGY = new NumberStrategy(Calendar.YEAR) { /** * {@inheritDoc} */ @Override public void setCalendar(FastDateParser parser, Calendar cal, String value) { int iValue= Integer.parseInt(value); if(iValue<100) { iValue= parser.adjustYear(iValue); } cal.set(Calendar.YEAR, iValue); } }; /** * A strategy that handles a timezone field in the parsing pattern */ private static class TimeZoneStrategy implements Strategy { final String validTimeZoneChars; final SortedMap<String, TimeZone> tzNames= new TreeMap<String, TimeZone>(String.CASE_INSENSITIVE_ORDER); /** * Construct a Strategy that parses a TimeZone * @param locale The Locale */ TimeZoneStrategy(Locale locale) { for(String id : TimeZone.getAvailableIDs()) { if(id.startsWith("GMT")) { continue; } TimeZone tz= TimeZone.getTimeZone(id); tzNames.put(tz.getDisplayName(false, TimeZone.SHORT, locale), tz); tzNames.put(tz.getDisplayName(false, TimeZone.LONG, locale), tz); if(tz.useDaylightTime()) { tzNames.put(tz.getDisplayName(true, TimeZone.SHORT, locale), tz); tzNames.put(tz.getDisplayName(true, TimeZone.LONG, locale), tz); } } StringBuilder sb= new StringBuilder(); sb.append("(GMT[+\\-]\\d{0,1}\\d{2}|[+\\-]\\d{2}:?\\d{2}|"); for(String id : tzNames.keySet()) { escapeRegex(sb, id, false).append('|'); } sb.setCharAt(sb.length()-1, ')'); validTimeZoneChars= sb.toString(); } /** * {@inheritDoc} */ @Override public boolean isNumber() { return false; } /** * {@inheritDoc} */ @Override public boolean addRegex(FastDateParser parser, StringBuilder regex) { regex.append(validTimeZoneChars); return true; } /** * {@inheritDoc} */ @Override public void setCalendar(FastDateParser parser, Calendar cal, String value) { TimeZone tz; if(value.charAt(0)=='+' || value.charAt(0)=='-') { tz= TimeZone.getTimeZone("GMT"+value); } else if(value.startsWith("GMT")) { tz= TimeZone.getTimeZone(value); } else { tz= tzNames.get(value); if(tz==null) { throw new IllegalArgumentException(value + " is not a supported timezone name"); } } cal.setTimeZone(tz); } } private static final Strategy ERA_STRATEGY = new TextStrategy(Calendar.ERA); private static final Strategy DAY_OF_WEEK_STRATEGY = new TextStrategy(Calendar.DAY_OF_WEEK); private static final Strategy AM_PM_STRATEGY = new TextStrategy(Calendar.AM_PM); private static final Strategy TEXT_MONTH_STRATEGY = new TextStrategy(Calendar.MONTH); private static final Strategy NUMBER_MONTH_STRATEGY = new NumberStrategy(Calendar.MONTH) { @Override public int modify(int iValue) { return iValue-1; } }; private static final Strategy LITERAL_YEAR_STRATEGY = new NumberStrategy(Calendar.YEAR); private static final Strategy WEEK_OF_YEAR_STRATEGY = new NumberStrategy(Calendar.WEEK_OF_YEAR); private static final Strategy WEEK_OF_MONTH_STRATEGY = new NumberStrategy(Calendar.WEEK_OF_MONTH); private static final Strategy DAY_OF_YEAR_STRATEGY = new NumberStrategy(Calendar.DAY_OF_YEAR); private static final Strategy DAY_OF_MONTH_STRATEGY = new NumberStrategy(Calendar.DAY_OF_MONTH); private static final Strategy DAY_OF_WEEK_IN_MONTH_STRATEGY = new NumberStrategy(Calendar.DAY_OF_WEEK_IN_MONTH); private static final Strategy HOUR_OF_DAY_STRATEGY = new NumberStrategy(Calendar.HOUR_OF_DAY); private static final Strategy MODULO_HOUR_OF_DAY_STRATEGY = new NumberStrategy(Calendar.HOUR_OF_DAY) { @Override public int modify(int iValue) { return iValue%24; } }; private static final Strategy MODULO_HOUR_STRATEGY = new NumberStrategy(Calendar.HOUR) { @Override public int modify(int iValue) { return iValue%12; } }; private static final Strategy HOUR_STRATEGY = new NumberStrategy(Calendar.HOUR); private static final Strategy MINUTE_STRATEGY = new NumberStrategy(Calendar.MINUTE); private static final Strategy SECOND_STRATEGY = new NumberStrategy(Calendar.SECOND); private static final Strategy MILLISECOND_STRATEGY = new NumberStrategy(Calendar.MILLISECOND); }