TemporalStringParser.java

/*
 * Copyright 2019 Gregory Graham.
 *
 * Commercial licenses are available, please contact info@gregs.co.nz for details.
 * 
 * This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. 
 * To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/ 
 * or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
 * 
 * You are free to:
 *     Share - copy and redistribute the material in any medium or format
 *     Adapt - remix, transform, and build upon the material
 * 
 *     The licensor cannot revoke these freedoms as long as you follow the license terms.               
 *     Under the following terms:
 *                 
 *         Attribution - 
 *             You must give appropriate credit, provide a link to the license, and indicate if changes were made. 
 *             You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
 *         NonCommercial - 
 *             You may not use the material for commercial purposes.
 *         ShareAlike - 
 *             If you remix, transform, or build upon the material, 
 *             you must distribute your contributions under the same license as the original.
 *         No additional restrictions - 
 *             You may not apply legal terms or technological measures that legally restrict others from doing anything the 
 *             license permits.
 * 
 * Check the Creative Commons website for any details, legalese, and updates.
 */
package nz.co.gregs.dbvolution.utility;

import java.sql.Timestamp;
import java.text.ParseException;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.TemporalAccessor;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
import nz.co.gregs.regexi.Regex;
import nz.co.gregs.regexi.RegexReplacement;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * Encapsulates robust date-time parsing.
 *
 * @author gregorygraham
 */
public class TemporalStringParser {

	static final Log LOG = LogFactory.getLog(TemporalStringParser.class);

	static final Parser[] INSTANT_FORMATTERS = Parser.generateInstantParsers();

	static final Parser[] LOCALDATE_FORMATTERS = Parser.generateLocalDateParsers();

	private TemporalStringParser() {
	}

	public static OffsetDateTime toOffsetDateTime(String inputDateString, String... expectedFormat) {
		final ZonedDateTime toZonedDateTime = toZonedDateTime(inputDateString, Parser.ofPatterns(expectedFormat));
		return toZonedDateTime == null ? null : toZonedDateTime.toOffsetDateTime();
	}

	public static Instant toInstant(String inputDateString, String... expectedFormat) throws DateTimeParseException {
		final ZonedDateTime toZonedDateTime = toZonedDateTime(inputDateString, Parser.ofPatterns(expectedFormat));
		return toZonedDateTime == null ? null : toZonedDateTime.toInstant();
	}

	public static Date toDate(String inputDateString, String... expectedFormat) throws DateTimeParseException {
		final LocalDateTime toLocalDateTime = toLocalDateTime(inputDateString, Parser.ofPatterns(expectedFormat));
		if (toLocalDateTime == null) {
			return null;
		}
		return Date.from(toLocalDateTime.atZone(ZoneId.systemDefault()).toInstant());
	}

	public static ZonedDateTime toZonedDateTime(String inputDateString, String... expectedFormat) throws DateTimeParseException {
		final ZonedDateTime toZonedDateTime = toZonedDateTime(inputDateString, Parser.ofPatterns(expectedFormat));
		return toZonedDateTime;
	}

	private static ZonedDateTime toZonedDateTime(String inputDateString, Parser... preferredFormats) throws DateTimeParseException {
		if (inputDateString == null) {
			return null;
		}
		ZonedDateTime zoneddatetime = null;
		
		// oracle sometimes produces unpadded time zone offsets
		String parsableString = inputDateString;
		Regex regex = Regex.empty().literal("+").namedCapture("offset").digit().literal(":").digit().digit().endNamedCapture().toRegex();
		RegexReplacement replacer = regex.replaceWith().literal("+0").namedReference("offset");
		parsableString = replacer.replaceFirst(parsableString);
		
		DateTimeParseException exception = new DateTimeParseException("Failed to parse datetime '"+parsableString+"'", parsableString, 0);
		for (Parser preferredFormat : preferredFormats) {
			if (preferredFormat != null && preferredFormat.isNotEmpty()) {
				try {
					return preferredFormat.interpretAsZonedDateTime(parsableString);
				} catch (Exception ex1) {
					printException(parsableString, preferredFormat, exception);
					if (ex1 instanceof DateTimeParseException) {
						exception = (DateTimeParseException) ex1;
					} else {
						exception = new DateTimeParseException("FAILED TO PARSE GENERIC DATETIME", parsableString, 0, ex1);
					}
				}
			}
		}
		for (Parser format : INSTANT_FORMATTERS) {
			try {
				final TemporalAccessor parsed = format.parse(parsableString);
				zoneddatetime = ZonedDateTime.from(parsed);
				System.out.println("PARSE SUCCEEDED: " + format.pattern);
				return zoneddatetime;
			} catch (Exception ex1) {
				printException(parsableString, format, exception);
				if (ex1 instanceof DateTimeParseException) {
					exception = (DateTimeParseException) ex1;
				} else {
					exception = new DateTimeParseException("Failed to parse '" + parsableString + "'", parsableString, 0, ex1);
				}
			}
		}
		for (Parser format : LOCALDATE_FORMATTERS) {
			try {
				final TemporalAccessor parsed = format.parse(parsableString);
				zoneddatetime = ZonedDateTime.of(LocalDateTime.from(parsed), ZoneId.of("Z"));
				System.out.println("PARSE SUCCEEDED: " + format.pattern);
				return zoneddatetime;
			} catch (Exception ex1) {
				printException(parsableString, format, exception);
				LOG.debug("PARSE FAILED: " + format.toString());
				LOG.debug("MESSAGE: " + ex1.getMessage());
			}
		}
		try {
			Timestamp timestamp = Timestamp.valueOf(parsableString);
			zoneddatetime = ZonedDateTime.of(timestamp.toLocalDateTime(), ZoneId.of("Z"));
			System.out.println("PARSE SUCCEEDED:  Timestamp.valueOf(" + parsableString + ")");
			LOG.debug("PARSE SUCCEEDED: Timestamp.valueOf(" + parsableString + ")");
			LOG.debug("PARSED STR: " + parsableString);
			LOG.debug("TO: " + zoneddatetime);
			return zoneddatetime;
		} catch (Exception ex1) {
			printException(parsableString, "Timestamp.valueOf: yyyy-[m]m-[d]d hh:mm:ss[.f...]", exception);
			LOG.debug("PARSE FAILED: Timestamp.valueOf(" + parsableString + ")");
			LOG.debug("MESSAGE: " + ex1.getMessage());
		}
		try {
			Timestamp timestamp = Timestamp.valueOf(parsableString);
			zoneddatetime = ZonedDateTime.of(timestamp.toLocalDateTime(), ZoneId.of("Z"));
			System.out.println("PARSE SUCCEEDED:  Timestamp.valueOf(" + parsableString + ")");
			LOG.debug("PARSE SUCCEEDED: Timestamp.valueOf(" + parsableString + ")");
			LOG.debug("PARSED inputDateString: " + parsableString);
			LOG.debug("TO: " + zoneddatetime);
			return zoneddatetime;
		} catch (Exception ex1) {
			printException(parsableString, "Timestamp.valueOf: yyyy-[m]m-[d]d hh:mm:ss[.f...]", exception);
			LOG.debug("PARSE FAILED: Timestamp.valueOf(" + parsableString + ")");
			LOG.debug("MESSAGE: " + ex1.getMessage());
			if (ex1 instanceof DateTimeParseException) {
				exception = (DateTimeParseException) ex1;
			} else {
				exception = new DateTimeParseException("Failed to parse Datetime '"+parsableString+"'", parsableString, 0, ex1);
			}
		}
		if (zoneddatetime != null) {
			return zoneddatetime;
		} else {
			LOG.debug("FAILED TO PARSE DATE");
			LOG.debug("INPUTSTRING: " + parsableString);
			LOG.debug("TEST VERSION: " + parsableString);
			throw exception;
		}
	}

	private static void printException(String input, Parser format, Exception exception) {
		printException(input, format.toString(), exception);
	}

	private static void printException(String inputDateString, String format, Exception exception) {
		LOG.debug("PARSING ORIGINAL: " + inputDateString);
		LOG.debug("PATTERN: " + format);
		LOG.debug("PARSE FAILED: " + inputDateString);
		LOG.debug("EXCEPTION: " + exception.getMessage());
		StackTraceElement[] stackTrace = exception.getStackTrace();
		for (int i = 0; i < Math.min(10, stackTrace.length); i++) {
			LOG.debug(stackTrace[i].toString());
		}
	}

	public static LocalDate toLocalDate(String inputFromResultSet, String... expectedFormats) throws DateTimeParseException {
		return toLocalDateTime(inputFromResultSet, Parser.ofPatterns(expectedFormats)).toLocalDate();
	}

	public static LocalDateTime toLocalDateTime(String inputFromResultSet, String... expectedFormats) throws DateTimeParseException {
		return toLocalDateTime(inputFromResultSet, Parser.ofPatterns(expectedFormats));
	}

	private static LocalDateTime toLocalDateTime(String inputDateString, Parser... preferredFormats) throws DateTimeParseException {
		if (inputDateString == null) {
			return null;
		}
		LocalDateTime localdatetime = null;
		String str = inputDateString;
		Exception exception = new ParseException(str, 0);
		final CharSequence sequence = str.subSequence(0, str.length());
		for (Parser preferredFormat : preferredFormats) {
			if (preferredFormat != null && preferredFormat.isNotEmpty()) {
				try {
					return preferredFormat.interpretAsLocalDateTime(str);
				} catch (Exception ex1) {
					printException(inputDateString, preferredFormat, exception);
					if (ex1 instanceof DateTimeParseException) {
						exception = ex1;
					} else {
						exception = new DateTimeParseException("FAILED TO PARSE GENERIC DATETIME", str, 0, ex1);
					}
				}
			}
		}
		for (Parser format : INSTANT_FORMATTERS) {
			try {
				final TemporalAccessor parsed = format.parse(sequence);
				localdatetime = ZonedDateTime.from(parsed).toLocalDateTime();
				LOG.debug("PARSE SUCCEEDED: " + format.toString());
				LOG.debug("PARSED: " + sequence);
				LOG.debug("TO: " + localdatetime);
			} catch (Exception ex1) {
				LOG.debug("PARSE FAILED: " + format.toString());
				LOG.debug("MESSAGE: " + ex1.getMessage());
				if (ex1 instanceof ParseException) {
					exception = (ParseException) ex1;
				}
			}
		}
		for (Parser format : LOCALDATE_FORMATTERS) {
			try {
				final TemporalAccessor parsed = format.parse(sequence);
				localdatetime = LocalDateTime.from(parsed);
				LOG.debug("PARSE SUCCEEDED: " + format.toString());
				LOG.debug("PARSED: " + sequence);
				LOG.debug("TO: " + localdatetime);
			} catch (Exception ex1) {
				LOG.debug("PARSE FAILED: " + format.toString());
				LOG.debug("MESSAGE: " + ex1.getMessage());
				if (ex1 instanceof ParseException) {
					exception = (ParseException) ex1;
				}
			}
		}
		try {
			Timestamp timestamp = Timestamp.valueOf(str);
			localdatetime = timestamp.toLocalDateTime();
			LOG.debug("PARSE SUCCEEDED: Timestamp.valueOf(str)");
			LOG.debug("PARSED: " + str);
			LOG.debug("TO: " + localdatetime);
		} catch (Exception ex1) {
			LOG.debug("PARSE FAILED: Timestamp.valueOf(" + str + ")");
			LOG.debug("MESSAGE: " + ex1.getMessage());
			if (ex1 instanceof ParseException) {
				exception = (ParseException) ex1;
			}
		}
		str = inputDateString;
		try {
			Timestamp timestamp = Timestamp.valueOf(str);
			localdatetime = timestamp.toLocalDateTime();
			LOG.debug("PARSE SUCCEEDED: Timestamp.valueOf(str)");
			LOG.debug("PARSED: " + str);
			LOG.debug("TO: " + localdatetime);
		} catch (Exception ex1) {
			LOG.debug("PARSE FAILED: Timestamp.valueOf(" + str + ")");
			LOG.debug("MESSAGE: " + ex1.getMessage());
			if (ex1 instanceof ParseException) {
				exception = (ParseException) ex1;
			}
		}
		if (localdatetime != null) {
			return localdatetime;
		} else {
			LOG.debug("PARSE FAILED:");
			LOG.debug("PARSED: " + inputDateString);
			throw new DateTimeParseException(inputDateString, sequence, 0, exception);
		}
	}

	private static class Parser {

		private final String pattern;
		private final DateTimeFormatter formatter;

		private Parser(String pattern) {
			this.pattern = pattern;
			this.formatter = pattern == null ? null : DateTimeFormatter.ofPattern(pattern);
		}

		private Parser(DateTimeFormatter formatter) {
			this.pattern = formatter.toString();
			this.formatter = formatter;
		}

		public static Parser ofPattern(String pattern) {
			return new Parser(pattern);
		}

		public static Parser[] ofPatterns(String... pattern) {
			List<Parser> list = List.of(pattern).stream().map(p -> new Parser(p)).collect(Collectors.toList());
			Parser[] array = list.toArray(new Parser[]{});
			return array;
		}

		public static Parser ofFormatter(DateTimeFormatter formatter) {
			return new Parser(formatter);
		}

		public boolean isNotEmpty() {
			return StringCheck.isNotEmptyNorNull(pattern);
		}

		@Override
		public String toString() {
			return pattern;
		}

		public ZonedDateTime interpretAsZonedDateTime(String dateString) throws DateTimeParseException {
			final TemporalAccessor parsed = parse(dateString);
			ZonedDateTime zoneddatetime;
			try {
				zoneddatetime = ZonedDateTime.from(parsed);
				return zoneddatetime;
			} catch (Exception ex) {
				try {
					zoneddatetime = ZonedDateTime.of(LocalDateTime.from(parsed), ZoneId.of("Z"));
					return zoneddatetime;
				} catch (Exception ex1) {
					DateTimeParseException exception;
					printException(dateString, this, ex1);
					if (ex1 instanceof DateTimeParseException) {
						exception = (DateTimeParseException) ex1;
					} else {
						exception = new DateTimeParseException("Failed to parse date string", dateString, 0, ex1);
					}
					throw exception;
				}
			}
		}

		public LocalDateTime interpretAsLocalDateTime(String dateString) throws DateTimeParseException {
			final TemporalAccessor parsed = parse(dateString);
			LocalDateTime localdatetime;
			try {
				localdatetime = ZonedDateTime.from(parsed).toLocalDateTime();
				return localdatetime;
			} catch (Exception ex) {
				try {
					localdatetime = LocalDateTime.from(parsed);
					return localdatetime;
				} catch (Exception ex1) {
					DateTimeParseException exception;
					printException(dateString, this, ex1);
					if (ex1 instanceof DateTimeParseException) {
						exception = (DateTimeParseException) ex1;
					} else {
						exception = new DateTimeParseException("Failed to parse date string", dateString, 0, ex1);
					}
					throw exception;
				}
			}
		}

		public TemporalAccessor parse(String dateString) {
			return parse(dateString.subSequence(0, dateString.length()));
		}

		public TemporalAccessor parse(CharSequence dateString) {
			return formatter.parse(dateString);
		}

		static String[] yearParts = new String[]{"uuuu", "yyyy"};
		static String[] monthParts = new String[]{"MM"};
		static String[] dayParts = new String[]{"dd"};
		static String[] dayPartDividerParts = new String[]{"-", ""};
		static String[] dayTimeDividerParts = new String[]{" ", "'T'", ""};
		static String[] timePartDividerParts = new String[]{":", ""};
		static String[] timeTimeZoneDividerParts = new String[]{" ", ""};
		static String[] hourParts = new String[]{"HH"};
		static String[] minuteParts = new String[]{"mm"};
		static String[] secondParts = new String[]{"ss"};
		static String[] subsecondParts = new String[]{"SSSSSSSSS", "SSSSSS", "SSSSS", "SSSS", "SSS", "SS", "S", ""};
		static String[] secondPartDividerParts = new String[]{".", ""};
		static String[] timezoneParts = new String[]{"VV", "zzzz", "OOOO", "XXXXX", "xxxxx", "ZZZZZ", "XXXX", "xxxx", "ZZZZ", "XXX", "xxx", "XX", "xx", "z", "O", "X", "x", "Z"};

		public static Parser[] generateInstantParsers() {
			List<Parser> parsers = new ArrayList<>();
			for (String dayPartDividerPart : dayPartDividerParts) {
				for (String timePartDividerPart : timePartDividerParts) {
					for (String secondPartDividerPart : secondPartDividerParts) {
						for (String yearPart : yearParts) {
							for (String monthPart : monthParts) {
								for (String dayPart : dayParts) {
									for (String dayTimeDividerPart : dayTimeDividerParts) {
										for (String hourPart : hourParts) {
											for (String minutePart : minuteParts) {
												for (String secondPart : secondParts) {
													for (String subsecondPart : subsecondParts) {
														for (String timeTimeZoneDivider : timeTimeZoneDividerParts) {
															for (String timezonePart : timezoneParts) {
																final String pattern = makeInstantPatternFromParts(yearPart, dayPartDividerPart, monthPart, dayPart, dayTimeDividerPart, hourPart, timePartDividerPart, minutePart, secondPart, secondPartDividerPart, subsecondPart, timeTimeZoneDivider, timezonePart);
																parsers.add(Parser.ofPattern(pattern));
															}
														}
													}
												}
											}
										}
									}
								}
							}
						}
					}
				}
			}
			parsers.add(Parser.ofFormatter(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
			parsers.add(Parser.ofFormatter(DateTimeFormatter.ISO_ZONED_DATE_TIME));
			parsers.add(Parser.ofFormatter(DateTimeFormatter.RFC_1123_DATE_TIME));
			parsers.add(Parser.ofFormatter(DateTimeFormatter.ISO_INSTANT));
			return parsers.toArray(new Parser[]{});
		}

		private static String makeInstantPatternFromParts(String yearPart, String dayPartDividerPart, String monthPart, String dayPart, String dayTimeDividerPart, String hourPart, String timePartDividerPart, String minutePart, String secondPart, String secondPartDividerPart, String subsecondPart, String timeTimeZoneDivider, String timezonePart) {
			return makeLocalDatePatternFromParts(yearPart, dayPartDividerPart, monthPart, dayPart, dayTimeDividerPart, hourPart, timePartDividerPart, minutePart, secondPart, secondPartDividerPart, subsecondPart) + timeTimeZoneDivider + timezonePart;
		}

		public static Parser[] generateLocalDateParsers() {
			List<Parser> parsers = new ArrayList<>();
			for (String dayPartDividerPart : dayPartDividerParts) {
				for (String timePartDividerPart : timePartDividerParts) {
					for (String secondPartDividerPart : secondPartDividerParts) {
						for (String yearPart : yearParts) {
							for (String monthPart : monthParts) {
								for (String dayPart : dayParts) {
									for (String dayTimeDividerPart : dayTimeDividerParts) {
										for (String hourPart : hourParts) {
											for (String minutePart : minuteParts) {
												for (String secondPart : secondParts) {
													for (String subsecondPart : subsecondParts) {
														final String pattern = makeLocalDatePatternFromParts(yearPart, dayPartDividerPart, monthPart, dayPart, dayTimeDividerPart, hourPart, timePartDividerPart, minutePart, secondPart, secondPartDividerPart, subsecondPart);
														parsers.add(Parser.ofPattern(pattern));
													}
												}
											}
										}
									}
								}
							}
						}
					}
				}
			}
			parsers.add(Parser.ofFormatter(DateTimeFormatter.ISO_DATE_TIME));
			parsers.add(Parser.ofFormatter(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
			parsers.add(Parser.ofFormatter(DateTimeFormatter.BASIC_ISO_DATE));
			return parsers.toArray(new Parser[]{});
		}

		private static String makeLocalDatePatternFromParts(String yearPart, String dayPartDividerPart, String monthPart, String dayPart, String dayTimeDividerPart, String hourPart, String timePartDividerPart, String minutePart, String secondPart, String secondPartDividerPart, String subsecondPart) {
			return "" + yearPart + dayPartDividerPart + monthPart + dayPartDividerPart + dayPart + dayTimeDividerPart + hourPart + timePartDividerPart + minutePart + timePartDividerPart + secondPart + secondPartDividerPart + subsecondPart;
		}
	}
}