diff --git a/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java b/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java new file mode 100644 index 00000000000..5862145c8fd --- /dev/null +++ b/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java @@ -0,0 +1,235 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.scheduling.support; + +import java.time.DateTimeException; +import java.time.temporal.Temporal; +import java.time.temporal.ValueRange; +import java.util.BitSet; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Efficient {@link BitSet}-based extension of {@link CronField}. + * Created using the {@code parse*} methods. + * + * @author Arjen Poutsma + * @since 5.3 + */ +final class BitsCronField extends CronField { + + private static final BitsCronField ZERO_NANOS; + + + static { + ZERO_NANOS = new BitsCronField(Type.NANO); + ZERO_NANOS.bits.set(0); + } + + private final BitSet bits; + + + + private BitsCronField(Type type) { + super(type); + this.bits = new BitSet((int) type.range().getMaximum()); + } + + /** + * Return a {@code BitsCronField} enabled for 0 nano seconds. + */ + public static BitsCronField zeroNanos() { + return BitsCronField.ZERO_NANOS; + } + + /** + * Parse the given value into a seconds {@code BitsCronField}, the first entry of a cron expression. + */ + public static BitsCronField parseSeconds(String value) { + return parseField(value, Type.SECOND); + } + + /** + * Parse the given value into a minutes {@code BitsCronField}, the second entry of a cron expression. + */ + public static BitsCronField parseMinutes(String value) { + return BitsCronField.parseField(value, Type.MINUTE); + } + + /** + * Parse the given value into a hours {@code BitsCronField}, the third entry of a cron expression. + */ + public static BitsCronField parseHours(String value) { + return BitsCronField.parseField(value, Type.HOUR); + } + + /** + * Parse the given value into a days of months {@code BitsCronField}, the fourth entry of a cron expression. + */ + public static BitsCronField parseDaysOfMonth(String value) { + return parseDate(value, Type.DAY_OF_MONTH); + } + + /** + * Parse the given value into a month {@code BitsCronField}, the fifth entry of a cron expression. + */ + public static BitsCronField parseMonth(String value) { + return BitsCronField.parseField(value, Type.MONTH); + } + + /** + * Parse the given value into a days of week {@code BitsCronField}, the sixth entry of a cron expression. + */ + public static BitsCronField parseDaysOfWeek(String value) { + BitsCronField result = parseDate(value, Type.DAY_OF_WEEK); + BitSet bits = result.bits; + if (bits.get(0)) { + // cron supports 0 for Sunday; we use 7 like java.time + bits.set(7); + bits.clear(0); + } + return result; + } + + + private static BitsCronField parseDate(String value, BitsCronField.Type type) { + if (value.indexOf('?') != -1) { + value = "*"; + } + return BitsCronField.parseField(value, type); + } + + private static BitsCronField parseField(String value, Type type) { + Assert.hasLength(value, "Value must not be empty"); + Assert.notNull(type, "Type must not be null"); + try { + BitsCronField result = new BitsCronField(type); + String[] fields = StringUtils.delimitedListToStringArray(value, ","); + for (String field : fields) { + int slashPos = field.indexOf('/'); + if (slashPos == -1) { + ValueRange range = parseRange(field, type); + result.setBits(range); + } + else { + String rangeStr = value.substring(0, slashPos); + String deltaStr = value.substring(slashPos + 1); + ValueRange range = parseRange(rangeStr, type); + if (rangeStr.indexOf('-') == -1) { + range = ValueRange.of(range.getMinimum(), type.range().getMaximum()); + } + int delta = Integer.parseInt(deltaStr); + if (delta <= 0) { + throw new IllegalArgumentException("Incrementer delta must be 1 or higher"); + } + result.setBits(range, delta); + } + } + return result; + } + catch (DateTimeException | IllegalArgumentException ex) { + String msg = ex.getMessage() + " '" + value + "'"; + throw new IllegalArgumentException(msg, ex); + } + } + + private static ValueRange parseRange(String value, Type type) { + if (value.indexOf('*') != -1) { + return type.range(); + } + else { + int hyphenPos = value.indexOf('-'); + if (hyphenPos == -1) { + int result = type.checkValidValue(Integer.parseInt(value)); + return ValueRange.of(result, result); + } + else { + int min = Integer.parseInt(value.substring(0, hyphenPos)); + int max = Integer.parseInt(value.substring(hyphenPos + 1)); + min = type.checkValidValue(min); + max = type.checkValidValue(max); + return ValueRange.of(min, max); + } + } + } + + @Nullable + @Override + public > T nextOrSame(T temporal) { + int current = type().get(temporal); + int next = this.bits.nextSetBit(current); + if (next == -1) { + temporal = type().rollForward(temporal); + next = this.bits.nextSetBit(0); + } + if (next == current) { + return temporal; + } + else { + int count = 0; + current = type().get(temporal); + while (current != next && count++ < CronExpression.MAX_ATTEMPTS) { + temporal = type().elapseUntil(temporal, next); + current = type().get(temporal); + } + if (count >= CronExpression.MAX_ATTEMPTS) { + return null; + } + return type().reset(temporal); + } + } + + BitSet bits() { + return this.bits; + } + + private void setBits(ValueRange range) { + this.bits.set((int) range.getMinimum(), (int) range.getMaximum() + 1); + } + + private void setBits(ValueRange range, int delta) { + for (int i = (int) range.getMinimum(); i <= range.getMaximum(); i += delta) { + this.bits.set(i); + } + } + + @Override + public int hashCode() { + return this.bits.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof BitsCronField)) { + return false; + } + BitsCronField other = (BitsCronField) o; + return type() == other.type() && + this.bits.equals(other.bits); + } + + @Override + public String toString() { + return type() + " " + this.bits; + } + +} diff --git a/spring-context/src/main/java/org/springframework/scheduling/support/CronExpression.java b/spring-context/src/main/java/org/springframework/scheduling/support/CronExpression.java index ff0a8732d07..e3bf57b27ee 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/support/CronExpression.java +++ b/spring-context/src/main/java/org/springframework/scheduling/support/CronExpression.java @@ -67,7 +67,7 @@ public final class CronExpression { String expression) { // to make sure we end up at 0 nanos, we add an extra field - this.fields = new CronField[]{daysOfWeek, months, daysOfMonth, hours, minutes, seconds, CronField.zeroNanos()}; + this.fields = new CronField[]{CronField.zeroNanos(), seconds, minutes, hours, daysOfMonth, months, daysOfWeek}; this.expression = expression; } @@ -100,14 +100,49 @@ public final class CronExpression { * Ranges of numbers are expressed by two numbers separated with a hyphen * ({@code -}). The specified range is inclusive. * - *
  • Following a range (or {@code *}) with {@code "/n"} specifies - * skips of the number's value through the range. + *
  • Following a range (or {@code *}) with {@code /n} specifies + * the interval of the number's value through the range. *
  • *
  • * English names can also be used for the "month" and "day of week" fields. * Use the first three letters of the particular day or month (case does not * matter). *
  • + *
  • + * The "day of month" and "day of week" fields can contain a + * {@code L}-character, which stands for "last", and has a different meaning + * in each field: + * + *
  • + *
  • + * The "day of month" field can be {@code nW}, which stands for "the nearest + * weekday to day of the month {@code n}". + * If {@code n} falls on Saturday, this yields the Friday before it. + * If {@code n} falls on Sunday, this yields the Monday after, + * which also happens if {@code n} is {@code 1} and falls on a Saturday + * (i.e. {@code 1W} stands for "the first weekday of the month"). + *
  • + *
  • + * The "day of week" field can be {@code d#n} (or {@code DDD#n}), which + * stands for "the {@code n}-th day of week {@code d} (or {@code DDD}) in + * the month". + *
  • * * *

    Example expressions: @@ -119,6 +154,15 @@ public final class CronExpression { *

  • {@code "0 0/30 8-10 * * *"} = 8:00, 8:30, 9:00, 9:30, 10:00 and 10:30 every day.
  • *
  • {@code "0 0 9-17 * * MON-FRI"} = on the hour nine-to-five weekdays
  • *
  • {@code "0 0 0 25 12 ?"} = every Christmas Day at midnight
  • + *
  • {@code "0 0 0 L * *"} = last day of the month at midnight
  • + *
  • {@code "0 0 0 L-3 * *"} = third-to-last day of the month at midnight
  • + *
  • {@code "0 0 0 1W * *"} = first weekday of the month at midnight
  • + *
  • {@code "0 0 0 LW * *"} = last weekday of the month at midnight
  • + *
  • {@code "0 0 0 * * L"} = last day of the week at midnight
  • + *
  • {@code "0 0 0 * * 5L"} = last Friday of the month at midnight
  • + *
  • {@code "0 0 0 * * THUL"} = last Thursday of the month at midnight
  • + *
  • {@code "0 0 0 ? * 5#2"} = the second Friday in the month at midnight
  • + *
  • {@code "0 0 0 ? * MON#1"} = the first Monday in the month at midnight
  • * * *

    The following macros are also supported: @@ -181,13 +225,13 @@ public final class CronExpression { * if no such temporal can be found */ @Nullable - public T next(T temporal) { + public > T next(T temporal) { return nextOrSame(ChronoUnit.NANOS.addTo(temporal, 1)); } @Nullable - private T nextOrSame(T temporal) { + private > T nextOrSame(T temporal) { for (int i = 0; i < MAX_ATTEMPTS; i++) { T result = nextOrSameInternal(temporal); if (result == null || result.equals(temporal)) { @@ -199,7 +243,7 @@ public final class CronExpression { } @Nullable - private T nextOrSameInternal(T temporal) { + private > T nextOrSameInternal(T temporal) { for (CronField field : this.fields) { temporal = field.nextOrSame(temporal); if (temporal == null) { diff --git a/spring-context/src/main/java/org/springframework/scheduling/support/CronField.java b/spring-context/src/main/java/org/springframework/scheduling/support/CronField.java index 866e507bb8f..6076cd8773d 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/support/CronField.java +++ b/spring-context/src/main/java/org/springframework/scheduling/support/CronField.java @@ -20,79 +20,69 @@ import java.time.DateTimeException; import java.time.temporal.ChronoField; import java.time.temporal.Temporal; import java.time.temporal.ValueRange; -import java.util.BitSet; import org.springframework.lang.Nullable; -import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** - * A single field in a cron pattern. Created using the {@code parse*} methods, + * Single field in a cron pattern. Created using the {@code parse*} methods, * main and only entry point is {@link #nextOrSame(Temporal)}. * * @author Arjen Poutsma * @since 5.3 */ -final class CronField { +abstract class CronField { private static final String[] MONTHS = new String[]{"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"}; private static final String[] DAYS = new String[]{"MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"}; - private static final CronField ZERO_NANOS; - - - static { - ZERO_NANOS = new CronField(Type.NANO); - ZERO_NANOS.bits.set(0); - } - - private final Type type; - private final BitSet bits; - - private CronField(Type type) { + protected CronField(Type type) { this.type = type; - this.bits = new BitSet((int) type.range().getMaximum()); } - /** * Return a {@code CronField} enabled for 0 nano seconds. */ public static CronField zeroNanos() { - return ZERO_NANOS; + return BitsCronField.zeroNanos(); } /** * Parse the given value into a seconds {@code CronField}, the first entry of a cron expression. */ public static CronField parseSeconds(String value) { - return parseField(value, Type.SECOND); + return BitsCronField.parseSeconds(value); } /** * Parse the given value into a minutes {@code CronField}, the second entry of a cron expression. */ public static CronField parseMinutes(String value) { - return parseField(value, Type.MINUTE); + return BitsCronField.parseMinutes(value); } /** * Parse the given value into a hours {@code CronField}, the third entry of a cron expression. */ public static CronField parseHours(String value) { - return parseField(value, Type.HOUR); + return BitsCronField.parseHours(value); } /** * Parse the given value into a days of months {@code CronField}, the fourth entry of a cron expression. */ public static CronField parseDaysOfMonth(String value) { - return parseDate(value, Type.DAY_OF_MONTH); + if (value.contains("L") || value.contains("W")) { + return QuartzCronField.parseDaysOfMonth(value); + } + else { + return BitsCronField.parseDaysOfMonth(value); + } } /** @@ -100,7 +90,7 @@ final class CronField { */ public static CronField parseMonth(String value) { value = replaceOrdinals(value, MONTHS); - return parseField(value, Type.MONTH); + return BitsCronField.parseMonth(value); } /** @@ -108,77 +98,15 @@ final class CronField { */ public static CronField parseDaysOfWeek(String value) { value = replaceOrdinals(value, DAYS); - CronField result = parseDate(value, Type.DAY_OF_WEEK); - if (result.bits.get(0)) { - // cron supports 0 for Sunday; we use 7 like java.time - result.bits.set(7); - result.bits.clear(0); - } - return result; - } - - - private static CronField parseDate(String value, Type type) { - if (value.indexOf('?') != -1) { - value = "*"; - } - return parseField(value, type); - } - - private static CronField parseField(String value, Type type) { - Assert.hasLength(value, "Value must not be empty"); - Assert.notNull(type, "Type must not be null"); - try { - CronField result = new CronField(type); - String[] fields = StringUtils.delimitedListToStringArray(value, ","); - for (String field : fields) { - int slashPos = field.indexOf('/'); - if (slashPos == -1) { - ValueRange range = parseRange(field, type); - result.setBits(range); - } - else { - String rangeStr = value.substring(0, slashPos); - String deltaStr = value.substring(slashPos + 1); - ValueRange range = parseRange(rangeStr, type); - if (rangeStr.indexOf('-') == -1) { - range = ValueRange.of(range.getMinimum(), type.range().getMaximum()); - } - int delta = Integer.parseInt(deltaStr); - if (delta <= 0) { - throw new IllegalArgumentException("Incrementer delta must be 1 or higher"); - } - result.setBits(range, delta); - } - } - return result; - } - catch (DateTimeException | IllegalArgumentException ex) { - String msg = ex.getMessage() + " '" + value + "'"; - throw new IllegalArgumentException(msg, ex); - } - } - - private static ValueRange parseRange(String value, Type type) { - if (value.indexOf('*') != -1) { - return type.range(); + if (value.contains("L") || value.contains("#")) { + return QuartzCronField.parseDaysOfWeek(value); } else { - int hyphenPos = value.indexOf('-'); - if (hyphenPos == -1) { - int result = type.checkValidValue(Integer.parseInt(value)); - return ValueRange.of(result, result); - } - else { - int min = Integer.parseInt(value.substring(0, hyphenPos)); - int max = Integer.parseInt(value.substring(hyphenPos + 1)); - min = type.checkValidValue(min); - max = type.checkValidValue(max); - return ValueRange.of(min, max); - } + return BitsCronField.parseDaysOfWeek(value); } } + private static String replaceOrdinals(String value, String[] list) { value = value.toUpperCase(); for (int i = 0; i < list.length; i++) { @@ -196,67 +124,11 @@ final class CronField { * @return the next or same temporal matching the pattern */ @Nullable - public T nextOrSame(T temporal) { - int current = this.type.get(temporal); - int next = this.bits.nextSetBit(current); - if (next == -1) { - temporal = this.type.rollForward(temporal); - next = this.bits.nextSetBit(0); - } - if (next == current) { - return temporal; - } - else { - int count = 0; - current = this.type.get(temporal); - while (current != next && count++ < CronExpression.MAX_ATTEMPTS) { - temporal = this.type.elapseUntil(temporal, next); - current = this.type.get(temporal); - } - if (count >= CronExpression.MAX_ATTEMPTS) { - return null; - } - return this.type.reset(temporal); - } - } - + public abstract > T nextOrSame(T temporal); - BitSet bits() { - return this.bits; - } - - private void setBits(ValueRange range) { - this.bits.set((int) range.getMinimum(), (int) range.getMaximum() + 1); - } - - private void setBits(ValueRange range, int delta) { - for (int i = (int) range.getMinimum(); i <= range.getMaximum(); i += delta) { - this.bits.set(i); - } - } - - - @Override - public int hashCode() { - return this.bits.hashCode(); - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof CronField)) { - return false; - } - CronField other = (CronField) o; - return this.type == other.type && - this.bits.equals(other.bits); - } - @Override - public String toString() { - return this.type + " " + this.bits; + protected Type type() { + return this.type; } @@ -264,7 +136,7 @@ final class CronField { * Represents the type of cron field, i.e. seconds, minutes, hours, * day-of-month, month, day-of-week. */ - private enum Type { + protected enum Type { NANO(ChronoField.NANO_OF_SECOND), SECOND(ChronoField.SECOND_OF_MINUTE, ChronoField.NANO_OF_SECOND), MINUTE(ChronoField.MINUTE_OF_HOUR, ChronoField.SECOND_OF_MINUTE, ChronoField.NANO_OF_SECOND), @@ -336,7 +208,7 @@ final class CronField { * @return the elapsed temporal, typically with {@code goal} as value * for this type. */ - public T elapseUntil(T temporal, int goal) { + public > T elapseUntil(T temporal, int goal) { int current = get(temporal); if (current < goal) { return this.field.getBaseUnit().addTo(temporal, goal - current); @@ -357,7 +229,7 @@ final class CronField { * @param the type of temporal * @return the rolled forward temporal */ - public T rollForward(T temporal) { + public > T rollForward(T temporal) { int current = get(temporal); ValueRange range = temporal.range(this.field); long amount = range.getMaximum() - current + 1; 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 new file mode 100644 index 00000000000..5db9d31bf36 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/scheduling/support/QuartzCronField.java @@ -0,0 +1,309 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.scheduling.support; + +import java.time.DateTimeException; +import java.time.DayOfWeek; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.time.temporal.Temporal; +import java.time.temporal.TemporalAdjuster; +import java.time.temporal.TemporalAdjusters; +import java.time.temporal.TemporalField; +import java.time.temporal.WeekFields; +import java.util.Locale; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Extension of {@link CronField} for + * = 0) { + throw new IllegalArgumentException("Offset '" + offset + " should be < 0 '" + value + "'"); + } + adjuster = lastDayWithOffset(offset); + } + } + return new QuartzCronField(Type.DAY_OF_MONTH, adjuster, value); + } + idx = value.lastIndexOf('W'); + if (idx != -1) { + if (idx == 0) { + throw new IllegalArgumentException("No day-of-month before 'W' in '" + value + "'"); + } + else if (idx != value.length() - 1) { + throw new IllegalArgumentException("Unrecognized characters after 'W' in '" + value + "'"); + } + else { // "[0-9]+W" + int dayOfMonth = Integer.parseInt(value.substring(0, idx)); + dayOfMonth = Type.DAY_OF_MONTH.checkValidValue(dayOfMonth); + TemporalAdjuster adjuster = weekdayNearestTo(dayOfMonth); + return new QuartzCronField(Type.DAY_OF_MONTH, adjuster, value); + } + } + throw new IllegalArgumentException("No 'L' or 'W' found in '" + value + "'"); + } + + /** + * Parse the given value into a days of week {@code QuartzCronField}, the sixth entry of a cron expression. + * Expects a "L" or "#" in the given value. + */ + public static QuartzCronField parseDaysOfWeek(String value) { + int idx = value.lastIndexOf('L'); + if (idx != -1) { + if (idx != value.length() - 1) { + throw new IllegalArgumentException("Unrecognized characters after 'L' in '" + value + "'"); + } + else { + TemporalAdjuster adjuster; + if (idx == 0) { // "L" + adjuster = lastDayOfWeek(Locale.getDefault()); + } + else { // "[0-7]L" + DayOfWeek dayOfWeek = parseDayOfWeek(value.substring(0, idx)); + adjuster = TemporalAdjusters.lastInMonth(dayOfWeek); + } + return new QuartzCronField(Type.DAY_OF_WEEK, Type.DAY_OF_MONTH, adjuster, value); + } + } + idx = value.lastIndexOf('#'); + if (idx != -1) { + if (idx == 0) { + throw new IllegalArgumentException("No day-of-week before '#' in '" + value + "'"); + } + else if (idx == value.length() - 1) { + throw new IllegalArgumentException("No ordinal after '#' in '" + value + "'"); + } + // "[0-7]#[0-9]+" + DayOfWeek dayOfWeek = parseDayOfWeek(value.substring(0, idx)); + int ordinal = Integer.parseInt(value.substring(idx + 1)); + + TemporalAdjuster adjuster = TemporalAdjusters.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) { + dayOfWeek = 7; // cron is 0 based; java.time 1 based + } + try { + return DayOfWeek.of(dayOfWeek); + } + catch (DateTimeException ex) { + String msg = ex.getMessage() + " '" + value + "'"; + throw new IllegalArgumentException(msg, ex); + } + } + + /** + * 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 + * @return a nth-to-last day-of-month adjuster + */ + private static TemporalAdjuster lastDayWithOffset(int offset) { + Assert.isTrue(offset < 0, "Offset should be < 0"); + return temporal -> { + Temporal lastDayOfMonth = TemporalAdjusters.lastDayOfMonth().adjustInto(temporal); + return lastDayOfMonth.plus(offset, ChronoUnit.DAYS); + }; + } + + /** + * Return a temporal adjuster that finds the last day-of-week, depending + * on the given locale. + * @param locale the locale to base the last day calculation on + * @return the last day-of-week adjuster + */ + private static TemporalAdjuster lastDayOfWeek(Locale locale) { + Assert.notNull(locale, "Locale must not be null"); + TemporalField dayOfWeek = WeekFields.of(locale).dayOfWeek(); + return temporal -> temporal.with(dayOfWeek, 7); + } + + /** + * Return a temporal adjuster that finds the weekday nearest to the given + * day-of-month. If {@code dayOfMonth} falls on a Saturday, the date is + * moved back to Friday; if it falls on a Sunday (or if {@code dayOfMonth} + * is 1 and it falls on a Saturday), it is moved forward to Monday. + * @param dayOfMonth the goal day-of-month + * @return the weekday-nearest-to adjuster + */ + private static TemporalAdjuster weekdayNearestTo(int dayOfMonth) { + return temporal -> { + int current = Type.DAY_OF_MONTH.get(temporal); + int dayOfWeek = temporal.get(ChronoField.DAY_OF_WEEK); + + if ((current == dayOfMonth && dayOfWeek < 6) || // dayOfMonth is a weekday + (dayOfWeek == 5 && current == dayOfMonth - 1) || // dayOfMonth is a Saturday, so Friday before + (dayOfWeek == 1 && current == dayOfMonth + 1) || // dayOfMonth is a Sunday, so Monday after + (dayOfWeek == 1 && dayOfMonth == 1 && current == 3)) { // dayOfMonth is the 1st, so Monday 3rd + return temporal; + } + int count = 0; + while (count++ < CronExpression.MAX_ATTEMPTS) { + temporal = Type.DAY_OF_MONTH.elapseUntil(cast(temporal), dayOfMonth); + current = Type.DAY_OF_MONTH.get(temporal); + if (current == dayOfMonth) { + dayOfWeek = temporal.get(ChronoField.DAY_OF_WEEK); + + if (dayOfWeek == 6) { // Saturday + if (dayOfMonth != 1) { + return temporal.minus(1, ChronoUnit.DAYS); + } + else { + // exception for "1W" fields: execute on nearest Monday + return temporal.plus(2, ChronoUnit.DAYS); + } + } + else if (dayOfWeek == 7) { // Sunday + return temporal.plus(1, ChronoUnit.DAYS); + } + else { + return temporal; + } + } + } + return null; + }; + } + + @SuppressWarnings("unchecked") + private static > T cast(Temporal temporal) { + return (T) temporal; + } + + + @Override + public > T nextOrSame(T temporal) { + T result = adjust(temporal); + if (result != null) { + if (result.compareTo(temporal) < 0) { + // We ended up before the start, roll forward and try again + temporal = this.rollForwardType.rollForward(temporal); + result = adjust(temporal); + } + } + return result; + } + + + @Nullable + @SuppressWarnings("unchecked") + private > T adjust(T temporal) { + return (T) this.adjuster.adjustInto(temporal); + } + + + @Override + public int hashCode() { + return this.value.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof QuartzCronField)) { + return false; + } + QuartzCronField other = (QuartzCronField) o; + return type() == other.type() && + this.value.equals(other.value); + } + + @Override + public String toString() { + return type() + " '" + this.value + "'"; + + } + +} diff --git a/spring-context/src/test/java/org/springframework/scheduling/support/BitsCronFieldTests.java b/spring-context/src/test/java/org/springframework/scheduling/support/BitsCronFieldTests.java new file mode 100644 index 00000000000..b608dd5a685 --- /dev/null +++ b/spring-context/src/test/java/org/springframework/scheduling/support/BitsCronFieldTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.scheduling.support; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.springframework.scheduling.support.BitSetAssert.assertThat; + +/** + * @author Arjen Poutsma + */ +public class BitsCronFieldTests { + + @Test + void parse() { + assertThat(BitsCronField.parseSeconds("42").bits()).hasUnsetRange(0, 41).hasSet(42).hasUnsetRange(43, 59); + assertThat(BitsCronField.parseMinutes("1,2,5,9").bits()).hasUnset(0).hasSet(1, 2).hasUnset(3,4).hasSet(5).hasUnsetRange(6,8).hasSet(9).hasUnsetRange(10,59); + assertThat(BitsCronField.parseSeconds("0-4,8-12").bits()).hasSetRange(0, 4).hasUnsetRange(5,7).hasSetRange(8, 12).hasUnsetRange(13,59); + assertThat(BitsCronField.parseHours("0-23/2").bits()).hasSet(0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22).hasUnset(1,3,5,7,9,11,13,15,17,19,21,23); + assertThat(BitsCronField.parseDaysOfWeek("0").bits()).hasUnsetRange(0, 6).hasSet(7, 7); + assertThat(BitsCronField.parseSeconds("57/2").bits()).hasUnsetRange(0, 56).hasSet(57).hasUnset(58).hasSet(59); + } + + @Test + void invalidRange() { + assertThatIllegalArgumentException().isThrownBy(() -> BitsCronField.parseSeconds("")); + assertThatIllegalArgumentException().isThrownBy(() -> BitsCronField.parseSeconds("0-12/0")); + assertThatIllegalArgumentException().isThrownBy(() -> BitsCronField.parseSeconds("60")); + assertThatIllegalArgumentException().isThrownBy(() -> BitsCronField.parseMinutes("60")); + assertThatIllegalArgumentException().isThrownBy(() -> BitsCronField.parseDaysOfMonth("0")); + assertThatIllegalArgumentException().isThrownBy(() -> BitsCronField.parseDaysOfMonth("32")); + assertThatIllegalArgumentException().isThrownBy(() -> BitsCronField.parseMonth("0")); + assertThatIllegalArgumentException().isThrownBy(() -> BitsCronField.parseMonth("13")); + assertThatIllegalArgumentException().isThrownBy(() -> BitsCronField.parseDaysOfWeek("8")); + assertThatIllegalArgumentException().isThrownBy(() -> BitsCronField.parseSeconds("20-10")); + } + + @Test + void parseWildcards() { + assertThat(BitsCronField.parseSeconds("*").bits()).hasSetRange(0, 60); + assertThat(BitsCronField.parseMinutes("*").bits()).hasSetRange(0, 60); + assertThat(BitsCronField.parseHours("*").bits()).hasSetRange(0, 23); + assertThat(BitsCronField.parseDaysOfMonth("*").bits()).hasUnset(0).hasSetRange(1, 31); + assertThat(BitsCronField.parseDaysOfMonth("?").bits()).hasUnset(0).hasSetRange(1, 31); + assertThat(BitsCronField.parseMonth("*").bits()).hasUnset(0).hasSetRange(1, 12); + assertThat(BitsCronField.parseDaysOfWeek("*").bits()).hasUnset(0).hasSetRange(1, 7); + assertThat(BitsCronField.parseDaysOfWeek("?").bits()).hasUnset(0).hasSetRange(1, 7); + } + + @Test + void names() { + assertThat(((BitsCronField)CronField.parseMonth("JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC")).bits()) + .hasUnset(0).hasSetRange(1, 12); + assertThat(((BitsCronField)CronField.parseDaysOfWeek("SUN,MON,TUE,WED,THU,FRI,SAT")).bits()) + .hasUnset(0).hasSetRange(1, 7); + } + +} 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 e77fd644d08..93206f1d5f7 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 @@ -22,11 +22,16 @@ import java.time.LocalTime; import java.time.Year; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.time.temporal.ChronoField; +import java.time.temporal.Temporal; +import java.util.Locale; +import org.assertj.core.api.Condition; import org.junit.jupiter.api.Test; import static java.time.DayOfWeek.FRIDAY; import static java.time.DayOfWeek.MONDAY; +import static java.time.DayOfWeek.SATURDAY; import static java.time.DayOfWeek.SUNDAY; import static java.time.DayOfWeek.TUESDAY; import static java.time.DayOfWeek.WEDNESDAY; @@ -38,6 +43,16 @@ import static org.assertj.core.api.Assertions.assertThat; */ class CronExpressionTests { + private static final Condition weekday = new Condition("weekday") { + + @Override + public boolean matches(Temporal value) { + int dayOfWeek = value.get(ChronoField.DAY_OF_WEEK); + return dayOfWeek != 6 && dayOfWeek != 7; + } + }; + + @Test void matchAll() { CronExpression expression = CronExpression.parse("* * * * * *"); @@ -583,4 +598,504 @@ class CronExpressionTests { } + @Test + void quartzLastDayOfMonth() { + CronExpression expression = CronExpression.parse("0 0 0 L * *"); + + LocalDateTime last = LocalDateTime.of(LocalDate.of(2008, 1, 4), LocalTime.now()); + LocalDateTime expected = LocalDateTime.of(2008, 1, 31, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = LocalDateTime.of(2008, 2, 29, 0, 0); + actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = LocalDateTime.of(2008, 3, 31, 0, 0); + actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = LocalDateTime.of(2008, 4, 30, 0, 0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void quartzLastDayOfMonthOffset() { + // L-3 = third-to-last day of the month + CronExpression expression = CronExpression.parse("0 0 0 L-3 * *"); + + LocalDateTime last = LocalDateTime.of(LocalDate.of(2008, 1, 4), LocalTime.now()); + LocalDateTime expected = LocalDateTime.of(2008, 1, 28, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = LocalDateTime.of(2008, 2, 26, 0, 0); + actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = LocalDateTime.of(2008, 3, 28, 0, 0); + actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = LocalDateTime.of(2008, 4, 27, 0, 0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void quartzLastWeekdayOfMonth() { + CronExpression expression = CronExpression.parse("0 0 0 LW * *"); + + LocalDateTime last = LocalDateTime.of(LocalDate.of(2008, 1, 4), LocalTime.now()); + LocalDateTime expected = LocalDateTime.of(2008, 1, 31, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2008, 2, 29, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2008, 3, 31, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2008, 4, 30, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2008, 5, 30, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2008, 6, 30, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2008, 7, 31, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2008, 8, 29, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2008, 9, 30, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2008, 10, 31, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2008, 11, 28, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2008, 12, 31, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + } + + @Test + public void quartzLastDayOfWeekFirstDayMonday() { + Locale defaultLocale = Locale.getDefault(); + try { + Locale.setDefault(Locale.UK); + + CronExpression expression = CronExpression.parse("0 0 0 * * L"); + + LocalDateTime last = LocalDateTime.of(LocalDate.of(2008, 1, 4), LocalTime.now()); + LocalDateTime expected = LocalDateTime.of(2008, 1, 6, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(SUNDAY); + + last = actual; + expected = expected.plusWeeks(1); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(SUNDAY); + } + finally { + Locale.setDefault(defaultLocale); + } + } + + @Test + public void quartzLastDayOfWeekFirstDaySunday() { + Locale defaultLocale = Locale.getDefault(); + try { + Locale.setDefault(Locale.US); + + CronExpression expression = CronExpression.parse("0 0 0 * * L"); + + LocalDateTime last = LocalDateTime.of(LocalDate.of(2008, 1, 4), LocalTime.now()); + LocalDateTime expected = LocalDateTime.of(2008, 1, 5, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(SATURDAY); + + last = actual; + expected = expected.plusWeeks(1); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(SATURDAY); + } + finally { + Locale.setDefault(defaultLocale); + } + } + + @Test + public void quartzLastDayOfWeekOffset() { + // last Friday (5) of the month + CronExpression expression = CronExpression.parse("0 0 0 * * 5L"); + + LocalDateTime last = LocalDateTime.of(LocalDate.of(2008, 1, 4), LocalTime.now()); + LocalDateTime expected = LocalDateTime.of(2008, 1, 25, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2008, 2, 29, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2008, 3, 28, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2008, 4, 25, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2008, 5, 30, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2008, 6, 27, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2008, 7, 25, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2008, 8, 29, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2008, 9, 26, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2008, 10, 31, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2008, 11, 28, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2008, 12, 26, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + } + + @Test + void quartzWeekdayNearestTo15() { + CronExpression expression = CronExpression.parse("0 0 0 15W * ?"); + + LocalDateTime last = LocalDateTime.of(2020, 1, 1, 0, 0); + LocalDateTime expected = LocalDateTime.of(2020, 1, 15, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2020, 2, 14, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2020, 3, 16, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2020, 4, 15, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + } + + @Test + void quartzWeekdayNearestTo1() { + CronExpression expression = CronExpression.parse("0 0 0 1W * ?"); + + LocalDateTime last = LocalDateTime.of(2019, 12, 31, 0, 0); + LocalDateTime expected = LocalDateTime.of(2020, 1, 1, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2020, 2, 3, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2020, 3, 2, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2020, 4, 1, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + } + + @Test + void quartzWeekdayNearestTo31() { + CronExpression expression = CronExpression.parse("0 0 0 31W * ?"); + + LocalDateTime last = LocalDateTime.of(2020, 1, 1, 0, 0); + LocalDateTime expected = LocalDateTime.of(2020, 1, 31, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2020, 3, 31, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2020, 7, 31, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2020, 8, 31, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2020, 10, 30, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + + last = actual; + expected = LocalDateTime.of(2020, 12, 31, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual).is(weekday); + } + + @Test + void quartz2ndFridayOfTheMonth() { + CronExpression expression = CronExpression.parse("0 0 0 ? * 5#2"); + + LocalDateTime last = LocalDateTime.of(2020, 1, 1, 0, 0); + LocalDateTime expected = LocalDateTime.of(2020, 1, 10, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2020, 2, 14, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2020, 3, 13, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2020, 4, 10, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + } + + @Test + void quartz2ndFridayOfTheMonthDayName() { + CronExpression expression = CronExpression.parse("0 0 0 ? * FRI#2"); + + LocalDateTime last = LocalDateTime.of(2020, 1, 1, 0, 0); + LocalDateTime expected = LocalDateTime.of(2020, 1, 10, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2020, 2, 14, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2020, 3, 13, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2020, 4, 10, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + } + + @Test + void quartzFifthWednesdayOfTheMonth() { + CronExpression expression = CronExpression.parse("0 0 0 ? * 3#5"); + + LocalDateTime last = LocalDateTime.of(2020, 1, 1, 0, 0); + LocalDateTime expected = LocalDateTime.of(2020, 1, 29, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(WEDNESDAY); + + last = actual; + expected = LocalDateTime.of(2020, 4, 29, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(WEDNESDAY); + + last = actual; + expected = LocalDateTime.of(2020, 7, 29, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(WEDNESDAY); + + last = actual; + expected = LocalDateTime.of(2020, 9, 30, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(WEDNESDAY); + + last = actual; + expected = LocalDateTime.of(2020, 12, 30, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(WEDNESDAY); + } } diff --git a/spring-context/src/test/java/org/springframework/scheduling/support/CronFieldTests.java b/spring-context/src/test/java/org/springframework/scheduling/support/CronFieldTests.java deleted file mode 100644 index 7e0d157b3a4..00000000000 --- a/spring-context/src/test/java/org/springframework/scheduling/support/CronFieldTests.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2002-2020 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.scheduling.support; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -import static org.springframework.scheduling.support.BitSetAssert.assertThat; - -/** - * @author Arjen Poutsma - */ -public class CronFieldTests { - - @Test - void parse() { - assertThat(CronField.parseSeconds("42").bits()).hasUnsetRange(0, 41).hasSet(42).hasUnsetRange(43, 59); - assertThat(CronField.parseMinutes("1,2,5,9").bits()).hasUnset(0).hasSet(1, 2).hasUnset(3,4).hasSet(5).hasUnsetRange(6,8).hasSet(9).hasUnsetRange(10,59); - assertThat(CronField.parseSeconds("0-4,8-12").bits()).hasSetRange(0, 4).hasUnsetRange(5,7).hasSetRange(8, 12).hasUnsetRange(13,59); - assertThat(CronField.parseHours("0-23/2").bits()).hasSet(0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22).hasUnset(1,3,5,7,9,11,13,15,17,19,21,23); - assertThat(CronField.parseDaysOfWeek("0").bits()).hasUnsetRange(0, 6).hasSet(7, 7); - assertThat(CronField.parseSeconds("57/2").bits()).hasUnsetRange(0, 56).hasSet(57).hasUnset(58).hasSet(59); - } - - @Test - void invalidRange() { - assertThatIllegalArgumentException().isThrownBy(() -> CronField.parseSeconds("")); - assertThatIllegalArgumentException().isThrownBy(() -> CronField.parseSeconds("0-12/0")); - assertThatIllegalArgumentException().isThrownBy(() -> CronField.parseSeconds("60")); - assertThatIllegalArgumentException().isThrownBy(() -> CronField.parseMinutes("60")); - assertThatIllegalArgumentException().isThrownBy(() -> CronField.parseDaysOfMonth("0")); - assertThatIllegalArgumentException().isThrownBy(() -> CronField.parseDaysOfMonth("32")); - assertThatIllegalArgumentException().isThrownBy(() -> CronField.parseMonth("0")); - assertThatIllegalArgumentException().isThrownBy(() -> CronField.parseMonth("13")); - assertThatIllegalArgumentException().isThrownBy(() -> CronField.parseDaysOfWeek("8")); - assertThatIllegalArgumentException().isThrownBy(() -> CronField.parseSeconds("20-10")); - } - - @Test - void parseWildcards() { - assertThat(CronField.parseSeconds("*").bits()).hasSetRange(0, 60); - assertThat(CronField.parseMinutes("*").bits()).hasSetRange(0, 60); - assertThat(CronField.parseHours("*").bits()).hasSetRange(0, 23); - assertThat(CronField.parseDaysOfMonth("*").bits()).hasUnset(0).hasSetRange(1, 31); - assertThat(CronField.parseDaysOfMonth("?").bits()).hasUnset(0).hasSetRange(1, 31); - assertThat(CronField.parseMonth("*").bits()).hasUnset(0).hasSetRange(1, 12); - assertThat(CronField.parseDaysOfWeek("*").bits()).hasUnset(0).hasSetRange(1, 7); - assertThat(CronField.parseDaysOfWeek("?").bits()).hasUnset(0).hasSetRange(1, 7); - } - - @Test - void names() { - assertThat(CronField.parseMonth("JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC").bits()) - .hasUnset(0).hasSetRange(1, 12); - assertThat(CronField.parseDaysOfWeek("SUN,MON,TUE,WED,THU,FRI,SAT").bits()) - .hasUnset(0).hasSetRange(1, 7); - } - -} 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 new file mode 100644 index 00000000000..3d3c79ada19 --- /dev/null +++ b/spring-context/src/test/java/org/springframework/scheduling/support/QuartzCronFieldTests.java @@ -0,0 +1,144 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.scheduling.support; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +import static java.time.DayOfWeek.SATURDAY; +import static java.time.DayOfWeek.SUNDAY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * @author Arjen Poutsma + */ +class QuartzCronFieldTests { + + @Test + void lastDayOfMonth() { + QuartzCronField field = QuartzCronField.parseDaysOfMonth("L"); + + LocalDate last = LocalDate.of(2020, 6, 16); + LocalDate expected = LocalDate.of(2020, 6, 30); + assertThat(field.nextOrSame(last)).isEqualTo(expected); + } + + @Test + void lastDayOfMonthOffset() { + QuartzCronField field = QuartzCronField.parseDaysOfMonth("L-3"); + + LocalDate last = LocalDate.of(2020, 6, 16); + LocalDate expected = LocalDate.of(2020, 6, 27); + assertThat(field.nextOrSame(last)).isEqualTo(expected); + } + + @Test + void lastWeekdayOfMonth() { + QuartzCronField field = QuartzCronField.parseDaysOfMonth("LW"); + + LocalDate last = LocalDate.of(2020, 6, 16); + LocalDate expected = LocalDate.of(2020, 6, 30); + LocalDate actual = field.nextOrSame(last); + assertThat(actual).isNotNull(); + assertThat(actual.getDayOfWeek()).isEqualTo(DayOfWeek.TUESDAY); + assertThat(actual).isEqualTo(expected); + } + + @Test + public void lastDayOfWeekFirstDayMonday() { + Locale defaultLocale = Locale.getDefault(); + try { + Locale.setDefault(Locale.UK); + QuartzCronField field = QuartzCronField.parseDaysOfWeek("L"); + + LocalDate last = LocalDate.of(2020, 6, 16); + LocalDate expected = LocalDate.of(2020, 6, 21); + assertThat(field.nextOrSame(last)).isEqualTo(expected); + + LocalDate actual = field.nextOrSame(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(SUNDAY); + } + finally { + Locale.setDefault(defaultLocale); + } + } + + @Test + public void lastDayOfWeekFirstDaySunday() { + Locale defaultLocale = Locale.getDefault(); + try { + Locale.setDefault(Locale.US); + QuartzCronField field = QuartzCronField.parseDaysOfWeek("L"); + + LocalDate last = LocalDate.of(2020, 6, 16); + LocalDate expected = LocalDate.of(2020, 6, 20); + assertThat(field.nextOrSame(last)).isEqualTo(expected); + + LocalDate actual = field.nextOrSame(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(SATURDAY); + } + finally { + Locale.setDefault(defaultLocale); + } + } + + @Test + void lastDayOfWeekOffset() { + // last Thursday (4) of the month + QuartzCronField field = QuartzCronField.parseDaysOfWeek("4L"); + + LocalDate last = LocalDate.of(2020, 6, 16); + LocalDate expected = LocalDate.of(2020, 6, 25); + assertThat(field.nextOrSame(last)).isEqualTo(expected); + } + + @Test + void invalidValues() { + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfMonth("")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfMonth("1")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfMonth("1L")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfMonth("LL")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfMonth("4L")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfMonth("0L")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfMonth("W")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfMonth("W1")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfMonth("WW")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfMonth("32W")); + + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("1")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("L1")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("LL")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("-4L")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("8L")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("#")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("1#")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("#1")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("1#L")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("L#1")); + assertThatIllegalArgumentException().isThrownBy(() -> QuartzCronField.parseDaysOfWeek("8#1")); + } + +}