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: 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..df31d1c339eb --- /dev/null +++ b/spring-context/src/main/java/org/springframework/format/annotation/DurationFormat.java @@ -0,0 +1,213 @@ +/* + * 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 org.springframework.lang.Nullable; + +/** + * Declares that a field or method parameter should be formatted as a {@link java.time.Duration}, + * according to the specified {@code style}. + * + * @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 {@link Unit} to fall back to in case the {@code style()} + * needs a unit for either parsing or printing, and none is explicitly provided in + * the input ({@code Unit.MILLIS} if unspecified). + */ + Unit defaultUnit() default Unit.MILLIS; + + /** + * Duration format styles. + */ + enum Style { + + /** + * 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, + + /** + * ISO-8601 formatting. + *

This is what the JDK uses in {@link java.time.Duration#parse(CharSequence)} + * and {@link Duration#toString()}. + */ + 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; + } + + /** + * 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; + } + for (Unit candidate : values()) { + if (candidate.chronoUnit == chronoUnit) { + return candidate; + } + } + 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)) { + 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/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..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. @@ -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 DurationFormat.Style} styles. * * @author Juergen Hoeller - * @since 4.2.4 - * @see Duration#parse - */ -class DurationFormatter implements Formatter { + * @since 6.1 + * @see DurationFormatterUtils + * @see DurationFormat.Style + */ //introduced as package-private in 4.2.4 +public class DurationFormatter implements Formatter { + + 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 DurationFormat.Unit#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 DurationFormatterUtils.parse(text, this.style); + } + return DurationFormatterUtils.parse(text, this.style, 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 DurationFormatterUtils.print(object, this.style); + } + 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..2a2b87911c8c --- /dev/null +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/DurationFormatterUtils.java @@ -0,0 +1,170 @@ +/* + * 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.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 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 + * @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 DurationFormat.Unit 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 DurationFormat.Unit 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 DurationFormat.Unit 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"); + // 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; + } + if (SIMPLE_PATTERN.matcher(value).matches()) { + return DurationFormat.Style.SIMPLE; + } + throw new IllegalArgumentException("'" + value + "' is not a valid duration, cannot detect any known style"); + } + + private static final Pattern ISO_8601_PATTERN = Pattern.compile("^[+-]?[pP].*$"); + private static final Pattern SIMPLE_PATTERN = Pattern.compile("^([+-]?\\d+)([a-zA-Z]{0,2})$"); + + private static 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 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); + DurationFormat.Unit parsingUnit = (fallbackUnit == null ? DurationFormat.Unit.MILLIS : fallbackUnit); + if (StringUtils.hasLength(suffix)) { + parsingUnit = DurationFormat.Unit.fromSuffix(suffix); + } + 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 DurationFormat.Unit unit) { + unit = (unit == null ? DurationFormat.Unit.MILLIS : unit); + return unit.print(duration); + } + +} 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/format/datetime/standard/DateTimeFormattingTests.java b/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java index 392fdd61c6e5..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 @@ -52,6 +52,8 @@ 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.support.FormattingConversionService; import org.springframework.validation.BindingResult; import org.springframework.validation.DataBinder; @@ -475,6 +477,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 +687,9 @@ public static class DateTimeBean { private Duration duration; + @DurationFormat(style = Style.SIMPLE, defaultUnit = DurationFormat.Unit.MICROS) + private Duration styleDuration; + private Year year; private Month month; @@ -834,6 +850,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; } 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..29f066d30def --- /dev/null +++ b/spring-context/src/test/java/org/springframework/format/datetime/standard/DurationFormatterUtilsTests.java @@ -0,0 +1,294 @@ +/* + * 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 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; +import static org.springframework.format.annotation.DurationFormat.Style.SIMPLE; + +class DurationFormatterUtilsTests { + + @Test + void parseSimpleWithUnits() { + 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); + 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, Unit.SECONDS)) + .hasSeconds(-123); + assertThat(DurationFormatterUtils.parse("456", SIMPLE, Unit.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, Unit.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, Unit.NANOS)) + .isEqualTo("12345ns"); + assertThat(DurationFormatterUtils.print(Duration.ofNanos(-12345), SIMPLE, Unit.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, Unit.HOURS)) + .isEqualTo("PT0.000012345S"); + assertThat(DurationFormatterUtils.print(Duration.ofSeconds(-3), ISO8601, Unit.HOURS)) + .isEqualTo("PT-3S"); + } + + @Test + void detectAndParse() { + assertThat(DurationFormatterUtils.detectAndParse("PT1.234S", Unit.NANOS)) + .as("iso") + .isEqualTo(Duration.ofMillis(1234)); + + assertThat(DurationFormatterUtils.detectAndParse("1234ms", Unit.NANOS)) + .as("simple with explicit unit") + .isEqualTo(Duration.ofMillis(1234)); + + assertThat(DurationFormatterUtils.detectAndParse("1234", Unit.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(); + } + + @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); + } + } + +} 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..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. @@ -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) {