Add multi-unit DurationFormat.Style for duration parsing/printing
This adds the COMPOSITE style, which allows multiple segments each similar to the SIMPLE style. See gh-30396 Closes gh-33262
This commit is contained in:
@@ -28,8 +28,8 @@ import java.util.function.Function;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
/**
|
||||
* Declares that a field or method parameter should be formatted as a {@link java.time.Duration},
|
||||
* according to the specified {@code style}.
|
||||
* Declares that a field or method parameter should be formatted as a
|
||||
* {@link java.time.Duration}, according to the specified {@code style}.
|
||||
*
|
||||
* @author Simon Baslé
|
||||
* @since 6.2
|
||||
@@ -40,15 +40,15 @@ import org.springframework.lang.Nullable;
|
||||
public @interface DurationFormat {
|
||||
|
||||
/**
|
||||
* Which {@code Style} to use for parsing and printing a {@code Duration}. Defaults to
|
||||
* the JDK style ({@link Style#ISO8601}).
|
||||
* Which {@code Style} to use for parsing and printing a {@code Duration}.
|
||||
* Defaults to the JDK style ({@link Style#ISO8601}).
|
||||
*/
|
||||
Style style() default Style.ISO8601;
|
||||
|
||||
/**
|
||||
* Define which {@link Unit} to fall back to in case the {@code style()}
|
||||
* needs a unit for either parsing or printing, and none is explicitly provided in
|
||||
* the input ({@code Unit.MILLIS} if unspecified).
|
||||
* needs a unit for either parsing or printing, and none is explicitly
|
||||
* provided in the input ({@code Unit.MILLIS} if unspecified).
|
||||
*/
|
||||
Unit defaultUnit() default Unit.MILLIS;
|
||||
|
||||
@@ -62,10 +62,11 @@ public @interface DurationFormat {
|
||||
* Supported unit suffixes are: {@code ns, us, ms, s, m, h, d}.
|
||||
* This corresponds to nanoseconds, microseconds, milliseconds, seconds,
|
||||
* minutes, hours and days respectively.
|
||||
* <p>Note that when printing a {@code Duration}, this style can be lossy if the
|
||||
* selected unit is bigger than the resolution of the duration. For example,
|
||||
* {@code Duration.ofMillis(5).plusNanos(1234)} would get truncated to {@code "5ms"}
|
||||
* when printing using {@code ChronoUnit.MILLIS}.
|
||||
* <p>Note that when printing a {@code Duration}, this style can be
|
||||
* lossy if the selected unit is bigger than the resolution of the
|
||||
* duration. For example, * {@code Duration.ofMillis(5).plusNanos(1234)}
|
||||
* would get truncated to {@code "5ms"} when printing using
|
||||
* {@code ChronoUnit.MILLIS}. Fractional durations are not supported.
|
||||
*/
|
||||
SIMPLE,
|
||||
|
||||
@@ -74,13 +75,25 @@ public @interface DurationFormat {
|
||||
* <p>This is what the JDK uses in {@link java.time.Duration#parse(CharSequence)}
|
||||
* and {@link Duration#toString()}.
|
||||
*/
|
||||
ISO8601
|
||||
ISO8601,
|
||||
|
||||
/**
|
||||
* Like {@link #SIMPLE}, but allows multiple segments ordered from
|
||||
* largest-to-smallest units of time, like {@code 1h12m27s}.
|
||||
* <p>
|
||||
* A single minus sign ({@code -}) is allowed to indicate the whole
|
||||
* duration is negative. Spaces are allowed between segments, and a
|
||||
* negative duration with spaced segments can optionally be surrounded
|
||||
* by parenthesis after the minus sign, like so: {@code -(34m 57s)}.
|
||||
*/
|
||||
COMPOSITE
|
||||
}
|
||||
|
||||
/**
|
||||
* Duration format unit, which mirrors a subset of {@link ChronoUnit} and allows conversion to and from
|
||||
* supported {@code ChronoUnit} as well as converting durations to longs.
|
||||
* The enum includes its corresponding suffix in the {@link Style#SIMPLE simple} Duration format style.
|
||||
* Duration format unit, which mirrors a subset of {@link ChronoUnit} and
|
||||
* allows conversion to and from supported {@code ChronoUnit} as well as
|
||||
* converting durations to longs. The enum includes its corresponding suffix
|
||||
* in the {@link Style#SIMPLE simple} Duration format style.
|
||||
*/
|
||||
enum Unit {
|
||||
/**
|
||||
@@ -101,7 +114,7 @@ public @interface DurationFormat {
|
||||
/**
|
||||
* Seconds ({@code "s"}).
|
||||
*/
|
||||
SECONDS(ChronoUnit.SECONDS, "s", Duration::getSeconds),
|
||||
SECONDS(ChronoUnit.SECONDS, "s", Duration::toSeconds),
|
||||
|
||||
/**
|
||||
* Minutes ({@code "m"}).
|
||||
@@ -131,23 +144,24 @@ public @interface DurationFormat {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert this {@code DurationFormat.Unit} to its {@link ChronoUnit} equivalent.
|
||||
* Convert this {@code DurationFormat.Unit} to its {@link ChronoUnit}
|
||||
* equivalent.
|
||||
*/
|
||||
public ChronoUnit asChronoUnit() {
|
||||
return this.chronoUnit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert this {@code DurationFormat.Unit} to a simple {@code String} suffix,
|
||||
* suitable for the {@link Style#SIMPLE} style.
|
||||
* Convert this {@code DurationFormat.Unit} to a simple {@code String}
|
||||
* suffix, suitable for the {@link Style#SIMPLE} style.
|
||||
*/
|
||||
public String asSuffix() {
|
||||
return this.suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a {@code long} from a {@code String} and interpret it to be a {@code Duration}
|
||||
* in the current unit.
|
||||
* Parse a {@code long} from a {@code String} and interpret it to be a
|
||||
* {@code Duration} in the current unit.
|
||||
* @param value the String representation of the long
|
||||
* @return the corresponding {@code Duration}
|
||||
*/
|
||||
@@ -156,22 +170,23 @@ public @interface DurationFormat {
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a {@code Duration} as a {@code String}, converting it to a long value
|
||||
* using this unit's precision via {@link #longValue(Duration)} and appending
|
||||
* this unit's simple {@link #asSuffix() suffix}.
|
||||
* Print a {@code Duration} as a {@code String}, converting it to a long
|
||||
* value using this unit's precision via {@link #longValue(Duration)}
|
||||
* and appending this unit's simple {@link #asSuffix() suffix}.
|
||||
* @param value the {@code Duration} to convert to String
|
||||
* @return the String representation of the {@code Duration} in the {@link Style#SIMPLE SIMPLE style}
|
||||
* @return the String representation of the {@code Duration} in the
|
||||
* {@link Style#SIMPLE SIMPLE style}
|
||||
*/
|
||||
public String print(Duration value) {
|
||||
return longValue(value) + asSuffix();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given {@code Duration} to a long value in the resolution of this
|
||||
* unit. Note that this can be lossy if the current unit is bigger than the
|
||||
* actual resolution of the duration.
|
||||
* <p>For example, {@code Duration.ofMillis(5).plusNanos(1234)} would get truncated
|
||||
* to {@code 5} for unit {@code MILLIS}.
|
||||
* Convert the given {@code Duration} to a long value in the resolution
|
||||
* of this unit. Note that this can be lossy if the current unit is
|
||||
* bigger than the actual resolution of the duration.
|
||||
* <p>For example, {@code Duration.ofMillis(5).plusNanos(1234)} would
|
||||
* get truncated to {@code 5} for unit {@code MILLIS}.
|
||||
* @param value the {@code Duration} to convert to long
|
||||
* @return the long value for the Duration in this Unit
|
||||
*/
|
||||
@@ -181,7 +196,8 @@ public @interface DurationFormat {
|
||||
|
||||
/**
|
||||
* Get the {@code Unit} corresponding to the given {@code ChronoUnit}.
|
||||
* @throws IllegalArgumentException if that particular ChronoUnit isn't supported
|
||||
* @throws IllegalArgumentException if that particular ChronoUnit isn't
|
||||
* supported
|
||||
*/
|
||||
public static Unit fromChronoUnit(@Nullable ChronoUnit chronoUnit) {
|
||||
if (chronoUnit == null) {
|
||||
|
||||
@@ -65,6 +65,7 @@ public abstract class DurationFormatterUtils {
|
||||
return switch (style) {
|
||||
case ISO8601 -> parseIso8601(value);
|
||||
case SIMPLE -> parseSimple(value, unit);
|
||||
case COMPOSITE -> parseComposite(value);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,6 +91,7 @@ public abstract class DurationFormatterUtils {
|
||||
return switch (style) {
|
||||
case ISO8601 -> value.toString();
|
||||
case SIMPLE -> printSimple(value, unit);
|
||||
case COMPOSITE -> printComposite(value);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,11 +134,16 @@ public abstract class DurationFormatterUtils {
|
||||
if (SIMPLE_PATTERN.matcher(value).matches()) {
|
||||
return DurationFormat.Style.SIMPLE;
|
||||
}
|
||||
if (COMPOSITE_PATTERN.matcher(value).matches()) {
|
||||
return DurationFormat.Style.COMPOSITE;
|
||||
}
|
||||
throw new IllegalArgumentException("'" + value + "' is not a valid duration, cannot detect any known style");
|
||||
}
|
||||
|
||||
private static final Pattern ISO_8601_PATTERN = Pattern.compile("^[+-]?[pP].*$");
|
||||
private static final Pattern SIMPLE_PATTERN = Pattern.compile("^([+-]?\\d+)([a-zA-Z]{0,2})$");
|
||||
private static final Pattern COMPOSITE_PATTERN = Pattern.compile("^([+-]?)\\(?\\s?(\\d+d)?\\s?(\\d+h)?\\s?(\\d+m)?"
|
||||
+ "\\s?(\\d+s)?\\s?(\\d+ms)?\\s?(\\d+us)?\\s?(\\d+ns)?\\)?$");
|
||||
|
||||
private static Duration parseIso8601(String value) {
|
||||
try {
|
||||
@@ -168,4 +175,71 @@ public abstract class DurationFormatterUtils {
|
||||
return unit.print(duration);
|
||||
}
|
||||
|
||||
private static Duration parseComposite(String text) {
|
||||
try {
|
||||
Matcher matcher = COMPOSITE_PATTERN.matcher(text);
|
||||
Assert.state(matcher.matches() && matcher.groupCount() > 1, "Does not match composite duration pattern");
|
||||
String sign = matcher.group(1);
|
||||
boolean negative = sign != null && sign.equals("-");
|
||||
|
||||
Duration result = Duration.ZERO;
|
||||
DurationFormat.Unit[] units = DurationFormat.Unit.values();
|
||||
for (int i = 2; i < matcher.groupCount() + 1; i++) {
|
||||
String segment = matcher.group(i);
|
||||
if (StringUtils.hasText(segment)) {
|
||||
DurationFormat.Unit unit = units[units.length - i + 1];
|
||||
result = result.plus(unit.parse(segment.replace(unit.asSuffix(), "")));
|
||||
}
|
||||
}
|
||||
return negative ? result.negated() : result;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new IllegalArgumentException("'" + text + "' is not a valid composite duration", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static String printComposite(Duration duration) {
|
||||
if (duration.isZero()) {
|
||||
return DurationFormat.Unit.SECONDS.print(duration);
|
||||
}
|
||||
StringBuilder result = new StringBuilder();
|
||||
if (duration.isNegative()) {
|
||||
result.append('-');
|
||||
duration = duration.negated();
|
||||
}
|
||||
long days = duration.toDaysPart();
|
||||
if (days != 0) {
|
||||
result.append(days).append(DurationFormat.Unit.DAYS.asSuffix());
|
||||
}
|
||||
int hours = duration.toHoursPart();
|
||||
if (hours != 0) {
|
||||
result.append(hours).append(DurationFormat.Unit.HOURS.asSuffix());
|
||||
}
|
||||
int minutes = duration.toMinutesPart();
|
||||
if (minutes != 0) {
|
||||
result.append(minutes).append(DurationFormat.Unit.MINUTES.asSuffix());
|
||||
}
|
||||
int seconds = duration.toSecondsPart();
|
||||
if (seconds != 0) {
|
||||
result.append(seconds).append(DurationFormat.Unit.SECONDS.asSuffix());
|
||||
}
|
||||
int millis = duration.toMillisPart();
|
||||
if (millis != 0) {
|
||||
result.append(millis).append(DurationFormat.Unit.MILLIS.asSuffix());
|
||||
}
|
||||
//special handling of nanos: remove the millis part and then divide into microseconds and nanoseconds
|
||||
long nanos = duration.toNanosPart() - Duration.ofMillis(millis).toNanos();
|
||||
if (nanos != 0) {
|
||||
long micros = nanos / 1000;
|
||||
long remainder = nanos - (micros * 1000);
|
||||
if (micros > 0) {
|
||||
result.append(micros).append(DurationFormat.Unit.MICROS.asSuffix());
|
||||
}
|
||||
if (remainder > 0) {
|
||||
result.append(remainder).append(DurationFormat.Unit.NANOS.asSuffix());
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user