Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Java 8 - ChronoUnit.MONTHS.between(fromDate,toDate) not working as expected

I need to find the no. of months between 2 dates, both months inclusive. I am using ChronoUnit.Months.between, but am getting unexpected results.

Code works for : Jan 31 - July 31

Fails for: Jan 31 - June 30

CODE THAT FAILS:

import java.time.LocalDate;
import static java.time.temporal.ChronoUnit.MONTHS;
public class HelloWorld{

     public static void main(String []args){
        LocalDate startDate=LocalDate.of(2018,01,31);
        LocalDate endDate=LocalDate.of(2018,6,30);
        //Both dates inclusive, hence adding 1
        long noOfMonths = (MONTHS.between(startDate,endDate)) + 1L;
        System.out.println(" ********* No. of months between the two dates : " + noOfMonths);
        }
}

Expected: 6 (Jan, Feb, Mar, Apr, May, Jun) Actual: 5

Code that works:

import java.time.LocalDate;
import static java.time.temporal.ChronoUnit.MONTHS;
public class HelloWorld{

     public static void main(String []args){
        LocalDate startDate=LocalDate.of(2018,01,31);
        LocalDate endDate=LocalDate.of(2018,7,31);
        //Both dates inclusive, hence adding 1
        long noOfMonths = (MONTHS.between(startDate,endDate)) + 1L;
        System.out.println(" ********* No. of months between the two dates : " + noOfMonths);
        }
}

Expected: 7 (Jan, Feb, Mar, Apr, May, Jun,July) Actual: 7

Do you see the same behaviour? Is there any alternate way of doing this?

Thanks

like image 744
Deepboy Avatar asked Jun 16 '18 18:06

Deepboy


3 Answers

Half-Open

The other two Answers by davidxxx and by Ole V.V. are both correct and informative.

I want to add about an alternative approach commonly used in date-time work for defining a span of time: Half-Open. In Half-Open approach, the beginning is inclusive while the ending is exclusive. So the months of the first half of the year (Jan, Feb, Mar, Apr, and Jun) are tracked as starting on January 1 and running up to, but not including, July 1.

Sometimes we intuitively use this approach in our daily life. For example, if a classroom of children breaks for lunch from 12:00 noon to 1 PM, the students are expected to be in their seats before the bell strikes at 1 PM. Lunch break runs up to, but does not include, 1 PM.

I believe you will find consistent use of the Half-Open approach makes your code easier to read, debug, and maintain.

The modern java.time classes use this approach in defining a span of time.

LocalDateRange

The Answer by Ole V.V. wisely suggests you look at YearMonth class to help your work.

Another helpful class is from the ThreeTen-Extra project: org.threeten.extra.LocalDateRange. You can represent your entire date range in a single object.

You can instantiate with a start date and a Period of six months.

LocalDateRange ldr = LocalDateRange.of( 
    LocalDate.of( 2018 , Month.JANUARY , 1 ) ,
    Period.ofMonths( 6 ) 
) ;

Or specify start and stop dates.

LocalDateRange ldr = LocalDateRange.of( 
    LocalDate.of( 2018 , Month.JANUARY , 1 ) ,
    LocalDate.of( 2018 , Month.JULY , 1 ) // Half-open.
) ;

While I don't recommend it, if you insist on using the Fully-Closed approach where beginning and ending are both inclusive, the LocalDateRange does offer LocalDateRange.ofClosed().

LocalDateRange ldr = LocalDateRange.ofClosed(     // `ofClosed` vs `of`.
    LocalDate.of( 2018 , Month.JANUARY , 1 ) ,
    YearMonth( 2018 , Month.JUNE ).atEndOfMonth() // Fully-closed.
) ;

You can get a count of months from a LocalDateRange via the Period class.

LocalDateRange ldr = LocalDateRange.of( 
    LocalDate.of( 2018 , Month.JANUARY , 1 ) ,
    Period.ofMonths( 6 ) 
) ;
Period p = ldr.toPeriod() ; 
long totalMonths = p.toTotalMonths() ;  // 6

About java.time

The java.time framework is built into Java 8 and later. These classes supplant the troublesome old legacy date-time classes such as java.util.Date, Calendar, & SimpleDateFormat.

The Joda-Time project, now in maintenance mode, advises migration to the java.time classes.

To learn more, see the Oracle Tutorial. And search Stack Overflow for many examples and explanations. Specification is JSR 310.

You may exchange java.time objects directly with your database. Use a JDBC driver compliant with JDBC 4.2 or later. No need for strings, no need for java.sql.* classes.

Where to obtain the java.time classes?

  • Java SE 8, Java SE 9, Java SE 10, and later
    • Built-in.
    • Part of the standard Java API with a bundled implementation.
    • Java 9 adds some minor features and fixes.
  • Java SE 6 and Java SE 7
    • Much of the java.time functionality is back-ported to Java 6 & 7 in ThreeTen-Backport.
  • Android
    • Later versions of Android bundle implementations of the java.time classes.
    • For earlier Android (<26), the ThreeTenABP project adapts ThreeTen-Backport (mentioned above). See How to use ThreeTenABP….

The ThreeTen-Extra project extends java.time with additional classes. This project is a proving ground for possible future additions to java.time. You may find some useful classes here such as Interval, YearWeek, YearQuarter, and more.

like image 165
Basil Bourque Avatar answered Sep 22 '22 10:09

Basil Bourque


davidxxx in the other answer has already explained why your code gave an unexpected result. Allow me to add that if you’re interested in months rather than dates, the YearMonth class is the good and correct one for you to use:

        YearMonth startMonth = YearMonth.of(2018, Month.JANUARY);
        YearMonth endMonth = YearMonth.of(2018, Month.JUNE);
        // Both months inclusive, hence adding 1
        long noOfMonths = ChronoUnit.MONTHS.between(startMonth, endMonth) + 1;
        System.out.println(" ********* No. of months between the two months (inclusive): " + noOfMonths);

Output:

********* No. of months between the two months (inclusive): 6

This obviously also eliminates the problems coming from months having unequal lengths.

If you already got LocalDate objects, it’s probably worth converting:

        LocalDate startDate = LocalDate.of(2018, Month.JANUARY, 31);
        YearMonth startMonth = YearMonth.from(startDate);

As an aside, do not use two-digit 01 for a month. For January it works, but neither 08 nor 09 will work for August or September, so it’s a bad habit to acquire. Java (and many other languages) understand numbers beginning with 0 as octal numbers.

like image 32
Ole V.V. Avatar answered Sep 21 '22 10:09

Ole V.V.


In terms of calendar, your question is understandable : the two comparisons represent 1 complete month.
But java.time.temporal.ChronoUnit.between doesn't reason in this way but in terms of complete units.
And the result is expected according to its javadoc :

The calculation returns a whole number, representing the number of complete units between the two temporals. For example, the amount in hours between the times 11:30 and 13:29 will only be one hour as it is one minute short of two hours.

The LocalDate.until javadoc that is used under the hood by ChronoUnit.between() is more explicit for your case and also gives an interesting example about between() used with MONTHS as TemporalUnit:

The calculation returns a whole number, representing the number of complete units between the two dates. For example, the amount in months between 2012-06-15 and 2012-08-14 will only be one month as it is one day short of two months.

In your working example :

LocalDate startDate=LocalDate.of(2018,01,31);
LocalDate endDate=LocalDate.of(2018,7,31);
long noOfMonths = MONTHS.between(startDate,endDate);

You have 31 days of month / 31 days of month, on 6 months => 6 months.

In your failing example :

LocalDate startDate=LocalDate.of(2018,01,31);
LocalDate endDate=LocalDate.of(2018,6,30);
long noOfMonths = MONTHS.between(startDate,endDate);

You have 30 days of month / 31 days of month, on 5 months
=> 4 months + 1 month short of 1 day <=> 4 months (rounded).

If you would write something like :

LocalDate startDate=LocalDate.of(2018,01,30);
LocalDate endDate=LocalDate.of(2018,6,30);
long noOfMonths = MONTHS.between(startDate,endDate);

You would have 30 days of month / 30 days of month, on 5 months => 5 months .

About your question :

Is there any alternate way of doing this?

The most simple : set the day of month to 1 or to any same number in both dates.
If it is not suitable for your requirement, you could check if the two dates are the last day of their current month, in this case, set it to the same month value.
You could write something like :

int lastMonthStart = startDate.getMonth()
                              .length(startDate.isLeapYear());
int lastMonthEnd = endDate.getMonth()
                          .length(endDate.isLeapYear());

if (startDate.getDayOfMonth() == lastMonthStart && endDate.getDayOfMonth() == lastMonthEnd) {
    startDate = startDate.withDayOfMonth(1);
    endDate = endDate.withDayOfMonth(1);
}
like image 29
davidxxx Avatar answered Sep 25 '22 10:09

davidxxx