Skip to content

Commit 4910c21

Browse files
committed
Explicit documentation note on cron-vs-quartz parsing convention
Closes gh-32128 (cherry picked from commit a738e4d)
1 parent f262046 commit 4910c21

File tree

6 files changed

+108
-49
lines changed

6 files changed

+108
-49
lines changed

Diff for: spring-context/src/main/java/org/springframework/scheduling/support/CronExpression.java

+8-3
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,14 @@
2929
* <a href="https://www.manpagez.com/man/5/crontab/">crontab expression</a>
3030
* that can calculate the next time it matches.
3131
*
32-
* <p>{@code CronExpression} instances are created through
33-
* {@link #parse(String)}; the next match is determined with
34-
* {@link #next(Temporal)}.
32+
* <p>{@code CronExpression} instances are created through {@link #parse(String)};
33+
* the next match is determined with {@link #next(Temporal)}.
34+
*
35+
* <p>Supports a Quartz day-of-month/week field with an L/# expression. Follows
36+
* common cron conventions in every other respect, including 0-6 for SUN-SAT
37+
* (plus 7 for SUN as well). Note that Quartz deviates from the day-of-week
38+
* convention in cron through 1-7 for SUN-SAT whereas Spring strictly follows
39+
* cron even in combination with the optional Quartz-specific L/# expressions.
3540
*
3641
* @author Arjen Poutsma
3742
* @since 5.3

Diff for: spring-context/src/main/java/org/springframework/scheduling/support/CronField.java

+13-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -31,15 +31,22 @@
3131
* Single field in a cron pattern. Created using the {@code parse*} methods,
3232
* main and only entry point is {@link #nextOrSame(Temporal)}.
3333
*
34+
* <p>Supports a Quartz day-of-month/week field with an L/# expression. Follows
35+
* common cron conventions in every other respect, including 0-6 for SUN-SAT
36+
* (plus 7 for SUN as well). Note that Quartz deviates from the day-of-week
37+
* convention in cron through 1-7 for SUN-SAT whereas Spring strictly follows
38+
* cron even in combination with the optional Quartz-specific L/# expressions.
39+
*
3440
* @author Arjen Poutsma
3541
* @since 5.3
3642
*/
3743
abstract class CronField {
3844

39-
private static final String[] MONTHS = new String[]{"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP",
40-
"OCT", "NOV", "DEC"};
45+
private static final String[] MONTHS = new String[]
46+
{"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"};
4147

42-
private static final String[] DAYS = new String[]{"MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"};
48+
private static final String[] DAYS = new String[]
49+
{"MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"};
4350

4451
private final Type type;
4552

@@ -48,6 +55,7 @@ protected CronField(Type type) {
4855
this.type = type;
4956
}
5057

58+
5159
/**
5260
* Return a {@code CronField} enabled for 0 nanoseconds.
5361
*/
@@ -169,6 +177,7 @@ protected static <T extends Temporal & Comparable<? super T>> T cast(Temporal te
169177
* day-of-month, month, day-of-week.
170178
*/
171179
protected enum Type {
180+
172181
NANO(ChronoField.NANO_OF_SECOND, ChronoUnit.SECONDS),
173182
SECOND(ChronoField.SECOND_OF_MINUTE, ChronoUnit.MINUTES, ChronoField.NANO_OF_SECOND),
174183
MINUTE(ChronoField.MINUTE_OF_HOUR, ChronoUnit.HOURS, ChronoField.SECOND_OF_MINUTE, ChronoField.NANO_OF_SECOND),
@@ -184,14 +193,12 @@ protected enum Type {
184193

185194
private final ChronoField[] lowerOrders;
186195

187-
188196
Type(ChronoField field, ChronoUnit higherOrder, ChronoField... lowerOrders) {
189197
this.field = field;
190198
this.higherOrder = higherOrder;
191199
this.lowerOrders = lowerOrders;
192200
}
193201

194-
195202
/**
196203
* Return the value of this type for the given temporal.
197204
* @return the value of this type

Diff for: spring-context/src/main/java/org/springframework/scheduling/support/CronTrigger.java

+8-2
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,14 @@
2727
import org.springframework.util.Assert;
2828

2929
/**
30-
* {@link Trigger} implementation for cron expressions.
31-
* Wraps a {@link CronExpression}.
30+
* {@link Trigger} implementation for cron expressions. Wraps a
31+
* {@link CronExpression} which parses according to common crontab conventions.
32+
*
33+
* <p>Supports a Quartz day-of-month/week field with an L/# expression. Follows
34+
* common cron conventions in every other respect, including 0-6 for SUN-SAT
35+
* (plus 7 for SUN as well). Note that Quartz deviates from the day-of-week
36+
* convention in cron through 1-7 for SUN-SAT whereas Spring strictly follows
37+
* cron even in combination with the optional Quartz-specific L/# expressions.
3238
*
3339
* @author Juergen Hoeller
3440
* @author Arjen Poutsma

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

+29-32
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2023 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -30,10 +30,15 @@
3030
/**
3131
* Extension of {@link CronField} for
3232
* <a href="https://www.quartz-scheduler.org">Quartz</a>-specific fields.
33-
*
34-
* <p>Created using the {@code parse*} methods, uses a {@link TemporalAdjuster}
33+
* Created using the {@code parse*} methods, uses a {@link TemporalAdjuster}
3534
* internally.
3635
*
36+
* <p>Supports a Quartz day-of-month/week field with an L/# expression. Follows
37+
* common cron conventions in every other respect, including 0-6 for SUN-SAT
38+
* (plus 7 for SUN as well). Note that Quartz deviates from the day-of-week
39+
* convention in cron through 1-7 for SUN-SAT whereas Spring strictly follows
40+
* cron even in combination with the optional Quartz-specific L/# expressions.
41+
*
3742
* @author Arjen Poutsma
3843
* @since 5.3
3944
*/
@@ -61,8 +66,9 @@ private QuartzCronField(Type type, Type rollForwardType, TemporalAdjuster adjust
6166
this.rollForwardType = rollForwardType;
6267
}
6368

69+
6470
/**
65-
* Returns whether the given value is a Quartz day-of-month field.
71+
* Determine whether the given value is a Quartz day-of-month field.
6672
*/
6773
public static boolean isQuartzDaysOfMonthField(String value) {
6874
return value.contains("L") || value.contains("W");
@@ -80,14 +86,14 @@ public static QuartzCronField parseDaysOfMonth(String value) {
8086
if (idx != 0) {
8187
throw new IllegalArgumentException("Unrecognized characters before 'L' in '" + value + "'");
8288
}
83-
else if (value.length() == 2 && value.charAt(1) == 'W') { // "LW"
89+
else if (value.length() == 2 && value.charAt(1) == 'W') { // "LW"
8490
adjuster = lastWeekdayOfMonth();
8591
}
8692
else {
87-
if (value.length() == 1) { // "L"
93+
if (value.length() == 1) { // "L"
8894
adjuster = lastDayOfMonth();
8995
}
90-
else { // "L-[0-9]+"
96+
else { // "L-[0-9]+"
9197
int offset = Integer.parseInt(value, idx + 1, value.length(), 10);
9298
if (offset >= 0) {
9399
throw new IllegalArgumentException("Offset '" + offset + " should be < 0 '" + value + "'");
@@ -105,7 +111,7 @@ else if (value.length() == 2 && value.charAt(1) == 'W') { // "LW"
105111
else if (idx != value.length() - 1) {
106112
throw new IllegalArgumentException("Unrecognized characters after 'W' in '" + value + "'");
107113
}
108-
else { // "[0-9]+W"
114+
else { // "[0-9]+W"
109115
int dayOfMonth = Integer.parseInt(value, 0, idx, 10);
110116
dayOfMonth = Type.DAY_OF_MONTH.checkValidValue(dayOfMonth);
111117
TemporalAdjuster adjuster = weekdayNearestTo(dayOfMonth);
@@ -116,7 +122,7 @@ else if (idx != value.length() - 1) {
116122
}
117123

118124
/**
119-
* Returns whether the given value is a Quartz day-of-week field.
125+
* Determine whether the given value is a Quartz day-of-week field.
120126
*/
121127
public static boolean isQuartzDaysOfWeekField(String value) {
122128
return value.contains("L") || value.contains("#");
@@ -138,7 +144,7 @@ public static QuartzCronField parseDaysOfWeek(String value) {
138144
if (idx == 0) {
139145
throw new IllegalArgumentException("No day-of-week before 'L' in '" + value + "'");
140146
}
141-
else { // "[0-7]L"
147+
else { // "[0-7]L"
142148
DayOfWeek dayOfWeek = parseDayOfWeek(value.substring(0, idx));
143149
adjuster = lastInMonth(dayOfWeek);
144150
}
@@ -160,7 +166,6 @@ else if (idx == value.length() - 1) {
160166
throw new IllegalArgumentException("Ordinal '" + ordinal + "' in '" + value +
161167
"' must be positive number ");
162168
}
163-
164169
TemporalAdjuster adjuster = dayOfWeekInMonth(ordinal, dayOfWeek);
165170
return new QuartzCronField(Type.DAY_OF_WEEK, Type.DAY_OF_MONTH, adjuster, value);
166171
}
@@ -170,14 +175,13 @@ else if (idx == value.length() - 1) {
170175
private static DayOfWeek parseDayOfWeek(String value) {
171176
int dayOfWeek = Integer.parseInt(value);
172177
if (dayOfWeek == 0) {
173-
dayOfWeek = 7; // cron is 0 based; java.time 1 based
178+
dayOfWeek = 7; // cron is 0 based; java.time 1 based
174179
}
175180
try {
176181
return DayOfWeek.of(dayOfWeek);
177182
}
178183
catch (DateTimeException ex) {
179-
String msg = ex.getMessage() + " '" + value + "'";
180-
throw new IllegalArgumentException(msg, ex);
184+
throw new IllegalArgumentException(ex.getMessage() + " '" + value + "'", ex);
181185
}
182186
}
183187

@@ -216,10 +220,10 @@ private static TemporalAdjuster lastWeekdayOfMonth() {
216220
Temporal lastDom = adjuster.adjustInto(temporal);
217221
Temporal result;
218222
int dow = lastDom.get(ChronoField.DAY_OF_WEEK);
219-
if (dow == 6) { // Saturday
223+
if (dow == 6) { // Saturday
220224
result = lastDom.minus(1, ChronoUnit.DAYS);
221225
}
222-
else if (dow == 7) { // Sunday
226+
else if (dow == 7) { // Sunday
223227
result = lastDom.minus(2, ChronoUnit.DAYS);
224228
}
225229
else {
@@ -256,10 +260,10 @@ private static TemporalAdjuster weekdayNearestTo(int dayOfMonth) {
256260
int current = Type.DAY_OF_MONTH.get(temporal);
257261
DayOfWeek dayOfWeek = DayOfWeek.from(temporal);
258262

259-
if ((current == dayOfMonth && isWeekday(dayOfWeek)) || // dayOfMonth is a weekday
260-
(dayOfWeek == DayOfWeek.FRIDAY && current == dayOfMonth - 1) || // dayOfMonth is a Saturday, so Friday before
261-
(dayOfWeek == DayOfWeek.MONDAY && current == dayOfMonth + 1) || // dayOfMonth is a Sunday, so Monday after
262-
(dayOfWeek == DayOfWeek.MONDAY && dayOfMonth == 1 && current == 3)) { // dayOfMonth is Saturday 1st, so Monday 3rd
263+
if ((current == dayOfMonth && isWeekday(dayOfWeek)) || // dayOfMonth is a weekday
264+
(dayOfWeek == DayOfWeek.FRIDAY && current == dayOfMonth - 1) || // dayOfMonth is a Saturday, so Friday before
265+
(dayOfWeek == DayOfWeek.MONDAY && current == dayOfMonth + 1) || // dayOfMonth is a Sunday, so Monday after
266+
(dayOfWeek == DayOfWeek.MONDAY && dayOfMonth == 1 && current == 3)) { // dayOfMonth is Saturday 1st, so Monday 3rd
263267
return temporal;
264268
}
265269
int count = 0;
@@ -357,26 +361,19 @@ private <T extends Temporal & Comparable<? super T>> T adjust(T temporal) {
357361

358362

359363
@Override
360-
public int hashCode() {
361-
return this.value.hashCode();
364+
public boolean equals(@Nullable Object other) {
365+
return (this == other || (other instanceof QuartzCronField that &&
366+
type() == that.type() && this.value.equals(that.value)));
362367
}
363368

364369
@Override
365-
public boolean equals(@Nullable Object o) {
366-
if (this == o) {
367-
return true;
368-
}
369-
if (!(o instanceof QuartzCronField other)) {
370-
return false;
371-
}
372-
return type() == other.type() &&
373-
this.value.equals(other.value);
370+
public int hashCode() {
371+
return this.value.hashCode();
374372
}
375373

376374
@Override
377375
public String toString() {
378376
return type() + " '" + this.value + "'";
379-
380377
}
381378

382379
}

Diff for: spring-context/src/test/java/org/springframework/scheduling/support/BitsCronFieldTests.java

+13-6
Original file line numberDiff line numberDiff line change
@@ -35,27 +35,33 @@ class BitsCronFieldTests {
3535
@Test
3636
void parse() {
3737
assertThat(BitsCronField.parseSeconds("42")).has(clearRange(0, 41)).has(set(42)).has(clearRange(43, 59));
38-
assertThat(BitsCronField.parseSeconds("0-4,8-12")).has(setRange(0, 4)).has(clearRange(5,7)).has(setRange(8, 12)).has(clearRange(13,59));
39-
assertThat(BitsCronField.parseSeconds("57/2")).has(clearRange(0, 56)).has(set(57)).has(clear(58)).has(set(59));
38+
assertThat(BitsCronField.parseSeconds("0-4,8-12")).has(setRange(0, 4)).has(clearRange(5,7))
39+
.has(setRange(8, 12)).has(clearRange(13,59));
40+
assertThat(BitsCronField.parseSeconds("57/2")).has(clearRange(0, 56)).has(set(57))
41+
.has(clear(58)).has(set(59));
4042

4143
assertThat(BitsCronField.parseMinutes("30")).has(set(30)).has(clearRange(1, 29)).has(clearRange(31, 59));
4244

4345
assertThat(BitsCronField.parseHours("23")).has(set(23)).has(clearRange(0, 23));
44-
assertThat(BitsCronField.parseHours("0-23/2")).has(set(0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22)).has(clear(1,3,5,7,9,11,13,15,17,19,21,23));
46+
assertThat(BitsCronField.parseHours("0-23/2")).has(set(0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22))
47+
.has(clear(1,3,5,7,9,11,13,15,17,19,21,23));
4548

4649
assertThat(BitsCronField.parseDaysOfMonth("1")).has(set(1)).has(clearRange(2, 31));
4750

4851
assertThat(BitsCronField.parseMonth("1")).has(set(1)).has(clearRange(2, 12));
4952

5053
assertThat(BitsCronField.parseDaysOfWeek("0")).has(set(7, 7)).has(clearRange(0, 6));
5154

52-
assertThat(BitsCronField.parseDaysOfWeek("7-5")).has(clear(0)).has(setRange(1, 5)).has(clear(6)).has(set(7));
55+
assertThat(BitsCronField.parseDaysOfWeek("7-5")).has(clear(0)).has(setRange(1, 5))
56+
.has(clear(6)).has(set(7));
5357
}
5458

5559
@Test
5660
void parseLists() {
57-
assertThat(BitsCronField.parseSeconds("15,30")).has(set(15, 30)).has(clearRange(1, 15)).has(clearRange(31, 59));
58-
assertThat(BitsCronField.parseMinutes("1,2,5,9")).has(set(1, 2, 5, 9)).has(clear(0)).has(clearRange(3, 4)).has(clearRange(6, 8)).has(clearRange(10, 59));
61+
assertThat(BitsCronField.parseSeconds("15,30")).has(set(15, 30)).has(clearRange(1, 15))
62+
.has(clearRange(31, 59));
63+
assertThat(BitsCronField.parseMinutes("1,2,5,9")).has(set(1, 2, 5, 9)).has(clear(0))
64+
.has(clearRange(3, 4)).has(clearRange(6, 8)).has(clearRange(10, 59));
5965
assertThat(BitsCronField.parseHours("1,2,3")).has(set(1, 2, 3)).has(clearRange(4, 23));
6066
assertThat(BitsCronField.parseDaysOfMonth("1,2,3")).has(set(1, 2, 3)).has(clearRange(4, 31));
6167
assertThat(BitsCronField.parseMonth("1,2,3")).has(set(1, 2, 3)).has(clearRange(4, 12));
@@ -107,6 +113,7 @@ void names() {
107113
.has(clear(0)).has(setRange(1, 7));
108114
}
109115

116+
110117
private static Condition<BitsCronField> set(int... indices) {
111118
return new Condition<>(String.format("set bits %s", Arrays.toString(indices))) {
112119
@Override

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

+37
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
* Unit tests for {@link QuartzCronField}.
2929
*
3030
* @author Arjen Poutsma
31+
* @author Juergen Hoeller
3132
*/
3233
class QuartzCronFieldTests {
3334

@@ -71,6 +72,42 @@ void lastDayOfWeekOffset() {
7172
assertThat(field.nextOrSame(last)).isEqualTo(expected);
7273
}
7374

75+
@Test
76+
void dayOfWeek_0(){
77+
QuartzCronField field = QuartzCronField.parseDaysOfWeek("0#3");
78+
79+
LocalDate last = LocalDate.of(2024, 1, 1);
80+
LocalDate expected = LocalDate.of(2024, 1, 21);
81+
assertThat(field.nextOrSame(last)).isEqualTo(expected);
82+
}
83+
84+
@Test
85+
void dayOfWeek_1(){
86+
QuartzCronField field = QuartzCronField.parseDaysOfWeek("1#3");
87+
88+
LocalDate last = LocalDate.of(2024, 1, 1);
89+
LocalDate expected = LocalDate.of(2024, 1, 15);
90+
assertThat(field.nextOrSame(last)).isEqualTo(expected);
91+
}
92+
93+
@Test
94+
void dayOfWeek_2(){
95+
QuartzCronField field = QuartzCronField.parseDaysOfWeek("2#3");
96+
97+
LocalDate last = LocalDate.of(2024, 1, 1);
98+
LocalDate expected = LocalDate.of(2024, 1, 16);
99+
assertThat(field.nextOrSame(last)).isEqualTo(expected);
100+
}
101+
102+
@Test
103+
void dayOfWeek_7() {
104+
QuartzCronField field = QuartzCronField.parseDaysOfWeek("7#3");
105+
106+
LocalDate last = LocalDate.of(2024, 1, 1);
107+
LocalDate expected = LocalDate.of(2024, 1, 21);
108+
assertThat(field.nextOrSame(last)).isEqualTo(expected);
109+
}
110+
74111
@Test
75112
void invalidValues() {
76113
assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfMonth(""));

0 commit comments

Comments
 (0)