/**
* 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.DateTimeException;
import java.time.Duration;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;
import java.time.temporal.Temporal;
import java.time.temporal.TemporalAccessor;
import java.time.temporal.TemporalField;
import java.time.temporal.TemporalQueries;
import java.time.temporal.TemporalQuery;
import java.time.temporal.TemporalUnit;
import java.time.temporal.UnsupportedTemporalTypeException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Optional;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeMap;
/**
* A temporal containing fields that can be used to define business hours. These
* fields must be continguous and have a fixed range.
*
* @author Maxime Suret
*/
public class BusinessTemporal implements Temporal, Comparable<Temporal> {
private final NavigableMap<ChronoField, Integer> fieldValues;
private BusinessTemporal(Map<ChronoField, Integer> fieldValues) {
super();
this.fieldValues = Collections.unmodifiableNavigableMap(new TreeMap<ChronoField, Integer>(fieldValues));
validateFields(this.fieldValues.navigableKeySet());
}
/**
* Builds a {@link BusinessTemporal} instance from its fields values
*
* @param fieldValues the values of the fields. The fields must be
* contiguous and have a fixed range.
* @return the {@link BusinessTemporal} instance
*/
public static BusinessTemporal of(Map<ChronoField, Integer> fieldValues) {
return new BusinessTemporal(fieldValues);
}
private static BusinessTemporal from(TemporalAccessor temporal, Set<ChronoField> supportedFields) {
Map<ChronoField, Integer> fieldValues = new HashMap<>();
supportedFields.forEach(field -> fieldValues.put(field, temporal.get(field)));
return new BusinessTemporal(fieldValues);
}
/**
* Check that the given fields are contiguous and have a fixed length.
*
* @param supportedFields the fields
*/
private static void validateFields(SortedSet<ChronoField> fields) {
TemporalUnit expectedBaseUnit = fields.first().getBaseUnit();
for (ChronoField field : fields) {
if (!field.getBaseUnit().equals(expectedBaseUnit)) {
throw new DateTimeException("the fields must be contiguous");
}
if (!field.range().isFixed()) {
throw new DateTimeException("the fields must have a fixed range");
}
expectedBaseUnit = field.getRangeUnit();
}
}
/**
* {@inheritDoc}
*/
@SuppressWarnings("unchecked")
@Override
public <R> R query(TemporalQuery<R> query) {
if (query == TemporalQueries.precision()) {
return (R) fieldValues.firstKey().getBaseUnit();
}
return Temporal.super.query(query);
}
/**
* {@inheritDoc}
*/
@Override
public boolean isSupported(TemporalField field) {
if (field instanceof ChronoField) {
return fieldValues.keySet().contains(field);
}
return field.isSupportedBy(this);
}
/**
* {@inheritDoc}
*/
@Override
public long getLong(TemporalField field) {
if (field instanceof ChronoField) {
return Optional
.ofNullable(fieldValues.get(field))
.orElseThrow(() -> new UnsupportedTemporalTypeException("Unsupported field: " + field))
.longValue();
}
return field.getFrom(this);
}
/**
* {@inheritDoc}
*/
@Override
public boolean isSupported(TemporalUnit unit) {
if (unit instanceof ChronoUnit) {
return fieldValues.keySet()
.stream()
.map(TemporalField::getBaseUnit)
.anyMatch(unit::equals);
}
return unit.isSupportedBy(this);
}
/**
* {@inheritDoc}
*/
@Override
public Temporal with(TemporalField field, long newValue) {
if (field instanceof ChronoField) {
ChronoField chronoField = (ChronoField) field;
//copy the field values and replace with the new ones
Map<ChronoField, Integer> newFieldValues = new HashMap<>(fieldValues);
Optional
.ofNullable(newFieldValues.replace(chronoField, chronoField.checkValidIntValue(newValue)))
.orElseThrow(() -> new UnsupportedTemporalTypeException("Unsupported field: " + field));
return new BusinessTemporal(newFieldValues);
}
return field.adjustInto(this, newValue);
}
/**
* {@inheritDoc}
*/
@Override
public Temporal plus(long amountToAdd, TemporalUnit unit) {
if (unit instanceof ChronoUnit) {
Map<ChronoField, Integer> newFieldValues = new HashMap<>(fieldValues);
SortedSet<ChronoField> relevantFields = fieldsBiggerThan((ChronoUnit) unit);
//add the given amount to the field, and cascade to the other fields if the new value is out of the possible range
for (ChronoField field : relevantFields) {
long rangeLength = field.range().getMaximum() - field.range().getMinimum() + 1;
long sum = fieldValues.get(field) + amountToAdd - field.range().getMinimum();
newFieldValues.put(field, field.checkValidIntValue(field.range().getMinimum() + Math.floorMod(sum, rangeLength)));
amountToAdd = Math.floorDiv(sum, rangeLength);
}
return new BusinessTemporal(newFieldValues);
}
return unit.addTo(this, amountToAdd);
}
private Duration durationUntil(Temporal end) {
TemporalUnit endPrecision = end.query(TemporalQueries.precision());
return fieldValues
.entrySet()
.stream()
.map(entry -> Duration.of(end.get(entry.getKey()) - entry.getValue(), entry.getKey().getBaseUnit()))
.reduce(Duration.ZERO, Duration::plus)
.plus(getLong(end, endPrecision, fieldValues.firstKey().getBaseUnit()), endPrecision);
}
/**
* {@inheritDoc}
*/
@Override
public long until(Temporal endExclusive, TemporalUnit unit) {
//the implementation requirements state that 'endExclusive' must first be converted into a BusinessTemporal
//it means that some precision can be lost and the result can only be/precise up to the smallest supported
//unit of this temporal
BusinessTemporal end = from(endExclusive, fieldValues.keySet());
if (unit instanceof ChronoUnit) {
return durationInUnit(durationUntil(end), unit);
}
return unit.between(this, end);
}
/**
* Increments the least significant field of this Business Temporal by one.
*
* @return a new Business Temporal with the incremented field
*/
public BusinessTemporal increment() {
return (BusinessTemporal) plus(1, fieldValues.firstKey().getBaseUnit());
}
/**
* Get the amount of time between the provided temporal and the next
* occurrence of this business temporal. Contrary to 'until', this method
* returns a result of a precision equivalent to the precision of the
* provided temporal, and guarantees that the result will be positive
*
* @param temporal the temporal, must support the "precision" query
* @param unit the unit to measure the amount in
* @return the time between the given temporal and this business temporal
* (always positive)
*/
public long since(final Temporal temporal, ChronoUnit unit) {
Duration duration
= durationUntil(temporal)
.multipliedBy(-1); //because 'since' is actually the opposite of 'until'
//if the result is negative, add the duration of the biggest supported field
if (duration.isNegative()) {
duration = duration.plus(fieldValues.lastKey().getRangeUnit().getDuration());
}
return durationInUnit(duration, unit);
}
/**
* Get all the fields that are measured with a unit greater than or equals
* to the provided unit.
*
* @param unit the unit
* @return a set containing the relevant fields
*/
private SortedSet<ChronoField> fieldsBiggerThan(ChronoUnit unit) {
return fieldValues.tailMap(
fieldValues
.keySet()
.stream()
.filter(f -> f.getBaseUnit().equals(unit))
.findAny()
.orElseThrow(() -> new UnsupportedTemporalTypeException("Unsupported unit: " + unit)),
true)
.navigableKeySet();
}
/**
* {@inheritDoc}
*/
@Override
public int compareTo(Temporal t) {
return Long.signum(-until(t, fieldValues.firstKey().getBaseUnit()));
}
/**
* {@inheritDoc}
*/
@Override
public int hashCode() {
return fieldValues.hashCode();
}
/**
* Tells if this business temporal is equals to another one. Two business
* temporals are equals if they support the same fields, and these fields
* have the same values.
* @param obj the object to comapare this business tempral to
* @return true if this object and obj are equals, false otherwise.
*/
@Override
public boolean equals(Object obj) {
return Optional.ofNullable(obj)
.filter(BusinessTemporal.class::isInstance)
.filter(other -> fieldValues.equals(((BusinessTemporal) other).fieldValues))
.isPresent();
}
/**
* Equivalent of Duration.get(unit), but supports units differents than
* SECOND and NANO.
*
* @param duration the duration
* @param unit the unit
* @return the provided duration expressed in the provided unit
*/
private static long durationInUnit(Duration duration, TemporalUnit unit) {
return duration.toNanos() / unit.getDuration().toNanos();
}
/**
* Equivalent of Temporal.getLong with arbitrary base and range units.
*
* @param temporal the temporal to query
* @param baseUnit the base unit
* @param rangeUnit the range unit
* @return the amount of baseUnit in rangeUnit from the temporal
*/
private static long getLong(Temporal temporal, TemporalUnit baseUnit, TemporalUnit rangeUnit) {
long result = 0;
long weight = 1;
TemporalUnit currentUnit = baseUnit;
//iterate through ChronoFields values (NANO_OF_SECOND -> SECOND_OF_MINUTE -> MINUTE_OF_HOUR etc.)
//and sum the corresponding fields until we reach a unit bigger than baseUnit
for (ChronoField field : ChronoField.values()) {
if (field.getBaseUnit() == currentUnit && currentUnit.getDuration().compareTo(rangeUnit.getDuration()) < 0) {
result += (temporal.getLong(field) - field.range().getMinimum()) * weight;
weight *= field.range().getMaximum() - field.range().getMinimum() + 1;
currentUnit = (ChronoUnit) field.getRangeUnit();
}
}
return result % durationInUnit(rangeUnit.getDuration(), baseUnit);
}
}