/**
* Copyright 2015 Dhatim
*
* 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.
*/
package org.dhatim.businesshours;
import java.time.temporal.ChronoField;
import java.time.temporal.Temporal;
import java.time.temporal.TemporalField;
import java.time.temporal.ValueRange;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.function.BinaryOperator;
import java.util.stream.Collectors;
import java.util.stream.LongStream;
import java.util.stream.Stream;
/**
* A representation of cron expressions.
* @author Maxime Suret
*/
public class CronExpression {
private static TemporalField[] CRON_FIELDS = {
ChronoField.MINUTE_OF_HOUR,
ChronoField.HOUR_OF_DAY,
ChronoField.DAY_OF_MONTH,
ChronoField.MONTH_OF_YEAR,
ChronoField.DAY_OF_WEEK};
private final Map<TemporalField, SortedSet<Integer>> fieldValues;
/**
* Create a CronExpression that triggers when the given temporal occurs.
* Only the following fields of the temporal (if they are supported) are
* supported:
* <ul>
* <li>{@link ChronoField#MINUTE_OF_HOUR}</li>
* <li>{@link ChronoField#HOUR_OF_DAY}</li>
* <li>{@link ChronoField#DAY_OF_MONTH}</li>
* <li>{@link ChronoField#MONTH_OF_YEAR}</li>
* <li>{@link ChronoField#DAY_OF_WEEK}</li>
* </ul>
*
* @param temporal the temporal from which to build the cron expression
*/
public CronExpression(Temporal temporal) {
//get the field values that are supported by the temporal
fieldValues = new HashMap<>(CRON_FIELDS.length);
long minSupportedUnitDuration = Long.MAX_VALUE;
List<TemporalField> unsupportedFields = new ArrayList<>(CRON_FIELDS.length);
for (TemporalField field : CRON_FIELDS) {
if (temporal.isSupported(field)) {
fieldValues.put(field, new TreeSet<>(Collections.singleton(temporal.get(field))));
minSupportedUnitDuration = BinaryOperator.<Long>minBy(Comparator.naturalOrder())
.apply(minSupportedUnitDuration, field.getBaseUnit().getDuration().getSeconds());
} else {
unsupportedFields.add(field);
}
}
//fields not supported by the temporal: either any value or only the minimum value is acceptable,
//depending on which side of the supported fields it is
for (TemporalField field : unsupportedFields) {
fieldValues.put(field, new TreeSet<>(
(field.getBaseUnit().getDuration().getSeconds() > minSupportedUnitDuration
? LongStream.rangeClosed(field.range().getMinimum(), field.range().getMaximum())
: LongStream.of(field.range().getMinimum()))
.mapToInt(value -> field.range().checkValidIntValue(value, field))
.mapToObj(Integer::valueOf)
.collect(Collectors.toSet())));
}
}
private CronExpression(Map<TemporalField, SortedSet<Integer>> fieldValues) {
this.fieldValues = Objects.requireNonNull(fieldValues);
}
private boolean canMergeWith(CronExpression other) {
//Two crons can be merged if there is at most one difference between them
return fieldValues
.entrySet()
.stream()
.mapToInt(entry -> other.fieldValues.get(entry.getKey()).equals(entry.getValue()) ? 0 : 1)
.sum() <= 1;
}
private CronExpression merge(CronExpression other) {
//merge the corresponding fields
return new CronExpression(
fieldValues
.entrySet()
.stream()
.collect(Collectors.<Map.Entry<TemporalField, SortedSet<Integer>>, TemporalField, SortedSet<Integer>>toMap(
Map.Entry::getKey,
entry -> Stream.concat(
entry.getValue().stream(),
other.fieldValues.get(entry.getKey()).stream())
.collect(Collectors.toCollection(TreeSet::new)))));
}
/** {@inheritDoc} */
@Override
public int hashCode() {
return fieldValues.hashCode();
}
/**
* Tells if this cron expression is equals to the given one.
* Two cron expresions are equals if they trigger at exactly the same instants.
* @param obj the object to this expression compare to
* @return true if the expressions are equals, false otherwise
*/
@Override
public boolean equals(Object obj) {
return Optional
.ofNullable(obj)
.filter(CronExpression.class::isInstance)
.filter(other -> fieldValues.equals(((CronExpression) other).fieldValues))
.isPresent();
}
/**
* Convert this cron expression into its string representation.
*
* @see <a href="https://en.wikipedia.org/wiki/Cron#CRON_expression">the
* cron expression format</a>
* @return the string representation of this cron expression
*/
@Override
public String toString() {
return Arrays.stream(CRON_FIELDS)
.map(field -> fieldToString(field.range(), fieldValues.get(field)))
.collect(Collectors.joining(" "));
}
private static String fieldToString(ValueRange range, SortedSet<Integer> fieldValues) {
return fieldValues.first().equals(range.checkValidIntValue(range.getMinimum(), null))
&& fieldValues.last().equals(range.checkValidIntValue(range.getMaximum(), null)) ? "*"
: toRanges(fieldValues)
.stream()
.map(CronExpression::rangeToString)
.collect(Collectors.joining(","));
}
private static Set<ValueRange> toRanges(SortedSet<Integer> fieldValues) {
Set<ValueRange> ranges = new HashSet<>();
ValueRange currentRange = null;
for (Integer value : fieldValues) {
if (currentRange == null) {
currentRange = ValueRange.of(value, value);
} else if (currentRange.getMaximum() == value - 1) {
currentRange = ValueRange.of(currentRange.getMinimum(), value);
} else {
ranges.add(currentRange);
currentRange = ValueRange.of(value, value);
}
}
ranges.add(currentRange);
return ranges;
}
private static String rangeToString(ValueRange range) {
return range.getMinimum() == range.getMaximum()
? String.valueOf(range.getMinimum())
: range.getMinimum() + "-" + range.getMaximum();
}
/**
* Merge the given cron expressions where it is possible.
* For instance, <code>"2 18 * * *"</code> and <code>"4 18 * * *"</code>
* can be merged in <code>"2,4 18 * * *"</code>.
* @param crons the cron expressions to merge
* @return the merged cron expressions.
* They will trigger at the exact same moments as the input cron expressions.
*/
public static Set<CronExpression> merge(Collection<CronExpression> crons) {
Set<CronExpression> mergedCrons = new HashSet<>(crons.size());
for (CronExpression cronToMerge : crons) {
CronExpression mergeWith = null;
for (CronExpression cron : mergedCrons) {
if (cron.canMergeWith(cronToMerge)) {
mergeWith = cron;
break;
}
}
if (mergeWith == null) {
mergedCrons.add(cronToMerge);
} else {
mergedCrons.remove(mergeWith);
mergedCrons.add(mergeWith.merge(cronToMerge));
}
}
return mergedCrons;
}
}