From 87c3bb579739aa4892f3b647f8eef7e2dc16a7f5 Mon Sep 17 00:00:00 2001 From: Arjen Poutsma Date: Wed, 15 Jul 2020 12:23:26 +0200 Subject: [PATCH] Introduce CronExpression This commit introduces CronExpression, a new for representing cron expressions, and a direct replacement for CronSequenceGenerator. --- .../scheduling/support/CronExpression.java | 209 +++++++++ .../scheduling/support/CronField.java | 385 ++++++++++++++++ .../support/CronSequenceGenerator.java | 10 +- .../scheduling/support/CronTrigger.java | 48 +- .../scheduling/support/BitSetAssert.java | 81 ++++ .../support/CronExpressionTests.java | 434 ++++++++++++++++++ .../scheduling/support/CronFieldTests.java | 73 +++ .../scheduling/support/CronTriggerTests.java | 2 +- 8 files changed, 1227 insertions(+), 15 deletions(-) create mode 100644 spring-context/src/main/java/org/springframework/scheduling/support/CronExpression.java create mode 100644 spring-context/src/main/java/org/springframework/scheduling/support/CronField.java create mode 100644 spring-context/src/test/java/org/springframework/scheduling/support/BitSetAssert.java create mode 100644 spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java create mode 100644 spring-context/src/test/java/org/springframework/scheduling/support/CronFieldTests.java 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 new file mode 100644 index 00000000000..c3f8990df34 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/scheduling/support/CronExpression.java @@ -0,0 +1,209 @@ +/* + * 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.temporal.ChronoUnit; +import java.time.temporal.Temporal; +import java.util.Arrays; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Representation of a + * crontab expression + * that can calculate the next time it matches. + * + *

{@code CronExpression} instances are created through + * {@link #parse(String)}; the next match is determined with + * {@link #next(Temporal)}. + * + * @author Arjen Poutsma + * @since 5.3 + * @see CronTrigger + */ +public final class CronExpression { + + static final int MAX_ATTEMPTS = 366; + + + private final CronField[] fields; + + private final String expression; + + + private CronExpression( + CronField seconds, + CronField minutes, + CronField hours, + CronField daysOfMonth, + CronField months, + CronField daysOfWeek, + 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.expression = expression; + } + + + /** + * Parse the given + * crontab expression + * string into a {@code CronExpression}. + * The string has six single space-separated time and date fields: + *

+	 * ┌───────────── second (0-59)
+	 * │ ┌───────────── minute (0 - 59)
+	 * │ │ ┌───────────── hour (0 - 23)
+	 * │ │ │ ┌───────────── day of the month (1 - 31)
+	 * │ │ │ │ ┌───────────── month (1 - 12) (or JAN-DEC)
+	 * │ │ │ │ │ ┌───────────── day of the week (0 - 7)
+	 * │ │ │ │ │ │          (0 or 7 is Sunday, or MON-SUN)
+	 * │ │ │ │ │ │
+	 * * * * * * *
+	 * 
+ * + *

The following rules apply: + *

+ * + *

Example expressions: + *

+ * + * @param expression the expression string to parse + * @return the parsed {@code CronExpression} object + * @throws IllegalArgumentException in the expression does not conform to + * the cron format + */ + public static CronExpression parse(String expression) { + Assert.hasLength(expression, "Expression string must not be empty"); + + String[] fields = StringUtils.tokenizeToStringArray(expression, " "); + if (fields.length != 6) { + throw new IllegalArgumentException(String.format( + "Cron expression must consist of 6 fields (found %d in \"%s\")", fields.length, expression)); + } + try { + CronField seconds = CronField.parseSeconds(fields[0]); + CronField minutes = CronField.parseMinutes(fields[1]); + CronField hours = CronField.parseHours(fields[2]); + CronField daysOfMonth = CronField.parseDaysOfMonth(fields[3]); + CronField months = CronField.parseMonth(fields[4]); + CronField daysOfWeek = CronField.parseDaysOfWeek(fields[5]); + + return new CronExpression(seconds, minutes, hours, daysOfMonth, months, daysOfWeek, expression); + } + catch (IllegalArgumentException ex) { + String msg = ex.getMessage() + " in cron expression \"" + expression + "\""; + throw new IllegalArgumentException(msg, ex); + } + } + + + /** + * Calculate the next {@link Temporal} that matches this expression. + * @param temporal the seed value + * @param the type of temporal + * @return the next temporal that matches this expression, or {@code null} + * if no such temporal can be found + */ + @Nullable + public T next(T temporal) { + return nextOrSame(ChronoUnit.NANOS.addTo(temporal, 1)); + } + + + @Nullable + private T nextOrSame(T temporal) { + for (int i = 0; i < MAX_ATTEMPTS; i++) { + T result = nextOrSameInternal(temporal); + if (result == null || result.equals(temporal)) { + return result; + } + temporal = result; + } + return null; + } + + @Nullable + private T nextOrSameInternal(T temporal) { + for (CronField field : this.fields) { + temporal = field.nextOrSame(temporal); + if (temporal == null) { + return null; + } + } + return temporal; + } + + + @Override + public int hashCode() { + return Arrays.hashCode(this.fields); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o instanceof CronExpression) { + CronExpression other = (CronExpression) o; + return Arrays.equals(this.fields, other.fields); + } + else { + return false; + } + } + + /** + * Return the expression string used to create this {@code CronExpression}. + * @return the expression string + */ + @Override + public String toString() { + return this.expression; + } + +} 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 new file mode 100644 index 00000000000..866e507bb8f --- /dev/null +++ b/spring-context/src/main/java/org/springframework/scheduling/support/CronField.java @@ -0,0 +1,385 @@ +/* + * 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.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, + * main and only entry point is {@link #nextOrSame(Temporal)}. + * + * @author Arjen Poutsma + * @since 5.3 + */ +final 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) { + 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; + } + + /** + * 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); + } + + /** + * 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); + } + + /** + * 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); + } + + /** + * 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); + } + + /** + * Parse the given value into a month {@code CronField}, the fifth entry of a cron expression. + */ + public static CronField parseMonth(String value) { + value = replaceOrdinals(value, MONTHS); + return parseField(value, Type.MONTH); + } + + /** + * Parse the given value into a days of week {@code CronField}, the sixth entry of a cron expression. + */ + 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(); + } + 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); + } + } + } + + private static String replaceOrdinals(String value, String[] list) { + value = value.toUpperCase(); + for (int i = 0; i < list.length; i++) { + String replacement = Integer.toString(i + 1); + value = StringUtils.replace(value, list[i], replacement); + } + return value; + } + + + /** + * Get the next or same {@link Temporal} in the sequence matching this + * cron field. + * @param temporal the seed value + * @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); + } + } + + + 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; + } + + + /** + * Represents the type of cron field, i.e. seconds, minutes, hours, + * day-of-month, month, day-of-week. + */ + private 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), + HOUR(ChronoField.HOUR_OF_DAY, ChronoField.MINUTE_OF_HOUR, ChronoField.SECOND_OF_MINUTE, ChronoField.NANO_OF_SECOND), + DAY_OF_MONTH(ChronoField.DAY_OF_MONTH, ChronoField.HOUR_OF_DAY, ChronoField.MINUTE_OF_HOUR, ChronoField.SECOND_OF_MINUTE, ChronoField.NANO_OF_SECOND), + MONTH(ChronoField.MONTH_OF_YEAR, ChronoField.DAY_OF_MONTH, ChronoField.HOUR_OF_DAY, ChronoField.MINUTE_OF_HOUR, ChronoField.SECOND_OF_MINUTE, ChronoField.NANO_OF_SECOND), + DAY_OF_WEEK(ChronoField.DAY_OF_WEEK, ChronoField.HOUR_OF_DAY, ChronoField.MINUTE_OF_HOUR, ChronoField.SECOND_OF_MINUTE, ChronoField.NANO_OF_SECOND); + + + private final ChronoField field; + + private final ChronoField[] lowerOrders; + + + Type(ChronoField field, ChronoField... lowerOrders) { + this.field = field; + this.lowerOrders = lowerOrders; + } + + + /** + * Return the value of this type for the given temporal. + * @return the value of this type + */ + public int get(Temporal date) { + return date.get(this.field); + } + + /** + * Return the general range of this type. For instance, this methods + * will return 0-31 for {@link #MONTH}. + * @return the range of this field + */ + public ValueRange range() { + return this.field.range(); + } + + /** + * Check whether the given value is valid, i.e. whether it falls in + * {@linkplain #range() range}. + * @param value the value to check + * @return the value that was passed in + * @throws IllegalArgumentException if the given value is invalid + */ + public int checkValidValue(int value) { + if (this == DAY_OF_WEEK && value == 0) { + return value; + } + else { + try { + return this.field.checkValidIntValue(value); + } + catch (DateTimeException ex) { + throw new IllegalArgumentException(ex.getMessage(), ex); + } + } + } + + /** + * Elapse the given temporal for the difference between the current + * value of this field and the goal value. Typically, the returned + * temporal will have the given goal as the current value for this type, + * but this is not the case for {@link #DAY_OF_MONTH}. For instance, + * if {@code goal} is 31, and {@code temporal} is April 16th, + * this method returns May 1st, because April 31st does not exist. + * @param temporal the temporal to elapse + * @param goal the goal value + * @param the type of temporal + * @return the elapsed temporal, typically with {@code goal} as value + * for this type. + */ + public T elapseUntil(T temporal, int goal) { + int current = get(temporal); + if (current < goal) { + return this.field.getBaseUnit().addTo(temporal, goal - current); + } + else { + ValueRange range = temporal.range(this.field); + long amount = goal + range.getMaximum() - current + 1 - range.getMinimum(); + return this.field.getBaseUnit().addTo(temporal, amount); + } + } + + /** + * Roll forward the give temporal until it reaches the next higher + * order field. Calling this method is equivalent to calling + * {@link #elapseUntil(Temporal, int)} with goal set to the + * minimum value of this field's range. + * @param temporal the temporal to roll forward + * @param the type of temporal + * @return the rolled forward temporal + */ + public T rollForward(T temporal) { + int current = get(temporal); + ValueRange range = temporal.range(this.field); + long amount = range.getMaximum() - current + 1; + return this.field.getBaseUnit().addTo(temporal, amount); + } + + /** + * Reset this and all lower order fields of the given temporal to their + * minimum value. For instance for {@link #MINUTE}, this method + * resets nanos, seconds, and minutes to 0. + * @param temporal the temporal to reset + * @param the type of temporal + * @return the reset temporal + */ + public T reset(T temporal) { + for (ChronoField lowerOrder : this.lowerOrders) { + if (temporal.isSupported(lowerOrder)) { + temporal = lowerOrder.adjustInto(temporal, temporal.range(lowerOrder).getMinimum()); + } + } + return temporal; + } + } + +} diff --git a/spring-context/src/main/java/org/springframework/scheduling/support/CronSequenceGenerator.java b/spring-context/src/main/java/org/springframework/scheduling/support/CronSequenceGenerator.java index 9325136ac5a..1f2b90f7cf1 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/support/CronSequenceGenerator.java +++ b/spring-context/src/main/java/org/springframework/scheduling/support/CronSequenceGenerator.java @@ -53,7 +53,9 @@ import org.springframework.util.StringUtils; * @author Ruslan Sibgatullin * @since 3.0 * @see CronTrigger + * @deprecated as of 5.3, in favor of {@link CronExpression} */ +@Deprecated public class CronSequenceGenerator { private final String expression; @@ -75,23 +77,27 @@ public class CronSequenceGenerator { /** - * Construct a {@link CronSequenceGenerator} from the pattern provided, + * Construct a {@code CronSequenceGenerator} from the pattern provided, * using the default {@link TimeZone}. * @param expression a space-separated list of time fields * @throws IllegalArgumentException if the pattern cannot be parsed * @see java.util.TimeZone#getDefault() + * @deprecated as of 5.3, in favor of {@link CronExpression#parse(String)} */ + @Deprecated public CronSequenceGenerator(String expression) { this(expression, TimeZone.getDefault()); } /** - * Construct a {@link CronSequenceGenerator} from the pattern provided, + * Construct a {@code CronSequenceGenerator} from the pattern provided, * using the specified {@link TimeZone}. * @param expression a space-separated list of time fields * @param timeZone the TimeZone to use for generated trigger times * @throws IllegalArgumentException if the pattern cannot be parsed + * @deprecated as of 5.3, in favor of {@link CronExpression#parse(String)} */ + @Deprecated public CronSequenceGenerator(String expression, TimeZone timeZone) { this.expression = expression; this.timeZone = timeZone; diff --git a/spring-context/src/main/java/org/springframework/scheduling/support/CronTrigger.java b/spring-context/src/main/java/org/springframework/scheduling/support/CronTrigger.java index c98a3134024..3af71481c27 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/support/CronTrigger.java +++ b/spring-context/src/main/java/org/springframework/scheduling/support/CronTrigger.java @@ -16,43 +16,65 @@ package org.springframework.scheduling.support; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.Date; import java.util.TimeZone; import org.springframework.lang.Nullable; import org.springframework.scheduling.Trigger; import org.springframework.scheduling.TriggerContext; +import org.springframework.util.Assert; /** * {@link Trigger} implementation for cron expressions. - * Wraps a {@link CronSequenceGenerator}. + * Wraps a {@link CronExpression}. * * @author Juergen Hoeller + * @author Arjen Poutsma * @since 3.0 - * @see CronSequenceGenerator + * @see CronExpression */ public class CronTrigger implements Trigger { - private final CronSequenceGenerator sequenceGenerator; + private final CronExpression expression; + + private final ZoneId zoneId; /** - * Build a {@link CronTrigger} from the pattern provided in the default time zone. + * Build a {@code CronTrigger} from the pattern provided in the default time zone. * @param expression a space-separated list of time fields, following cron * expression conventions */ public CronTrigger(String expression) { - this.sequenceGenerator = new CronSequenceGenerator(expression); + this(expression, ZoneId.systemDefault()); } /** - * Build a {@link CronTrigger} from the pattern provided in the given time zone. + * Build a {@code CronTrigger} from the pattern provided in the given time zone. * @param expression a space-separated list of time fields, following cron * expression conventions * @param timeZone a time zone in which the trigger times will be generated */ public CronTrigger(String expression, TimeZone timeZone) { - this.sequenceGenerator = new CronSequenceGenerator(expression, timeZone); + this(expression, timeZone.toZoneId()); + } + + /** + * Build a {@code CronTrigger} from the pattern provided in the given time zone. + * @param expression a space-separated list of time fields, following cron + * expression conventions + * @param zoneId a time zone in which the trigger times will be generated + * @since 5.3 + * @see CronExpression#parse(String) + */ + public CronTrigger(String expression, ZoneId zoneId) { + Assert.hasLength(expression, "Expression must not be empty"); + Assert.notNull(zoneId, "ZoneId must not be null"); + + this.expression = CronExpression.parse(expression); + this.zoneId = zoneId; } @@ -60,7 +82,7 @@ public class CronTrigger implements Trigger { * Return the cron pattern that this trigger has been built with. */ public String getExpression() { - return this.sequenceGenerator.getExpression(); + return this.expression.toString(); } @@ -85,24 +107,26 @@ public class CronTrigger implements Trigger { else { date = new Date(); } - return this.sequenceGenerator.next(date); + ZonedDateTime dateTime = ZonedDateTime.ofInstant(date.toInstant(), this.zoneId); + ZonedDateTime next = this.expression.next(dateTime); + return next != null ? Date.from(next.toInstant()) : null; } @Override public boolean equals(@Nullable Object other) { return (this == other || (other instanceof CronTrigger && - this.sequenceGenerator.equals(((CronTrigger) other).sequenceGenerator))); + this.expression.equals(((CronTrigger) other).expression))); } @Override public int hashCode() { - return this.sequenceGenerator.hashCode(); + return this.expression.hashCode(); } @Override public String toString() { - return this.sequenceGenerator.toString(); + return this.expression.toString(); } } diff --git a/spring-context/src/test/java/org/springframework/scheduling/support/BitSetAssert.java b/spring-context/src/test/java/org/springframework/scheduling/support/BitSetAssert.java new file mode 100644 index 00000000000..a22ca83209e --- /dev/null +++ b/spring-context/src/test/java/org/springframework/scheduling/support/BitSetAssert.java @@ -0,0 +1,81 @@ +/* + * 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.util.BitSet; + +import org.assertj.core.api.AbstractAssert; + +/** + * @author Arjen Poutsma + */ +public class BitSetAssert extends AbstractAssert { + + private BitSetAssert(BitSet bitSet) { + super(bitSet, BitSetAssert.class); + } + + public static BitSetAssert assertThat(BitSet actual) { + return new BitSetAssert(actual); + } + + public BitSetAssert hasSet(int... indices) { + isNotNull(); + + for (int index : indices) { + if (!this.actual.get(index)) { + failWithMessage("Invalid disabled bit at @%d", index); + } + } + return this; + } + + public BitSetAssert hasSetRange(int min, int max) { + isNotNull(); + + for (int i = min; i < max; i++) { + if (!this.actual.get(i)) { + failWithMessage("Invalid disabled bit at @%d", i); + } + } + return this; + } + + public BitSetAssert hasUnset(int... indices) { + isNotNull(); + + for (int index : indices) { + if (this.actual.get(index)) { + failWithMessage("Invalid enabled bit at @%d", index); + } + } + return this; + } + + public BitSetAssert hasUnsetRange(int min, int max) { + isNotNull(); + + for (int i = min; i < max; i++) { + if (this.actual.get(i)) { + failWithMessage("Invalid enabled bit at @%d", i); + } + } + return this; + } + +} + 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 new file mode 100644 index 00000000000..b1e2b54e7d1 --- /dev/null +++ b/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java @@ -0,0 +1,434 @@ +/* + * 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.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.Year; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import org.junit.jupiter.api.Test; + +import static java.time.DayOfWeek.MONDAY; +import static java.time.DayOfWeek.TUESDAY; +import static java.time.DayOfWeek.WEDNESDAY; +import static java.time.temporal.TemporalAdjusters.next; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Arjen Poutsma + */ +class CronExpressionTests { + + @Test + void matchAll() { + CronExpression expression = CronExpression.parse("* * * * * *"); + + LocalDateTime last = LocalDateTime.now(); + LocalDateTime expected = last.plusSeconds(1).withNano(0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void matchLastSecond() { + CronExpression expression = CronExpression.parse("* * * * * *"); + + LocalDateTime last = LocalDateTime.now().withSecond(58); + LocalDateTime expected = last.plusSeconds(1).withNano(0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void matchSpecificSecond() { + CronExpression expression = CronExpression.parse("10 * * * * *"); + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime last = now.withSecond(9); + LocalDateTime expected = last.withSecond(10).withNano(0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void incrementSecondByOne() { + CronExpression expression = CronExpression.parse("11 * * * * *"); + + LocalDateTime last = LocalDateTime.now().withSecond(10); + LocalDateTime expected = last.plusSeconds(1).withNano(0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void incrementSecondAndRollover() { + CronExpression expression = CronExpression.parse("10 * * * * *"); + + LocalDateTime last = LocalDateTime.now().withSecond(11); + LocalDateTime expected = last.plusMinutes(1).withSecond(10).withNano(0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void secondRange() { + CronExpression expression = CronExpression.parse("10-15 * * * * *"); + LocalDateTime now = LocalDateTime.now(); + + for (int i = 9; i < 15; i++) { + LocalDateTime last = now.withSecond(i); + LocalDateTime expected = last.plusSeconds(1).withNano(0); + assertThat(expression.next(last)).isEqualTo(expected); + } + } + + @Test + void incrementMinute() { + CronExpression expression = CronExpression.parse("0 * * * * *"); + + LocalDateTime last = LocalDateTime.now().withMinute(10); + LocalDateTime expected = last.plusMinutes(1).withSecond(0).withNano(0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.plusMinutes(1); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void incrementMinuteByOne() { + CronExpression expression = CronExpression.parse("0 11 * * * *"); + + LocalDateTime last = LocalDateTime.now().withMinute(10); + LocalDateTime expected = last.plusMinutes(1).withSecond(0).withNano(0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void incrementMinuteAndRollover() { + CronExpression expression = CronExpression.parse("0 10 * * * *"); + + LocalDateTime last = LocalDateTime.now().withMinute(11).withSecond(0); + LocalDateTime expected = last.plusMinutes(59).withNano(0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void incrementHour() { + CronExpression expression = CronExpression.parse("0 0 * * * *"); + + int year = Year.now().getValue(); + LocalDateTime last = LocalDateTime.of(year, 10, 30, 11, 1); + LocalDateTime expected = last.withHour(12).withMinute(0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.withHour(13); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void incrementHourAndRollover() { + CronExpression expression = CronExpression.parse("0 0 * * * *"); + + int year = Year.now().getValue(); + LocalDateTime last = LocalDateTime.of(year, 9, 10, 23, 1); + LocalDateTime expected = last.withDayOfMonth(11).withHour(0).withMinute(0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void incrementDayOfMonth() { + CronExpression expression = CronExpression.parse("0 0 0 * * *"); + + LocalDateTime last = LocalDateTime.now().withDayOfMonth(1); + LocalDateTime expected = last.plusDays(1).withHour(0).withMinute(0).withSecond(0).withNano(0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.plusDays(1); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void incrementDayOfMonthByOne() { + CronExpression expression = CronExpression.parse("* * * 10 * *"); + + LocalDateTime last = LocalDateTime.now().withDayOfMonth(9); + LocalDateTime expected = last.plusDays(1).withHour(0).withMinute(0).withSecond(0).withNano(0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void incrementDayOfMonthAndRollover() { + CronExpression expression = CronExpression.parse("* * * 10 * *"); + + LocalDateTime last = LocalDateTime.now().withDayOfMonth(11); + LocalDateTime expected = + last.plusMonths(1).withDayOfMonth(10).withHour(0).withMinute(0).withSecond(0).withNano(0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void dailyTriggerInShortMonth() { + CronExpression expression = CronExpression.parse("0 0 0 * * *"); + + // September: 30 days + LocalDateTime last = LocalDateTime.now().withMonth(9).withDayOfMonth(30); + LocalDateTime expected = LocalDateTime.of(last.getYear(), 10, 1, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.withDayOfMonth(2); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void dailyTriggerInLongMonth() { + CronExpression expression = CronExpression.parse("0 0 0 * * *"); + + // August: 31 days and not a daylight saving boundary + LocalDateTime last = LocalDateTime.now().withMonth(8).withDayOfMonth(30); + LocalDateTime expected = last.plusDays(1).withHour(0).withMinute(0).withSecond(0).withNano(0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.plusDays(1); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void dailyTriggerOnDaylightSavingBoundary() { + CronExpression expression = CronExpression.parse("0 0 0 * * *"); + + // October: 31 days and a daylight saving boundary in CET + ZonedDateTime last = ZonedDateTime.now(ZoneId.of("CET")).withMonth(10).withDayOfMonth(30); + ZonedDateTime expected = last.withDayOfMonth(31).withHour(0).withMinute(0).withSecond(0).withNano(0); + ZonedDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.withMonth(11).withDayOfMonth(1); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void incrementMonth() { + CronExpression expression = CronExpression.parse("0 0 0 1 * *"); + + LocalDateTime last = LocalDateTime.now().withMonth(10).withDayOfMonth(30); + LocalDateTime expected = LocalDateTime.of(last.getYear(), 11, 1, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.withMonth(12); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void incrementMonthAndRollover() { + CronExpression expression = CronExpression.parse("0 0 0 1 * *"); + + LocalDateTime last = LocalDateTime.now().withYear(2010).withMonth(12).withDayOfMonth(31); + LocalDateTime expected = LocalDateTime.of(2011, 1, 1, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.plusMonths(1); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void monthlyTriggerInLongMonth() { + CronExpression expression = CronExpression.parse("0 0 0 31 * *"); + + LocalDateTime last = LocalDateTime.now().withMonth(10).withDayOfMonth(30); + LocalDateTime expected = last.withDayOfMonth(31).withHour(0).withMinute(0).withSecond(0).withNano(0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void monthlyTriggerInShortMonth() { + CronExpression expression = CronExpression.parse("0 0 0 1 * *"); + + LocalDateTime last = LocalDateTime.now().withMonth(10).withDayOfMonth(30); + LocalDateTime expected = LocalDateTime.of(last.getYear(), 11, 1, 0, 0); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void incrementDayOfWeekByOne() { + CronExpression expression = CronExpression.parse("* * * * * 2"); + + LocalDateTime last = LocalDateTime.now().with(next(MONDAY)); + LocalDateTime expected = last.plusDays(1).withHour(0).withMinute(0).withSecond(0).withNano(0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(TUESDAY); + } + + @Test + void incrementDayOfWeekAndRollover() { + CronExpression expression = CronExpression.parse("* * * * * 2"); + + LocalDateTime last = LocalDateTime.now().with(next(WEDNESDAY)); + LocalDateTime expected = last.plusDays(6).withHour(0).withMinute(0).withSecond(0).withNano(0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(TUESDAY); + } + + @Test + void specificMinuteSecond() { + CronExpression expression = CronExpression.parse("55 5 * * * *"); + + LocalDateTime last = LocalDateTime.now().withMinute(4).withSecond(54); + LocalDateTime expected = last.plusMinutes(1).withSecond(55).withNano(0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.plusHours(1); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void specificHourSecond() { + CronExpression expression = CronExpression.parse("55 * 10 * * *"); + + LocalDateTime last = LocalDateTime.now().withHour(9).withSecond(54); + LocalDateTime expected = last.plusHours(1).withMinute(0).withSecond(55).withNano(0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.plusMinutes(1); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void specificMinuteHour() { + CronExpression expression = CronExpression.parse("* 5 10 * * *"); + + LocalDateTime last = LocalDateTime.now().withHour(9).withMinute(4); + LocalDateTime expected = last.plusHours(1).plusMinutes(1).withSecond(0).withNano(0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + // next trigger is in one second because second is wildcard + expected = expected.plusSeconds(1); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void specificDayOfMonthSecond() { + CronExpression expression = CronExpression.parse("55 * * 3 * *"); + + LocalDateTime last = LocalDateTime.now().withDayOfMonth(2).withSecond(54); + LocalDateTime expected = last.plusDays(1).withHour(0).withMinute(0).withSecond(55).withNano(0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.plusMinutes(1); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void specificDate() { + CronExpression expression = CronExpression.parse("* * * 3 11 *"); + + LocalDateTime last = LocalDateTime.now().withMonth(10).withDayOfMonth(2); + LocalDateTime expected = LocalDateTime.of(last.getYear(), 11, 3, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.plusSeconds(1); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void nonExistentSpecificDate() { + CronExpression expression = CronExpression.parse("0 0 0 31 6 *"); + + LocalDateTime last = LocalDateTime.now().withMonth(3).withDayOfMonth(10); + assertThat(expression.next(last)).isNull(); + } + + @Test + void leapYearSpecificDate() { + CronExpression expression = CronExpression.parse("0 0 0 29 2 *"); + + LocalDateTime last = LocalDateTime.now().withYear(2007).withMonth(2).withDayOfMonth(10); + LocalDateTime expected = LocalDateTime.of(2008, 2, 29, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.plusYears(4); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void weekDaySequence() { + CronExpression expression = CronExpression.parse("0 0 7 ? * MON-FRI"); + + // This is a Saturday + LocalDateTime last = LocalDateTime.of(LocalDate.of(2009, 9, 26), LocalTime.now()); + LocalDateTime expected = last.plusDays(2).withHour(7).withMinute(0).withSecond(0).withNano(0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + // Next day is a week day so add one + last = actual; + expected = expected.plusDays(1); + actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.plusDays(1); + assertThat(expression.next(last)).isEqualTo(expected); + } + + @Test + void monthSequence() { + CronExpression expression = CronExpression.parse("0 30 23 30 1/3 ?"); + + LocalDateTime last = LocalDateTime.of(LocalDate.of(2010, 12, 30), LocalTime.now()); + LocalDateTime expected = last.plusMonths(1).withHour(23).withMinute(30).withSecond(0).withSecond(0).withNano(0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + // Next trigger is 3 months later + last = actual; + expected = expected.plusMonths(3); + actual = expression.next(last); + assertThat(actual).isEqualTo(expected); + + last = actual; + expected = expected.plusMonths(3); + assertThat(expression.next(last)).isEqualTo(expected); + } + +} 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 new file mode 100644 index 00000000000..7e0d157b3a4 --- /dev/null +++ b/spring-context/src/test/java/org/springframework/scheduling/support/CronFieldTests.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 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/CronTriggerTests.java b/spring-context/src/test/java/org/springframework/scheduling/support/CronTriggerTests.java index 2f326ae74b9..119b5bdbd27 100644 --- a/spring-context/src/test/java/org/springframework/scheduling/support/CronTriggerTests.java +++ b/spring-context/src/test/java/org/springframework/scheduling/support/CronTriggerTests.java @@ -557,7 +557,7 @@ class CronTriggerTests { this.calendar.set(Calendar.MONTH, 2); Date localDate = this.calendar.getTime(); TriggerContext context1 = getTriggerContext(localDate); - assertThatIllegalArgumentException().isThrownBy(() -> trigger.nextExecutionTime(context1)); + assertThat(trigger.nextExecutionTime(context1)).isNull(); } @ParameterizedCronTriggerTest