FormatElementEnum.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;

import org.apache.calcite.avatica.util.DateTimeUtils;
import org.apache.calcite.util.TryThreadLocal;

import org.apache.commons.lang3.StringUtils;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.format.TextStyle;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;

import static java.util.Objects.requireNonNull;

/**
 * Implementation of {@link FormatElement} containing the standard format
 * elements. These are based on Oracle's format model documentation.
 *
 * <p>See
 * <a href="https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlqr/Format-Models.html">
 * Oracle format model reference.</a>
 *
 * @see FormatModels#DEFAULT
 */
public enum FormatElementEnum implements FormatElement {
  CC("cc", "century (2 digits) (the twenty-first century starts on 2001-01-01)") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%2d", calendar.get(Calendar.YEAR) / 100 + 1));
    }
  },
  D("", "The weekday (Sunday as the first day of the week) as a decimal number (1-7)") {
    @Override public void toPattern(StringBuilder sb) throws UnsupportedOperationException {
      throwToPatternNotImplemented();
    }
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%d", calendar.get(Calendar.DAY_OF_WEEK)));
    }
  },
  DAY("EEEE", "The full weekday name, in uppercase") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.getDayFromDate(date, TextStyle.FULL).toUpperCase(Locale.ROOT));
    }
  },
  Day("EEEE", "The full weekday name, capitalized") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.getDayFromDate(date, TextStyle.FULL));
    }
  },
  day("EEEE", "The full weekday name, in lowercase") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.getDayFromDate(date, TextStyle.FULL).toLowerCase(Locale.ROOT));
    }
  },
  DD("dd", "The day of the month as a decimal number (01-31)") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%02d", calendar.get(Calendar.DAY_OF_MONTH)));
    }
  },
  DDD("D", "The day of the year as a decimal number (001-366)") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%03d", calendar.get(Calendar.DAY_OF_YEAR)));
    }
  },
  DY("EEE", "The abbreviated weekday name, in uppercase") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.getDayFromDate(date, TextStyle.SHORT).toUpperCase(Locale.ROOT));
    }
  },
  Dy("EEE", "The abbreviated weekday name, capitalized") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.getDayFromDate(date, TextStyle.SHORT));
    }
  },
  dy("EEE", "The abbreviated weekday name, in lowercase") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.getDayFromDate(date, TextStyle.SHORT).toLowerCase(Locale.ROOT));
    }
  },
  E("d", "The day of the month as a decimal number (1-31); "
      + "single digits are left-padded with space.") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%2d", calendar.get(Calendar.DAY_OF_MONTH)));
    }
  },
  FF1("S", "Fractional seconds to 1 digit") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      // Extracting 1 decimal place as SimpleDateFormat returns precision with 3 places.
      // Refer to <a href="https://issues.apache.org/jira/projects/CALCITE/issues/CALCITE-6269">
      // [CALCITE-6269] Fix missing/broken BigQuery date-time format elements</a>.
      sb.append(work.sssFormat.format(date).charAt(0));
    }
  },
  FF2("S", "Fractional seconds to 2 digits") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      // Extracting 2 decimal places as SimpleDateFormat returns precision with 3 places.
      // Refer to <a href="https://issues.apache.org/jira/projects/CALCITE/issues/CALCITE-6269">
      // [CALCITE-6269] Fix missing/broken BigQuery date-time format elements</a>.
      sb.append(work.sssFormat.format(date), 0, 2);
    }
  },
  FF3("S", "Fractional seconds to 3 digits") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.sssFormat.format(date));
    }
  },
  FF4("S", "Fractional seconds to 4 digits") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      // Padding zeroes to right as SimpleDateFormat supports precision only up to 3 places.
      // Refer to <a href="https://issues.apache.org/jira/projects/CALCITE/issues/CALCITE-6269">
      // [CALCITE-6269] Fix missing/broken BigQuery date-time format elements</a>.
      sb.append(StringUtils.rightPad(work.sssFormat.format(date), 4, "0"));
    }
  },
  FF5("S", "Fractional seconds to 5 digits") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      // Padding zeroes to right as SimpleDateFormat supports precision only up to 3 places.
      // Refer to <a href="https://issues.apache.org/jira/projects/CALCITE/issues/CALCITE-6269">
      // [CALCITE-6269] Fix missing/broken BigQuery date-time format elements</a>.
      sb.append(StringUtils.rightPad(work.sssFormat.format(date), 5, "0"));
    }
  },
  FF6("S", "Fractional seconds to 6 digits") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      // Padding zeroes to right as SimpleDateFormat supports precision only up to 3 places.
      // Refer to <a href="https://issues.apache.org/jira/projects/CALCITE/issues/CALCITE-6269">
      // [CALCITE-6269] Fix missing/broken BigQuery date-time format elements</a>.
      sb.append(StringUtils.rightPad(work.sssFormat.format(date), 6, "0"));
    }
  },
  FF7("S", "Fractional seconds to 6 digits") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      // Padding zeroes to right as SimpleDateFormat supports precision only up to 3 places.
      // Refer to <a href="https://issues.apache.org/jira/projects/CALCITE/issues/CALCITE-6269">
      // [CALCITE-6269] Fix missing/broken BigQuery date-time format elements</a>.
      sb.append(StringUtils.rightPad(work.sssFormat.format(date), 7, "0"));
    }
  },
  FF8("S", "Fractional seconds to 6 digits") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      // Padding zeroes to right as SimpleDateFormat supports precision only up to 3 places.
      // Refer to <a href="https://issues.apache.org/jira/projects/CALCITE/issues/CALCITE-6269">
      // [CALCITE-6269] Fix missing/broken BigQuery date-time format elements</a>.
      sb.append(StringUtils.rightPad(work.sssFormat.format(date), 8, "0"));
    }
  },
  FF9("S", "Fractional seconds to 6 digits") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      // Padding zeroes to right as SimpleDateFormat supports precision only up to 3 places.
      // Refer to <a href="https://issues.apache.org/jira/projects/CALCITE/issues/CALCITE-6269">
      // [CALCITE-6269] Fix missing/broken BigQuery date-time format elements</a>.
      sb.append(StringUtils.rightPad(work.sssFormat.format(date), 9, "0"));
    }
  },
  HH12("h", "The hour (12-hour clock) as a decimal number (01-12)") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      int hour = calendar.get(Calendar.HOUR);
      sb.append(String.format(Locale.ROOT, "%02d", hour == 0 ? 12 : hour));
    }
  },
  HH24("H", "The hour (24-hour clock) as a decimal number (00-23)") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%02d", calendar.get(Calendar.HOUR_OF_DAY)));
    }
  },
  ID("u", "The weekday (Monday as the first day of the week) as a decimal number (1-7)") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      int weekDay = calendar.get(Calendar.DAY_OF_WEEK);
      // Converting Sun(1)...Sat(7) to Mon(1)...Sun(7)
      sb.append(weekDay == 1 ? 7 : weekDay - 1);
    }
  },
  IW("", "The ISO 8601 week number of the year (Monday as the first day of the week) "
      + "as a decimal number (01-53). If the week containing January 1 has four or more days "
      + "in the new year, then it is week 1; otherwise it is week 53 of the previous year, "
      + "and the next week is week 1.") {
    @Override public void toPattern(StringBuilder sb) throws UnsupportedOperationException {
      throwToPatternNotImplemented();
    }
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().iso8601Calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%02d", calendar.get(Calendar.WEEK_OF_YEAR)));
    }
  },
  IYY("YY", "The ISO 8601 year without century as a decimal number. Each ISO year begins on "
      + "the Monday before the first Thursday of the Gregorian calendar year.") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().iso8601Calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%02d", calendar.getWeekYear() % 100));
    }
  },
  IYYYY("YYYY", "The ISO 8601 year with century as a decimal number. Each ISO year begins on "
      + "the Monday before the first Thursday of the Gregorian calendar year.") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().iso8601Calendar;
      calendar.setTime(date);
      sb.append(calendar.getWeekYear());
    }
  },
  MI("m", "The minute as a decimal number (00-59)") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%02d", calendar.get(Calendar.MINUTE)));
    }
  },
  MM("MM", "The month as a decimal number (01-12)") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%02d", calendar.get(Calendar.MONTH) + 1));
    }
  },
  MON("MMM", "The abbreviated month name, in uppercase") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.mmmFormat.format(date).toUpperCase(Locale.ROOT));
    }
  },
  Mon("MMM", "The abbreviated month name, capitalized") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.mmmFormat.format(date));
    }
  },
  mon("MMM", "The abbreviated month name, in lowercase") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.mmmFormat.format(date).toLowerCase(Locale.ROOT));
    }
  },
  MONTH("MMMM", "The full month name (English), in uppercase") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.mmmmFormat.format(date).toUpperCase(Locale.ROOT));
    }
  },
  Month("MMMM", "The full month name (English), capitalized") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.mmmmFormat.format(date));
    }
  },
  month("MMMM", "The full month name (English), in lowercase") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.mmmmFormat.format(date).toLowerCase(Locale.ROOT));
    }
  },
  // PM can represent both AM and PM
  PM("a", "Meridian indicator without periods") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      String meridian = calendar.get(Calendar.HOUR_OF_DAY) < 12 ? "AM" : "PM";
      sb.append(meridian);
    }
  },
  Q("", "The quarter as a decimal number (1-4)") {
    @Override public void toPattern(StringBuilder sb) throws UnsupportedOperationException {
      throwToPatternNotImplemented();
    }
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%d", (calendar.get(Calendar.MONTH) / 3) + 1));
    }
  },
  AMPM("", "The time as Meridian Indicator in uppercase") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(calendar.get(Calendar.AM_PM) == Calendar.AM ? "AM" : "PM");
    }
  },
  AM_PM("", "The time as Meridian Indicator in uppercase with dot") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(calendar.get(Calendar.AM_PM) == Calendar.AM ? "A.M." : "P.M.");
    }
  },
  ampm("", "The time as Meridian Indicator in lowercase") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(calendar.get(Calendar.AM_PM) == Calendar.AM ? "am" : "pm");
    }
  },
  am_pm("", "The time as Meridian Indicator in uppercase") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(calendar.get(Calendar.AM_PM) == Calendar.AM ? "a.m." : "p.m.");
    }
  },
  MS("SSS", "The millisecond as a decimal number (000-999)") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%03d", calendar.get(Calendar.MILLISECOND)));
    }
  },
  SS("s", "The second as a decimal number (00-60)") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%02d", calendar.get(Calendar.SECOND)));
    }
  },
  SSSSS("s", "The seconds of the day (00000-86400)") {
    @Override public void format(StringBuilder sb, Date date) {
      Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      long timeInMillis = calendar.getTimeInMillis();

      // Set calendar to start of day for input date
      calendar.set(Calendar.HOUR_OF_DAY, 0);
      calendar.set(Calendar.MINUTE, 0);
      calendar.set(Calendar.SECOND, 0);
      calendar.set(Calendar.MILLISECOND, 0);
      long dayStartInMillis = calendar.getTimeInMillis();

      // Get seconds of the day as difference from day start time
      long secondsPassed = (timeInMillis - dayStartInMillis) / 1000;
      sb.append(String.format(Locale.ROOT, "%05d", secondsPassed));
    }
  },
  TZR("z", "The time zone name") {
    @Override public void format(StringBuilder sb, Date date) {
      // TODO: how to support timezones?
    }
  },
  W("W", "The week number of the month (Sunday as the first day of the week) as a decimal "
      + "number (1-5)") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%d", calendar.get(Calendar.WEEK_OF_MONTH)));
    }
  },
  WW("w", "The week number of the year (Sunday as the first day of the week) as a decimal "
      + "number (00-53)") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%02d", calendar.get(Calendar.WEEK_OF_YEAR)));
    }
  },
  Y("y", "Last digit of year") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      String formattedYear = work.yyFormat.format(date);
      sb.append(formattedYear.substring(formattedYear.length() - 1));
    }
  },
  YY("yy", "Last 2 digits of year") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.yyFormat.format(date));
    }
  },
  YYY("yyy", "Last 3 digits of year") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      String formattedYear = work.yyyyFormat.format(date);
      sb.append(formattedYear.substring(formattedYear.length() - 3));
    }
  },
  YYYY("yyyy", "The year with century as a decimal number") {
    @Override public void format(StringBuilder sb, Date date) {
      final Work work = Work.get();
      sb.append(work.yyyyFormat.format(date));
    }
  },
  pctY("yyyy", "The year with century as a decimal number") {
    @Override public void format(StringBuilder sb, Date date) {
      final Calendar calendar = Work.get().calendar;
      calendar.setTime(date);
      sb.append(String.format(Locale.ROOT, "%d", calendar.get(Calendar.YEAR)));
    }
  };

  private final String description;
  final String javaFmt;

  // TODO: be sure to deal with TZ

  FormatElementEnum(String javaFmt, String description) {
    this.javaFmt = requireNonNull(javaFmt, "javaFmt");
    this.description = requireNonNull(description, "description");
  }

  @Override public String getDescription() {
    return description;
  }

  @Override public void toPattern(StringBuilder sb) {
    sb.append(this.javaFmt);
  }

  final void throwToPatternNotImplemented() {
    throw new UnsupportedOperationException("Cannot convert '"
        + this.name().toUpperCase(Locale.ROOT) + "' FormatElement to Java pattern");
  }

  /** Work space. Provides a value for each mutable data structure that might
   * be needed by a format element. Ensures thread-safety. */
  static class Work {
    private static final TryThreadLocal<Work> THREAD_WORK =
        TryThreadLocal.withInitial(Work::new);

    /** Returns an instance of Work for this thread. */
    static Work get() {
      return THREAD_WORK.get();
    }

    final Calendar calendar = new Calendar.Builder()
        .setWeekDefinition(Calendar.SUNDAY, 1)
        .setTimeZone(DateTimeUtils.DEFAULT_ZONE)
        .setLocale(Locale.ROOT).build();

    final Calendar iso8601Calendar = new Calendar.Builder()
        .setCalendarType("iso8601")
        .setTimeZone(DateTimeUtils.DEFAULT_ZONE)
        .setLocale(Locale.ROOT).build();

    final DateFormat mmmFormat = new SimpleDateFormat(MON.javaFmt, Locale.US);
    /* Need to sse Locale.US instead of Locale.ROOT, because Locale.ROOT
     * may actually return the *short* month name instead of the long name.
     * See [CALCITE-6252] BigQuery FORMAT_DATE uses the wrong calendar for Julian dates:
     * https://issues.apache.org/jira/browse/CALCITE-6252.  This may be
     * specific to Java 11. */
    final DateFormat mmmmFormat = new SimpleDateFormat(MONTH.javaFmt, Locale.US);
    final DateFormat sssFormat = new SimpleDateFormat(FF3.javaFmt, Locale.ROOT);
    final DateFormat yyFormat = new SimpleDateFormat(YY.javaFmt, Locale.ROOT);
    final DateFormat yyyyFormat = new SimpleDateFormat(YYYY.javaFmt, Locale.ROOT);

    /** Util to return the full or abbreviated weekday name from date and expected TextStyle. */
    private String getDayFromDate(Date date, TextStyle style) {
      calendar.setTime(date);
      // The Calendar and SimpleDateFormatter do not seem to give correct results
      // for the day of the week prior to the Julian to Gregorian date change.
      // So we resort to using a LocalDate representation.
      LocalDate ld =
          LocalDate.of(calendar.get(Calendar.YEAR),
              // Calendar months are numbered from 0
              calendar.get(Calendar.MONTH) + 1,
              calendar.get(Calendar.DAY_OF_MONTH));
      return ld.getDayOfWeek().getDisplayName(style, Locale.ENGLISH);
    }
  }
}