DateOps.java

/*
** Module   : DateOps.java
** Abstract : Progress 4GL compatible date operations.
**
** Copyright (c) 2013-2022, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
** 001 GES 20130121 Created initial version. 
** 002 OM  20130531 Added missing implementation.
** 003 CA  20191211 Do not use datetimetz when adding the millis, but a duplicate of the existing instance.
** 004 IAS 20211015 Fixed potential ClassCastException in the addInterval method
** 005 CA  20220622 Fixed INTERVAL and ADD-INTERVAL - they weren't addressing the timezone in a datetimetz
**                  properly.
**     OM  20220622 Further improve interval operations.
*/

/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
** 
** Additional terms under GNU Affero GPL version 3 section 7:
** 
**   Under Section 7 of the GNU Affero GPL version 3, the following additional
**   terms apply to the works covered under the License.  These additional terms
**   are non-permissive additional terms allowed under Section 7 of the GNU
**   Affero GPL version 3 and may not be removed by you.
** 
**   0. Attribution Requirement.
** 
**     You must preserve all legal notices or author attributions in the covered
**     work or Appropriate Legal Notices displayed by works containing the covered
**     work.  You may not remove from the covered work any author or developer
**     credit already included within the covered work.
** 
**   1. No License To Use Trademarks.
** 
**     This license does not grant any license or rights to use the trademarks
**     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
**     of Golden Code Development Corporation. You are not authorized to use the
**     name Golden Code, FWD, or the names of any author or contributor, for
**     publicity purposes without written authorization.
** 
**   2. No Misrepresentation of Affiliation.
** 
**     You may not represent yourself as Golden Code Development Corporation or FWD.
** 
**     You may not represent yourself for publicity purposes as associated with
**     Golden Code Development Corporation, FWD, or any author or contributor to
**     the covered work, without written authorization.
** 
**   3. No Misrepresentation of Source or Origin.
** 
**     You may not represent the covered work as solely your work.  All modified
**     versions of the covered work must be marked in a reasonable way to make it
**     clear that the modified work is not originating from Golden Code Development
**     Corporation or FWD.  All modified versions must contain the notices of
**     attribution required in this license.
*/

package com.goldencode.p2j.util;

import java.util.*;

/**
 * A class that provides Progress 4GL compatible date functions. This class
 * provides all functionality as statics and has no instance data.
 * <p>
 * The following is the mapping of Progress language features to the
 * corresponding feature in this class:
 * <p>
 * <pre>
 * add-interval    function                 {@link #addInterval}
 * interval        function                 {@link #interval}
 * </pre>
 * <p>
 * The above methods all return an instance of wrapper classes which allow
 * the Progress unknown value to be properly handled.  Overloaded versions of
 * the methods exist which handle different combinations of primative type
 * parameters, allowing the resulting code to be implemented without
 * unnecessary construction of the wrapper types for all parameters.
 */
public final class DateOps
{
   /**
    * Adds or subtracts a time amount to/from an instance of a date class
    * and returns the new date instance.  This can be used with {@link date}
    * (or any subclass) and the same type will be returned.
    * 
    * @param    dat
    *           The date to add to or subtract from.
    * @param    amount
    *           If positive, the amount to add.  If negative, the amount to subtract.
    * @param    unit
    *           One of the following time units: 'years', 'months', 'weeks', 'days', 'hours', 
    *           'minutes', 'seconds' or 'milliseconds'. Any case is accepted and it may be
    *           specified as singular.
    *
    * @return   The computed value or <code>unknown</code> if any input is unknown.
    */
   @SuppressWarnings("unchecked") // duplicate(), instantiateUnknown() and instantiateDefault() are safe 
   public static <T extends date> T addInterval(T dat, long amount, String unit)
   {
      int calField = getGregorianField(unit);
      if (calField < 0)
      {
         // parameter validation
         ErrorManager.recordOrThrowError(11262, 
               "Third parameter for the INTERVAL/ADD-INTERVAL function must be unit of measure");
         return (T) dat.instantiateUnknown();
      }
      
      // validation, trivial case: no computation needed
      if (amount == 0)
      {
         return (T) dat.duplicate();
      }
      
      // validation, second trivial case: no computation needed
      if (dat.isUnknown())
      {
         return (T) dat.instantiateUnknown();
      }
      
      // simple case: add a fixed amount of time that will be converted to milliseconds
      long delta = getIntervalInMillis(amount, unit);
      if (delta != 0)
      {
         datetime inter = dat instanceof datetime ? (datetime) dat.duplicate() : new datetime(dat);
         inter = datetime.plusMillis(inter, delta);
         
         T ret = (T) dat.duplicate();
         ret.assign(inter);
         return ret;
      }
      
      // second simple case: add a variable amount of days (years/months)
      Calendar jcal = GregorianCalendar.getInstance();
      jcal.setTime(dat.dateValue());
      jcal.add(calField, (int) amount);
      
      T ret = (T) dat.duplicate();
      date inter2 = new date(jcal.getTime());
      ret.setDayNumber(inter2.intValue());
      return ret;
      
      // no other cases
   }

   /**
    * Adds or subtracts a time amount to/from an instance of a date class
    * and returns the new date instance.  This can be used with {@link date}
    * (or any subclass) and the same type will be returned.
    * 
    * @param    dat
    *           The date to add to or subtract from.
    * @param    amount
    *           If positive, the amount to add.  If negative, the amount to
    *           subtract.
    * @param    unit
    *           One of the following time units: 'years', 'months', 'weeks',
    *           'days', 'hours', 'minutes', 'seconds' or 'milliseconds'. Any 
    *           case is accepted and it may be specified as singular.
    *
    * @return   The computed value or <code>unknown</code> if any input is
    *           unknown.
    */
   @SuppressWarnings("unchecked")
   public static <T extends date> T addInterval(T dat, NumberType amount, String unit)
   {
      if (dat.isUnknown() || amount.isUnknown())
      {
         return (T) dat.instantiateUnknown();
      }
      
      return addInterval(dat, amount.longValue(), unit);
   }

   /**
    * Adds or subtracts a time amount to/from an instance of a date class
    * and returns the new date instance.  This can be used with {@link date}
    * (or any subclass) and the same type will be returned.
    * 
    * @param    dat
    *           The date to add to or subtract from.
    * @param    amount
    *           If positive, the amount to add.  If negative, the amount to
    *           subtract.
    * @param    unit
    *           One of the following time units: 'years', 'months', 'weeks',
    *           'days', 'hours', 'minutes', 'seconds' or 'milliseconds'. Any 
    *           case is accepted and it may be specified as singular.
    *
    * @return   The computed value or <code>unknown</code> if any input is
    *           unknown.
    */
   @SuppressWarnings("unchecked")
   public static <T extends date> T addInterval(T dat, long amount, character unit)
   {
      if (dat.isUnknown() || unit.isUnknown())
      {
         return (T) dat.instantiateUnknown();
      }
      
      return addInterval(dat, amount, unit.toStringMessage());
   }

   /**
    * Adds or subtracts a time amount to/from an instance of a date class
    * and returns the new date instance.  This can be used with {@link date}
    * (or any subclass) and the same type will be returned.
    * 
    * @param    dat
    *           The date to add to or subtract from.
    * @param    amount
    *           If positive, the amount to add.  If negative, the amount to
    *           subtract.
    * @param    unit
    *           One of the following time units: 'years', 'months', 'weeks',
    *           'days', 'hours', 'minutes', 'seconds' or 'milliseconds'. Any 
    *           case is accepted and it may be specified as singular.
    *
    * @return   The computed value or <code>unknown</code> if any input is
    *           unknown.
    */
   @SuppressWarnings("unchecked")
   public static <T extends date> T addInterval(T dat, NumberType amount, character  unit)
   {
      if (dat.isUnknown() || amount.isUnknown() || unit.isUnknown())
      {
         return (T) dat.instantiateUnknown();
      }
      
      return addInterval(dat, amount.longValue(), unit.toStringMessage());
   }

   /**
    * Calculate the time interval between two date values and return it in user-specified units.
    * 
    * @param    d1
    *           The first date value.
    * @param    d2
    *           The second date value.
    * @param    unit
    *           One of the following time units: 'years', 'months', 'weeks',
    *           'days', 'hours', 'minutes', 'seconds' or 'milliseconds'. Any 
    *           case is accepted and it may be specified as singular.
    *
    * @return   The computed value or <code>unknown</code> if any input is unknown.
    */
   public static int64 interval(date d1, date d2, String unit)
   {
      int calField = getGregorianField(unit);
      if (calField < 0)
      {
         // parameter validation
         ErrorManager.recordOrThrowError(11262, 
              "Third parameter for the INTERVAL/ADD-INTERVAL function must be unit of measure");
         return new int64();
      }
      
      if (d1.isUnknown() || d2.isUnknown())
      {
         return new int64();
      }
      
      // adjust both to UTC (to have a common denominator with the other date instant)
      datetime d1utc = d1.utc();
      datetime d2utc = d2.utc(); 
      
      unit = unit.toLowerCase();
      
      // day difference between dates:
      long dd = d1utc.longValue() - d2utc.longValue();
      // compute time difference between dates:
      long dt = d1utc.getTime() - d2utc.getTime();
      
      // adjust dd & dt to have the same sign
      if (dd < 0)
      {
         if (dt > 0)
         {
            ++dd;
            dt -= date.MILLIS_PER_DAY;
         }
         // else they are both negative
      }
      else if (dd > 0)
      {
         if (dt < 0)
         {
            --dd;
            dt += date.MILLIS_PER_DAY;
         }
         // else they are both positive
      }
      // else dd is 0 so only compute using dt
      
      long multiplier = getIntervalInMillis(1, unit);
      if (multiplier > 0)
      {
         // we know the exact unit size
         return new int64(((dd * date.MILLIS_PER_DAY) + dt) / multiplier);
      }
      
      // else unit is 'year' or 'month' or.. other invalid value
      boolean umYear = "years".equals(unit) || "year".equals(unit);
      boolean umMonth = "month".equals(unit) || "months".equals(unit);
      if (!umYear && !umMonth)
      {
         // unrecognized unit, returning unknown value
         return new int64();
      }
      
      // try to order the dates so that they are in REVERSE chronological order 
      // as date/datetime and datetime-tz are not directly comparable; we do it ad-hoc:
      boolean rev = false;
      if (d1utc.longValue() < d2utc.longValue())
      {
         // different days, d1 is less, they will be switched
         rev = true;
      }
      else if (d1utc.longValue() == d2utc.longValue())
      {
         // same day, check the time
         if (d1utc.getTime() < d2utc.getTime())
         {
            // if d1 is early, the values will be switched
            rev = true;
         }
      }
      if (rev)
      {
         datetime tmp = d1utc;
         d1utc  = d2utc;
         d2utc  = tmp;
      }
      // now d1 > d2 so the difference d1 - d2 is always positive.
      
      int y1 = d1utc.getYear();
      int y2 = d2utc.getYear();
      int m1 = d1utc.getMonth();
      int m2 = d2utc.getMonth();
      int z1 = d1utc.getDay();
      int z2 = d2utc.getDay();
      long t1 = d1utc.getTime();
      long t2 = d2utc.getTime();
      
      if (umYear)
      {
         // dy is always positive as the date were eventually reordered (rev is true in this case)
         int dy = y1 - y2;
         if (m1 > m2) 
         {
            return new int64(rev ? -dy : dy);
         }
         else if (m1 == m2)
         {
            if (z1 > z2)
            {
               return new int64(rev ? -dy : dy);
            }
            else if (z1 == z2)
            {
               if (t1 >= t2)
               {
                  return new int64(rev ? -dy : dy);
               }
            }
         }
         // there is not yet a full year
         return new int64(rev ? 1 - dy : dy - 1);
      }
      else // umMonth = true
      {
         // dm is always positive as the date were eventually reordered (rev is true in this case)
         long dm = (y1 - y2) * 12L + (m1 - m2); 
         if (z1 > z2)
         {
            return new int64(rev ? -dm : dm);
         }
         else if (z1 == z2)
         {
            if (t1 >= t2)
            {
               return new int64(rev ? -dm : dm);
            }
         }
         // there is not yet a full month
         return new int64(rev ? 1 - dm : dm - 1);
      }
   }

   /**
    * Calculate the time interval between two date values and return it
    * in user-specified units.
    * 
    * @param    d1
    *           The first date value.
    * @param    d2
    *           The second date value.
    * @param    unit
    *           One of the following time units: 'years', 'months', 'weeks',
    *           'days', 'hours', 'minutes', 'seconds' or 'milliseconds'. Any 
    *           case is accepted and it may be specified as singular.
    *
    * @return   The computed value or <code>unknown</code> if any input is
    *           unknown.
    */
   public static int64 interval(date d1, date d2, character unit)
   {
      if (unit.isUnknown())
      {
         return new int64();
      }

      return interval(d1, d2, unit.toStringMessage());
   }
   
   /**
    * Computes and returns an interval of a fixed size time unit.
    *  
    * @param    amount
    *           The amount of units.
    * @param    unit
    *           One of the following time units: 'weeks', 'days', 'hours', 'minutes', 'seconds' 
    *           or 'milliseconds'. Any case is accepted and it may be specified as singular.
    * 
    * @return   The milliseconds in terms of the given unit. 
    */
   private static long getIntervalInMillis(long amount, String unit)
   {
      unit = unit.toLowerCase();
      
      if ("milliseconds".equals(unit) || "millisecond".equals(unit))
      {
         return amount;
      }

      amount *= 1000;
      if ("seconds".equals(unit) || "second".equals(unit))
      {
         return amount;
      }

      amount *= 60;
      if ("minutes".equals(unit) || "minute".equals(unit))
      {
         return amount;
      }

      amount *= 60;
      if ("hours".equals(unit) || "hour".equals(unit))
      {
         return amount;
      }

      amount *= 24;
      if ("days".equals(unit) || "day".equals(unit))
      {
         return amount;
      }

      amount *= 7;
      if ("weeks".equals(unit) || "week".equals(unit))
      {
         return amount;
      }

      return 0;
   }
   
   /**
    * Maps Progress calendar fields to Java Calendar fields.
    *  
    * @param   pUnit
    *          The name of time unit from Progress.
    *
    * @return  The corresponding Calendar field Id from java.util.Calendar. 
    */
   private static int getGregorianField(String pUnit)
   {
      // to simplify things, convert to lowercase
      pUnit = pUnit.toLowerCase();
      
      if ("milliseconds".equals(pUnit) || "millisecond".equals(pUnit))
      {
         return Calendar.MILLISECOND;
      }

      if ("seconds".equals(pUnit) || "second".equals(pUnit))
      {
         return Calendar.SECOND;
      }

      if ("minutes".equals(pUnit) || "minute".equals(pUnit))
      {
         return Calendar.MINUTE;
      }

      if ("hours".equals(pUnit) || "hour".equals(pUnit))
      {
         return Calendar.HOUR_OF_DAY;
      }

      if ("days".equals(pUnit) || "day".equals(pUnit))
      {
         return Calendar.DATE;
      }

      if ("weeks".equals(pUnit) || "week".equals(pUnit))
      {
         return Calendar.WEEK_OF_YEAR;
      }

      if ("months".equals(pUnit) || "month".equals(pUnit))
      {
         return Calendar.MONTH;
      }

      if ("years".equals(pUnit) || "year".equals(pUnit))
      {
         return Calendar.YEAR;
      }
      
      return -1;
   }
   
   /**
    * Compares two date-related values. 
    * The arguments can be any date-related values. If needed they are converted to local time
    * before doing actual comparison.
    * 
    * @param   d1
    *          The first date to compare.
    * @param   d2
    *          The second date to compare.
    *          
    * @return  0 if operands are equals, -1 if fist is less and 1 if first is greater than second.
    */
   public static int compare(date d1, date d2)
   {
      boolean u1 = d1.isUnknown();
      boolean u2 = d2.isUnknown();
      
      if (u1 && u2)
      {
         // two unknown are equals
         return 0;
      }
      if (u1 || u2)
      {
         // only of is unknown, the other is not
         return -1;
      }
      
      datetime dt1 = d1.utc();
      datetime dt2 = d2.utc();
      long d1AbsTime = dt1.longValue() * date.MILLIS_PER_DAY + dt1.getTime();
      long d2AbsTime = dt2.longValue() * date.MILLIS_PER_DAY + dt2.getTime();
      
      if (d1AbsTime < d2AbsTime)
      {
         return -1;
      }
      else if (d1AbsTime > d2AbsTime)
      {
         return +1;
      }
      
      // else they are equals:
      return 0;
   }
}