CompiledDateTimeFormat.java

/*
 * 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.calcite.util.format.postgresql;

import org.apache.calcite.util.format.postgresql.format.compiled.CompiledItem;
import org.apache.calcite.util.format.postgresql.format.compiled.CompiledPattern;
import org.apache.calcite.util.format.postgresql.format.compiled.LiteralCompiledItem;

import com.google.common.collect.ImmutableList;

import org.checkerframework.checker.nullness.qual.Nullable;

import java.text.ParseException;
import java.text.ParsePosition;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;
import java.time.temporal.IsoFields;
import java.time.temporal.JulianFields;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

/**
 * Contains a parsed date/time format. Able to parse a string into a date/time value,
 * or convert a date/time value to a string.
 */
public class CompiledDateTimeFormat {
  private final ImmutableList<CompiledItem> compiledItems;

  public CompiledDateTimeFormat(ImmutableList<CompiledItem> compiledItems) {
    this.compiledItems = compiledItems;
  }

  /**
   * Parses a date/time value from a string. The format used is in compiledItems.
   *
   * @param input the String to parse
   * @param zoneId timezone to convert the result to
   * @param locale Locale to use for parsing day or month names if the TM modifier was present
   * @return the parsed date/time value
   * @throws ParseException if the string to parse did not meet the required format
   */
  public ZonedDateTime parseDateTime(String input, ZoneId zoneId, Locale locale)
      throws ParseException {
    final ParsePosition parsePosition = new ParsePosition(0);
    final Map<ChronoUnitEnum, Integer> dateTimeParts = new HashMap<>();

    for (int i = 0; i < compiledItems.size(); i++) {
      final CompiledItem currentFormatItem = compiledItems.get(i);
      final boolean nextItemNumeric = isItemNumeric(i + 1);

      if (currentFormatItem instanceof LiteralCompiledItem) {
        final LiteralCompiledItem literal = (LiteralCompiledItem) currentFormatItem;
        parsePosition.setIndex(parsePosition.getIndex() + literal.getFormatPatternLength());
      } else if (currentFormatItem instanceof CompiledPattern) {
        final CompiledPattern pattern = (CompiledPattern) currentFormatItem;
        dateTimeParts.put(
            pattern.getChronoUnit(), pattern.parseValue(
            parsePosition, input, nextItemNumeric, locale));
      }
    }

    return constructDateTimeFromParts(dateTimeParts, zoneId);
  }

  private boolean isItemNumeric(int index) {
    if (index < compiledItems.size() && compiledItems.get(index) instanceof CompiledPattern) {
      return ((CompiledPattern) compiledItems.get(index)).isNumeric();
    }

    return false;
  }

  /**
   * Converts a date/time value to a string. The output format is in compiledItems.
   *
   * @param dateTime date/time value to convert
   * @param locale Locale to use for outputting day and month names if the TM modifier was present
   * @return the date/time value formatted as a String
   */
  public String formatDateTime(ZonedDateTime dateTime, Locale locale) {
    final StringBuilder outputBuilder = new StringBuilder();

    for (CompiledItem compiledItem : compiledItems) {
      outputBuilder.append(compiledItem.convertToString(dateTime, locale));
    }

    return outputBuilder.toString();
  }

  private static ZonedDateTime constructDateTimeFromParts(Map<ChronoUnitEnum, Integer> dateParts,
      ZoneId zoneId) {
    LocalDateTime constructedDateTime = LocalDateTime.now(zoneId)
        .truncatedTo(ChronoUnit.DAYS);

    DateCalendarEnum calendar = DateCalendarEnum.NONE;
    boolean containsCentury = false;
    for (ChronoUnitEnum unit : dateParts.keySet()) {
      if (unit.getCalendars().size() == 1) {
        DateCalendarEnum unitCalendar = unit.getCalendars().iterator().next();
        if (unitCalendar != DateCalendarEnum.NONE) {
          calendar = unitCalendar;
          break;
        }
      } else if (unit == ChronoUnitEnum.CENTURIES) {
        containsCentury = true;
      }
    }

    if (calendar == DateCalendarEnum.NONE && containsCentury) {
      calendar = DateCalendarEnum.GREGORIAN;
    }

    switch (calendar) {
    case NONE:
      constructedDateTime = constructedDateTime
          .withYear(1)
          .withMonth(1)
          .withDayOfMonth(1);
      break;
    case GREGORIAN:
      constructedDateTime = updateWithGregorianFields(constructedDateTime, dateParts);
      break;
    case ISO_8601:
      constructedDateTime = updateWithIso8601Fields(constructedDateTime, dateParts);
      break;
    case JULIAN:
      final Integer julianDays = dateParts.get(ChronoUnitEnum.DAYS_JULIAN);
      if (julianDays != null) {
        constructedDateTime = constructedDateTime.with(JulianFields.JULIAN_DAY, julianDays);
      }
      break;
    }

    constructedDateTime = updateWithTimeFields(constructedDateTime, dateParts);

    if (dateParts.containsKey(ChronoUnitEnum.TIMEZONE_HOURS)
        || dateParts.containsKey(ChronoUnitEnum.TIMEZONE_MINUTES)) {
      final int hours = dateParts.getOrDefault(ChronoUnitEnum.TIMEZONE_HOURS, 0);
      final int minutes = dateParts.getOrDefault(ChronoUnitEnum.TIMEZONE_MINUTES, 0);

      return ZonedDateTime.of(constructedDateTime, ZoneOffset.ofHoursMinutes(hours, minutes))
          .withZoneSameInstant(zoneId);
    }

    return ZonedDateTime.of(constructedDateTime, zoneId);
  }

  private static LocalDateTime updateWithGregorianFields(LocalDateTime dateTime,
      Map<ChronoUnitEnum, Integer> dateParts) {
    LocalDateTime updatedDateTime = dateTime.withYear(getGregorianYear(dateParts)).withDayOfYear(1);

    if (dateParts.containsKey(ChronoUnitEnum.MONTHS_IN_YEAR)) {
      updatedDateTime =
          updatedDateTime.withMonth(dateParts.get(ChronoUnitEnum.MONTHS_IN_YEAR));
    }

    if (dateParts.containsKey(ChronoUnitEnum.DAYS_IN_MONTH)) {
      updatedDateTime =
          updatedDateTime.withDayOfMonth(dateParts.get(ChronoUnitEnum.DAYS_IN_MONTH));
    }

    if (dateParts.containsKey(ChronoUnitEnum.WEEKS_IN_MONTH)) {
      updatedDateTime =
          updatedDateTime.withDayOfMonth(
              dateParts.get(ChronoUnitEnum.WEEKS_IN_MONTH) * 7 - 6);
    }

    if (dateParts.containsKey(ChronoUnitEnum.WEEKS_IN_YEAR)) {
      updatedDateTime =
          updatedDateTime.withDayOfYear(
              dateParts.get(ChronoUnitEnum.WEEKS_IN_YEAR) * 7 - 6);
    }

    if (dateParts.containsKey(ChronoUnitEnum.DAYS_IN_YEAR)) {
      updatedDateTime =
          updatedDateTime.withDayOfYear(dateParts.get(ChronoUnitEnum.DAYS_IN_YEAR));
    }

    return updatedDateTime;
  }

  private static int getGregorianYear(Map<ChronoUnitEnum, Integer> dateParts) {
    int year =
        getYear(
            dateParts.get(ChronoUnitEnum.ERAS),
            dateParts.get(ChronoUnitEnum.YEARS),
            dateParts.get(ChronoUnitEnum.CENTURIES),
            dateParts.get(ChronoUnitEnum.YEARS_IN_MILLENIA),
            dateParts.get(ChronoUnitEnum.YEARS_IN_CENTURY));
    return year == 0 ? 1 : year;
  }

  private static LocalDateTime updateWithIso8601Fields(LocalDateTime dateTime,
      Map<ChronoUnitEnum, Integer> dateParts) {
    final int year = getIso8601Year(dateParts);

    if (!dateParts.containsKey(ChronoUnitEnum.WEEKS_IN_YEAR_ISO_8601)
        && !dateParts.containsKey(ChronoUnitEnum.DAYS_IN_YEAR_ISO_8601)) {
      return dateTime.withYear(year).withDayOfYear(1);
    }

    LocalDateTime updatedDateTime = dateTime
        .with(ChronoField.DAY_OF_WEEK, 1)
        .with(IsoFields.WEEK_BASED_YEAR, year)
        .with(IsoFields.WEEK_OF_WEEK_BASED_YEAR, 1);

    if (dateParts.containsKey(ChronoUnitEnum.WEEKS_IN_YEAR_ISO_8601)) {
      updatedDateTime =
          updatedDateTime.with(IsoFields.WEEK_OF_WEEK_BASED_YEAR,
              dateParts.get(ChronoUnitEnum.WEEKS_IN_YEAR_ISO_8601));

      if (dateParts.containsKey(ChronoUnitEnum.DAYS_IN_WEEK)) {
        updatedDateTime =
            updatedDateTime.with(ChronoField.DAY_OF_WEEK,
                dateParts.get(ChronoUnitEnum.DAYS_IN_WEEK));
      }
    } else if (dateParts.containsKey(ChronoUnitEnum.DAYS_IN_YEAR_ISO_8601)) {
      updatedDateTime =
          updatedDateTime.plusDays(dateParts.get(ChronoUnitEnum.DAYS_IN_YEAR_ISO_8601) - 1);
    }

    return updatedDateTime;
  }

  private static int getIso8601Year(Map<ChronoUnitEnum, Integer> dateParts) {
    int year =
        getYear(
            dateParts.get(ChronoUnitEnum.ERAS),
            dateParts.get(ChronoUnitEnum.YEARS_ISO_8601),
            dateParts.get(ChronoUnitEnum.CENTURIES),
            dateParts.get(ChronoUnitEnum.YEARS_IN_MILLENIA_ISO_8601),
            dateParts.get(ChronoUnitEnum.YEARS_IN_CENTURY_ISO_8601));
    return year == 0 ? 1 : year;
  }

  private static int getYear(@Nullable Integer era, @Nullable Integer years,
      @Nullable Integer centuries, @Nullable Integer yearsInMillenia,
      @Nullable Integer yearsInCentury) {
    int yearSign = 1;
    if (era != null) {
      if (era == 0) {
        yearSign = -1;
      }
    }

    if (yearsInMillenia != null) {
      int year = yearsInMillenia;
      if (year < 520) {
        year += 2000;
      } else {
        year += 1000;
      }

      return yearSign * year;
    }

    if (centuries != null) {
      int year = 100 * (centuries - 1);

      if (yearsInCentury != null) {
        year += yearsInCentury;
      } else {
        year += 1;
      }

      return yearSign * year;
    }

    if (years != null) {
      return yearSign * years;
    }

    if (yearsInCentury != null) {
      int year = yearsInCentury;
      if (year < 70) {
        year += 2000;
      } else if (year < 100) {
        year += 1900;
      }

      return yearSign * year;
    }

    return yearSign;
  }

  private static LocalDateTime updateWithTimeFields(LocalDateTime dateTime,
      Map<ChronoUnitEnum, Integer> dateParts) {
    LocalDateTime updatedDateTime = dateTime;

    if (dateParts.containsKey(ChronoUnitEnum.HOURS_IN_DAY)) {
      updatedDateTime =
          updatedDateTime.withHour(dateParts.get(ChronoUnitEnum.HOURS_IN_DAY));
    }

    if (dateParts.containsKey(ChronoUnitEnum.HALF_DAYS)
        && dateParts.containsKey(ChronoUnitEnum.HOURS_IN_HALF_DAY)) {
      updatedDateTime =
          updatedDateTime.withHour(dateParts.get(ChronoUnitEnum.HALF_DAYS) * 12
              + dateParts.get(ChronoUnitEnum.HOURS_IN_HALF_DAY));
    } else if (dateParts.containsKey(ChronoUnitEnum.HOURS_IN_HALF_DAY)) {
      updatedDateTime =
          updatedDateTime.withHour(dateParts.get(ChronoUnitEnum.HOURS_IN_HALF_DAY));
    }

    if (dateParts.containsKey(ChronoUnitEnum.MINUTES_IN_HOUR)) {
      updatedDateTime =
          updatedDateTime.withMinute(dateParts.get(ChronoUnitEnum.MINUTES_IN_HOUR));
    }

    if (dateParts.containsKey(ChronoUnitEnum.SECONDS_IN_DAY)) {
      updatedDateTime =
          updatedDateTime.with(ChronoField.SECOND_OF_DAY,
              dateParts.get(ChronoUnitEnum.SECONDS_IN_DAY));
    }

    if (dateParts.containsKey(ChronoUnitEnum.SECONDS_IN_MINUTE)) {
      updatedDateTime =
          updatedDateTime.withSecond(dateParts.get(ChronoUnitEnum.SECONDS_IN_MINUTE));
    }

    if (dateParts.containsKey(ChronoUnitEnum.MILLIS)) {
      updatedDateTime =
          updatedDateTime.with(ChronoField.MILLI_OF_SECOND,
              dateParts.get(ChronoUnitEnum.MILLIS));
    }

    if (dateParts.containsKey(ChronoUnitEnum.MICROS)) {
      updatedDateTime =
          updatedDateTime.with(ChronoField.MICRO_OF_SECOND,
              dateParts.get(ChronoUnitEnum.MICROS));
    }

    if (dateParts.containsKey(ChronoUnitEnum.TENTHS_OF_SECOND)) {
      updatedDateTime =
          updatedDateTime.with(ChronoField.MILLI_OF_SECOND,
              100L * dateParts.get(ChronoUnitEnum.TENTHS_OF_SECOND));
    }

    if (dateParts.containsKey(ChronoUnitEnum.HUNDREDTHS_OF_SECOND)) {
      updatedDateTime =
          updatedDateTime.with(ChronoField.MILLI_OF_SECOND,
              10L * dateParts.get(ChronoUnitEnum.HUNDREDTHS_OF_SECOND));
    }

    if (dateParts.containsKey(ChronoUnitEnum.THOUSANDTHS_OF_SECOND)) {
      updatedDateTime =
          updatedDateTime.with(ChronoField.MILLI_OF_SECOND,
              dateParts.get(ChronoUnitEnum.THOUSANDTHS_OF_SECOND));
    }

    if (dateParts.containsKey(ChronoUnitEnum.TENTHS_OF_MS)) {
      updatedDateTime =
          updatedDateTime.with(ChronoField.MICRO_OF_SECOND,
              100L * dateParts.get(ChronoUnitEnum.TENTHS_OF_MS));
    }

    if (dateParts.containsKey(ChronoUnitEnum.HUNDREDTHS_OF_MS)) {
      updatedDateTime =
          updatedDateTime.with(ChronoField.MICRO_OF_SECOND,
              10L * dateParts.get(ChronoUnitEnum.HUNDREDTHS_OF_MS));
    }

    if (dateParts.containsKey(ChronoUnitEnum.THOUSANDTHS_OF_MS)) {
      updatedDateTime =
          updatedDateTime.with(ChronoField.MICRO_OF_SECOND,
              dateParts.get(ChronoUnitEnum.THOUSANDTHS_OF_MS));
    }

    return updatedDateTime;
  }
}