Skip to content

Commit 67112b2

Browse files
committed
Rollback to midnight in quartz expressions
This commit makes sure that the CronExpression rolls back the time to midnight when dealing with Quartz expression fields (such as "L", "LW", etc.). Closes gh-26390
1 parent 799885f commit 67112b2

File tree

3 files changed

+205
-25
lines changed

3 files changed

+205
-25
lines changed

spring-context/src/main/java/org/springframework/scheduling/support/QuartzCronField.java

Lines changed: 98 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -38,24 +38,6 @@
3838
*/
3939
final class QuartzCronField extends CronField {
4040

41-
/**
42-
* Temporal adjuster that returns the last weekday of the month.
43-
*/
44-
private static final TemporalAdjuster lastWeekdayOfMonth = temporal -> {
45-
Temporal lastDayOfMonth = TemporalAdjusters.lastDayOfMonth().adjustInto(temporal);
46-
int dayOfWeek = lastDayOfMonth.get(ChronoField.DAY_OF_WEEK);
47-
if (dayOfWeek == 6) { // Saturday
48-
return lastDayOfMonth.minus(1, ChronoUnit.DAYS);
49-
}
50-
else if (dayOfWeek == 7) { // Sunday
51-
return lastDayOfMonth.minus(2, ChronoUnit.DAYS);
52-
}
53-
else {
54-
return lastDayOfMonth;
55-
}
56-
};
57-
58-
5941
private final Type rollForwardType;
6042

6143
private final TemporalAdjuster adjuster;
@@ -97,11 +79,11 @@ public static QuartzCronField parseDaysOfMonth(String value) {
9779
throw new IllegalArgumentException("Unrecognized characters before 'L' in '" + value + "'");
9880
}
9981
else if (value.length() == 2 && value.charAt(1) == 'W') { // "LW"
100-
adjuster = lastWeekdayOfMonth;
82+
adjuster = lastWeekdayOfMonth();
10183
}
10284
else {
10385
if (value.length() == 1) { // "L"
104-
adjuster = TemporalAdjusters.lastDayOfMonth();
86+
adjuster = lastDayOfMonth();
10587
}
10688
else { // "L-[0-9]+"
10789
int offset = Integer.parseInt(value.substring(idx + 1));
@@ -155,7 +137,7 @@ public static QuartzCronField parseDaysOfWeek(String value) {
155137
}
156138
else { // "[0-7]L"
157139
DayOfWeek dayOfWeek = parseDayOfWeek(value.substring(0, idx));
158-
adjuster = TemporalAdjusters.lastInMonth(dayOfWeek);
140+
adjuster = lastInMonth(dayOfWeek);
159141
}
160142
return new QuartzCronField(Type.DAY_OF_WEEK, Type.DAY_OF_MONTH, adjuster, value);
161143
}
@@ -171,14 +153,17 @@ else if (idx == value.length() - 1) {
171153
// "[0-7]#[0-9]+"
172154
DayOfWeek dayOfWeek = parseDayOfWeek(value.substring(0, idx));
173155
int ordinal = Integer.parseInt(value.substring(idx + 1));
156+
if (ordinal <= 0) {
157+
throw new IllegalArgumentException("Ordinal '" + ordinal + "' in '" + value +
158+
"' must be positive number ");
159+
}
174160

175-
TemporalAdjuster adjuster = TemporalAdjusters.dayOfWeekInMonth(ordinal, dayOfWeek);
161+
TemporalAdjuster adjuster = dayOfWeekInMonth(ordinal, dayOfWeek);
176162
return new QuartzCronField(Type.DAY_OF_WEEK, Type.DAY_OF_MONTH, adjuster, value);
177163
}
178164
throw new IllegalArgumentException("No 'L' or '#' found in '" + value + "'");
179165
}
180166

181-
182167
private static DayOfWeek parseDayOfWeek(String value) {
183168
int dayOfWeek = Integer.parseInt(value);
184169
if (dayOfWeek == 0) {
@@ -193,16 +178,65 @@ private static DayOfWeek parseDayOfWeek(String value) {
193178
}
194179
}
195180

181+
/**
182+
* Returns an adjuster that resets to midnight.
183+
*/
184+
private static TemporalAdjuster atMidnight() {
185+
return temporal -> {
186+
if (temporal.isSupported(ChronoField.NANO_OF_DAY)) {
187+
return temporal.with(ChronoField.NANO_OF_DAY, 0);
188+
}
189+
else {
190+
return temporal;
191+
}
192+
};
193+
}
194+
195+
/**
196+
* Returns an adjuster that returns a new temporal set to the last
197+
* day of the current month at midnight.
198+
*/
199+
private static TemporalAdjuster lastDayOfMonth() {
200+
TemporalAdjuster adjuster = TemporalAdjusters.lastDayOfMonth();
201+
return temporal -> {
202+
Temporal result = adjuster.adjustInto(temporal);
203+
return rollbackToMidnight(temporal, result);
204+
};
205+
}
206+
207+
/**
208+
* Returns an adjuster that returns the last weekday of the month.
209+
*/
210+
private static TemporalAdjuster lastWeekdayOfMonth() {
211+
TemporalAdjuster adjuster = TemporalAdjusters.lastDayOfMonth();
212+
return temporal -> {
213+
Temporal lastDom = adjuster.adjustInto(temporal);
214+
Temporal result;
215+
int dow = lastDom.get(ChronoField.DAY_OF_WEEK);
216+
if (dow == 6) { // Saturday
217+
result = lastDom.minus(1, ChronoUnit.DAYS);
218+
}
219+
else if (dow == 7) { // Sunday
220+
result = lastDom.minus(2, ChronoUnit.DAYS);
221+
}
222+
else {
223+
result = lastDom;
224+
}
225+
return rollbackToMidnight(temporal, result);
226+
};
227+
}
228+
196229
/**
197230
* Return a temporal adjuster that finds the nth-to-last day of the month.
198231
* @param offset the negative offset, i.e. -3 means third-to-last
199232
* @return a nth-to-last day-of-month adjuster
200233
*/
201234
private static TemporalAdjuster lastDayWithOffset(int offset) {
202235
Assert.isTrue(offset < 0, "Offset should be < 0");
236+
TemporalAdjuster adjuster = TemporalAdjusters.lastDayOfMonth();
203237
return temporal -> {
204-
Temporal lastDayOfMonth = TemporalAdjusters.lastDayOfMonth().adjustInto(temporal);
205-
return lastDayOfMonth.plus(offset, ChronoUnit.DAYS);
238+
Temporal result = adjuster.adjustInto(temporal).plus(offset, ChronoUnit.DAYS);
239+
return rollbackToMidnight(temporal, result);
206240
};
207241
}
208242

@@ -228,6 +262,7 @@ private static TemporalAdjuster weekdayNearestTo(int dayOfMonth) {
228262
int count = 0;
229263
while (count++ < CronExpression.MAX_ATTEMPTS) {
230264
temporal = Type.DAY_OF_MONTH.elapseUntil(cast(temporal), dayOfMonth);
265+
temporal = atMidnight().adjustInto(temporal);
231266
current = Type.DAY_OF_MONTH.get(temporal);
232267
if (current == dayOfMonth) {
233268
dayOfWeek = temporal.get(ChronoField.DAY_OF_WEEK);
@@ -253,6 +288,44 @@ else if (dayOfWeek == 7) { // Sunday
253288
};
254289
}
255290

291+
/**
292+
* Return a temporal adjuster that finds the last of the given doy-of-week
293+
* in a month.
294+
*/
295+
private static TemporalAdjuster lastInMonth(DayOfWeek dayOfWeek) {
296+
TemporalAdjuster adjuster = TemporalAdjusters.lastInMonth(dayOfWeek);
297+
return temporal -> {
298+
Temporal result = adjuster.adjustInto(temporal);
299+
return rollbackToMidnight(temporal, result);
300+
};
301+
}
302+
303+
/**
304+
* Returns a temporal adjuster that finds {@code ordinal}-th occurrence of
305+
* the given day-of-week in a month.
306+
*/
307+
private static TemporalAdjuster dayOfWeekInMonth(int ordinal, DayOfWeek dayOfWeek) {
308+
TemporalAdjuster adjuster = TemporalAdjusters.dayOfWeekInMonth(ordinal, dayOfWeek);
309+
return temporal -> {
310+
Temporal result = adjuster.adjustInto(temporal);
311+
return rollbackToMidnight(temporal, result);
312+
};
313+
}
314+
315+
/**
316+
* Rolls back the given {@code result} to midnight. When
317+
* {@code current} has the same day of month as {@code result}, the former
318+
* is returned, to make sure that we don't end up before where we started.
319+
*/
320+
private static Temporal rollbackToMidnight(Temporal current, Temporal result) {
321+
if (result.get(ChronoField.DAY_OF_MONTH) == current.get(ChronoField.DAY_OF_MONTH)) {
322+
return current;
323+
}
324+
else {
325+
return atMidnight().adjustInto(result);
326+
}
327+
}
328+
256329
@SuppressWarnings("unchecked")
257330
private static <T extends Temporal & Comparable<? super T>> T cast(Temporal temporal) {
258331
return (T) temporal;

spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,4 +1137,110 @@ void dayOfWeekListWithQuartz() {
11371137
assertThat(actual).isEqualTo(expected);
11381138
assertThat(actual.getDayOfWeek()).isEqualTo(THURSDAY);
11391139
}
1140+
1141+
@Test
1142+
void quartzLastDayOfMonthEveryHour() {
1143+
CronExpression expression = CronExpression.parse("0 0 * L * *");
1144+
1145+
LocalDateTime last = LocalDateTime.of(2021, 1, 30, 0, 1);
1146+
LocalDateTime expected = LocalDateTime.of(2021, 1, 31, 0, 0);
1147+
LocalDateTime actual = expression.next(last);
1148+
assertThat(actual).isNotNull();
1149+
assertThat(actual).isEqualTo(expected);
1150+
1151+
last = LocalDateTime.of(2021, 1, 31, 1, 0);
1152+
expected = LocalDateTime.of(2021, 1, 31, 2, 0);
1153+
actual = expression.next(last);
1154+
assertThat(actual).isNotNull();
1155+
assertThat(actual).isEqualTo(expected);
1156+
}
1157+
1158+
@Test
1159+
void quartzLastDayOfMonthOffsetEveryHour() {
1160+
CronExpression expression = CronExpression.parse("0 0 * L-1 * *");
1161+
1162+
LocalDateTime last = LocalDateTime.of(2021, 1, 29, 0, 1);
1163+
LocalDateTime expected = LocalDateTime.of(2021, 1, 30, 0, 0);
1164+
LocalDateTime actual = expression.next(last);
1165+
assertThat(actual).isNotNull();
1166+
assertThat(actual).isEqualTo(expected);
1167+
1168+
last = LocalDateTime.of(2021, 1, 30, 1, 0);
1169+
expected = LocalDateTime.of(2021, 1, 30, 2, 0);
1170+
actual = expression.next(last);
1171+
assertThat(actual).isNotNull();
1172+
assertThat(actual).isEqualTo(expected);
1173+
}
1174+
1175+
@Test
1176+
void quartzFirstWeekdayOfMonthEveryHour() {
1177+
CronExpression expression = CronExpression.parse("0 0 * 1W * *");
1178+
1179+
LocalDateTime last = LocalDateTime.of(2021, 1, 31, 0, 1);
1180+
LocalDateTime expected = LocalDateTime.of(2021, 2, 1, 0, 0);
1181+
LocalDateTime actual = expression.next(last);
1182+
assertThat(actual).isNotNull();
1183+
assertThat(actual).isEqualTo(expected);
1184+
1185+
last = LocalDateTime.of(2021, 2, 1, 1, 0);
1186+
expected = LocalDateTime.of(2021, 2, 1, 2, 0);
1187+
actual = expression.next(last);
1188+
assertThat(actual).isNotNull();
1189+
assertThat(actual).isEqualTo(expected);
1190+
}
1191+
1192+
@Test
1193+
void quartzLastWeekdayOfMonthEveryHour() {
1194+
CronExpression expression = CronExpression.parse("0 0 * LW * *");
1195+
1196+
LocalDateTime last = LocalDateTime.of(2021, 1, 28, 0, 1);
1197+
LocalDateTime expected = LocalDateTime.of(2021, 1, 29, 0, 0);
1198+
LocalDateTime actual = expression.next(last);
1199+
assertThat(actual).isNotNull();
1200+
assertThat(actual).isEqualTo(expected);
1201+
1202+
last = LocalDateTime.of(2021, 1, 29, 1, 0);
1203+
expected = LocalDateTime.of(2021, 1, 29, 2, 0);
1204+
actual = expression.next(last);
1205+
assertThat(actual).isNotNull();
1206+
assertThat(actual).isEqualTo(expected);
1207+
}
1208+
1209+
@Test
1210+
void quartz5thFridayOfTheMonthEveryHour() {
1211+
CronExpression expression = CronExpression.parse("0 0 * ? * FRI#5");
1212+
1213+
LocalDateTime last = LocalDateTime.of(2021, 1, 28, 0, 1);
1214+
LocalDateTime expected = LocalDateTime.of(2021, 1, 29, 0, 0);
1215+
LocalDateTime actual = expression.next(last);
1216+
assertThat(actual).isNotNull();
1217+
assertThat(actual).isEqualTo(expected);
1218+
assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY);
1219+
1220+
last = LocalDateTime.of(2021, 1, 29, 1, 0);
1221+
expected = LocalDateTime.of(2021, 1, 29, 2, 0);
1222+
actual = expression.next(last);
1223+
assertThat(actual).isNotNull();
1224+
assertThat(actual).isEqualTo(expected);
1225+
}
1226+
1227+
@Test
1228+
void quartzLastFridayOfTheMonthEveryHour() {
1229+
CronExpression expression = CronExpression.parse("0 0 * ? * FRIL");
1230+
1231+
LocalDateTime last = LocalDateTime.of(2021, 1, 28, 0, 1);
1232+
LocalDateTime expected = LocalDateTime.of(2021, 1, 29, 0, 0);
1233+
LocalDateTime actual = expression.next(last);
1234+
assertThat(actual).isNotNull();
1235+
assertThat(actual).isEqualTo(expected);
1236+
assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY);
1237+
1238+
last = LocalDateTime.of(2021, 1, 29, 1, 0);
1239+
expected = LocalDateTime.of(2021, 1, 29, 2, 0);
1240+
actual = expression.next(last);
1241+
assertThat(actual).isNotNull();
1242+
assertThat(actual).isEqualTo(expected);
1243+
}
1244+
1245+
11401246
}

spring-context/src/test/java/org/springframework/scheduling/support/QuartzCronFieldTests.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ void invalidValues() {
9898
assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("L#1"));
9999
assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("8#1"));
100100
assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("2#1,2#3,2#5"));
101+
assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("FRI#-1"));
101102
}
102103

103104
}

0 commit comments

Comments
 (0)