From f0b9905b9d37d2edd4714572931d18d3df89098e Mon Sep 17 00:00:00 2001 From: Marc Baloup Date: Wed, 29 Dec 2021 01:43:28 +0100 Subject: [PATCH] Added cron expression parser so we can have one-line scheduling config entries --- .../lib/core/util/CronExpression.java | 654 ++++++++++++++++++ 1 file changed, 654 insertions(+) create mode 100644 Core/src/main/java/fr/pandacube/lib/core/util/CronExpression.java diff --git a/Core/src/main/java/fr/pandacube/lib/core/util/CronExpression.java b/Core/src/main/java/fr/pandacube/lib/core/util/CronExpression.java new file mode 100644 index 0000000..f4c9247 --- /dev/null +++ b/Core/src/main/java/fr/pandacube/lib/core/util/CronExpression.java @@ -0,0 +1,654 @@ +package fr.pandacube.lib.core.util; + +// from https://github.com/frode-carlsen/cron/blob/master/java8/src/main/java/fc/cron/CronExpression.java + +// if there are changes, indicate them here (there is none currently) + +/* + * Copyright (C) 2012- Frode Carlsen. + * + * 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 + * + * http://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. + * + * Note: rewritten to standard Java 8 DateTime by zemiak (c) 2016 + */ +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * This provides cron support for java8 using java-time. + *

+ * + * Parser for unix-like cron expressions: Cron expressions allow specifying combinations of criteria for time + * such as: "Each Monday-Friday at 08:00" or "Every last friday of the month at 01:30" + *

+ * A cron expressions consists of 5 or 6 mandatory fields (seconds may be omitted) separated by space.
+ * These are: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Field Allowable values Special Characters
Seconds (may be omitted)  + * 0-59  + * , - * /
Minutes  + * 0-59  + * , - * /
Hours  + * 0-23  + * , - * /
Day of month  + * 1-31  + * , - * ? / L W
Month  + * 1-12 or JAN-DEC (note: english abbreviations)  + * , - * /
Day of week  + * 1-7 or MON-SUN (note: english abbreviations)  + * , - * ? / L #
+ * + *

+ * '*' Can be used in all fields and means 'for all values'. E.g. "*" in minutes, means 'for all minutes' + *

+ * '?' Can be used in Day-of-month and Day-of-week fields. Used to signify 'no special value'. It is used when one want + * to specify something for one of those two fields, but not the other. + *

+ * '-' Used to specify a time interval. E.g. "10-12" in Hours field means 'for hours 10, 11 and 12' + *

+ * ',' Used to specify multiple values for a field. E.g. "MON,WED,FRI" in Day-of-week field means "for + * monday, wednesday and friday" + *

+ * '/' Used to specify increments. E.g. "0/15" in Seconds field means "for seconds 0, 15, 30, ad + * 45". And "5/15" in seconds field means "for seconds 5, 20, 35, and 50". If '*' s specified + * before '/' it is the same as saying it starts at 0. For every field there's a list of values that can be turned on or + * off. For Seconds and Minutes these range from 0-59. For Hours from 0 to 23, For Day-of-month it's 1 to 31, For Months + * 1 to 12. "/" character helsp turn some of these values back on. Thus "7/6" in Months field + * specify just Month 7. It doesn't turn on every 6 month following, since cron fields never roll over + *

+ * 'L' Can be used on Day-of-month and Day-of-week fields. It signifies last day of the set of allowed values. In + * Day-of-month field it's the last day of the month (e.g.. 31 jan, 28 feb (29 in leap years), 31 march, etc.). In + * Day-of-week field it's Sunday. If there's a prefix, this will be subtracted (5L in Day-of-month means 5 days before + * last day of Month: 26 jan, 23 feb, etc.) + *

+ * 'W' Can be specified in Day-of-Month field. It specifies closest weekday (monday-friday). Holidays are not accounted + * for. "15W" in Day-of-Month field means 'closest weekday to 15 i in given month'. If the 15th is a Saturday, + * it gives Friday. If 15th is a Sunday, the it gives following Monday. + *

+ * '#' Can be used in Day-of-Week field. For example: "5#3" means 'third friday in month' (day 5 = friday, #3 + * - the third). If the day does not exist (e.g. "5#5" - 5th friday of month) and there aren't 5 fridays in + * the month, then it won't match until the next month with 5 fridays. + *

+ * Case-sensitive No fields are case-sensitive + *

+ * Dependencies between fields Fields are always evaluated independently, but the expression doesn't match until + * the constraints of each field are met. Overlap of intervals are not allowed. That is: for + * Day-of-week field "FRI-MON" is invalid,but "FRI-SUN,MON" is valid + * + */ +public class CronExpression { + + enum CronFieldType { + SECOND(0, 59, null) { + @Override + int getValue(ZonedDateTime dateTime) { + return dateTime.getSecond(); + } + + @Override + ZonedDateTime setValue(ZonedDateTime dateTime, int value) { + return dateTime.withSecond(value).withNano(0); + } + + @Override + ZonedDateTime overflow(ZonedDateTime dateTime) { + return dateTime.plusMinutes(1).withSecond(0).withNano(0); + } + }, + MINUTE(0, 59, null) { + @Override + int getValue(ZonedDateTime dateTime) { + return dateTime.getMinute(); + } + + @Override + ZonedDateTime setValue(ZonedDateTime dateTime, int value) { + return dateTime.withMinute(value).withSecond(0).withNano(0); + } + + @Override + ZonedDateTime overflow(ZonedDateTime dateTime) { + return dateTime.plusHours(1).withMinute(0).withSecond(0).withNano(0); + } + }, + HOUR(0, 23, null) { + @Override + int getValue(ZonedDateTime dateTime) { + return dateTime.getHour(); + } + + @Override + ZonedDateTime setValue(ZonedDateTime dateTime, int value) { + return dateTime.withHour(value).withMinute(0).withSecond(0).withNano(0); + } + + @Override + ZonedDateTime overflow(ZonedDateTime dateTime) { + return dateTime.plusDays(1).withHour(0).withMinute(0).withSecond(0).withNano(0); + } + }, + DAY_OF_MONTH(1, 31, null) { + @Override + int getValue(ZonedDateTime dateTime) { + return dateTime.getDayOfMonth(); + } + + @Override + ZonedDateTime setValue(ZonedDateTime dateTime, int value) { + return dateTime.withDayOfMonth(value).withHour(0).withMinute(0).withSecond(0).withNano(0); + } + + @Override + ZonedDateTime overflow(ZonedDateTime dateTime) { + return dateTime.plusMonths(1).withDayOfMonth(0).withHour(0).withMinute(0).withSecond(0).withNano(0); + } + }, + MONTH(1, 12, + Arrays.asList("JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC")) { + @Override + int getValue(ZonedDateTime dateTime) { + return dateTime.getMonthValue(); + } + + @Override + ZonedDateTime setValue(ZonedDateTime dateTime, int value) { + return dateTime.withMonth(value).withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0); + } + + @Override + ZonedDateTime overflow(ZonedDateTime dateTime) { + return dateTime.plusYears(1).withMonth(1).withHour(0).withDayOfMonth(1).withMinute(0).withSecond(0).withNano(0); + } + }, + DAY_OF_WEEK(1, 7, Arrays.asList("MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN")) { + @Override + int getValue(ZonedDateTime dateTime) { + return dateTime.getDayOfWeek().getValue(); + } + + @Override + ZonedDateTime setValue(ZonedDateTime dateTime, int value) { + throw new UnsupportedOperationException(); + } + + @Override + ZonedDateTime overflow(ZonedDateTime dateTime) { + throw new UnsupportedOperationException(); + } + }; + + final int from, to; + final List names; + + CronFieldType(int from, int to, List names) { + this.from = from; + this.to = to; + this.names = names; + } + + /** + * @param dateTime {@link ZonedDateTime} instance + * @return The field time or date value from {@code dateTime} + */ + abstract int getValue(ZonedDateTime dateTime); + + /** + * @param dateTime Initial {@link ZonedDateTime} instance to use + * @param value to set for this field in {@code dateTime} + * @return {@link ZonedDateTime} with {@code value} set for this field and all smaller fields cleared + */ + abstract ZonedDateTime setValue(ZonedDateTime dateTime, int value); + + /** + * Handle when this field overflows and the next higher field should be incremented + * + * @param dateTime Initial {@link ZonedDateTime} instance to use + * @return {@link ZonedDateTime} with the next greater field incremented and all smaller fields cleared + */ + abstract ZonedDateTime overflow(ZonedDateTime dateTime); + } + + private final String expr; + private final SimpleField secondField; + private final SimpleField minuteField; + private final SimpleField hourField; + private final DayOfWeekField dayOfWeekField; + private final SimpleField monthField; + private final DayOfMonthField dayOfMonthField; + + public CronExpression(final String expr) { + this(expr, true); + } + + public CronExpression(final String expr, final boolean withSeconds) { + if (expr == null) { + throw new IllegalArgumentException("expr is null"); //$NON-NLS-1$ + } + + this.expr = expr; + + final int expectedParts = withSeconds ? 6 : 5; + final String[] parts = expr.split("\\s+"); //$NON-NLS-1$ + if (parts.length != expectedParts) { + throw new IllegalArgumentException(String.format("Invalid cron expression [%s], expected %s field, got %s", expr, expectedParts, parts.length)); + } + + int ix = withSeconds ? 1 : 0; + this.secondField = new SimpleField(CronFieldType.SECOND, withSeconds ? parts[0] : "0"); + this.minuteField = new SimpleField(CronFieldType.MINUTE, parts[ix++]); + this.hourField = new SimpleField(CronFieldType.HOUR, parts[ix++]); + this.dayOfMonthField = new DayOfMonthField(parts[ix++]); + this.monthField = new SimpleField(CronFieldType.MONTH, parts[ix++]); + this.dayOfWeekField = new DayOfWeekField(parts[ix++]); + } + + public static CronExpression create(final String expr) { + return new CronExpression(expr, true); + } + + public static CronExpression createWithoutSeconds(final String expr) { + return new CronExpression(expr, false); + } + + public ZonedDateTime nextTimeAfter(ZonedDateTime afterTime) { + // will search for the next time within the next 4 years. If there is no + // time matching, an InvalidArgumentException will be thrown (it is very + // likely that the cron expression is invalid, like the February 30th). + return nextTimeAfter(afterTime, afterTime.plusYears(4)); + } + + public LocalDateTime nextLocalDateTimeAfter(LocalDateTime dateTime) { + return nextTimeAfter(ZonedDateTime.of(dateTime, ZoneId.systemDefault())).toLocalDateTime(); + } + + public ZonedDateTime nextTimeAfter(ZonedDateTime afterTime, long durationInMillis) { + // will search for the next time within the next durationInMillis + // millisecond. Be aware that the duration is specified in millis, + // but in fact the limit is checked on a day-to-day basis. + return nextTimeAfter(afterTime, afterTime.plus(Duration.ofMillis(durationInMillis))); + } + + public ZonedDateTime nextTimeAfter(ZonedDateTime afterTime, ZonedDateTime dateTimeBarrier) { + ZonedDateTime[] nextDateTime = { afterTime.plusSeconds(1).withNano(0) }; + + while (true) { + checkIfDateTimeBarrierIsReached(nextDateTime[0], dateTimeBarrier); + if (!monthField.nextMatch(nextDateTime)) { + continue; + } + if (!findDay(nextDateTime, dateTimeBarrier)) { + continue; + } + if (!hourField.nextMatch(nextDateTime)) { + continue; + } + if (!minuteField.nextMatch(nextDateTime)) { + continue; + } + if (!secondField.nextMatch(nextDateTime)) { + continue; + } + + checkIfDateTimeBarrierIsReached(nextDateTime[0], dateTimeBarrier); + return nextDateTime[0]; + } + } + + /** + * Find the next match for the day field. + *

+ * This is handled different than all other fields because there are two ways to describe the day and it is easier + * to handle them together in the same method. + * + * @param dateTime Initial {@link ZonedDateTime} instance to start from + * @param dateTimeBarrier At which point stop searching for next execution time + * @return {@code true} if a match was found for this field or {@code false} if the field overflowed + * @see {@link SimpleField#nextMatch(ZonedDateTime[])} + */ + private boolean findDay(ZonedDateTime[] dateTime, ZonedDateTime dateTimeBarrier) { + int month = dateTime[0].getMonthValue(); + + while (!(dayOfMonthField.matches(dateTime[0].toLocalDate()) + && dayOfWeekField.matches(dateTime[0].toLocalDate()))) { + dateTime[0] = dateTime[0].plusDays(1).withHour(0).withMinute(0).withSecond(0).withNano(0); + if (dateTime[0].getMonthValue() != month) { + return false; + } + } + return true; + } + + private static void checkIfDateTimeBarrierIsReached(ZonedDateTime nextTime, ZonedDateTime dateTimeBarrier) { + if (nextTime.isAfter(dateTimeBarrier)) { + throw new IllegalArgumentException("No next execution time could be determined that is before the limit of " + dateTimeBarrier); + } + } + + @Override + public String toString() { + return getClass().getSimpleName() + "<" + expr + ">"; + } + + static class FieldPart implements Comparable { + private int from = -1, to = -1, increment = -1; + private String modifier, incrementModifier; + + @Override + public int compareTo(FieldPart o) { + return Integer.compare(from, o.from); + } + } + + abstract static class BasicField { + private static final Pattern CRON_FIELD_REGEXP = Pattern + .compile("(?: # start of group 1\n" + + " (?:(?\\*)|(?\\?)|(?L)) # global flag (L, ?, *)\n" + + " | (?[0-9]{1,2}|[a-z]{3,3}) # or start number or symbol\n" + + " (?: # start of group 2\n" + + " (?L|W) # modifier (L,W)\n" + + " | -(?[0-9]{1,2}|[a-z]{3,3}) # or end nummer or symbol (in range)\n" + + " )? # end of group 2\n" + + ") # end of group 1\n" + + "(?:(?/|\\#)(?[0-9]{1,7}))? # increment and increment modifier (/ or \\#)\n", + Pattern.CASE_INSENSITIVE | Pattern.COMMENTS); + + final CronFieldType fieldType; + final List parts = new ArrayList<>(); + + private BasicField(CronFieldType fieldType, String fieldExpr) { + this.fieldType = fieldType; + parse(fieldExpr); + } + + private void parse(String fieldExpr) { // NOSONAR + String[] rangeParts = fieldExpr.split(","); + for (String rangePart : rangeParts) { + Matcher m = CRON_FIELD_REGEXP.matcher(rangePart); + if (!m.matches()) { + throw new IllegalArgumentException("Invalid cron field '" + rangePart + "' for field [" + fieldType + "]"); + } + String startNummer = m.group("start"); + String modifier = m.group("mod"); + String sluttNummer = m.group("end"); + String incrementModifier = m.group("incmod"); + String increment = m.group("inc"); + + FieldPart part = new FieldPart(); + part.increment = 999; + if (startNummer != null) { + part.from = mapValue(startNummer); + part.modifier = modifier; + if (sluttNummer != null) { + part.to = mapValue(sluttNummer); + part.increment = 1; + } else if (increment != null) { + part.to = fieldType.to; + } else { + part.to = part.from; + } + } else if (m.group("all") != null) { + part.from = fieldType.from; + part.to = fieldType.to; + part.increment = 1; + } else if (m.group("ignore") != null) { + part.modifier = m.group("ignore"); + } else if (m.group("last") != null) { + part.modifier = m.group("last"); + } else { + throw new IllegalArgumentException("Invalid cron part: " + rangePart); + } + + if (increment != null) { + part.incrementModifier = incrementModifier; + part.increment = Integer.parseInt(increment); + } + + validateRange(part); + validatePart(part); + parts.add(part); + } + + Collections.sort(parts); + } + + protected void validatePart(FieldPart part) { + if (part.modifier != null) { + throw new IllegalArgumentException(String.format("Invalid modifier [%s]", part.modifier)); + } else if (part.incrementModifier != null && !"/".equals(part.incrementModifier)) { + throw new IllegalArgumentException(String.format("Invalid increment modifier [%s]", part.incrementModifier)); + } + } + + private void validateRange(FieldPart part) { + if ((part.from != -1 && part.from < fieldType.from) || (part.to != -1 && part.to > fieldType.to)) { + throw new IllegalArgumentException(String.format("Invalid interval [%s-%s], must be %s<=_<=%s", part.from, part.to, fieldType.from, + fieldType.to)); + } else if (part.from != -1 && part.to != -1 && part.from > part.to) { + throw new IllegalArgumentException( + String.format( + "Invalid interval [%s-%s]. Rolling periods are not supported (ex. 5-1, only 1-5) since this won't give a deterministic result. Must be %s<=_<=%s", + part.from, part.to, fieldType.from, fieldType.to)); + } + } + + protected int mapValue(String value) { + int idx; + if (fieldType.names != null && (idx = fieldType.names.indexOf(value.toUpperCase(Locale.getDefault()))) >= 0) { + return idx + fieldType.from; + } + return Integer.parseInt(value); + } + + protected boolean matches(int val, FieldPart part) { + if (val >= part.from && val <= part.to && (val - part.from) % part.increment == 0) { + return true; + } + return false; + } + + protected int nextMatch(int val, FieldPart part) { + if (val > part.to) { + return -1; + } + int nextPotential = Math.max(val, part.from); + if (part.increment == 1 || nextPotential == part.from) { + return nextPotential; + } + + int remainder = ((nextPotential - part.from) % part.increment); + if (remainder != 0) { + nextPotential += part.increment - remainder; + } + + return nextPotential <= part.to ? nextPotential : -1; + } + } + + static class SimpleField extends BasicField { + SimpleField(CronFieldType fieldType, String fieldExpr) { + super(fieldType, fieldExpr); + } + + public boolean matches(int val) { + if (val >= fieldType.from && val <= fieldType.to) { + for (FieldPart part : parts) { + if (matches(val, part)) { + return true; + } + } + } + return false; + } + + /** + * Find the next match for this field. If a match cannot be found force an overflow and increase the next + * greatest field. + * + * @param dateTime {@link ZonedDateTime} array so the reference can be modified + * @return {@code true} if a match was found for this field or {@code false} if the field overflowed + */ + protected boolean nextMatch(ZonedDateTime[] dateTime) { + int value = fieldType.getValue(dateTime[0]); + + for (FieldPart part : parts) { + int nextMatch = nextMatch(value, part); + if (nextMatch > -1) { + if (nextMatch != value) { + dateTime[0] = fieldType.setValue(dateTime[0], nextMatch); + } + return true; + } + } + + dateTime[0] = fieldType.overflow(dateTime[0]); + return false; + } + } + + static class DayOfWeekField extends BasicField { + + DayOfWeekField(String fieldExpr) { + super(CronFieldType.DAY_OF_WEEK, fieldExpr); + } + + boolean matches(LocalDate dato) { + for (FieldPart part : parts) { + if ("L".equals(part.modifier)) { + YearMonth ym = YearMonth.of(dato.getYear(), dato.getMonth().getValue()); + return dato.getDayOfWeek() == DayOfWeek.of(part.from) && dato.getDayOfMonth() > (ym.lengthOfMonth() - 7); + } else if ("#".equals(part.incrementModifier)) { + if (dato.getDayOfWeek() == DayOfWeek.of(part.from)) { + int num = dato.getDayOfMonth() / 7; + return part.increment == (dato.getDayOfMonth() % 7 == 0 ? num : num + 1); + } + return false; + } else if (matches(dato.getDayOfWeek().getValue(), part)) { + return true; + } + } + return false; + } + + @Override + protected int mapValue(String value) { + // Use 1-7 for weedays, but 0 will also represent sunday (linux practice) + return "0".equals(value) ? 7 : super.mapValue(value); + } + + @Override + protected boolean matches(int val, FieldPart part) { + return "?".equals(part.modifier) || super.matches(val, part); + } + + @Override + protected void validatePart(FieldPart part) { + if (part.modifier != null && Arrays.asList("L", "?").indexOf(part.modifier) == -1) { + throw new IllegalArgumentException(String.format("Invalid modifier [%s]", part.modifier)); + } else if (part.incrementModifier != null && Arrays.asList("/", "#").indexOf(part.incrementModifier) == -1) { + throw new IllegalArgumentException(String.format("Invalid increment modifier [%s]", part.incrementModifier)); + } + } + } + + static class DayOfMonthField extends BasicField { + DayOfMonthField(String fieldExpr) { + super(CronFieldType.DAY_OF_MONTH, fieldExpr); + } + + boolean matches(LocalDate dato) { + for (FieldPart part : parts) { + if ("L".equals(part.modifier)) { + YearMonth ym = YearMonth.of(dato.getYear(), dato.getMonth().getValue()); + return dato.getDayOfMonth() == (ym.lengthOfMonth() - (part.from == -1 ? 0 : part.from)); + } else if ("W".equals(part.modifier)) { + if (dato.getDayOfWeek().getValue() <= 5) { + if (dato.getDayOfMonth() == part.from) { + return true; + } else if (dato.getDayOfWeek().getValue() == 5) { + return dato.plusDays(1).getDayOfMonth() == part.from; + } else if (dato.getDayOfWeek().getValue() == 1) { + return dato.minusDays(1).getDayOfMonth() == part.from; + } + } + } else if (matches(dato.getDayOfMonth(), part)) { + return true; + } + } + return false; + } + + @Override + protected void validatePart(FieldPart part) { + if (part.modifier != null && Arrays.asList("L", "W", "?").indexOf(part.modifier) == -1) { + throw new IllegalArgumentException(String.format("Invalid modifier [%s]", part.modifier)); + } else if (part.incrementModifier != null && !"/".equals(part.incrementModifier)) { + throw new IllegalArgumentException(String.format("Invalid increment modifier [%s]", part.incrementModifier)); + } + } + + @Override + protected boolean matches(int val, FieldPart part) { + return "?".equals(part.modifier) || super.matches(val, part); + } + } +} \ No newline at end of file