Skip to content

Commit 6174d95

Browse files
committed
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
1 parent f967f6f commit 6174d95

File tree

4 files changed

+268
-30
lines changed

4 files changed

+268
-30
lines changed

spring-context/src/main/java/org/springframework/format/annotation/DurationFormat.java

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828
import org.springframework.lang.Nullable;
2929

3030
/**
31-
* Declares that a field or method parameter should be formatted as a {@link java.time.Duration},
32-
* according to the specified {@code style}.
31+
* Declares that a field or method parameter should be formatted as a
32+
* {@link java.time.Duration}, according to the specified {@code style}.
3333
*
3434
* @author Simon Baslé
3535
* @since 6.2
@@ -40,15 +40,15 @@
4040
public @interface DurationFormat {
4141

4242
/**
43-
* Which {@code Style} to use for parsing and printing a {@code Duration}. Defaults to
44-
* the JDK style ({@link Style#ISO8601}).
43+
* Which {@code Style} to use for parsing and printing a {@code Duration}.
44+
* Defaults to the JDK style ({@link Style#ISO8601}).
4545
*/
4646
Style style() default Style.ISO8601;
4747

4848
/**
4949
* Define which {@link Unit} to fall back to in case the {@code style()}
50-
* needs a unit for either parsing or printing, and none is explicitly provided in
51-
* the input ({@code Unit.MILLIS} if unspecified).
50+
* needs a unit for either parsing or printing, and none is explicitly
51+
* provided in the input ({@code Unit.MILLIS} if unspecified).
5252
*/
5353
Unit defaultUnit() default Unit.MILLIS;
5454

@@ -62,10 +62,11 @@ enum Style {
6262
* Supported unit suffixes are: {@code ns, us, ms, s, m, h, d}.
6363
* This corresponds to nanoseconds, microseconds, milliseconds, seconds,
6464
* minutes, hours and days respectively.
65-
* <p>Note that when printing a {@code Duration}, this style can be lossy if the
66-
* selected unit is bigger than the resolution of the duration. For example,
67-
* {@code Duration.ofMillis(5).plusNanos(1234)} would get truncated to {@code "5ms"}
68-
* when printing using {@code ChronoUnit.MILLIS}.
65+
* <p>Note that when printing a {@code Duration}, this style can be
66+
* lossy if the selected unit is bigger than the resolution of the
67+
* duration. For example, * {@code Duration.ofMillis(5).plusNanos(1234)}
68+
* would get truncated to {@code "5ms"} when printing using
69+
* {@code ChronoUnit.MILLIS}. Fractional durations are not supported.
6970
*/
7071
SIMPLE,
7172

@@ -74,13 +75,25 @@ enum Style {
7475
* <p>This is what the JDK uses in {@link java.time.Duration#parse(CharSequence)}
7576
* and {@link Duration#toString()}.
7677
*/
77-
ISO8601
78+
ISO8601,
79+
80+
/**
81+
* Like {@link #SIMPLE}, but allows multiple segments ordered from
82+
* largest-to-smallest units of time, like {@code 1h12m27s}.
83+
* <p>
84+
* A single minus sign ({@code -}) is allowed to indicate the whole
85+
* duration is negative. Spaces are allowed between segments, and a
86+
* negative duration with spaced segments can optionally be surrounded
87+
* by parenthesis after the minus sign, like so: {@code -(34m 57s)}.
88+
*/
89+
COMPOSITE
7890
}
7991

8092
/**
81-
* Duration format unit, which mirrors a subset of {@link ChronoUnit} and allows conversion to and from
82-
* supported {@code ChronoUnit} as well as converting durations to longs.
83-
* The enum includes its corresponding suffix in the {@link Style#SIMPLE simple} Duration format style.
93+
* Duration format unit, which mirrors a subset of {@link ChronoUnit} and
94+
* allows conversion to and from supported {@code ChronoUnit} as well as
95+
* converting durations to longs. The enum includes its corresponding suffix
96+
* in the {@link Style#SIMPLE simple} Duration format style.
8497
*/
8598
enum Unit {
8699
/**
@@ -101,7 +114,7 @@ enum Unit {
101114
/**
102115
* Seconds ({@code "s"}).
103116
*/
104-
SECONDS(ChronoUnit.SECONDS, "s", Duration::getSeconds),
117+
SECONDS(ChronoUnit.SECONDS, "s", Duration::toSeconds),
105118

106119
/**
107120
* Minutes ({@code "m"}).
@@ -131,23 +144,24 @@ enum Unit {
131144
}
132145

133146
/**
134-
* Convert this {@code DurationFormat.Unit} to its {@link ChronoUnit} equivalent.
147+
* Convert this {@code DurationFormat.Unit} to its {@link ChronoUnit}
148+
* equivalent.
135149
*/
136150
public ChronoUnit asChronoUnit() {
137151
return this.chronoUnit;
138152
}
139153

140154
/**
141-
* Convert this {@code DurationFormat.Unit} to a simple {@code String} suffix,
142-
* suitable for the {@link Style#SIMPLE} style.
155+
* Convert this {@code DurationFormat.Unit} to a simple {@code String}
156+
* suffix, suitable for the {@link Style#SIMPLE} style.
143157
*/
144158
public String asSuffix() {
145159
return this.suffix;
146160
}
147161

148162
/**
149-
* Parse a {@code long} from a {@code String} and interpret it to be a {@code Duration}
150-
* in the current unit.
163+
* Parse a {@code long} from a {@code String} and interpret it to be a
164+
* {@code Duration} in the current unit.
151165
* @param value the String representation of the long
152166
* @return the corresponding {@code Duration}
153167
*/
@@ -156,22 +170,23 @@ public Duration parse(String value) {
156170
}
157171

158172
/**
159-
* Print a {@code Duration} as a {@code String}, converting it to a long value
160-
* using this unit's precision via {@link #longValue(Duration)} and appending
161-
* this unit's simple {@link #asSuffix() suffix}.
173+
* Print a {@code Duration} as a {@code String}, converting it to a long
174+
* value using this unit's precision via {@link #longValue(Duration)}
175+
* and appending this unit's simple {@link #asSuffix() suffix}.
162176
* @param value the {@code Duration} to convert to String
163-
* @return the String representation of the {@code Duration} in the {@link Style#SIMPLE SIMPLE style}
177+
* @return the String representation of the {@code Duration} in the
178+
* {@link Style#SIMPLE SIMPLE style}
164179
*/
165180
public String print(Duration value) {
166181
return longValue(value) + asSuffix();
167182
}
168183

169184
/**
170-
* Convert the given {@code Duration} to a long value in the resolution of this
171-
* unit. Note that this can be lossy if the current unit is bigger than the
172-
* actual resolution of the duration.
173-
* <p>For example, {@code Duration.ofMillis(5).plusNanos(1234)} would get truncated
174-
* to {@code 5} for unit {@code MILLIS}.
185+
* Convert the given {@code Duration} to a long value in the resolution
186+
* of this unit. Note that this can be lossy if the current unit is
187+
* bigger than the actual resolution of the duration.
188+
* <p>For example, {@code Duration.ofMillis(5).plusNanos(1234)} would
189+
* get truncated to {@code 5} for unit {@code MILLIS}.
175190
* @param value the {@code Duration} to convert to long
176191
* @return the long value for the Duration in this Unit
177192
*/
@@ -181,7 +196,8 @@ public long longValue(Duration value) {
181196

182197
/**
183198
* Get the {@code Unit} corresponding to the given {@code ChronoUnit}.
184-
* @throws IllegalArgumentException if that particular ChronoUnit isn't supported
199+
* @throws IllegalArgumentException if that particular ChronoUnit isn't
200+
* supported
185201
*/
186202
public static Unit fromChronoUnit(@Nullable ChronoUnit chronoUnit) {
187203
if (chronoUnit == null) {

spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatterUtils.java

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public static Duration parse(String value, DurationFormat.Style style, @Nullable
6565
return switch (style) {
6666
case ISO8601 -> parseIso8601(value);
6767
case SIMPLE -> parseSimple(value, unit);
68+
case COMPOSITE -> parseComposite(value);
6869
};
6970
}
7071

@@ -90,6 +91,7 @@ public static String print(Duration value, DurationFormat.Style style, @Nullable
9091
return switch (style) {
9192
case ISO8601 -> value.toString();
9293
case SIMPLE -> printSimple(value, unit);
94+
case COMPOSITE -> printComposite(value);
9395
};
9496
}
9597

@@ -132,11 +134,16 @@ public static DurationFormat.Style detect(String value) {
132134
if (SIMPLE_PATTERN.matcher(value).matches()) {
133135
return DurationFormat.Style.SIMPLE;
134136
}
137+
if (COMPOSITE_PATTERN.matcher(value).matches()) {
138+
return DurationFormat.Style.COMPOSITE;
139+
}
135140
throw new IllegalArgumentException("'" + value + "' is not a valid duration, cannot detect any known style");
136141
}
137142

138143
private static final Pattern ISO_8601_PATTERN = Pattern.compile("^[+-]?[pP].*$");
139144
private static final Pattern SIMPLE_PATTERN = Pattern.compile("^([+-]?\\d+)([a-zA-Z]{0,2})$");
145+
private static final Pattern COMPOSITE_PATTERN = Pattern.compile("^([+-]?)\\(?\\s?(\\d+d)?\\s?(\\d+h)?\\s?(\\d+m)?"
146+
+ "\\s?(\\d+s)?\\s?(\\d+ms)?\\s?(\\d+us)?\\s?(\\d+ns)?\\)?$");
140147

141148
private static Duration parseIso8601(String value) {
142149
try {
@@ -168,4 +175,71 @@ private static String printSimple(Duration duration, @Nullable DurationFormat.Un
168175
return unit.print(duration);
169176
}
170177

178+
private static Duration parseComposite(String text) {
179+
try {
180+
Matcher matcher = COMPOSITE_PATTERN.matcher(text);
181+
Assert.state(matcher.matches() && matcher.groupCount() > 1, "Does not match composite duration pattern");
182+
String sign = matcher.group(1);
183+
boolean negative = sign != null && sign.equals("-");
184+
185+
Duration result = Duration.ZERO;
186+
DurationFormat.Unit[] units = DurationFormat.Unit.values();
187+
for (int i = 2; i < matcher.groupCount() + 1; i++) {
188+
String segment = matcher.group(i);
189+
if (StringUtils.hasText(segment)) {
190+
DurationFormat.Unit unit = units[units.length - i + 1];
191+
result = result.plus(unit.parse(segment.replace(unit.asSuffix(), "")));
192+
}
193+
}
194+
return negative ? result.negated() : result;
195+
}
196+
catch (Exception ex) {
197+
throw new IllegalArgumentException("'" + text + "' is not a valid composite duration", ex);
198+
}
199+
}
200+
201+
private static String printComposite(Duration duration) {
202+
if (duration.isZero()) {
203+
return DurationFormat.Unit.SECONDS.print(duration);
204+
}
205+
StringBuilder result = new StringBuilder();
206+
if (duration.isNegative()) {
207+
result.append('-');
208+
duration = duration.negated();
209+
}
210+
long days = duration.toDaysPart();
211+
if (days != 0) {
212+
result.append(days).append(DurationFormat.Unit.DAYS.asSuffix());
213+
}
214+
int hours = duration.toHoursPart();
215+
if (hours != 0) {
216+
result.append(hours).append(DurationFormat.Unit.HOURS.asSuffix());
217+
}
218+
int minutes = duration.toMinutesPart();
219+
if (minutes != 0) {
220+
result.append(minutes).append(DurationFormat.Unit.MINUTES.asSuffix());
221+
}
222+
int seconds = duration.toSecondsPart();
223+
if (seconds != 0) {
224+
result.append(seconds).append(DurationFormat.Unit.SECONDS.asSuffix());
225+
}
226+
int millis = duration.toMillisPart();
227+
if (millis != 0) {
228+
result.append(millis).append(DurationFormat.Unit.MILLIS.asSuffix());
229+
}
230+
//special handling of nanos: remove the millis part and then divide into microseconds and nanoseconds
231+
long nanos = duration.toNanosPart() - Duration.ofMillis(millis).toNanos();
232+
if (nanos != 0) {
233+
long micros = nanos / 1000;
234+
long remainder = nanos - (micros * 1000);
235+
if (micros > 0) {
236+
result.append(micros).append(DurationFormat.Unit.MICROS.asSuffix());
237+
}
238+
if (remainder > 0) {
239+
result.append(remainder).append(DurationFormat.Unit.NANOS.asSuffix());
240+
}
241+
}
242+
return result.toString();
243+
}
244+
171245
}

spring-context/src/test/java/org/springframework/format/datetime/standard/DurationFormatterUtilsTests.java

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
import org.springframework.format.annotation.DurationFormat.Unit;
2828

2929
import static org.assertj.core.api.Assertions.assertThat;
30+
import static org.assertj.core.api.Assertions.assertThatException;
3031
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
32+
import static org.springframework.format.annotation.DurationFormat.Style.COMPOSITE;
3133
import static org.springframework.format.annotation.DurationFormat.Style.ISO8601;
3234
import static org.springframework.format.annotation.DurationFormat.Style.SIMPLE;
3335

@@ -132,6 +134,60 @@ void parseIsoThrows() {
132134
.withCause(new DateTimeParseException("Text cannot be parsed to a Duration", "", 0));
133135
}
134136

137+
@Test
138+
void parseComposite() {
139+
assertThat(DurationFormatterUtils.parse("1d2h34m57s28ms3us2ns", COMPOSITE))
140+
.isEqualTo(Duration.ofDays(1).plusHours(2)
141+
.plusMinutes(34).plusSeconds(57)
142+
.plusMillis(28).plusNanos(3002));
143+
}
144+
145+
@Test
146+
void parseCompositeWithExplicitPlusSign() {
147+
assertThat(DurationFormatterUtils.parse("+1d2h34m57s28ms3us2ns", COMPOSITE))
148+
.isEqualTo(Duration.ofDays(1).plusHours(2)
149+
.plusMinutes(34).plusSeconds(57)
150+
.plusMillis(28).plusNanos(3002));
151+
}
152+
153+
@Test
154+
void parseCompositeWithExplicitMinusSign() {
155+
assertThat(DurationFormatterUtils.parse("-1d2h34m57s28ms3us2ns", COMPOSITE))
156+
.isEqualTo(Duration.ofDays(-1).plusHours(-2)
157+
.plusMinutes(-34).plusSeconds(-57)
158+
.plusMillis(-28).plusNanos(-3002));
159+
}
160+
161+
@Test
162+
void parseCompositePartial() {
163+
assertThat(DurationFormatterUtils.parse("34m57s", COMPOSITE))
164+
.isEqualTo(Duration.ofMinutes(34).plusSeconds(57));
165+
}
166+
167+
@Test
168+
void parseCompositePartialWithSpaces() {
169+
assertThat(DurationFormatterUtils.parse("34m 57s", COMPOSITE))
170+
.isEqualTo(Duration.ofMinutes(34).plusSeconds(57));
171+
}
172+
173+
@Test //Kotlin style compatibility
174+
void parseCompositeNegativeWithSpacesAndParenthesis() {
175+
assertThat(DurationFormatterUtils.parse("-(34m 57s)", COMPOSITE))
176+
.isEqualTo(Duration.ofMinutes(-34).plusSeconds(-57));
177+
}
178+
179+
@Test
180+
void parseCompositeBadSign() {
181+
assertThatException().isThrownBy(() -> DurationFormatterUtils.parse("+-34m57s", COMPOSITE))
182+
.havingCause().withMessage("Does not match composite duration pattern");
183+
}
184+
185+
@Test
186+
void parseCompositeBadUnit() {
187+
assertThatException().isThrownBy(() -> DurationFormatterUtils.parse("34mo57s", COMPOSITE))
188+
.havingCause().withMessage("Does not match composite duration pattern");
189+
}
190+
135191
@Test
136192
void printSimple() {
137193
assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), SIMPLE, Unit.NANOS))
@@ -164,6 +220,26 @@ void printIsoIgnoresChronoUnit() {
164220
.isEqualTo("PT-3S");
165221
}
166222

223+
@Test
224+
void printCompositePositive() {
225+
Duration composite = DurationFormatterUtils.parse("+1d2h34m57s28ms3us2ns", COMPOSITE);
226+
assertThat(DurationFormatterUtils.print(composite, COMPOSITE))
227+
.isEqualTo("1d2h34m57s28ms3us2ns");
228+
}
229+
230+
@Test
231+
void printCompositeZero() {
232+
assertThat(DurationFormatterUtils.print(Duration.ZERO, COMPOSITE))
233+
.isEqualTo("0s");
234+
}
235+
236+
@Test
237+
void printCompositeNegative() {
238+
Duration composite = DurationFormatterUtils.parse("-1d2h34m57s28ms3us2ns", COMPOSITE);
239+
assertThat(DurationFormatterUtils.print(composite, COMPOSITE))
240+
.isEqualTo("-1d2h34m57s28ms3us2ns");
241+
}
242+
167243
@Test
168244
void detectAndParse() {
169245
assertThat(DurationFormatterUtils.detectAndParse("PT1.234S", Unit.NANOS))
@@ -177,6 +253,10 @@ void detectAndParse() {
177253
assertThat(DurationFormatterUtils.detectAndParse("1234", Unit.NANOS))
178254
.as("simple without suffix")
179255
.isEqualTo(Duration.ofNanos(1234));
256+
257+
assertThat(DurationFormatterUtils.detectAndParse("3s45ms", Unit.NANOS))
258+
.as("composite")
259+
.isEqualTo(Duration.ofMillis(3045));
180260
}
181261

182262
@Test
@@ -192,6 +272,10 @@ void detectAndParseNoChronoUnit() {
192272
assertThat(DurationFormatterUtils.detectAndParse("1234"))
193273
.as("simple without suffix")
194274
.isEqualTo(Duration.ofMillis(1234));
275+
276+
assertThat(DurationFormatterUtils.detectAndParse("3s45ms"))
277+
.as("composite")
278+
.isEqualTo(Duration.ofMillis(3045));
195279
}
196280

197281
@Test
@@ -210,6 +294,10 @@ void detect() {
210294
.as("invalid yet matching ISO8601 pattern")
211295
.isEqualTo(ISO8601);
212296

297+
assertThat(DurationFormatterUtils.detect("-(1d 2h 34m 2ns)"))
298+
.as("COMPOSITE")
299+
.isEqualTo(COMPOSITE);
300+
213301
assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.detect("WPT2H-4M"))
214302
.withMessage("'WPT2H-4M' is not a valid duration, cannot detect any known style")
215303
.withNoCause();

0 commit comments

Comments
 (0)