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:
Simon Baslé
2024-07-30 16:55:09 +02:00
parent f967f6f9f0
commit 6174d95ba2
4 changed files with 268 additions and 30 deletions

View File

@@ -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) {

View File

@@ -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();
}
}