DateTimeParser.java
/*
Copyright (c) 2026 James Ahlborn
Licensed 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 com.healthmarketscience.jackcess.impl.expr;
import java.text.DateFormatSymbols;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Year;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.healthmarketscience.jackcess.expr.LocaleContext;
import com.healthmarketscience.jackcess.expr.TemporalConfig;
import com.healthmarketscience.jackcess.expr.Value;
import com.healthmarketscience.jackcess.impl.ColumnImpl;
/**
* Parses a date/time value the way ms access reads one. Ms access reads the
* text of a date literal and a string which is converted to a date/time (as
* in CDate) by the same rules, so this one parse is used for both.
* <p>
* Ms access does not match the value against a format. It splits the value
* into numbers, an optional month name, an optional time and an optional
* am/pm, and then tries the possible orders of the numbers until one gives a
* valid date. This parse does the same:
* <ul>
* <li>"/", "-", "," and spaces separate the date fields, as does the
* configured date separator. A separator other than a space must be
* between two fields, so "-1.5" is a number, not a date, and "1//2" is
* not a date.</li>
* <li>":" and "." separate the time fields, as does the configured time
* separator, unless that character separates the date fields. So "1.2"
* is the time 01:02.</li>
* <li>Three numbers are tried in the configured date order, then as year,
* month, day, then as day, month, year, then as month, day, year. So
* with the US order, 13/1/2003 is January 13 and 13/1/03 is January 3,
* 2013.</li>
* <li>Two numbers are tried as month and day in the configured order, then
* in the other order, in the current year, then as month and year, then
* as year and month, on the first of the month. So 2/30 is February 1,
* 2030.</li>
* <li>With a month name, one number is the day in the current year, or the
* year on the first of the month if that day is not valid. Two numbers
* are the day and the year, or the year and the day if that date is not
* valid.</li>
* <li>A year below 100 is in the years 1950 to 2049, see
* {@link ValueSupport#expandShortYear(int)}. The rule is the value, not
* the number of digits, so 0030 is 2030.</li>
* <li>With pm, an hour below 12 is 12 hours later. With am, hour 12 is
* hour 0. Any other hour is a 24 hour hour, so 13:30 PM is 13:30. The
* am/pm text is the configured text or its first letter, in any case, at
* the end of the value. With am/pm, a number alone is the hour, so
* 10 PM is 22:00.</li>
* </ul>
* These rules are what ms access 2010 does in the US configuration. The
* orders which follow the configured one are the orders ms access uses in
* that configuration.
*/
final class DateTimeParser
{
// the date orders to try after the configured one
private static final DateOrder[] FALLBACK_ORDERS = {
DateOrder.YMD, DateOrder.DMY, DateOrder.MDY};
// more digits than this is not a date or time field
private static final int MAX_FIELD_DIGITS = 4;
// the date orders to try for each configured order
private static final Map<DateOrder,List<DateOrder>> ORDERS =
new EnumMap<>(DateOrder.class);
// the lower case full and short month names of each locale which has been
// used, 24 for each locale
private static final Map<Locale,String[]> MONTH_NAMES =
new ConcurrentHashMap<>();
/** the order of the three numbers of a date */
private enum DateOrder
{
MDY(0, 1, 2), DMY(1, 0, 2), YMD(1, 2, 0);
// the index of the month, the day and the year in the value
private final int _monthIdx;
private final int _dayIdx;
private final int _yearIdx;
private DateOrder(int monthIdx, int dayIdx, int yearIdx) {
_monthIdx = monthIdx;
_dayIdx = dayIdx;
_yearIdx = yearIdx;
}
}
static {
for(DateOrder first : DateOrder.values()) {
List<DateOrder> orders = new ArrayList<>();
orders.add(first);
for(DateOrder order : FALLBACK_ORDERS) {
if(!orders.contains(order)) {
orders.add(order);
}
}
ORDERS.put(first, List.copyOf(orders));
}
}
private DateTimeParser() {}
/**
* @return the date/time value, with the type of its value (date, time or
* both), or {@code null} if the value is not a date or time, such
* as a number or a word
* @throws DateTimeException if the value is a date or time but is not a
* valid one, such as 4/31/2003 or 10:60
*/
static Value parse(String str, LocaleContext ctx)
{
TemporalConfig cfg = ctx.getTemporalConfig();
Fields fields = Fields.split(str, cfg);
if(fields == null) {
return null;
}
LocalDate ld = null;
if(fields.hasDate()) {
ld = toDate(fields, cfg, ctx.getZoneId());
}
LocalTime lt = null;
if(fields.hasTime()) {
lt = toTime(fields);
}
// the type comes from the value, not from the fields written, so
// 1/2/2003 0:00 is a date and 12/30/1899 is a time
return ValueSupport.toValue(LocalDateTime.of(
((ld != null) ? ld : ColumnImpl.BASE_LD),
((lt != null) ? lt : ColumnImpl.BASE_LT)));
}
private static LocalDate toDate(Fields fields, TemporalConfig cfg,
ZoneId zoneId)
{
List<Integer> nums = fields._dateNums;
if(fields._month > 0) {
return toDateWithMonth(fields._month, nums, zoneId);
}
DateOrder cfgOrder = getDateOrder(cfg);
if(nums.size() == 2) {
int n1 = nums.get(0);
int n2 = nums.get(1);
// the configured order says which of month and day comes first
boolean dayFirst = (cfgOrder == DateOrder.DMY);
int month = (dayFirst ? n2 : n1);
int day = (dayFirst ? n1 : n2);
int curYear = Year.now(zoneId).getValue();
LocalDate ld = toDate(curYear, month, day);
if(ld == null) {
ld = toDate(curYear, day, month);
}
if(ld == null) {
ld = toDate(toYear(n2), n1, 1);
}
if(ld == null) {
ld = toDate(toYear(n1), n2, 1);
}
return requireDate(ld);
}
if(nums.size() == 3) {
for(DateOrder order : ORDERS.get(cfgOrder)) {
LocalDate ld = toDate(toYear(nums.get(order._yearIdx)),
nums.get(order._monthIdx),
nums.get(order._dayIdx));
if(ld != null) {
return ld;
}
}
return requireDate(null);
}
throw new DateTimeException("Invalid date");
}
private static LocalDate toDateWithMonth(int month, List<Integer> nums,
ZoneId zoneId)
{
if(nums.size() == 1) {
int n = nums.get(0);
LocalDate ld = toDate(Year.now(zoneId).getValue(), month, n);
if(ld == null) {
ld = toDate(toYear(n), month, 1);
}
return requireDate(ld);
}
if(nums.size() == 2) {
int n1 = nums.get(0);
int n2 = nums.get(1);
LocalDate ld = toDate(toYear(n2), month, n1);
if(ld == null) {
ld = toDate(toYear(n1), month, n2);
}
return requireDate(ld);
}
throw new DateTimeException("Invalid date");
}
private static LocalTime toTime(Fields fields)
{
List<Integer> nums = fields._timeNums;
int hour = nums.get(0);
if(fields._isPm && (hour < 12)) {
hour += 12;
} else if(fields._isAm && (hour == 12)) {
hour = 0;
}
int second = ((nums.size() > 2) ? nums.get(2) : 0);
return LocalTime.of(hour, nums.get(1), second);
}
/**
* @return the date, or {@code null} if the fields are not a valid date
*/
private static LocalDate toDate(int year, int month, int day)
{
try {
return LocalDate.of(year, month, day);
} catch(DateTimeException de) {
return null;
}
}
private static LocalDate requireDate(LocalDate ld)
{
if(ld == null) {
throw new DateTimeException("Invalid date");
}
return ld;
}
private static int toYear(int year)
{
return ValueSupport.expandShortYear(year);
}
/**
* @return the order of the month, day and year in the configured date
* format, or month, day, year if the format does not show it
*/
private static DateOrder getDateOrder(TemporalConfig cfg)
{
String fmt = cfg.getDateFormat();
int monthIdx = indexOfField(fmt, "ML");
int dayIdx = indexOfField(fmt, "d");
int yearIdx = indexOfField(fmt, "uy");
if((monthIdx < 0) || (dayIdx < 0) || (yearIdx < 0)) {
return DateOrder.MDY;
}
if(yearIdx < Math.min(monthIdx, dayIdx)) {
return DateOrder.YMD;
}
return ((dayIdx < monthIdx) ? DateOrder.DMY : DateOrder.MDY);
}
/**
* @return the index of the first of the given pattern letters outside of
* quoted text, or -1 if there is none
*/
private static int indexOfField(String fmt, String letters)
{
boolean inQuote = false;
for(int i = 0; i < fmt.length(); ++i) {
char c = fmt.charAt(i);
if(c == '\'') {
inQuote = !inQuote;
} else if(!inQuote && (letters.indexOf(c) >= 0)) {
return i;
}
}
return -1;
}
/**
* @return the month, 1 to 12, of the given full or short month name, or 0
* if it is not a month name
*/
private static int toMonth(String name, Locale locale)
{
String[] names = MONTH_NAMES.computeIfAbsent(
locale, DateTimeParser::loadMonthNames);
name = name.toLowerCase(locale);
for(int i = 0; i < names.length; ++i) {
if(!names[i].isEmpty() && names[i].equals(name)) {
return (i % 12) + 1;
}
}
return 0;
}
/**
* @return the lower case full month names of the given locale, then the
* short ones
*/
private static String[] loadMonthNames(Locale locale)
{
DateFormatSymbols syms = DateFormatSymbols.getInstance(locale);
String[] full = syms.getMonths();
String[] abbrev = syms.getShortMonths();
String[] names = new String[24];
for(int i = 0; i < 12; ++i) {
names[i] = full[i].toLowerCase(locale);
// some locales end a short month name with a "."
String monthName = abbrev[i];
if(monthName.endsWith(".")) {
monthName = monthName.substring(0, monthName.length() - 1);
}
names[i + 12] = monthName.toLowerCase(locale);
}
return names;
}
/**
* The fields of a date/time value: the numbers and the month name of the
* date, the numbers of the time, and the am/pm text.
*/
private static final class Fields
{
private final List<Integer> _dateNums = new ArrayList<>();
private int _month;
private final List<Integer> _timeNums = new ArrayList<>();
private boolean _isAm;
private boolean _isPm;
// a number with more digits than a date or time field has
private boolean _tooLong;
private boolean hasDate() {
return ((_month > 0) || !_dateNums.isEmpty());
}
private boolean hasTime() {
return !_timeNums.isEmpty();
}
/**
* @return the fields of the given value, or {@code null} if the value
* is not a date or time
* @throws DateTimeException if the value is a date or time but its
* fields cannot be read, such as a second time
*/
static Fields split(String str, TemporalConfig cfg)
{
char dateSep = cfg.getDateSeparator();
char cfgTimeSep = cfg.getTimeSeparator();
Locale locale = cfg.getLocale();
Fields fields = new Fields();
// the am/pm text may hold "." or a space, as "a.m." does, so it is
// found as text before the value is split
str = fields.removeAmPm(str.trim(), cfg.getAmPmStrings());
int len = str.length();
int i = 0;
// a separator which is not a space must be between two fields
boolean hasField = false;
boolean afterSep = false;
while(i < len) {
char c = str.charAt(i);
boolean isField = true;
if(ExpressionTokenizer.isDigit(c)) {
int numEnd = digitsEnd(str, i);
if(isTimeSep(str, numEnd, dateSep, cfgTimeSep)) {
if(fields.hasTime()) {
throw new DateTimeException("Invalid time " + str);
}
i = fields.readTime(str, i, dateSep, cfgTimeSep);
} else {
fields._dateNums.add(fields.toNumber(str, i, numEnd));
i = numEnd;
}
} else if(Character.isLetter(c)) {
int wordEnd = i + 1;
while((wordEnd < len) && Character.isLetter(str.charAt(wordEnd))) {
++wordEnd;
}
int month = toMonth(str.substring(i, wordEnd), locale);
if((month == 0) || (fields._month > 0)) {
// a word which is not part of a date
return null;
}
fields._month = month;
// a short month name may end with a "."
if((wordEnd < len) && (str.charAt(wordEnd) == '.')) {
++wordEnd;
}
i = wordEnd;
} else if(Character.isWhitespace(c)) {
isField = false;
++i;
} else if((c == '/') || (c == '-') || (c == ',') || (c == dateSep)) {
if(!hasField || afterSep) {
return null;
}
isField = false;
afterSep = true;
++i;
} else {
// a character which is not part of a date or time
return null;
}
if(isField) {
hasField = true;
afterSep = false;
}
}
if(afterSep) {
return null;
}
if(fields.hasAmPm() && !fields.hasTime()) {
// with am/pm, a number alone is the hour, as in 10 PM
if(fields._dateNums.isEmpty()) {
return null;
}
fields._timeNums.add(
fields._dateNums.remove(fields._dateNums.size() - 1));
fields._timeNums.add(0);
}
if(!fields.hasTime() && (fields._month == 0) &&
(fields._dateNums.size() < 2)) {
// a single number, which is not a date
return null;
}
if(fields._tooLong) {
throw new DateTimeException("Invalid date/time field " + str);
}
return fields;
}
private boolean hasAmPm() {
return (_isAm || _isPm);
}
/**
* The am/pm text may also be its first letter alone, as in 10:11 p.
*
* @return the given value without the am/pm text at its end, if it has
* it after a space or a digit
*/
private String removeAmPm(String str, String[] amPmStrs)
{
String[] allAmPms = {amPmStrs[0], amPmStrs[1],
amPmStrs[0].substring(0, 1),
amPmStrs[1].substring(0, 1)};
for(int i = 0; i < allAmPms.length; ++i) {
String amPm = allAmPms[i];
int start = str.length() - amPm.length();
if((start > 0) && str.regionMatches(true, start, amPm, 0,
amPm.length())) {
char prev = str.charAt(start - 1);
if(Character.isWhitespace(prev) || ExpressionTokenizer.isDigit(prev)) {
_isAm = ((i % 2) == 0);
_isPm = !_isAm;
return str.substring(0, start);
}
}
}
return str;
}
/**
* Reads the numbers of a time, which starts at the given index.
*
* @return the index after the time
*/
private int readTime(String str, int start, char dateSep, char cfgTimeSep)
{
int i = start;
while(true) {
int numEnd = digitsEnd(str, i);
_timeNums.add(toNumber(str, i, numEnd));
i = numEnd;
if(!isTimeSep(str, i, dateSep, cfgTimeSep)) {
break;
}
++i;
}
if(_timeNums.size() > 3) {
throw new DateTimeException("Invalid time " + str);
}
return i;
}
/**
* @return {@code true} if the given index holds a time separator which
* is followed by a digit
*/
private static boolean isTimeSep(String str, int idx, char dateSep,
char cfgTimeSep)
{
if((idx + 1 >= str.length()) ||
!ExpressionTokenizer.isDigit(str.charAt(idx + 1))) {
return false;
}
char c = str.charAt(idx);
return (((c == ':') || (c == '.') || (c == cfgTimeSep)) &&
(c != dateSep));
}
private static int digitsEnd(String str, int start)
{
int i = start;
while((i < str.length()) && ExpressionTokenizer.isDigit(str.charAt(i))) {
++i;
}
return i;
}
/**
* @return the number, or 0 if it has too many digits. A value which is
* a single long number is not a date, so the error waits until
* the value is known to be a date.
*/
private int toNumber(String str, int start, int end)
{
if((end - start) > MAX_FIELD_DIGITS) {
_tooLong = true;
return 0;
}
return Integer.parseInt(str.substring(start, end));
}
}
}