From 6001d014ab85cb9c476503d449392084a9469518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Thu, 27 Apr 2023 16:44:43 +0200 Subject: [PATCH 1/9] first pass at introduction DurationFormat with Style and Unit --- .../format/annotation/DurationFormat.java | 323 ++++++++++++++++++ .../standard/DateTimeFormatterRegistrar.java | 1 + ...ationFormatAnnotationFormatterFactory.java | 57 ++++ .../datetime/standard/DurationFormatter.java | 58 +++- .../standard/DateTimeFormattingTests.java | 25 ++ 5 files changed, 460 insertions(+), 4 deletions(-) create mode 100644 spring-context/src/main/java/org/springframework/format/annotation/DurationFormat.java create mode 100644 spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatAnnotationFormatterFactory.java 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 new file mode 100644 index 000000000000..e3ccef7b60f2 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/format/annotation/DurationFormat.java @@ -0,0 +1,323 @@ +/* + * Copyright 2002-2023 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.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Declares that a field or method parameter should be formatted as a {@link java.time.Duration}, + * either using the default JDK parsing and printing of {@code Duration} or a simplified + * representation. + * + * @author Simon Baslé + * @since 6.1 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) +public @interface DurationFormat { + + /** + * 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 {@code 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. Can also be used to convert a number to a {@code Duration}. + */ + Unit defaultUnit() default Unit.MILLIS; + + /** + * Duration format styles. + * + * @author Phillip Webb + * @author Valentine Wu + */ + enum Style { + + /** + * Simple formatting, for example '1s'. + */ + SIMPLE("^([+-]?\\d+)([a-zA-Z]{0,2})$") { + + @Override + public Duration parse(String value, @Nullable Unit unit) { + try { + Matcher matcher = matcher(value); + Assert.state(matcher.matches(), "Does not match simple duration pattern"); + String suffix = matcher.group(2); + Unit parsingUnit = (unit == null ? Unit.MILLIS : unit); + if (StringUtils.hasLength(suffix)) { + parsingUnit = Unit.fromSuffix(suffix); + } + return parsingUnit.parse(matcher.group(1)); + } + catch (Exception ex) { + throw new IllegalArgumentException("'" + value + "' is not a valid simple duration", ex); + } + } + + @Override + public String print(Duration value, @Nullable Unit unit) { + return (unit == null ? Unit.MILLIS : unit).print(value); + } + + }, + + /** + * ISO-8601 formatting. + */ + ISO8601("^[+-]?[pP].*$") { + @Override + public Duration parse(String value) { + try { + return Duration.parse(value); + } + catch (Exception ex) { + throw new IllegalArgumentException("'" + value + "' is not a valid ISO-8601 duration", ex); + } + } + + @Override + public Duration parse(String value, @Nullable Unit unit) { + return parse(value); + } + + @Override + public String print(Duration value, @Nullable Unit unit) { + return value.toString(); + } + + }; + + private final Pattern pattern; + + Style(String pattern) { + this.pattern = Pattern.compile(pattern); + } + + protected final boolean matches(String value) { + return this.pattern.matcher(value).matches(); + } + + protected final Matcher matcher(String value) { + return this.pattern.matcher(value); + } + + /** + * Parse the given value to a duration. + * @param value the value to parse + * @return a duration + */ + public Duration parse(String value) { + return parse(value, null); + } + + /** + * Parse the given value to a duration. + * @param value the value to parse + * @param unit the duration unit to use if the value doesn't specify one ({@code null} + * will default to ms) + * @return a duration + */ + public abstract Duration parse(String value, @Nullable Unit unit); + + /** + * Print the specified duration. + * @param value the value to print + * @return the printed result + */ + public String print(Duration value) { + return print(value, null); + } + + /** + * Print the specified duration using the given unit. + * @param value the value to print + * @param unit the value to use for printing, if relevant + * @return the printed result + */ + public abstract String print(Duration value, @Nullable Unit unit); + + /** + * Detect the style then parse the value to return a duration. + * @param value the value to parse + * @return the parsed duration + * @throws IllegalArgumentException if the value is not a known style or cannot be + * parsed + */ + public static Duration detectAndParse(String value) { + return detectAndParse(value, null); + } + + /** + * Detect the style then parse the value to return a duration. + * @param value the value to parse + * @param unit the duration unit to use if the value doesn't specify one ({@code null} + * will default to ms) + * @return the parsed duration + * @throws IllegalArgumentException if the value is not a known style or cannot be + * parsed + */ + public static Duration detectAndParse(String value, @Nullable Unit unit) { + return detect(value).parse(value, unit); + } + + /** + * Detect the style from the given source value. + * @param value the source value + * @return the duration style + * @throws IllegalArgumentException if the value is not a known style + */ + public static Style detect(String value) { + Assert.notNull(value, "Value must not be null"); + for (Style candidate : values()) { + if (candidate.matches(value)) { + return candidate; + } + } + throw new IllegalArgumentException("'" + value + "' is not a valid duration"); + } + } + + /** + * Duration format units, similar to {@code ChronoUnit} with additional meta-data like + * a short String {@link #asSuffix()}. + *

This enum helps to deal with units when {@link #parse(String) parsing} or + * {@link #print(Duration) printing} {@code Durations}, allows conversion {@link #asChronoUnit() to} + * and {@link #fromChronoUnit(ChronoUnit) from} {@code ChronoUnit}, as well as to a + * {@link #toLongValue(Duration) long} representation. + *

The short suffix in particular is mostly relevant in the {@link Style#SIMPLE SIMPLE} + * {@code Style}. + * + * @author Phillip Webb + * @author Valentine Wu + * @author Simon Baslé + */ + enum Unit { //TODO increase javadoc coverage + + /** + * Nanoseconds. + */ + NANOS(ChronoUnit.NANOS, "ns", Duration::toNanos), + + /** + * Microseconds. + */ + MICROS(ChronoUnit.MICROS, "us", duration -> duration.toNanos() / 1000L), + + /** + * Milliseconds. + */ + MILLIS(ChronoUnit.MILLIS, "ms", Duration::toMillis), + + /** + * Seconds. + */ + SECONDS(ChronoUnit.SECONDS, "s", Duration::getSeconds), + + /** + * Minutes. + */ + MINUTES(ChronoUnit.MINUTES, "m", Duration::toMinutes), + + /** + * Hours. + */ + HOURS(ChronoUnit.HOURS, "h", Duration::toHours), + + /** + * Days. + */ + DAYS(ChronoUnit.DAYS, "d", Duration::toDays); + + private final ChronoUnit chronoUnit; + + private final String suffix; + + private final Function longValue; + + Unit(ChronoUnit chronoUnit, String suffix, Function toUnit) { + this.chronoUnit = chronoUnit; + this.suffix = suffix; + this.longValue = toUnit; + } + + //note: newly defined accessors + public ChronoUnit asChronoUnit() { + return this.chronoUnit; + } + + public String asSuffix() { + return this.suffix; + } + + public Duration parse(String value) { + return Duration.of(Long.parseLong(value), this.chronoUnit); + } + + public String print(Duration value) { + return toLongValue(value) + this.suffix; + } + + public long toLongValue(Duration value) { + // Note: This method signature / name is similar but not exactly equal to Boot's version. + // There's no way to have the Boot enum inherit this one, so we just need to maintain a compatible feature set + return this.longValue.apply(value); + } + + public static Unit fromChronoUnit(@Nullable ChronoUnit chronoUnit) { + if (chronoUnit == null) { + return Unit.MILLIS; + } + for (Unit candidate : values()) { + if (candidate.chronoUnit == chronoUnit) { + return candidate; + } + } + throw new IllegalArgumentException("Unknown unit " + chronoUnit); + } + + public static Unit fromSuffix(String suffix) { + for (Unit candidate : values()) { + if (candidate.suffix.equalsIgnoreCase(suffix)) { + return candidate; + } + } + throw new IllegalArgumentException("Unknown unit '" + suffix + "'"); + } + + } + + +} diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterRegistrar.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterRegistrar.java index 1a72d4494938..a82819419327 100644 --- a/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterRegistrar.java +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterRegistrar.java @@ -197,6 +197,7 @@ public void registerFormatters(FormatterRegistry registry) { registry.addFormatterForFieldType(MonthDay.class, new MonthDayFormatter()); registry.addFormatterForFieldAnnotation(new Jsr310DateTimeFormatAnnotationFormatterFactory()); + registry.addFormatterForFieldAnnotation(new DurationFormatAnnotationFormatterFactory()); } private DateTimeFormatter getFormatter(Type type) { diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatAnnotationFormatterFactory.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatAnnotationFormatterFactory.java new file mode 100644 index 000000000000..add248cd9d79 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatAnnotationFormatterFactory.java @@ -0,0 +1,57 @@ +/* + * Copyright 2002-2023 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 java.time.Duration; +import java.util.Set; + +import org.springframework.context.support.EmbeddedValueResolutionSupport; +import org.springframework.format.AnnotationFormatterFactory; +import org.springframework.format.Parser; +import org.springframework.format.Printer; +import org.springframework.format.annotation.DurationFormat; + +/** + * Formats fields annotated with the {@link DurationFormat} annotation using the + * selected style for parsing and printing JSR-310 {@code Duration}. + * + * @author Simon Baslé + * @since 6.1 + * @see DurationFormat + * @see DurationFormatter + */ +public class DurationFormatAnnotationFormatterFactory extends EmbeddedValueResolutionSupport + implements AnnotationFormatterFactory { + + // Create the set of field types that may be annotated with @DurationFormat. + private static final Set> FIELD_TYPES = Set.of(Duration.class); + + @Override + public final Set> getFieldTypes() { + return FIELD_TYPES; + } + + @Override + public Printer getPrinter(DurationFormat annotation, Class fieldType) { + return new DurationFormatter(annotation.style(), annotation.defaultUnit()); + } + + @Override + public Parser getParser(DurationFormat annotation, Class fieldType) { + return new DurationFormatter(annotation.style(), annotation.defaultUnit()); + } +} diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java index b36169bdddb9..0cc568ecaf88 100644 --- a/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java @@ -21,25 +21,75 @@ import java.util.Locale; import org.springframework.format.Formatter; +import org.springframework.format.annotation.DurationFormat; +import org.springframework.lang.Nullable; /** * {@link Formatter} implementation for a JSR-310 {@link Duration}, - * following JSR-310's parsing rules for a Duration. + * following JSR-310's parsing rules for a Duration by default and + * supporting additional {@code DurationStyle} styles. * * @author Juergen Hoeller * @since 4.2.4 * @see Duration#parse + * @see DurationFormat.Style */ -class DurationFormatter implements Formatter { +class DurationFormatter implements Formatter { //TODO why is this one package-private ? make public and change since taglet ? + + private final DurationFormat.Style style; + @Nullable + private final DurationFormat.Unit defaultUnit; + + /** + * Create a {@code DurationFormatter} following JSR-310's parsing rules for a Duration + * (the {@link DurationFormat.Style#ISO8601 ISO-8601} style). + */ + DurationFormatter() { + this(DurationFormat.Style.ISO8601); + } + + /** + * Create a {@code DurationFormatter} in a specific {@link DurationFormat.Style}. + *

When a unit is needed but cannot be determined (e.g. printing a Duration in the + * {@code SIMPLE} style), {@code ChronoUnit#MILLIS} is used. + */ + public DurationFormatter(DurationFormat.Style style) { + this(style, null); + } + + /** + * Create a {@code DurationFormatter} in a specific {@link DurationFormat.Style} with an + * optional {@code DurationFormat.Unit}. + *

If a {@code defaultUnit} is specified, it may be used in parsing cases when no + * unit is present in the string (provided the style allows for such a case). It will + * also be used as the representation's resolution when printing in the + * {@link DurationFormat.Style#SIMPLE} style. Otherwise, the style defines its default + * unit. + * + * @param style the {@code DurationStyle} to use + * @param defaultUnit the {@code DurationFormat.Unit} to fall back to when parsing and printing + */ + public DurationFormatter(DurationFormat.Style style, @Nullable DurationFormat.Unit defaultUnit) { + this.style = style; + this.defaultUnit = defaultUnit; + } @Override public Duration parse(String text, Locale locale) throws ParseException { - return Duration.parse(text); + if (this.defaultUnit == null) { + //delegate to the style + return this.style.parse(text); + } + return this.style.parse(text, this.defaultUnit); } @Override public String print(Duration object, Locale locale) { - return object.toString(); + if (this.defaultUnit == null) { + //delegate the ultimate of the default unit to the style + return this.style.print(object); + } + return this.style.print(object, this.defaultUnit); } } diff --git a/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java b/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java index 392fdd61c6e5..215ff43215d5 100644 --- a/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java +++ b/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java @@ -52,6 +52,9 @@ import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat.ISO; +import org.springframework.format.annotation.DurationFormat; +import org.springframework.format.annotation.DurationFormat.Style; +import org.springframework.format.annotation.DurationFormat.Unit; import org.springframework.format.support.FormattingConversionService; import org.springframework.validation.BindingResult; import org.springframework.validation.DataBinder; @@ -475,6 +478,17 @@ void testBindDuration() { assertThat(binder.getBindingResult().getFieldValue("duration").toString()).isEqualTo("PT8H6M12.345S"); } + @Test + void testBindDurationAnnotated() { + MutablePropertyValues propertyValues = new MutablePropertyValues(); + propertyValues.add("styleDuration", "2ms"); + binder.bind(propertyValues); + assertThat(binder.getBindingResult().getFieldValue("styleDuration")) + .isNotNull() + .isEqualTo("2000us"); + assertThat(binder.getBindingResult().getAllErrors()).isEmpty(); + } + @Test void testBindYear() { MutablePropertyValues propertyValues = new MutablePropertyValues(); @@ -674,6 +688,9 @@ public static class DateTimeBean { private Duration duration; + @DurationFormat(style = Style.SIMPLE, defaultUnit = Unit.MICROS) + private Duration styleDuration; + private Year year; private Month month; @@ -834,6 +851,14 @@ public void setDuration(Duration duration) { this.duration = duration; } + public Duration getStyleDuration() { + return this.styleDuration; + } + + public void setStyleDuration(Duration styleDuration) { + this.styleDuration = styleDuration; + } + public Year getYear() { return this.year; } From d0bcb7c55fc4aa9901118cd48120840ab71ba44f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Thu, 27 Apr 2023 18:06:20 +0200 Subject: [PATCH 2/9] Trying different approach without Unit and externalizing parsing code --- .../format/annotation/DurationFormat.java | 278 +----------------- .../datetime/standard/DurationFormatter.java | 21 +- .../standard/DurationFormatterUtils.java | 205 +++++++++++++ .../standard/DateTimeFormattingTests.java | 4 +- 4 files changed, 234 insertions(+), 274 deletions(-) create mode 100644 spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatterUtils.java 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 e3ccef7b60f2..515d269b14a8 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 @@ -23,18 +23,10 @@ import java.lang.annotation.Target; import java.time.Duration; import java.time.temporal.ChronoUnit; -import java.util.function.Function; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; -import org.springframework.util.StringUtils; /** * Declares that a field or method parameter should be formatted as a {@link java.time.Duration}, - * either using the default JDK parsing and printing of {@code Duration} or a simplified - * representation. + * according to the specified {@code style}. * * @author Simon Baslé * @since 6.1 @@ -51,273 +43,35 @@ Style style() default Style.ISO8601; /** - * Define which {@code Unit} to fall back to in case the {@code style()} + * Define which {@code ChronoUnit} 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. Can also be used to convert a number to a {@code Duration}. + * the input. */ - Unit defaultUnit() default Unit.MILLIS; + ChronoUnit defaultUnit() default ChronoUnit.MILLIS; /** * Duration format styles. - * - * @author Phillip Webb - * @author Valentine Wu */ enum Style { /** - * Simple formatting, for example '1s'. + * Simple formatting based on a short suffix, for example '1s'. + * 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}. */ - SIMPLE("^([+-]?\\d+)([a-zA-Z]{0,2})$") { - - @Override - public Duration parse(String value, @Nullable Unit unit) { - try { - Matcher matcher = matcher(value); - Assert.state(matcher.matches(), "Does not match simple duration pattern"); - String suffix = matcher.group(2); - Unit parsingUnit = (unit == null ? Unit.MILLIS : unit); - if (StringUtils.hasLength(suffix)) { - parsingUnit = Unit.fromSuffix(suffix); - } - return parsingUnit.parse(matcher.group(1)); - } - catch (Exception ex) { - throw new IllegalArgumentException("'" + value + "' is not a valid simple duration", ex); - } - } - - @Override - public String print(Duration value, @Nullable Unit unit) { - return (unit == null ? Unit.MILLIS : unit).print(value); - } - - }, + SIMPLE, /** * ISO-8601 formatting. + *

This is what the JDK uses in {@link java.time.Duration#parse(CharSequence)} + * and {@link Duration#toString()}. */ - ISO8601("^[+-]?[pP].*$") { - @Override - public Duration parse(String value) { - try { - return Duration.parse(value); - } - catch (Exception ex) { - throw new IllegalArgumentException("'" + value + "' is not a valid ISO-8601 duration", ex); - } - } - - @Override - public Duration parse(String value, @Nullable Unit unit) { - return parse(value); - } - - @Override - public String print(Duration value, @Nullable Unit unit) { - return value.toString(); - } - - }; - - private final Pattern pattern; - - Style(String pattern) { - this.pattern = Pattern.compile(pattern); - } - - protected final boolean matches(String value) { - return this.pattern.matcher(value).matches(); - } - - protected final Matcher matcher(String value) { - return this.pattern.matcher(value); - } - - /** - * Parse the given value to a duration. - * @param value the value to parse - * @return a duration - */ - public Duration parse(String value) { - return parse(value, null); - } - - /** - * Parse the given value to a duration. - * @param value the value to parse - * @param unit the duration unit to use if the value doesn't specify one ({@code null} - * will default to ms) - * @return a duration - */ - public abstract Duration parse(String value, @Nullable Unit unit); - - /** - * Print the specified duration. - * @param value the value to print - * @return the printed result - */ - public String print(Duration value) { - return print(value, null); - } - - /** - * Print the specified duration using the given unit. - * @param value the value to print - * @param unit the value to use for printing, if relevant - * @return the printed result - */ - public abstract String print(Duration value, @Nullable Unit unit); - - /** - * Detect the style then parse the value to return a duration. - * @param value the value to parse - * @return the parsed duration - * @throws IllegalArgumentException if the value is not a known style or cannot be - * parsed - */ - public static Duration detectAndParse(String value) { - return detectAndParse(value, null); - } - - /** - * Detect the style then parse the value to return a duration. - * @param value the value to parse - * @param unit the duration unit to use if the value doesn't specify one ({@code null} - * will default to ms) - * @return the parsed duration - * @throws IllegalArgumentException if the value is not a known style or cannot be - * parsed - */ - public static Duration detectAndParse(String value, @Nullable Unit unit) { - return detect(value).parse(value, unit); - } - - /** - * Detect the style from the given source value. - * @param value the source value - * @return the duration style - * @throws IllegalArgumentException if the value is not a known style - */ - public static Style detect(String value) { - Assert.notNull(value, "Value must not be null"); - for (Style candidate : values()) { - if (candidate.matches(value)) { - return candidate; - } - } - throw new IllegalArgumentException("'" + value + "' is not a valid duration"); - } - } - - /** - * Duration format units, similar to {@code ChronoUnit} with additional meta-data like - * a short String {@link #asSuffix()}. - *

This enum helps to deal with units when {@link #parse(String) parsing} or - * {@link #print(Duration) printing} {@code Durations}, allows conversion {@link #asChronoUnit() to} - * and {@link #fromChronoUnit(ChronoUnit) from} {@code ChronoUnit}, as well as to a - * {@link #toLongValue(Duration) long} representation. - *

The short suffix in particular is mostly relevant in the {@link Style#SIMPLE SIMPLE} - * {@code Style}. - * - * @author Phillip Webb - * @author Valentine Wu - * @author Simon Baslé - */ - enum Unit { //TODO increase javadoc coverage - - /** - * Nanoseconds. - */ - NANOS(ChronoUnit.NANOS, "ns", Duration::toNanos), - - /** - * Microseconds. - */ - MICROS(ChronoUnit.MICROS, "us", duration -> duration.toNanos() / 1000L), - - /** - * Milliseconds. - */ - MILLIS(ChronoUnit.MILLIS, "ms", Duration::toMillis), - - /** - * Seconds. - */ - SECONDS(ChronoUnit.SECONDS, "s", Duration::getSeconds), - - /** - * Minutes. - */ - MINUTES(ChronoUnit.MINUTES, "m", Duration::toMinutes), - - /** - * Hours. - */ - HOURS(ChronoUnit.HOURS, "h", Duration::toHours), - - /** - * Days. - */ - DAYS(ChronoUnit.DAYS, "d", Duration::toDays); - - private final ChronoUnit chronoUnit; - - private final String suffix; - - private final Function longValue; - - Unit(ChronoUnit chronoUnit, String suffix, Function toUnit) { - this.chronoUnit = chronoUnit; - this.suffix = suffix; - this.longValue = toUnit; - } - - //note: newly defined accessors - public ChronoUnit asChronoUnit() { - return this.chronoUnit; - } - - public String asSuffix() { - return this.suffix; - } - - public Duration parse(String value) { - return Duration.of(Long.parseLong(value), this.chronoUnit); - } - - public String print(Duration value) { - return toLongValue(value) + this.suffix; - } - - public long toLongValue(Duration value) { - // Note: This method signature / name is similar but not exactly equal to Boot's version. - // There's no way to have the Boot enum inherit this one, so we just need to maintain a compatible feature set - return this.longValue.apply(value); - } - - public static Unit fromChronoUnit(@Nullable ChronoUnit chronoUnit) { - if (chronoUnit == null) { - return Unit.MILLIS; - } - for (Unit candidate : values()) { - if (candidate.chronoUnit == chronoUnit) { - return candidate; - } - } - throw new IllegalArgumentException("Unknown unit " + chronoUnit); - } - - public static Unit fromSuffix(String suffix) { - for (Unit candidate : values()) { - if (candidate.suffix.equalsIgnoreCase(suffix)) { - return candidate; - } - } - throw new IllegalArgumentException("Unknown unit '" + suffix + "'"); - } - + ISO8601; } - } diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java index 0cc568ecaf88..345a6b6b192c 100644 --- a/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java @@ -18,6 +18,7 @@ import java.text.ParseException; import java.time.Duration; +import java.time.temporal.ChronoUnit; import java.util.Locale; import org.springframework.format.Formatter; @@ -27,18 +28,18 @@ /** * {@link Formatter} implementation for a JSR-310 {@link Duration}, * following JSR-310's parsing rules for a Duration by default and - * supporting additional {@code DurationStyle} styles. + * supporting additional {@code DurationFormat.Style} styles. * * @author Juergen Hoeller * @since 4.2.4 - * @see Duration#parse + * @see DurationFormatterUtils * @see DurationFormat.Style */ class DurationFormatter implements Formatter { //TODO why is this one package-private ? make public and change since taglet ? private final DurationFormat.Style style; @Nullable - private final DurationFormat.Unit defaultUnit; + private final ChronoUnit defaultUnit; /** * Create a {@code DurationFormatter} following JSR-310's parsing rules for a Duration @@ -59,7 +60,7 @@ public DurationFormatter(DurationFormat.Style style) { /** * Create a {@code DurationFormatter} in a specific {@link DurationFormat.Style} with an - * optional {@code DurationFormat.Unit}. + * optional {@code ChronoUnit}. *

If a {@code defaultUnit} is specified, it may be used in parsing cases when no * unit is present in the string (provided the style allows for such a case). It will * also be used as the representation's resolution when printing in the @@ -67,9 +68,9 @@ public DurationFormatter(DurationFormat.Style style) { * unit. * * @param style the {@code DurationStyle} to use - * @param defaultUnit the {@code DurationFormat.Unit} to fall back to when parsing and printing + * @param defaultUnit the {@code ChronoUnit} to fall back to when parsing and printing */ - public DurationFormatter(DurationFormat.Style style, @Nullable DurationFormat.Unit defaultUnit) { + public DurationFormatter(DurationFormat.Style style, @Nullable ChronoUnit defaultUnit) { this.style = style; this.defaultUnit = defaultUnit; } @@ -78,18 +79,18 @@ public DurationFormatter(DurationFormat.Style style, @Nullable DurationFormat.Un public Duration parse(String text, Locale locale) throws ParseException { if (this.defaultUnit == null) { //delegate to the style - return this.style.parse(text); + return DurationFormatterUtils.parse(text, this.style); } - return this.style.parse(text, this.defaultUnit); + return DurationFormatterUtils.parse(text, this.style, this.defaultUnit); } @Override public String print(Duration object, Locale locale) { if (this.defaultUnit == null) { //delegate the ultimate of the default unit to the style - return this.style.print(object); + return DurationFormatterUtils.print(object, this.style); } - return this.style.print(object, this.defaultUnit); + return DurationFormatterUtils.print(object, this.style, this.defaultUnit); } } 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 new file mode 100644 index 000000000000..5c74a3dfc600 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatterUtils.java @@ -0,0 +1,205 @@ +/* + * Copyright 2002-2023 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 java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.format.annotation.DurationFormat; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Support {@code Duration} parsing and printing in several styles, as listed in + * {@link DurationFormat.Style}. + *

Some styles may not enforce any unit to be present, defaulting to {@code ChronoUnit#MILLIS} + * in that case. Methods in this class offer overloads that take a {@code ChronoUnit} to + * be used as a fall-back instead of the ultimate MILLIS default. + * + * @author Phillip Webb + * @author Valentine Wu + * @author Simon Baslé + */ +public abstract class DurationFormatterUtils { + + private DurationFormatterUtils() { + // singleton + } + + /** + * Parse the given value to a duration. + * @param value the value to parse + * @param style the style in which to parse + * @return a duration + */ + public static Duration parse(String value, DurationFormat.Style style) { + return parse(value, style, null); + } + + /** + * Parse the given value to a duration. + * @param value the value to parse + * @param style the style in which to parse + * @param unit the duration unit to use if the value doesn't specify one ({@code null} + * will default to ms) + * @return a duration + */ + public static Duration parse(String value, DurationFormat.Style style, @Nullable ChronoUnit unit) { + return switch (style) { + case ISO8601 -> parseIso8601(value); + case SIMPLE -> parseSimple(value, unit); + }; + } + + /** + * Print the specified duration in the specified style. + * @param value the value to print + * @param style the style to print in + * @return the printed result + */ + public static String print(Duration value, DurationFormat.Style style) { + return print(value, style, null); + } + + /** + * Print the specified duration in the specified style using the given unit. + * @param value the value to print + * @param style the style to print in + * @param unit the unit to use for printing, if relevant ({@code null} will default + * to ms) + * @return the printed result + */ + public static String print(Duration value, DurationFormat.Style style, @Nullable ChronoUnit unit) { + return switch (style) { + case ISO8601 -> value.toString(); + case SIMPLE -> printSimple(value, unit); + }; + } + + /** + * Detect the style then parse the value to return a duration. + * @param value the value to parse + * @return the parsed duration + * @throws IllegalArgumentException if the value is not a known style or cannot be + * parsed + */ + public static Duration detectAndParse(String value) { + return detectAndParse(value, null); + } + + /** + * Detect the style then parse the value to return a duration. + * @param value the value to parse + * @param unit the duration unit to use if the value doesn't specify one ({@code null} + * will default to ms) + * @return the parsed duration + * @throws IllegalArgumentException if the value is not a known style or cannot be + * parsed + */ + public static Duration detectAndParse(String value, @Nullable ChronoUnit unit) { + return parse(value, detect(value), unit); + } + + /** + * Detect the style from the given source value. + * @param value the source value + * @return the duration style + * @throws IllegalArgumentException if the value is not a known style + */ + public static DurationFormat.Style detect(String value) { + Assert.notNull(value, "Value must not be null"); + if (ISO_8601_PATTERN.matcher(value).matches()) { + return DurationFormat.Style.ISO8601; + } + if (SIMPLE_PATTERN.matcher(value).matches()) { + return DurationFormat.Style.SIMPLE; + } + throw new IllegalArgumentException("'" + value + "' is not a valid duration"); + } + + 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 Duration parseIso8601(String value) { + try { + return Duration.parse(value); + } + catch (Throwable ex) { + throw new IllegalArgumentException("'" + value + "' is not a valid ISO-8601 duration", ex); + } + } + + private static Duration parseSimple(String text, @Nullable ChronoUnit fallbackUnit) { + try { + Matcher matcher = SIMPLE_PATTERN.matcher(text); + Assert.state(matcher.matches(), "Does not match simple duration pattern"); + String suffix = matcher.group(2); + ChronoUnit parsingUnit = (fallbackUnit == null ? ChronoUnit.MILLIS : fallbackUnit); + if (StringUtils.hasLength(suffix)) { + parsingUnit = unitFromSuffix(suffix); + } + return Duration.of(Long.parseLong(matcher.group(1)), parsingUnit); + } + catch (Exception ex) { + throw new IllegalArgumentException("'" + text + "' is not a valid simple duration", ex); + } + } + + /* package-private */ static ChronoUnit unitFromSuffix(String suffix) { + return switch (suffix.toLowerCase()) { + case "ns" -> ChronoUnit.NANOS; + case "us" -> ChronoUnit.MICROS; + case "ms" -> ChronoUnit.MILLIS; + case "s" -> ChronoUnit.SECONDS; + case "m" -> ChronoUnit.MINUTES; + case "h" -> ChronoUnit.HOURS; + case "d" -> ChronoUnit.DAYS; + default -> throw new IllegalArgumentException("'" + suffix + "' is not a valid simple duration unit"); + }; + } + + /* package-private */ static String suffixFromUnit(ChronoUnit unit) { + return switch (unit) { + case NANOS -> "ns"; + case MICROS -> "us"; + case MILLIS -> "ms"; + case SECONDS -> "s"; + case MINUTES -> "m"; + case HOURS -> "h"; + case DAYS -> "d"; + default -> throw new IllegalArgumentException("'" + unit + "' is not a supported ChronoUnit for simple duration representation"); + }; + } + + private static String printSimple(Duration duration, ChronoUnit unit) { + long longValue = switch (unit) { + case NANOS -> duration.toNanos(); + case MICROS -> duration.toNanos() / 1000L; + case MILLIS -> duration.toMillis(); + case SECONDS -> duration.toSeconds(); + case MINUTES -> duration.toMinutes(); + case HOURS -> duration.toHours(); + case DAYS -> duration.toDays(); + default -> throw new IllegalArgumentException("'" + unit + "' is not a supported ChronoUnit for simple duration representation"); + }; + + return longValue + suffixFromUnit(unit); + } +} diff --git a/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java b/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java index 215ff43215d5..3250e165f099 100644 --- a/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java +++ b/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java @@ -31,6 +31,7 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.format.FormatStyle; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Date; import java.util.GregorianCalendar; @@ -54,7 +55,6 @@ import org.springframework.format.annotation.DateTimeFormat.ISO; import org.springframework.format.annotation.DurationFormat; import org.springframework.format.annotation.DurationFormat.Style; -import org.springframework.format.annotation.DurationFormat.Unit; import org.springframework.format.support.FormattingConversionService; import org.springframework.validation.BindingResult; import org.springframework.validation.DataBinder; @@ -688,7 +688,7 @@ public static class DateTimeBean { private Duration duration; - @DurationFormat(style = Style.SIMPLE, defaultUnit = Unit.MICROS) + @DurationFormat(style = Style.SIMPLE, defaultUnit = ChronoUnit.MICROS) private Duration styleDuration; private Year year; From d9e1ecfcbe7cec67d4ecf46eaeb01932b88721ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Thu, 27 Apr 2023 18:35:45 +0200 Subject: [PATCH 3/9] extract public method for longValueFromUnit conversion, polish --- .../standard/DurationFormatterUtils.java | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) 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 5c74a3dfc600..4a3e24c9a7f0 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 @@ -131,7 +131,20 @@ public static DurationFormat.Style detect(String value) { if (SIMPLE_PATTERN.matcher(value).matches()) { return DurationFormat.Style.SIMPLE; } - throw new IllegalArgumentException("'" + value + "' is not a valid duration"); + throw new IllegalArgumentException("'" + value + "' is not a valid duration, cannot detect any known style"); + } + + public static long longValueFromUnit(Duration duration, ChronoUnit unit) { + return switch (unit) { + case NANOS -> duration.toNanos(); + case MICROS -> duration.toNanos() / 1000L; + case MILLIS -> duration.toMillis(); + case SECONDS -> duration.toSeconds(); + case MINUTES -> duration.toMinutes(); + case HOURS -> duration.toHours(); + case DAYS -> duration.toDays(); + default -> throw new IllegalArgumentException("'" + unit.name() + "' is not a supported ChronoUnit for simple duration representation"); + }; } private static final Pattern ISO_8601_PATTERN = Pattern.compile("^[+-]?[pP].*$"); @@ -162,6 +175,11 @@ private static Duration parseSimple(String text, @Nullable ChronoUnit fallbackUn } } + private static String printSimple(Duration duration, @Nullable ChronoUnit unit) { + unit = (unit == null ? ChronoUnit.MILLIS : unit); + return longValueFromUnit(duration, unit) + suffixFromUnit(unit); + } + /* package-private */ static ChronoUnit unitFromSuffix(String suffix) { return switch (suffix.toLowerCase()) { case "ns" -> ChronoUnit.NANOS; @@ -184,22 +202,8 @@ private static Duration parseSimple(String text, @Nullable ChronoUnit fallbackUn case MINUTES -> "m"; case HOURS -> "h"; case DAYS -> "d"; - default -> throw new IllegalArgumentException("'" + unit + "' is not a supported ChronoUnit for simple duration representation"); + default -> throw new IllegalArgumentException("'" + unit.name() + "' is not a supported ChronoUnit for simple duration representation"); }; } - private static String printSimple(Duration duration, ChronoUnit unit) { - long longValue = switch (unit) { - case NANOS -> duration.toNanos(); - case MICROS -> duration.toNanos() / 1000L; - case MILLIS -> duration.toMillis(); - case SECONDS -> duration.toSeconds(); - case MINUTES -> duration.toMinutes(); - case HOURS -> duration.toHours(); - case DAYS -> duration.toDays(); - default -> throw new IllegalArgumentException("'" + unit + "' is not a supported ChronoUnit for simple duration representation"); - }; - - return longValue + suffixFromUnit(unit); - } } From 9214cfbf8fe63eaef3614c437ee8f4bffd6fafcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Fri, 28 Apr 2023 16:27:18 +0200 Subject: [PATCH 4/9] Cover DurationFormatterUtilsTests with tests --- .../standard/DurationFormatterUtilsTests.java | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 spring-context/src/test/java/org/springframework/format/datetime/standard/DurationFormatterUtilsTests.java 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 new file mode 100644 index 000000000000..1619ba61e6dc --- /dev/null +++ b/spring-context/src/test/java/org/springframework/format/datetime/standard/DurationFormatterUtilsTests.java @@ -0,0 +1,281 @@ +/* + * Copyright 2002-2023 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 java.time.Duration; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.springframework.format.annotation.DurationFormat.Style.ISO8601; +import static org.springframework.format.annotation.DurationFormat.Style.SIMPLE; + +class DurationFormatterUtilsTests { + + @Test + void parseSimpleWithUnits() { + Duration nanos = DurationFormatterUtils.parse("1ns", SIMPLE, ChronoUnit.SECONDS); + Duration micros = DurationFormatterUtils.parse("-2us", SIMPLE, ChronoUnit.SECONDS); + Duration millis = DurationFormatterUtils.parse("+3ms", SIMPLE, ChronoUnit.SECONDS); + Duration seconds = DurationFormatterUtils.parse("4s", SIMPLE, ChronoUnit.SECONDS); + Duration minutes = DurationFormatterUtils.parse("5m", SIMPLE, ChronoUnit.SECONDS); + Duration hours = DurationFormatterUtils.parse("6h", SIMPLE, ChronoUnit.SECONDS); + Duration days = DurationFormatterUtils.parse("-10d", SIMPLE, ChronoUnit.SECONDS); + + assertThat(nanos).hasNanos(1); + assertThat(micros).hasNanos(-2 * 1000); + assertThat(millis).hasMillis(3); + assertThat(seconds).hasSeconds(4); + assertThat(minutes).hasMinutes(5); + assertThat(hours).hasHours(6); + assertThat(days).hasDays(-10); + } + + @Test + void parseSimpleWithoutUnits() { + assertThat(DurationFormatterUtils.parse("-123", SIMPLE, ChronoUnit.SECONDS)) + .hasSeconds(-123); + assertThat(DurationFormatterUtils.parse("456", SIMPLE, ChronoUnit.SECONDS)) + .hasSeconds(456); + } + + @Test + void parseNoChronoUnitSimpleWithoutUnitsDefaultsToMillis() { + assertThat(DurationFormatterUtils.parse("-123", SIMPLE)) + .hasMillis(-123); + assertThat(DurationFormatterUtils.parse("456", SIMPLE)) + .hasMillis(456); + } + + @Test + void parseNullChronoUnitSimpleWithoutUnitsDefaultsToMillis() { + assertThat(DurationFormatterUtils.parse("-123", SIMPLE, null)) + .hasMillis(-123); + assertThat(DurationFormatterUtils.parse("456", SIMPLE, null)) + .hasMillis(456); + } + + @Test + void parseSimpleThrows() { + assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.parse(";23s", SIMPLE)) + .withMessage("';23s' is not a valid simple duration") + .withCause(new IllegalStateException("Does not match simple duration pattern")); + + assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.parse("+23y", SIMPLE)) + .withMessage("'+23y' is not a valid simple duration") + .withCause(new IllegalArgumentException("'y' is not a valid simple duration unit")); + } + + @Test + void parseIsoNoChronoUnit() { + //these are based on the examples given in Duration.parse +// "PT20.345S" -- parses as "20.345 seconds" + assertThat(DurationFormatterUtils.parse("PT20.345S", ISO8601)) + .hasMillis(20345); +// "PT15M" -- parses as "15 minutes" (where a minute is 60 seconds) + assertThat(DurationFormatterUtils.parse("PT15M", ISO8601)) + .hasSeconds(15*60); +// "PT10H" -- parses as "10 hours" (where an hour is 3600 seconds) + assertThat(DurationFormatterUtils.parse("PT10H", ISO8601)) + .hasHours(10); +// "P2D" -- parses as "2 days" (where a day is 24 hours or 86400 seconds) + assertThat(DurationFormatterUtils.parse("P2D", ISO8601)) + .hasDays(2); +// "P2DT3H4M" -- parses as "2 days, 3 hours and 4 minutes" + assertThat(DurationFormatterUtils.parse("P2DT3H4M", ISO8601)) + .isEqualTo(Duration.ofDays(2).plusHours(3).plusMinutes(4)); +// "PT-6H3M" -- parses as "-6 hours and +3 minutes" + assertThat(DurationFormatterUtils.parse("PT-6H3M", ISO8601)) + .isEqualTo(Duration.ofHours(-6).plusMinutes(3)); +// "-PT6H3M" -- parses as "-6 hours and -3 minutes" + assertThat(DurationFormatterUtils.parse("-PT6H3M", ISO8601)) + .isEqualTo(Duration.ofHours(-6).plusMinutes(-3)); +// "-PT-6H+3M" -- parses as "+6 hours and -3 minutes" + assertThat(DurationFormatterUtils.parse("-PT-6H+3M", ISO8601)) + .isEqualTo(Duration.ofHours(6).plusMinutes(-3)); + } + + @Test + void parseIsoIgnoresFallbackChronoUnit() { + assertThat(DurationFormatterUtils.parse("P2DT3H4M", ISO8601, ChronoUnit.NANOS)) + .isEqualTo(Duration.ofDays(2).plusHours(3).plusMinutes(4)); + } + + @Test + void parseIsoThrows() { + assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.parse("P2DWV3H-4M", ISO8601)) + .withMessage("'P2DWV3H-4M' is not a valid ISO-8601 duration") + .withCause(new DateTimeParseException("Text cannot be parsed to a Duration", "", 0)); + } + + @Test + void printSimple() { + assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), SIMPLE, ChronoUnit.NANOS)) + .isEqualTo("12345ns"); + assertThat(DurationFormatterUtils.print(Duration.ofNanos(-12345), SIMPLE, ChronoUnit.MICROS)) + .isEqualTo("-12us"); + } + + @Test + void printSimpleNoChronoUnit() { + assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), SIMPLE)) + .isEqualTo("0ms"); + assertThat(DurationFormatterUtils.print(Duration.ofSeconds(-3), SIMPLE)) + .isEqualTo("-3000ms"); + } + + @Test + void printIsoNoChronoUnit() { + assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), ISO8601)) + .isEqualTo("PT0.000012345S"); + assertThat(DurationFormatterUtils.print(Duration.ofSeconds(-3), ISO8601)) + .isEqualTo("PT-3S"); + } + + @Test + void printIsoIgnoresChronoUnit() { + assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), ISO8601, ChronoUnit.HOURS)) + .isEqualTo("PT0.000012345S"); + assertThat(DurationFormatterUtils.print(Duration.ofSeconds(-3), ISO8601, ChronoUnit.HOURS)) + .isEqualTo("PT-3S"); + } + + @Test + void detectAndParse() { + assertThat(DurationFormatterUtils.detectAndParse("PT1.234S", ChronoUnit.NANOS)) + .as("iso") + .isEqualTo(Duration.ofMillis(1234)); + + assertThat(DurationFormatterUtils.detectAndParse("1234ms", ChronoUnit.NANOS)) + .as("simple with explicit unit") + .isEqualTo(Duration.ofMillis(1234)); + + assertThat(DurationFormatterUtils.detectAndParse("1234", ChronoUnit.NANOS)) + .as("simple without suffix") + .isEqualTo(Duration.ofNanos(1234)); + } + + @Test + void detectAndParseNoChronoUnit() { + assertThat(DurationFormatterUtils.detectAndParse("PT1.234S")) + .as("iso") + .isEqualTo(Duration.ofMillis(1234)); + + assertThat(DurationFormatterUtils.detectAndParse("1234ms")) + .as("simple with explicit unit") + .isEqualTo(Duration.ofMillis(1234)); + + assertThat(DurationFormatterUtils.detectAndParse("1234")) + .as("simple without suffix") + .isEqualTo(Duration.ofMillis(1234)); + } + + @Test + void detect() { + assertThat(DurationFormatterUtils.detect("+3ms")) + .as("SIMPLE") + .isEqualTo(SIMPLE); + assertThat(DurationFormatterUtils.detect("-10y")) + .as("invalid yet matching SIMPLE pattern") + .isEqualTo(SIMPLE); + + assertThat(DurationFormatterUtils.detect("P2DT3H-4M")) + .as("ISO8601") + .isEqualTo(ISO8601); + assertThat(DurationFormatterUtils.detect("P2DWV3H-4M")) + .as("invalid yet matching ISO8601 pattern") + .isEqualTo(ISO8601); + + assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.detect("WPT2H-4M")) + .withMessage("'WPT2H-4M' is not a valid duration, cannot detect any known style") + .withNoCause(); + } + + @Test + void longValueFromUnit() { + Duration nanos = Duration.ofSeconds(3).plusMillis(22).plusNanos(1111); + assertThat(DurationFormatterUtils.longValueFromUnit(nanos, ChronoUnit.NANOS)) + .as("NANOS") + .isEqualTo(3022001111L); + assertThat(DurationFormatterUtils.longValueFromUnit(nanos, ChronoUnit.MICROS)) + .as("MICROS") + .isEqualTo(3022001); + assertThat(DurationFormatterUtils.longValueFromUnit(nanos, ChronoUnit.MILLIS)) + .as("MILLIS") + .isEqualTo(3022); + assertThat(DurationFormatterUtils.longValueFromUnit(nanos, ChronoUnit.SECONDS)) + .as("SECONDS") + .isEqualTo(3); + + Duration minutes = Duration.ofHours(1).plusMinutes(23); + assertThat(DurationFormatterUtils.longValueFromUnit(minutes, ChronoUnit.MINUTES)) + .as("MINUTES") + .isEqualTo(83); + assertThat(DurationFormatterUtils.longValueFromUnit(minutes, ChronoUnit.HOURS)) + .as("HOURS") + .isEqualTo(1); + + Duration days = Duration.ofHours(48 + 5); + assertThat(DurationFormatterUtils.longValueFromUnit(days, ChronoUnit.HOURS)) + .as("HOURS in days") + .isEqualTo(53); + assertThat(DurationFormatterUtils.longValueFromUnit(days, ChronoUnit.DAYS)) + .as("DAYS") + .isEqualTo(2); + } + + @Test + void longValueFromUnsupportedUnit() { + assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.longValueFromUnit(Duration.ofDays(3), + ChronoUnit.HALF_DAYS)).as("HALF_DAYS") + .withMessage("'HALF_DAYS' is not a supported ChronoUnit for simple duration representation"); + assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.longValueFromUnit(Duration.ofDays(23), + ChronoUnit.WEEKS)).as("WEEKS") + .withMessage("'WEEKS' is not a supported ChronoUnit for simple duration representation"); + } + + @Test + void unitFromSuffix() { + assertThat(DurationFormatterUtils.unitFromSuffix("ns")).as("ns").isEqualTo(ChronoUnit.NANOS); + assertThat(DurationFormatterUtils.unitFromSuffix("us")).as("us").isEqualTo(ChronoUnit.MICROS); + assertThat(DurationFormatterUtils.unitFromSuffix("ms")).as("ms").isEqualTo(ChronoUnit.MILLIS); + assertThat(DurationFormatterUtils.unitFromSuffix("s")).as("s").isEqualTo(ChronoUnit.SECONDS); + assertThat(DurationFormatterUtils.unitFromSuffix("m")).as("m").isEqualTo(ChronoUnit.MINUTES); + assertThat(DurationFormatterUtils.unitFromSuffix("h")).as("h").isEqualTo(ChronoUnit.HOURS); + assertThat(DurationFormatterUtils.unitFromSuffix("d")).as("d").isEqualTo(ChronoUnit.DAYS); + + assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.unitFromSuffix("ws")) + .withMessage("'ws' is not a valid simple duration unit"); + } + + @Test + void suffixFromUnit() { + assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.NANOS)).as("NANOS").isEqualTo("ns"); + assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.MICROS)).as("MICROS").isEqualTo("us"); + assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.MILLIS)).as("MILLIS").isEqualTo("ms"); + assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.SECONDS)).as("SECONDS").isEqualTo("s"); + assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.MINUTES)).as("MINUTES").isEqualTo("m"); + assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.HOURS)).as("HOURS").isEqualTo("h"); + assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.DAYS)).as("DAYS").isEqualTo("d"); + + assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.suffixFromUnit(ChronoUnit.MILLENNIA)) + .withMessage("'MILLENNIA' is not a supported ChronoUnit for simple duration representation"); + } +} From 7b545039ef7fed7b761721f68f28559f21c7e084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Fri, 12 May 2023 16:32:18 +0200 Subject: [PATCH 5/9] Reintroduce Unit to only have valid units in the enum --- .../format/annotation/DurationFormat.java | 105 +++++++++- .../datetime/standard/DurationFormatter.java | 11 +- .../standard/DurationFormatterUtils.java | 64 ++---- .../standard/DateTimeFormattingTests.java | 3 +- .../standard/DurationFormatterUtilsTests.java | 185 ++++++++++-------- 5 files changed, 219 insertions(+), 149 deletions(-) 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 515d269b14a8..0b69b32e2915 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 @@ -23,6 +23,9 @@ import java.lang.annotation.Target; import java.time.Duration; import java.time.temporal.ChronoUnit; +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}, @@ -43,11 +46,11 @@ Style style() default Style.ISO8601; /** - * Define which {@code ChronoUnit} to fall back to in case the {@code style()} + * 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. + * the input ({@code Unit.MILLIS} if unspecified). */ - ChronoUnit defaultUnit() default ChronoUnit.MILLIS; + Unit defaultUnit() default Unit.MILLIS; /** * Duration format styles. @@ -74,4 +77,100 @@ enum Style { ISO8601; } + /** + * 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 { + /** + * Nanoseconds ({@code "ns"}). + */ + NANOS(ChronoUnit.NANOS, "ns", Duration::toNanos), + + /** + * Microseconds ({@code "us"}). + */ + MICROS(ChronoUnit.MICROS, "us", duration -> duration.toNanos() / 1000L), + + /** + * Milliseconds ({@code "ms"}). + */ + MILLIS(ChronoUnit.MILLIS, "ms", Duration::toMillis), + + /** + * Seconds ({@code "s"}). + */ + SECONDS(ChronoUnit.SECONDS, "s", Duration::getSeconds), + + /** + * Minutes ({@code "m"}). + */ + MINUTES(ChronoUnit.MINUTES, "m", Duration::toMinutes), + + /** + * Hours ({@code "h"}). + */ + HOURS(ChronoUnit.HOURS, "h", Duration::toHours), + + /** + * Days ({@code "d"}). + */ + DAYS(ChronoUnit.DAYS, "d", Duration::toDays); + + private final ChronoUnit chronoUnit; + + private final String suffix; + + private final Function longValue; + + Unit(ChronoUnit chronoUnit, String suffix, Function toUnit) { + this.chronoUnit = chronoUnit; + this.suffix = suffix; + this.longValue = toUnit; + } + + public ChronoUnit asChronoUnit() { + return this.chronoUnit; + } + + public String asSuffix() { + return this.suffix; + } + + public Duration parse(String value) { + return Duration.of(Long.parseLong(value), asChronoUnit()); + } + + public String print(Duration value) { + return longValue(value) + asSuffix(); + } + + public long longValue(Duration value) { + return this.longValue.apply(value); + } + + public static Unit fromChronoUnit(@Nullable ChronoUnit chronoUnit) { + if (chronoUnit == null) { + return Unit.MILLIS; + } + for (Unit candidate : values()) { + if (candidate.chronoUnit == chronoUnit) { + return candidate; + } + } + throw new IllegalArgumentException("No matching Unit for ChronoUnit." + chronoUnit.name()); + } + + public static Unit fromSuffix(String suffix) { + for (Unit candidate : values()) { + if (candidate.suffix.equalsIgnoreCase(suffix)) { + return candidate; + } + } + throw new IllegalArgumentException("'" + suffix + "' is not a valid simple duration Unit"); + } + + } + } diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java index 345a6b6b192c..39584ef1f3c5 100644 --- a/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java @@ -18,7 +18,6 @@ import java.text.ParseException; import java.time.Duration; -import java.time.temporal.ChronoUnit; import java.util.Locale; import org.springframework.format.Formatter; @@ -39,7 +38,7 @@ class DurationFormatter implements Formatter { //TODO why is this one private final DurationFormat.Style style; @Nullable - private final ChronoUnit defaultUnit; + private final DurationFormat.Unit defaultUnit; /** * Create a {@code DurationFormatter} following JSR-310's parsing rules for a Duration @@ -52,7 +51,7 @@ class DurationFormatter implements Formatter { //TODO why is this one /** * Create a {@code DurationFormatter} in a specific {@link DurationFormat.Style}. *

When a unit is needed but cannot be determined (e.g. printing a Duration in the - * {@code SIMPLE} style), {@code ChronoUnit#MILLIS} is used. + * {@code SIMPLE} style), {@code DurationFormat.Unit#MILLIS} is used. */ public DurationFormatter(DurationFormat.Style style) { this(style, null); @@ -60,7 +59,7 @@ public DurationFormatter(DurationFormat.Style style) { /** * Create a {@code DurationFormatter} in a specific {@link DurationFormat.Style} with an - * optional {@code ChronoUnit}. + * optional {@code DurationFormat.Unit}. *

If a {@code defaultUnit} is specified, it may be used in parsing cases when no * unit is present in the string (provided the style allows for such a case). It will * also be used as the representation's resolution when printing in the @@ -68,9 +67,9 @@ public DurationFormatter(DurationFormat.Style style) { * unit. * * @param style the {@code DurationStyle} to use - * @param defaultUnit the {@code ChronoUnit} to fall back to when parsing and printing + * @param defaultUnit the {@code DurationFormat.Unit} to fall back to when parsing and printing */ - public DurationFormatter(DurationFormat.Style style, @Nullable ChronoUnit defaultUnit) { + public DurationFormatter(DurationFormat.Style style, @Nullable DurationFormat.Unit defaultUnit) { this.style = style; this.defaultUnit = defaultUnit; } 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 4a3e24c9a7f0..6cb5855456c3 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 @@ -17,7 +17,6 @@ package org.springframework.format.datetime.standard; import java.time.Duration; -import java.time.temporal.ChronoUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -29,8 +28,8 @@ /** * Support {@code Duration} parsing and printing in several styles, as listed in * {@link DurationFormat.Style}. - *

Some styles may not enforce any unit to be present, defaulting to {@code ChronoUnit#MILLIS} - * in that case. Methods in this class offer overloads that take a {@code ChronoUnit} to + *

Some styles may not enforce any unit to be present, defaulting to {@code DurationFormat.Unit#MILLIS} + * in that case. Methods in this class offer overloads that take a {@link DurationFormat.Unit} to * be used as a fall-back instead of the ultimate MILLIS default. * * @author Phillip Webb @@ -61,7 +60,7 @@ public static Duration parse(String value, DurationFormat.Style style) { * will default to ms) * @return a duration */ - public static Duration parse(String value, DurationFormat.Style style, @Nullable ChronoUnit unit) { + public static Duration parse(String value, DurationFormat.Style style, @Nullable DurationFormat.Unit unit) { return switch (style) { case ISO8601 -> parseIso8601(value); case SIMPLE -> parseSimple(value, unit); @@ -86,7 +85,7 @@ public static String print(Duration value, DurationFormat.Style style) { * to ms) * @return the printed result */ - public static String print(Duration value, DurationFormat.Style style, @Nullable ChronoUnit unit) { + public static String print(Duration value, DurationFormat.Style style, @Nullable DurationFormat.Unit unit) { return switch (style) { case ISO8601 -> value.toString(); case SIMPLE -> printSimple(value, unit); @@ -113,7 +112,7 @@ public static Duration detectAndParse(String value) { * @throws IllegalArgumentException if the value is not a known style or cannot be * parsed */ - public static Duration detectAndParse(String value, @Nullable ChronoUnit unit) { + public static Duration detectAndParse(String value, @Nullable DurationFormat.Unit unit) { return parse(value, detect(value), unit); } @@ -134,19 +133,6 @@ public static DurationFormat.Style detect(String value) { throw new IllegalArgumentException("'" + value + "' is not a valid duration, cannot detect any known style"); } - public static long longValueFromUnit(Duration duration, ChronoUnit unit) { - return switch (unit) { - case NANOS -> duration.toNanos(); - case MICROS -> duration.toNanos() / 1000L; - case MILLIS -> duration.toMillis(); - case SECONDS -> duration.toSeconds(); - case MINUTES -> duration.toMinutes(); - case HOURS -> duration.toHours(); - case DAYS -> duration.toDays(); - default -> throw new IllegalArgumentException("'" + unit.name() + "' is not a supported ChronoUnit for simple duration representation"); - }; - } - private static final Pattern ISO_8601_PATTERN = Pattern.compile("^[+-]?[pP].*$"); private static final Pattern SIMPLE_PATTERN = Pattern.compile("^([+-]?\\d+)([a-zA-Z]{0,2})$"); @@ -159,51 +145,25 @@ private static Duration parseIso8601(String value) { } } - private static Duration parseSimple(String text, @Nullable ChronoUnit fallbackUnit) { + private static Duration parseSimple(String text, @Nullable DurationFormat.Unit fallbackUnit) { try { Matcher matcher = SIMPLE_PATTERN.matcher(text); Assert.state(matcher.matches(), "Does not match simple duration pattern"); String suffix = matcher.group(2); - ChronoUnit parsingUnit = (fallbackUnit == null ? ChronoUnit.MILLIS : fallbackUnit); + DurationFormat.Unit parsingUnit = (fallbackUnit == null ? DurationFormat.Unit.MILLIS : fallbackUnit); if (StringUtils.hasLength(suffix)) { - parsingUnit = unitFromSuffix(suffix); + parsingUnit = DurationFormat.Unit.fromSuffix(suffix); } - return Duration.of(Long.parseLong(matcher.group(1)), parsingUnit); + return parsingUnit.parse(matcher.group(1)); } catch (Exception ex) { throw new IllegalArgumentException("'" + text + "' is not a valid simple duration", ex); } } - private static String printSimple(Duration duration, @Nullable ChronoUnit unit) { - unit = (unit == null ? ChronoUnit.MILLIS : unit); - return longValueFromUnit(duration, unit) + suffixFromUnit(unit); - } - - /* package-private */ static ChronoUnit unitFromSuffix(String suffix) { - return switch (suffix.toLowerCase()) { - case "ns" -> ChronoUnit.NANOS; - case "us" -> ChronoUnit.MICROS; - case "ms" -> ChronoUnit.MILLIS; - case "s" -> ChronoUnit.SECONDS; - case "m" -> ChronoUnit.MINUTES; - case "h" -> ChronoUnit.HOURS; - case "d" -> ChronoUnit.DAYS; - default -> throw new IllegalArgumentException("'" + suffix + "' is not a valid simple duration unit"); - }; - } - - /* package-private */ static String suffixFromUnit(ChronoUnit unit) { - return switch (unit) { - case NANOS -> "ns"; - case MICROS -> "us"; - case MILLIS -> "ms"; - case SECONDS -> "s"; - case MINUTES -> "m"; - case HOURS -> "h"; - case DAYS -> "d"; - default -> throw new IllegalArgumentException("'" + unit.name() + "' is not a supported ChronoUnit for simple duration representation"); - }; + private static String printSimple(Duration duration, @Nullable DurationFormat.Unit unit) { + unit = (unit == null ? DurationFormat.Unit.MILLIS : unit); + return unit.print(duration); } } diff --git a/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java b/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java index 3250e165f099..2e02085018d0 100644 --- a/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java +++ b/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java @@ -31,7 +31,6 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.format.FormatStyle; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Date; import java.util.GregorianCalendar; @@ -688,7 +687,7 @@ public static class DateTimeBean { private Duration duration; - @DurationFormat(style = Style.SIMPLE, defaultUnit = ChronoUnit.MICROS) + @DurationFormat(style = Style.SIMPLE, defaultUnit = DurationFormat.Unit.MICROS) private Duration styleDuration; private Year year; 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 1619ba61e6dc..29f066d30def 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 @@ -19,9 +19,13 @@ import java.time.Duration; import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import org.junit.jupiter.api.Nested; 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.assertThatIllegalArgumentException; import static org.springframework.format.annotation.DurationFormat.Style.ISO8601; @@ -31,13 +35,13 @@ class DurationFormatterUtilsTests { @Test void parseSimpleWithUnits() { - Duration nanos = DurationFormatterUtils.parse("1ns", SIMPLE, ChronoUnit.SECONDS); - Duration micros = DurationFormatterUtils.parse("-2us", SIMPLE, ChronoUnit.SECONDS); - Duration millis = DurationFormatterUtils.parse("+3ms", SIMPLE, ChronoUnit.SECONDS); - Duration seconds = DurationFormatterUtils.parse("4s", SIMPLE, ChronoUnit.SECONDS); - Duration minutes = DurationFormatterUtils.parse("5m", SIMPLE, ChronoUnit.SECONDS); - Duration hours = DurationFormatterUtils.parse("6h", SIMPLE, ChronoUnit.SECONDS); - Duration days = DurationFormatterUtils.parse("-10d", SIMPLE, ChronoUnit.SECONDS); + Duration nanos = DurationFormatterUtils.parse("1ns", SIMPLE, Unit.SECONDS); + Duration micros = DurationFormatterUtils.parse("-2us", SIMPLE, Unit.SECONDS); + Duration millis = DurationFormatterUtils.parse("+3ms", SIMPLE, Unit.SECONDS); + Duration seconds = DurationFormatterUtils.parse("4s", SIMPLE, Unit.SECONDS); + Duration minutes = DurationFormatterUtils.parse("5m", SIMPLE, Unit.SECONDS); + Duration hours = DurationFormatterUtils.parse("6h", SIMPLE, Unit.SECONDS); + Duration days = DurationFormatterUtils.parse("-10d", SIMPLE, Unit.SECONDS); assertThat(nanos).hasNanos(1); assertThat(micros).hasNanos(-2 * 1000); @@ -50,9 +54,9 @@ void parseSimpleWithUnits() { @Test void parseSimpleWithoutUnits() { - assertThat(DurationFormatterUtils.parse("-123", SIMPLE, ChronoUnit.SECONDS)) + assertThat(DurationFormatterUtils.parse("-123", SIMPLE, Unit.SECONDS)) .hasSeconds(-123); - assertThat(DurationFormatterUtils.parse("456", SIMPLE, ChronoUnit.SECONDS)) + assertThat(DurationFormatterUtils.parse("456", SIMPLE, Unit.SECONDS)) .hasSeconds(456); } @@ -80,7 +84,7 @@ void parseSimpleThrows() { assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.parse("+23y", SIMPLE)) .withMessage("'+23y' is not a valid simple duration") - .withCause(new IllegalArgumentException("'y' is not a valid simple duration unit")); + .withCause(new IllegalArgumentException("'y' is not a valid simple duration Unit")); } @Test @@ -114,7 +118,7 @@ void parseIsoNoChronoUnit() { @Test void parseIsoIgnoresFallbackChronoUnit() { - assertThat(DurationFormatterUtils.parse("P2DT3H4M", ISO8601, ChronoUnit.NANOS)) + assertThat(DurationFormatterUtils.parse("P2DT3H4M", ISO8601, Unit.NANOS)) .isEqualTo(Duration.ofDays(2).plusHours(3).plusMinutes(4)); } @@ -127,9 +131,9 @@ void parseIsoThrows() { @Test void printSimple() { - assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), SIMPLE, ChronoUnit.NANOS)) + assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), SIMPLE, Unit.NANOS)) .isEqualTo("12345ns"); - assertThat(DurationFormatterUtils.print(Duration.ofNanos(-12345), SIMPLE, ChronoUnit.MICROS)) + assertThat(DurationFormatterUtils.print(Duration.ofNanos(-12345), SIMPLE, Unit.MICROS)) .isEqualTo("-12us"); } @@ -151,23 +155,23 @@ void printIsoNoChronoUnit() { @Test void printIsoIgnoresChronoUnit() { - assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), ISO8601, ChronoUnit.HOURS)) + assertThat(DurationFormatterUtils.print(Duration.ofNanos(12345), ISO8601, Unit.HOURS)) .isEqualTo("PT0.000012345S"); - assertThat(DurationFormatterUtils.print(Duration.ofSeconds(-3), ISO8601, ChronoUnit.HOURS)) + assertThat(DurationFormatterUtils.print(Duration.ofSeconds(-3), ISO8601, Unit.HOURS)) .isEqualTo("PT-3S"); } @Test void detectAndParse() { - assertThat(DurationFormatterUtils.detectAndParse("PT1.234S", ChronoUnit.NANOS)) + assertThat(DurationFormatterUtils.detectAndParse("PT1.234S", Unit.NANOS)) .as("iso") .isEqualTo(Duration.ofMillis(1234)); - assertThat(DurationFormatterUtils.detectAndParse("1234ms", ChronoUnit.NANOS)) + assertThat(DurationFormatterUtils.detectAndParse("1234ms", Unit.NANOS)) .as("simple with explicit unit") .isEqualTo(Duration.ofMillis(1234)); - assertThat(DurationFormatterUtils.detectAndParse("1234", ChronoUnit.NANOS)) + assertThat(DurationFormatterUtils.detectAndParse("1234", Unit.NANOS)) .as("simple without suffix") .isEqualTo(Duration.ofNanos(1234)); } @@ -208,74 +212,83 @@ void detect() { .withNoCause(); } - @Test - void longValueFromUnit() { - Duration nanos = Duration.ofSeconds(3).plusMillis(22).plusNanos(1111); - assertThat(DurationFormatterUtils.longValueFromUnit(nanos, ChronoUnit.NANOS)) - .as("NANOS") - .isEqualTo(3022001111L); - assertThat(DurationFormatterUtils.longValueFromUnit(nanos, ChronoUnit.MICROS)) - .as("MICROS") - .isEqualTo(3022001); - assertThat(DurationFormatterUtils.longValueFromUnit(nanos, ChronoUnit.MILLIS)) - .as("MILLIS") - .isEqualTo(3022); - assertThat(DurationFormatterUtils.longValueFromUnit(nanos, ChronoUnit.SECONDS)) - .as("SECONDS") - .isEqualTo(3); - - Duration minutes = Duration.ofHours(1).plusMinutes(23); - assertThat(DurationFormatterUtils.longValueFromUnit(minutes, ChronoUnit.MINUTES)) - .as("MINUTES") - .isEqualTo(83); - assertThat(DurationFormatterUtils.longValueFromUnit(minutes, ChronoUnit.HOURS)) - .as("HOURS") - .isEqualTo(1); - - Duration days = Duration.ofHours(48 + 5); - assertThat(DurationFormatterUtils.longValueFromUnit(days, ChronoUnit.HOURS)) - .as("HOURS in days") - .isEqualTo(53); - assertThat(DurationFormatterUtils.longValueFromUnit(days, ChronoUnit.DAYS)) - .as("DAYS") - .isEqualTo(2); - } - - @Test - void longValueFromUnsupportedUnit() { - assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.longValueFromUnit(Duration.ofDays(3), - ChronoUnit.HALF_DAYS)).as("HALF_DAYS") - .withMessage("'HALF_DAYS' is not a supported ChronoUnit for simple duration representation"); - assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.longValueFromUnit(Duration.ofDays(23), - ChronoUnit.WEEKS)).as("WEEKS") - .withMessage("'WEEKS' is not a supported ChronoUnit for simple duration representation"); - } - - @Test - void unitFromSuffix() { - assertThat(DurationFormatterUtils.unitFromSuffix("ns")).as("ns").isEqualTo(ChronoUnit.NANOS); - assertThat(DurationFormatterUtils.unitFromSuffix("us")).as("us").isEqualTo(ChronoUnit.MICROS); - assertThat(DurationFormatterUtils.unitFromSuffix("ms")).as("ms").isEqualTo(ChronoUnit.MILLIS); - assertThat(DurationFormatterUtils.unitFromSuffix("s")).as("s").isEqualTo(ChronoUnit.SECONDS); - assertThat(DurationFormatterUtils.unitFromSuffix("m")).as("m").isEqualTo(ChronoUnit.MINUTES); - assertThat(DurationFormatterUtils.unitFromSuffix("h")).as("h").isEqualTo(ChronoUnit.HOURS); - assertThat(DurationFormatterUtils.unitFromSuffix("d")).as("d").isEqualTo(ChronoUnit.DAYS); - - assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.unitFromSuffix("ws")) - .withMessage("'ws' is not a valid simple duration unit"); + @Nested + class DurationFormatUnit { + + @Test + void longValueFromUnit() { + Duration nanos = Duration.ofSeconds(3).plusMillis(22).plusNanos(1111); + assertThat(Unit.NANOS.longValue(nanos)) + .as("NANOS") + .isEqualTo(3022001111L); + assertThat(Unit.MICROS.longValue(nanos)) + .as("MICROS") + .isEqualTo(3022001); + assertThat(Unit.MILLIS.longValue(nanos)) + .as("MILLIS") + .isEqualTo(3022); + assertThat(Unit.SECONDS.longValue(nanos)) + .as("SECONDS") + .isEqualTo(3); + + Duration minutes = Duration.ofHours(1).plusMinutes(23); + assertThat(Unit.MINUTES.longValue(minutes)) + .as("MINUTES") + .isEqualTo(83); + assertThat(Unit.HOURS.longValue(minutes)) + .as("HOURS") + .isEqualTo(1); + + Duration days = Duration.ofHours(48 + 5); + assertThat(Unit.HOURS.longValue(days)) + .as("HOURS in days") + .isEqualTo(53); + assertThat(Unit.DAYS.longValue(days)) + .as("DAYS") + .isEqualTo(2); + } + + @Test + void unitFromSuffix() { + assertThat(Unit.fromSuffix("ns")).as("ns").isEqualTo(Unit.NANOS); + assertThat(Unit.fromSuffix("us")).as("us").isEqualTo(Unit.MICROS); + assertThat(Unit.fromSuffix("ms")).as("ms").isEqualTo(Unit.MILLIS); + assertThat(Unit.fromSuffix("s")).as("s").isEqualTo(Unit.SECONDS); + assertThat(Unit.fromSuffix("m")).as("m").isEqualTo(Unit.MINUTES); + assertThat(Unit.fromSuffix("h")).as("h").isEqualTo(Unit.HOURS); + assertThat(Unit.fromSuffix("d")).as("d").isEqualTo(Unit.DAYS); + + assertThatIllegalArgumentException().isThrownBy(() -> Unit.fromSuffix("ws")) + .withMessage("'ws' is not a valid simple duration Unit"); + } + + @Test + void unitFromChronoUnit() { + assertThat(Unit.fromChronoUnit(ChronoUnit.NANOS)).as("ns").isEqualTo(Unit.NANOS); + assertThat(Unit.fromChronoUnit(ChronoUnit.MICROS)).as("us").isEqualTo(Unit.MICROS); + assertThat(Unit.fromChronoUnit(ChronoUnit.MILLIS)).as("ms").isEqualTo(Unit.MILLIS); + assertThat(Unit.fromChronoUnit(ChronoUnit.SECONDS)).as("s").isEqualTo(Unit.SECONDS); + assertThat(Unit.fromChronoUnit(ChronoUnit.MINUTES)).as("m").isEqualTo(Unit.MINUTES); + assertThat(Unit.fromChronoUnit(ChronoUnit.HOURS)).as("h").isEqualTo(Unit.HOURS); + assertThat(Unit.fromChronoUnit(ChronoUnit.DAYS)).as("d").isEqualTo(Unit.DAYS); + + assertThatIllegalArgumentException().isThrownBy(() -> Unit.fromChronoUnit(ChronoUnit.CENTURIES)) + .withMessage("No matching Unit for ChronoUnit.CENTURIES"); + } + + @Test + void unitSuffixSmokeTest() { + assertThat(Arrays.stream(Unit.values()).map(u -> u.name() + "->" + u.asSuffix())) + .containsExactly("NANOS->ns", "MICROS->us", "MILLIS->ms", "SECONDS->s", + "MINUTES->m", "HOURS->h", "DAYS->d"); + } + + @Test + void chronoUnitSmokeTest() { + assertThat(Arrays.stream(Unit.values()).map(Unit::asChronoUnit)) + .containsExactly(ChronoUnit.NANOS, ChronoUnit.MICROS, ChronoUnit.MILLIS, + ChronoUnit.SECONDS, ChronoUnit.MINUTES, ChronoUnit.HOURS, ChronoUnit.DAYS); + } } - @Test - void suffixFromUnit() { - assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.NANOS)).as("NANOS").isEqualTo("ns"); - assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.MICROS)).as("MICROS").isEqualTo("us"); - assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.MILLIS)).as("MILLIS").isEqualTo("ms"); - assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.SECONDS)).as("SECONDS").isEqualTo("s"); - assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.MINUTES)).as("MINUTES").isEqualTo("m"); - assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.HOURS)).as("HOURS").isEqualTo("h"); - assertThat(DurationFormatterUtils.suffixFromUnit(ChronoUnit.DAYS)).as("DAYS").isEqualTo("d"); - - assertThatIllegalArgumentException().isThrownBy(() -> DurationFormatterUtils.suffixFromUnit(ChronoUnit.MILLENNIA)) - .withMessage("'MILLENNIA' is not a supported ChronoUnit for simple duration representation"); - } } From 05cf6496ced0f63691cbf382b6277ff555a1649b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Fri, 12 May 2023 17:55:23 +0200 Subject: [PATCH 6/9] Add some javadoc --- .../format/annotation/DurationFormat.java | 37 +++++++++++++++++++ .../standard/DurationFormatterUtils.java | 1 + 2 files changed, 38 insertions(+) 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 0b69b32e2915..df31d1c339eb 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 @@ -130,26 +130,59 @@ enum Unit { this.longValue = toUnit; } + /** + * 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. + */ 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. + * @param value the String representation of the long + * @return the corresponding {@code Duration} + */ public Duration parse(String value) { return Duration.of(Long.parseLong(value), asChronoUnit()); } + /** + * 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} + */ 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}. + * @param value the {@code Duration} to convert to long + * @return the long value for the Duration in this Unit + */ public long longValue(Duration value) { return this.longValue.apply(value); } + /** + * Get the {@code Unit} corresponding to the given {@code ChronoUnit}. + * @throws IllegalArgumentException if that particular ChronoUnit isn't supported + */ public static Unit fromChronoUnit(@Nullable ChronoUnit chronoUnit) { if (chronoUnit == null) { return Unit.MILLIS; @@ -162,6 +195,10 @@ public static Unit fromChronoUnit(@Nullable ChronoUnit chronoUnit) { throw new IllegalArgumentException("No matching Unit for ChronoUnit." + chronoUnit.name()); } + /** + * Get the {@code Unit} corresponding to the given {@code String} suffix. + * @throws IllegalArgumentException if that particular suffix is unknown + */ public static Unit fromSuffix(String suffix) { for (Unit candidate : values()) { if (candidate.suffix.equalsIgnoreCase(suffix)) { 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 6cb5855456c3..2a2b87911c8c 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 @@ -124,6 +124,7 @@ public static Duration detectAndParse(String value, @Nullable DurationFormat.Uni */ public static DurationFormat.Style detect(String value) { Assert.notNull(value, "Value must not be null"); + // warning: the order of parsing starts to matter if multiple patterns accept a plain integer (no unit suffix) if (ISO_8601_PATTERN.matcher(value).matches()) { return DurationFormat.Style.ISO8601; } From e5c3f3fb0a6072a3c202dafee70f436806633b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Mon, 15 May 2023 11:40:59 +0200 Subject: [PATCH 7/9] Add alt Duration parsing support to Scheduled annotation --- .../scheduling/annotation/Scheduled.java | 51 ++++++++++++++----- .../ScheduledAnnotationBeanPostProcessor.java | 23 +++------ ...duledAnnotationBeanPostProcessorTests.java | 4 ++ 3 files changed, 50 insertions(+), 28 deletions(-) diff --git a/spring-context/src/main/java/org/springframework/scheduling/annotation/Scheduled.java b/spring-context/src/main/java/org/springframework/scheduling/annotation/Scheduled.java index 1b4cca077887..6a6920283e0d 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/annotation/Scheduled.java +++ b/spring-context/src/main/java/org/springframework/scheduling/annotation/Scheduled.java @@ -120,10 +120,19 @@ /** * Execute the annotated method with a fixed period between the end of the * last invocation and the start of the next. - *

The time unit is milliseconds by default but can be overridden via - * {@link #timeUnit}. - * @return the delay as a String value — for example, a placeholder - * or a {@link java.time.Duration#parse java.time.Duration} compliant value + *

The duration String can be in several formats: + *

+ * @return the delay as a String value — for example a placeholder, + * or a {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 java.time.Duration} compliant value + * or a {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE simple format} compliant value * @since 3.2.2 */ String fixedDelayString() default ""; @@ -138,10 +147,19 @@ /** * Execute the annotated method with a fixed period between invocations. - *

The time unit is milliseconds by default but can be overridden via - * {@link #timeUnit}. - * @return the period as a String value — for example, a placeholder - * or a {@link java.time.Duration#parse java.time.Duration} compliant value + *

The duration String can be in several formats: + *

+ * @return the period as a String value — for example a placeholder, + * or a {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 java.time.Duration} compliant value + * or a {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE simple format} compliant value * @since 3.2.2 */ String fixedRateString() default ""; @@ -159,10 +177,19 @@ /** * Number of units of time to delay before the first execution of a * {@link #fixedRate} or {@link #fixedDelay} task. - *

The time unit is milliseconds by default but can be overridden via - * {@link #timeUnit}. - * @return the initial delay as a String value — for example, a placeholder - * or a {@link java.time.Duration#parse java.time.Duration} compliant value + *

The duration String can be in several formats: + *

+ * @return the initial delay as a String value — for example a placeholder, + * or a {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 java.time.Duration} compliant value + * or a {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE simple format} compliant value * @since 3.2.2 */ String initialDelayString() default ""; diff --git a/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java b/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java index 14272d35e776..fe6ace5f6841 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java +++ b/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java @@ -61,6 +61,8 @@ import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.format.annotation.DurationFormat; +import org.springframework.format.datetime.standard.DurationFormatterUtils; import org.springframework.lang.Nullable; import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.Trigger; @@ -414,7 +416,7 @@ protected void processScheduled(Scheduled scheduled, Method method, Object bean) } catch (RuntimeException ex) { throw new IllegalArgumentException( - "Invalid initialDelayString value \"" + initialDelayString + "\" - cannot parse into long"); + "Invalid initialDelayString value \"" + initialDelayString + "\"", ex); } } } @@ -469,7 +471,7 @@ protected void processScheduled(Scheduled scheduled, Method method, Object bean) } catch (RuntimeException ex) { throw new IllegalArgumentException( - "Invalid fixedDelayString value \"" + fixedDelayString + "\" - cannot parse into long"); + "Invalid fixedDelayString value \"" + fixedDelayString + "\"", ex); } tasks.add(this.registrar.scheduleFixedDelayTask(new FixedDelayTask(runnable, fixedDelay, initialDelay))); } @@ -495,7 +497,7 @@ protected void processScheduled(Scheduled scheduled, Method method, Object bean) } catch (RuntimeException ex) { throw new IllegalArgumentException( - "Invalid fixedRateString value \"" + fixedRateString + "\" - cannot parse into long"); + "Invalid fixedRateString value \"" + fixedRateString + "\"", ex); } tasks.add(this.registrar.scheduleFixedRateTask(new FixedRateTask(runnable, fixedRate, initialDelay))); } @@ -536,21 +538,10 @@ private static Duration toDuration(long value, TimeUnit timeUnit) { } private static Duration toDuration(String value, TimeUnit timeUnit) { - if (isDurationString(value)) { - return Duration.parse(value); - } - return toDuration(Long.parseLong(value), timeUnit); - } - - private static boolean isDurationString(String value) { - return (value.length() > 1 && (isP(value.charAt(0)) || isP(value.charAt(1)))); + DurationFormat.Unit unit = DurationFormat.Unit.fromChronoUnit(timeUnit.toChronoUnit()); + return DurationFormatterUtils.detectAndParse(value, unit); // interpreting as long as fallback already } - private static boolean isP(char ch) { - return (ch == 'P' || ch == 'p'); - } - - /** * Return all currently scheduled tasks, from {@link Scheduled} methods * as well as from programmatic {@link SchedulingConfigurer} interaction. diff --git a/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessorTests.java b/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessorTests.java index a1cd409d205f..063f74843aa1 100644 --- a/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessorTests.java +++ b/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessorTests.java @@ -521,8 +521,10 @@ void propertyPlaceholderWithInactiveCron() { @CsvSource(textBlock = """ PropertyPlaceholderWithFixedDelay, 5000, 1000, 5_000, 1_000 PropertyPlaceholderWithFixedDelay, PT5S, PT1S, 5_000, 1_000 + PropertyPlaceholderWithFixedDelay, 5400ms, 1s, 5_400, 1_000 PropertyPlaceholderWithFixedDelayInSeconds, 5000, 1000, 5_000_000, 1_000_000 PropertyPlaceholderWithFixedDelayInSeconds, PT5S, PT1S, 5_000, 1_000 + PropertyPlaceholderWithFixedDelayInSeconds, 5400ms, 500ms, 5_400, 500 """) void propertyPlaceholderWithFixedDelay(@NameToClass Class beanClass, String fixedDelay, String initialDelay, long expectedInterval, long expectedInitialDelay) { @@ -565,8 +567,10 @@ void propertyPlaceholderWithFixedDelay(@NameToClass Class beanClass, String f @CsvSource(textBlock = """ PropertyPlaceholderWithFixedRate, 3000, 1000, 3_000, 1_000 PropertyPlaceholderWithFixedRate, PT3S, PT1S, 3_000, 1_000 + PropertyPlaceholderWithFixedRate, 3200ms, 1s, 3_200, 1_000 PropertyPlaceholderWithFixedRateInSeconds, 3000, 1000, 3_000_000, 1_000_000 PropertyPlaceholderWithFixedRateInSeconds, PT3S, PT1S, 3_000, 1_000 + PropertyPlaceholderWithFixedRateInSeconds, 3200ms, 500ms, 3_200, 500 """) void propertyPlaceholderWithFixedRate(@NameToClass Class beanClass, String fixedRate, String initialDelay, long expectedInterval, long expectedInitialDelay) { From 2d30c6af88aed8634ee9dbb88ac8b53a2467a69c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Mon, 15 May 2023 15:48:21 +0200 Subject: [PATCH 8/9] Add mention of annotation in refguide --- .../modules/ROOT/pages/core/validation/format.adoc | 6 ++++-- framework-docs/modules/ROOT/pages/web/webflux/config.adoc | 2 +- .../ROOT/pages/web/webmvc/mvc-config/conversion.adoc | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/core/validation/format.adoc b/framework-docs/modules/ROOT/pages/core/validation/format.adoc index 920e2a44d970..73c7d04f9c93 100644 --- a/framework-docs/modules/ROOT/pages/core/validation/format.adoc +++ b/framework-docs/modules/ROOT/pages/core/validation/format.adoc @@ -74,7 +74,8 @@ The `format` subpackages provide several `Formatter` implementations as a conven The `number` package provides `NumberStyleFormatter`, `CurrencyStyleFormatter`, and `PercentStyleFormatter` to format `Number` objects that use a `java.text.NumberFormat`. The `datetime` package provides a `DateFormatter` to format `java.util.Date` objects with -a `java.text.DateFormat`. +a `java.text.DateFormat`, as well as a `DurationFormatter` to format `Duration` objects +in different styles defined in the `@DurationFormat.Style` enum (see <>). The following `DateFormatter` is an example `Formatter` implementation: @@ -280,7 +281,8 @@ Kotlin:: A portable format annotation API exists in the `org.springframework.format.annotation` package. You can use `@NumberFormat` to format `Number` fields such as `Double` and -`Long`, and `@DateTimeFormat` to format `java.util.Date`, `java.util.Calendar`, `Long` +`Long`, `@DurationFormat` to format `Duration` fields in ISO8601 and simplified styles, +and `@DateTimeFormat` to format `java.util.Date`, `java.util.Calendar`, `Long` (for millisecond timestamps) as well as JSR-310 `java.time`. The following example uses `@DateTimeFormat` to format a `java.util.Date` as an ISO Date diff --git a/framework-docs/modules/ROOT/pages/web/webflux/config.adoc b/framework-docs/modules/ROOT/pages/web/webflux/config.adoc index 10e87907b6dc..caed2e07002e 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/config.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/config.adoc @@ -91,7 +91,7 @@ class WebConfig : WebFluxConfigurer { [.small]#xref:web/webmvc/mvc-config/conversion.adoc[See equivalent in the Servlet stack]# By default, formatters for various number and date types are installed, along with support -for customization via `@NumberFormat` and `@DateTimeFormat` on fields. +for customization via `@NumberFormat`, `@DurationFormat` and `@DateTimeFormat` on fields. To register custom formatters and converters in Java config, use the following: diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/conversion.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/conversion.adoc index 35d0998934de..8ad2f71624cf 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/conversion.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/conversion.adoc @@ -4,7 +4,7 @@ [.small]#xref:web/webflux/config.adoc#webflux-config-conversion[See equivalent in the Reactive stack]# By default, formatters for various number and date types are installed, along with support -for customization via `@NumberFormat` and `@DateTimeFormat` on fields. +for customization via `@NumberFormat`, `@DurationFormat` and `@DateTimeFormat` on fields. To register custom formatters and converters in Java config, use the following: From 4c9a60dc0aee9af95c49887571fce0110c3bf3dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Mon, 15 May 2023 15:51:46 +0200 Subject: [PATCH 9/9] polish, make DurationFormatter public --- .../format/datetime/standard/DurationFormatter.java | 8 ++++---- .../ScheduledAnnotationBeanPostProcessorTests.java | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java index 39584ef1f3c5..00ac4dd4f3fb 100644 --- a/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2023 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. @@ -30,11 +30,11 @@ * supporting additional {@code DurationFormat.Style} styles. * * @author Juergen Hoeller - * @since 4.2.4 + * @since 6.1 * @see DurationFormatterUtils * @see DurationFormat.Style - */ -class DurationFormatter implements Formatter { //TODO why is this one package-private ? make public and change since taglet ? + */ //introduced as package-private in 4.2.4 +public class DurationFormatter implements Formatter { private final DurationFormat.Style style; @Nullable diff --git a/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessorTests.java b/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessorTests.java index 063f74843aa1..f92d2c65f182 100644 --- a/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessorTests.java +++ b/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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.