diff --git a/spring-context/src/main/java/org/springframework/format/annotation/DurationFormat.java b/spring-context/src/main/java/org/springframework/format/annotation/DurationFormat.java index 49beceea93..406887600c 100644 --- a/spring-context/src/main/java/org/springframework/format/annotation/DurationFormat.java +++ b/spring-context/src/main/java/org/springframework/format/annotation/DurationFormat.java @@ -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. - *
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}. + *
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 { *
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}. + *
+ * 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. - *
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. + *
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) { diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatterUtils.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatterUtils.java index 4962a8faf0..00f95ed07d 100644 --- a/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatterUtils.java +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatterUtils.java @@ -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(); + } + } diff --git a/spring-context/src/test/java/org/springframework/format/datetime/standard/DurationFormatterUtilsTests.java b/spring-context/src/test/java/org/springframework/format/datetime/standard/DurationFormatterUtilsTests.java index 580a6d6da2..b1f78236a8 100644 --- a/spring-context/src/test/java/org/springframework/format/datetime/standard/DurationFormatterUtilsTests.java +++ b/spring-context/src/test/java/org/springframework/format/datetime/standard/DurationFormatterUtilsTests.java @@ -27,7 +27,9 @@ import org.junit.jupiter.api.Test; import org.springframework.format.annotation.DurationFormat.Unit; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatException; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.springframework.format.annotation.DurationFormat.Style.COMPOSITE; import static org.springframework.format.annotation.DurationFormat.Style.ISO8601; import static org.springframework.format.annotation.DurationFormat.Style.SIMPLE; @@ -132,6 +134,60 @@ class DurationFormatterUtilsTests { .withCause(new DateTimeParseException("Text cannot be parsed to a Duration", "", 0)); } + @Test + void parseComposite() { + assertThat(DurationFormatterUtils.parse("1d2h34m57s28ms3us2ns", COMPOSITE)) + .isEqualTo(Duration.ofDays(1).plusHours(2) + .plusMinutes(34).plusSeconds(57) + .plusMillis(28).plusNanos(3002)); + } + + @Test + void parseCompositeWithExplicitPlusSign() { + assertThat(DurationFormatterUtils.parse("+1d2h34m57s28ms3us2ns", COMPOSITE)) + .isEqualTo(Duration.ofDays(1).plusHours(2) + .plusMinutes(34).plusSeconds(57) + .plusMillis(28).plusNanos(3002)); + } + + @Test + void parseCompositeWithExplicitMinusSign() { + assertThat(DurationFormatterUtils.parse("-1d2h34m57s28ms3us2ns", COMPOSITE)) + .isEqualTo(Duration.ofDays(-1).plusHours(-2) + .plusMinutes(-34).plusSeconds(-57) + .plusMillis(-28).plusNanos(-3002)); + } + + @Test + void parseCompositePartial() { + assertThat(DurationFormatterUtils.parse("34m57s", COMPOSITE)) + .isEqualTo(Duration.ofMinutes(34).plusSeconds(57)); + } + + @Test + void parseCompositePartialWithSpaces() { + assertThat(DurationFormatterUtils.parse("34m 57s", COMPOSITE)) + .isEqualTo(Duration.ofMinutes(34).plusSeconds(57)); + } + + @Test //Kotlin style compatibility + void parseCompositeNegativeWithSpacesAndParenthesis() { + assertThat(DurationFormatterUtils.parse("-(34m 57s)", COMPOSITE)) + .isEqualTo(Duration.ofMinutes(-34).plusSeconds(-57)); + } + + @Test + void parseCompositeBadSign() { + assertThatException().isThrownBy(() -> DurationFormatterUtils.parse("+-34m57s", COMPOSITE)) + .havingCause().withMessage("Does not match composite duration pattern"); + } + + @Test + void parseCompositeBadUnit() { + assertThatException().isThrownBy(() -> DurationFormatterUtils.parse("34mo57s", COMPOSITE)) + .havingCause().withMessage("Does not match composite duration pattern"); + } + @Test void printSimple() { assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), SIMPLE, Unit.NANOS)) @@ -164,6 +220,26 @@ class DurationFormatterUtilsTests { .isEqualTo("PT-3S"); } + @Test + void printCompositePositive() { + Duration composite = DurationFormatterUtils.parse("+1d2h34m57s28ms3us2ns", COMPOSITE); + assertThat(DurationFormatterUtils.print(composite, COMPOSITE)) + .isEqualTo("1d2h34m57s28ms3us2ns"); + } + + @Test + void printCompositeZero() { + assertThat(DurationFormatterUtils.print(Duration.ZERO, COMPOSITE)) + .isEqualTo("0s"); + } + + @Test + void printCompositeNegative() { + Duration composite = DurationFormatterUtils.parse("-1d2h34m57s28ms3us2ns", COMPOSITE); + assertThat(DurationFormatterUtils.print(composite, COMPOSITE)) + .isEqualTo("-1d2h34m57s28ms3us2ns"); + } + @Test void detectAndParse() { assertThat(DurationFormatterUtils.detectAndParse("PT1.234S", Unit.NANOS)) @@ -177,6 +253,10 @@ class DurationFormatterUtilsTests { assertThat(DurationFormatterUtils.detectAndParse("1234", Unit.NANOS)) .as("simple without suffix") .isEqualTo(Duration.ofNanos(1234)); + + assertThat(DurationFormatterUtils.detectAndParse("3s45ms", Unit.NANOS)) + .as("composite") + .isEqualTo(Duration.ofMillis(3045)); } @Test @@ -192,6 +272,10 @@ class DurationFormatterUtilsTests { assertThat(DurationFormatterUtils.detectAndParse("1234")) .as("simple without suffix") .isEqualTo(Duration.ofMillis(1234)); + + assertThat(DurationFormatterUtils.detectAndParse("3s45ms")) + .as("composite") + .isEqualTo(Duration.ofMillis(3045)); } @Test @@ -210,6 +294,10 @@ class DurationFormatterUtilsTests { .as("invalid yet matching ISO8601 pattern") .isEqualTo(ISO8601); + assertThat(DurationFormatterUtils.detect("-(1d 2h 34m 2ns)")) + .as("COMPOSITE") + .isEqualTo(COMPOSITE); + assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.detect("WPT2H-4M")) .withMessage("'WPT2H-4M' is not a valid duration, cannot detect any known style") .withNoCause(); diff --git a/spring-context/src/test/kotlin/org/springframework/format/datetime/standard/DurationFormatterUtilsKotlinTests.kt b/spring-context/src/test/kotlin/org/springframework/format/datetime/standard/DurationFormatterUtilsKotlinTests.kt new file mode 100644 index 0000000000..b074e95cfb --- /dev/null +++ b/spring-context/src/test/kotlin/org/springframework/format/datetime/standard/DurationFormatterUtilsKotlinTests.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * 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 + * + * https://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.springframework.format.datetime.standard + +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import org.springframework.format.annotation.DurationFormat +import kotlin.time.Duration +import kotlin.time.toKotlinDuration + +/** + * Tests for [DurationFormatterUtils] compatibility with Kotlin. + * + * @author Simon Baslé + */ +class DurationFormatterUtilsKotlinTests { + + @Test + fun `Composite style parses Kotlin toString() with seconds resolution`() { + val kotlinString = "-(1d 2h 34m 57s)" + val duration = Duration.parse(kotlinString) + + val parseResult = DurationFormatterUtils.parse(kotlinString, DurationFormat.Style.COMPOSITE) + .toKotlinDuration() + + Assertions.assertThat(parseResult).isEqualTo(duration) + } + + @Test + fun `Composite style fails Kotlin toString() with sub-second resolution`() { + val kotlinString = "-(1d 2h 34m 57.028003002s)" + + Assertions.assertThatException().isThrownBy { + DurationFormatterUtils.parse(kotlinString, DurationFormat.Style.COMPOSITE) } + .withMessage("'$kotlinString' is not a valid composite duration") + } + + @Test + fun `Detect and parse fails Kotlin toString() with sub-second resolution`() { + val kotlinString = "-(1d 2h 34m 57.028003002s)" + + Assertions.assertThatException().isThrownBy { DurationFormatterUtils.detectAndParse(kotlinString) } + .withMessage("'$kotlinString' is not a valid duration, cannot detect any known style") + } + +} \ No newline at end of file