diff --git a/spring-context/src/main/java/org/springframework/scheduling/support/QuartzCronField.java b/spring-context/src/main/java/org/springframework/scheduling/support/QuartzCronField.java index eadf66f51c..8a3c5ba67e 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/support/QuartzCronField.java +++ b/spring-context/src/main/java/org/springframework/scheduling/support/QuartzCronField.java @@ -38,24 +38,6 @@ import org.springframework.util.Assert; */ final class QuartzCronField extends CronField { - /** - * Temporal adjuster that returns the last weekday of the month. - */ - private static final TemporalAdjuster lastWeekdayOfMonth = temporal -> { - Temporal lastDayOfMonth = TemporalAdjusters.lastDayOfMonth().adjustInto(temporal); - int dayOfWeek = lastDayOfMonth.get(ChronoField.DAY_OF_WEEK); - if (dayOfWeek == 6) { // Saturday - return lastDayOfMonth.minus(1, ChronoUnit.DAYS); - } - else if (dayOfWeek == 7) { // Sunday - return lastDayOfMonth.minus(2, ChronoUnit.DAYS); - } - else { - return lastDayOfMonth; - } - }; - - private final Type rollForwardType; private final TemporalAdjuster adjuster; @@ -97,11 +79,11 @@ final class QuartzCronField extends CronField { throw new IllegalArgumentException("Unrecognized characters before 'L' in '" + value + "'"); } else if (value.length() == 2 && value.charAt(1) == 'W') { // "LW" - adjuster = lastWeekdayOfMonth; + adjuster = lastWeekdayOfMonth(); } else { if (value.length() == 1) { // "L" - adjuster = TemporalAdjusters.lastDayOfMonth(); + adjuster = lastDayOfMonth(); } else { // "L-[0-9]+" int offset = Integer.parseInt(value.substring(idx + 1)); @@ -155,7 +137,7 @@ final class QuartzCronField extends CronField { } else { // "[0-7]L" DayOfWeek dayOfWeek = parseDayOfWeek(value.substring(0, idx)); - adjuster = TemporalAdjusters.lastInMonth(dayOfWeek); + adjuster = lastInMonth(dayOfWeek); } return new QuartzCronField(Type.DAY_OF_WEEK, Type.DAY_OF_MONTH, adjuster, value); } @@ -171,14 +153,17 @@ final class QuartzCronField extends CronField { // "[0-7]#[0-9]+" DayOfWeek dayOfWeek = parseDayOfWeek(value.substring(0, idx)); int ordinal = Integer.parseInt(value.substring(idx + 1)); + if (ordinal <= 0) { + throw new IllegalArgumentException("Ordinal '" + ordinal + "' in '" + value + + "' must be positive number "); + } - TemporalAdjuster adjuster = TemporalAdjusters.dayOfWeekInMonth(ordinal, dayOfWeek); + TemporalAdjuster adjuster = dayOfWeekInMonth(ordinal, dayOfWeek); return new QuartzCronField(Type.DAY_OF_WEEK, Type.DAY_OF_MONTH, adjuster, value); } throw new IllegalArgumentException("No 'L' or '#' found in '" + value + "'"); } - private static DayOfWeek parseDayOfWeek(String value) { int dayOfWeek = Integer.parseInt(value); if (dayOfWeek == 0) { @@ -193,6 +178,54 @@ final class QuartzCronField extends CronField { } } + /** + * Returns an adjuster that resets to midnight. + */ + private static TemporalAdjuster atMidnight() { + return temporal -> { + if (temporal.isSupported(ChronoField.NANO_OF_DAY)) { + return temporal.with(ChronoField.NANO_OF_DAY, 0); + } + else { + return temporal; + } + }; + } + + /** + * Returns an adjuster that returns a new temporal set to the last + * day of the current month at midnight. + */ + private static TemporalAdjuster lastDayOfMonth() { + TemporalAdjuster adjuster = TemporalAdjusters.lastDayOfMonth(); + return temporal -> { + Temporal result = adjuster.adjustInto(temporal); + return rollbackToMidnight(temporal, result); + }; + } + + /** + * Returns an adjuster that returns the last weekday of the month. + */ + private static TemporalAdjuster lastWeekdayOfMonth() { + TemporalAdjuster adjuster = TemporalAdjusters.lastDayOfMonth(); + return temporal -> { + Temporal lastDom = adjuster.adjustInto(temporal); + Temporal result; + int dow = lastDom.get(ChronoField.DAY_OF_WEEK); + if (dow == 6) { // Saturday + result = lastDom.minus(1, ChronoUnit.DAYS); + } + else if (dow == 7) { // Sunday + result = lastDom.minus(2, ChronoUnit.DAYS); + } + else { + result = lastDom; + } + return rollbackToMidnight(temporal, result); + }; + } + /** * Return a temporal adjuster that finds the nth-to-last day of the month. * @param offset the negative offset, i.e. -3 means third-to-last @@ -200,9 +233,10 @@ final class QuartzCronField extends CronField { */ private static TemporalAdjuster lastDayWithOffset(int offset) { Assert.isTrue(offset < 0, "Offset should be < 0"); + TemporalAdjuster adjuster = TemporalAdjusters.lastDayOfMonth(); return temporal -> { - Temporal lastDayOfMonth = TemporalAdjusters.lastDayOfMonth().adjustInto(temporal); - return lastDayOfMonth.plus(offset, ChronoUnit.DAYS); + Temporal result = adjuster.adjustInto(temporal).plus(offset, ChronoUnit.DAYS); + return rollbackToMidnight(temporal, result); }; } @@ -228,6 +262,7 @@ final class QuartzCronField extends CronField { int count = 0; while (count++ < CronExpression.MAX_ATTEMPTS) { temporal = Type.DAY_OF_MONTH.elapseUntil(cast(temporal), dayOfMonth); + temporal = atMidnight().adjustInto(temporal); current = Type.DAY_OF_MONTH.get(temporal); if (current == dayOfMonth) { dayOfWeek = temporal.get(ChronoField.DAY_OF_WEEK); @@ -253,6 +288,44 @@ final class QuartzCronField extends CronField { }; } + /** + * Return a temporal adjuster that finds the last of the given doy-of-week + * in a month. + */ + private static TemporalAdjuster lastInMonth(DayOfWeek dayOfWeek) { + TemporalAdjuster adjuster = TemporalAdjusters.lastInMonth(dayOfWeek); + return temporal -> { + Temporal result = adjuster.adjustInto(temporal); + return rollbackToMidnight(temporal, result); + }; + } + + /** + * Returns a temporal adjuster that finds {@code ordinal}-th occurrence of + * the given day-of-week in a month. + */ + private static TemporalAdjuster dayOfWeekInMonth(int ordinal, DayOfWeek dayOfWeek) { + TemporalAdjuster adjuster = TemporalAdjusters.dayOfWeekInMonth(ordinal, dayOfWeek); + return temporal -> { + Temporal result = adjuster.adjustInto(temporal); + return rollbackToMidnight(temporal, result); + }; + } + + /** + * Rolls back the given {@code result} to midnight. When + * {@code current} has the same day of month as {@code result}, the former + * is returned, to make sure that we don't end up before where we started. + */ + private static Temporal rollbackToMidnight(Temporal current, Temporal result) { + if (result.get(ChronoField.DAY_OF_MONTH) == current.get(ChronoField.DAY_OF_MONTH)) { + return current; + } + else { + return atMidnight().adjustInto(result); + } + } + @SuppressWarnings("unchecked") private static > T cast(Temporal temporal) { return (T) temporal; diff --git a/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java b/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java index ed69750052..c7e1bb5c2b 100644 --- a/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java +++ b/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java @@ -1137,4 +1137,110 @@ class CronExpressionTests { assertThat(actual).isEqualTo(expected); assertThat(actual.getDayOfWeek()).isEqualTo(THURSDAY); } + + @Test + void quartzLastDayOfMonthEveryHour() { + CronExpression expression = CronExpression.parse("0 0 * L * *"); + + LocalDateTime last = LocalDateTime.of(2021, 1, 30, 0, 1); + LocalDateTime expected = LocalDateTime.of(2021, 1, 31, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + + last = LocalDateTime.of(2021, 1, 31, 1, 0); + expected = LocalDateTime.of(2021, 1, 31, 2, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + } + + @Test + void quartzLastDayOfMonthOffsetEveryHour() { + CronExpression expression = CronExpression.parse("0 0 * L-1 * *"); + + LocalDateTime last = LocalDateTime.of(2021, 1, 29, 0, 1); + LocalDateTime expected = LocalDateTime.of(2021, 1, 30, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + + last = LocalDateTime.of(2021, 1, 30, 1, 0); + expected = LocalDateTime.of(2021, 1, 30, 2, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + } + + @Test + void quartzFirstWeekdayOfMonthEveryHour() { + CronExpression expression = CronExpression.parse("0 0 * 1W * *"); + + LocalDateTime last = LocalDateTime.of(2021, 1, 31, 0, 1); + LocalDateTime expected = LocalDateTime.of(2021, 2, 1, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + + last = LocalDateTime.of(2021, 2, 1, 1, 0); + expected = LocalDateTime.of(2021, 2, 1, 2, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + } + + @Test + void quartzLastWeekdayOfMonthEveryHour() { + CronExpression expression = CronExpression.parse("0 0 * LW * *"); + + LocalDateTime last = LocalDateTime.of(2021, 1, 28, 0, 1); + LocalDateTime expected = LocalDateTime.of(2021, 1, 29, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + + last = LocalDateTime.of(2021, 1, 29, 1, 0); + expected = LocalDateTime.of(2021, 1, 29, 2, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + } + + @Test + void quartz5thFridayOfTheMonthEveryHour() { + CronExpression expression = CronExpression.parse("0 0 * ? * FRI#5"); + + LocalDateTime last = LocalDateTime.of(2021, 1, 28, 0, 1); + LocalDateTime expected = LocalDateTime.of(2021, 1, 29, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = LocalDateTime.of(2021, 1, 29, 1, 0); + expected = LocalDateTime.of(2021, 1, 29, 2, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + } + + @Test + void quartzLastFridayOfTheMonthEveryHour() { + CronExpression expression = CronExpression.parse("0 0 * ? * FRIL"); + + LocalDateTime last = LocalDateTime.of(2021, 1, 28, 0, 1); + LocalDateTime expected = LocalDateTime.of(2021, 1, 29, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = LocalDateTime.of(2021, 1, 29, 1, 0); + expected = LocalDateTime.of(2021, 1, 29, 2, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + } + + } diff --git a/spring-context/src/test/java/org/springframework/scheduling/support/QuartzCronFieldTests.java b/spring-context/src/test/java/org/springframework/scheduling/support/QuartzCronFieldTests.java index a1ea9d8112..ec56f366b5 100644 --- a/spring-context/src/test/java/org/springframework/scheduling/support/QuartzCronFieldTests.java +++ b/spring-context/src/test/java/org/springframework/scheduling/support/QuartzCronFieldTests.java @@ -98,6 +98,7 @@ class QuartzCronFieldTests { assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("L#1")); assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("8#1")); assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("2#1,2#3,2#5")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("FRI#-1")); } }