0

Hi buddies I'm in a trouble trying to migrate a behavior from calendar to localdate.

payDate.set(Calendar.DAY_OF_MONTH,payDay)

Lets imagine that payDate had the current date, 2020-01-29

for business reasons payDay can had the value of 0, so, when the previous code line is executed with the previous scenario, the result is that payDate update the date to 2019-12-31, that is to say the the date back to the last day of the past month.

I'm not sure, the technical reason of this, if someone can explain to me this I'll be so thankful, I tried checking the java doc but it was not helpful.

So I need to replicate that behavior with LocalDate java library. From my point of view; the similar of set method from Calendar with the value of DAY_OF_MONTH in LocalDate is:

payDate.withDayOfMonth(payDay)

But when the below scenario is presented and payDay is equal to 0 I get an error:

java.time.DateTimeException: Invalid value for DayOfMonth (valid values 1 - 28/31): 0

Also I had some ideas about how can I get the same result of calendar in localDate when the rule comes on (if payDay is 0, return to the last day of previous month), but are too verbose.

If you know a similar behavior on LocalDate please help me. Thanks.

Juanbg.dev
  • 21
  • 6
  • When you use `withDayOfMonth` you are saying "*take `25/12/2020` and replace `_/12/2020` with the value I give you*" . There is no zero-th of December, or any month. If you have some value that is zero-indexed then add 1 to it. – Michael Jan 29 '20 at 18:12
  • 1
    Basically, the `Calendar` API is distinctly odd - the docs explain it in great detail, but the `java.time` API is much more sensible... so weird things you got away with in when using `Calendar` won't work. If you want "0" to mean "the day before the start of the month" I suggest you write code for that - check whether `payDay` is 0, and if so, use `withDayOfMonth(1).plusDays(-1)` – Jon Skeet Jan 29 '20 at 18:19
  • @JonSkeet `LocalDate` has a [`minusDays`](https://docs.oracle.com/javase/8/docs/api/java/time/LocalDate.html#minusDays-long-) method. Is there any reason you decided to add a negative? – Michael Jan 29 '20 at 18:23
  • @Michael: Not particularly. `minusDays(1)` works fine for me too. (It's probably just bias from Noda Time, where we only have `PlusDays`.) – Jon Skeet Jan 29 '20 at 18:36
  • @JonSkeet The `Calendar` API isn't that odd. The C `struct tm` and `mktime()` API had a similar feature, in that you could set fields to out-of-range values and it would adjust things to fit appropriately. No doubt the designers of the `Calendar` API were influenced by that. https://stackoverflow.com/a/9575245/636009 – David Conrad Jan 29 '20 at 18:43
  • 4
    @DavidConrad: We may to agree to disagree. The fact that it needs so much documentation suggests it's too complicated, IMO. (Things like deferred evaluation don't help, either.) I wouldn't say that the C API is a great example to start from - I completely agree that the `Date` designers were probably influenced by it, I just wish they hadn't been. – Jon Skeet Jan 29 '20 at 18:49

3 Answers3

4

TL;DR: Use payDate = payDate.plusDays(payDay - payDate.getDayOfMonth());


The behavior of Calendar you're describing is documented in the javadoc:

Leniency

Calendar has two modes for interpreting the calendar fields, lenient and non-lenient. When a Calendar is in lenient mode, it accepts a wider range of calendar field values than it produces. When a Calendar recomputes calendar field values for return by get(), all of the calendar fields are normalized. For example, a lenient GregorianCalendar interprets MONTH == JANUARY, DAY_OF_MONTH == 32 as February 1.

When a Calendar is in non-lenient mode, it throws an exception if there is any inconsistency in its calendar fields. For example, a GregorianCalendar always produces DAY_OF_MONTH values between 1 and the length of the month. A non-lenient GregorianCalendar throws an exception upon calculating its time or calendar field values if any out-of-range field value has been set.

To show the effect of this, try setting the date of a Calendar to January 70, 2020:

Calendar cal = Calendar.getInstance();
cal.clear();
cal.set(2020, Calendar.JANUARY, 70);
System.out.println(new SimpleDateFormat("yyyy-MM-dd").format(cal.getTime()));

Output

2020-03-10

You would get the same result if you did:

cal.set(2020, Calendar.JANUARY, 1);
cal.add(Calendar.DAY_OF_MONTH, 69);

LocalDate is always non-lenient, so you can't set the day-of-month value to a value that is out-of-range. You can however get the same result as what Calendar does, by changing the operation to "add" instead of "set".

So, if you have a particular date, e.g. the 2020-01-29 date mentioned in the question, and you want to "set" the day-of-month value to 70 or 0, with same lenient overflow logic as Calendar has, do this:

LocalDate date = LocalDate.parse("2020-01-29");
date = date.plusDays(70 - date.getDayOfMonth());
System.out.println(date);
LocalDate date = LocalDate.parse("2020-01-29");
date = date.plusDays(0 - date.getDayOfMonth());
System.out.println(date);

Output

2020-03-10
2019-12-31

As you can see, date.plusDays(dayToSet - date.getDayOfMonth()) will give you the desired result.

Community
  • 1
  • 1
Andreas
  • 154,647
  • 11
  • 152
  • 247
2

Here’s how I would go about it:

    LocalDate payDate = LocalDate.now(); // or whatever
    int payDay = 0;
    if (payDay == 0) {
        // simulate `GregorianCalendar` behaviour: day 0 is the day before day 1
        payDate = payDate.withDayOfMonth(1).minusDays(1);
    } else {
        payDate = payDate.withDayOfMonth(payDay);
    }
    System.out.println(payDate);

When I ran the snippet just now, the output was the date you already mentioned:

2019-12-31

If we wanted it shorter, we could use payDate.withDayOfMonth(1).minusDays(1).plusDays(payDay) or the trick from Andreas’ answer, and we would not need the if statement. I would not, though. (1) It’s harder to read. (2) It doesn’t give the validation of payDay that comes for free in the snippet above.

The confusing behaviour of Calendar comes from not range checking the argument to set(). So day 0 of the month is the day before day 1 of the month. Day -1 would be the day before that, and so forth. It’s in this snippet from the documentation (or was supposed to be, at least):

When a Calendar is in lenient mode, it accepts a wider range of calendar field values than it produces. When a Calendar recomputes calendar field values for return by get(), all of the calendar fields are normalized. For example, a lenient GregorianCalendar interprets MONTH == JANUARY, DAY_OF_MONTH == 32 as February 1.

You may read it with this snippet from the documentation of the setLenient method:

The default is lenient.

Links

Ole V.V.
  • 81,772
  • 15
  • 137
  • 161
1

You're not going to be able to just invoke one method to achieve the same results. If you're sure that setting DAY_OF_MONTH to 0 should cause it to roll back one month (this is the type of thing I'd run past a business analyst or product owner for a sanity check) then you're going to have to do something like this:

    int payDay = 0;
    LocalDate payDate = LocalDate.of(2020, Month.JANUARY, 29);

    if(payDay == 0) {
        payDate = payDate.minusMonths(1);
        payDay = payDate.lengthOfMonth();
    }

    payDate = payDate.withDayOfMonth(payDay);

Another approach:

   int payDay = 0;
   LocalDate payDate = LocalDate.of(2020, Month.JANUARY, 29);
   if(payDay == 0) {
      payDate = payDate.withDayOfMonth(1).minusDays(1);
   } else {
      payDate = payDate.withDayOfMonth(payDay);
   }
Jordan
  • 2,273
  • 9
  • 16
  • 1
    I'd probably special case 0 differently - rather than work out "the day to set" just set to 1 and then take the previous day (see my comment on the question). I think that's simpler to read and reason about than subtracting a month and working out the length of the month. (In particular, it removes questions about what subtracting a month means for March 30th, for example.) – Jon Skeet Jan 29 '20 at 18:20
  • 1
    @JonSkeet That's fair. I've added that suggestion as another option. It has the bonus of not modifying `payDay`, which is probably a good call. – Jordan Jan 29 '20 at 18:23