Skip to content

Commit 5ae623c

Browse files
committed
Polish 'Add Period converter support'
Polish period converter support, primarily by changing `PeriodStyle` to parse and print periods that include more than one unit. See gh-21136
1 parent dc4d71f commit 5ae623c

File tree

6 files changed

+153
-76
lines changed

6 files changed

+153
-76
lines changed

spring-boot-project/spring-boot-docs/src/docs/asciidoc/spring-boot-features.adoc

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,6 +1450,26 @@ Doing so gives a transparent upgrade path while supporting a much richer format.
14501450

14511451

14521452

1453+
[[boot-features-external-config-conversion-period]]
1454+
===== Converting periods
1455+
In addition to durations, Spring Boot can also work with `java.time.Period` type.
1456+
The following formats can be used in application properties:
1457+
1458+
* An regular `int` representation (using days as the default unit unless a `@PeriodUnit` has been specified)
1459+
* The standard ISO-8601 format {java-api}/java/time/Period.html#parse-java.lang.CharSequence-[used by `java.time.Period`]
1460+
* A simpler format where the value and the unit pairs are coupled (e.g. `1y3d` means 1 year and 3 days)
1461+
1462+
The following units are supported with the simple format:
1463+
1464+
* `y` for years
1465+
* `m` for months
1466+
* `w` for weeks
1467+
* `d` for days
1468+
1469+
NOTE: The `java.time.Period` type never actually stores the number of weeks, it is simply a shortcut that means "`7 days`".
1470+
1471+
1472+
14531473
[[boot-features-external-config-conversion-datasize]]
14541474
===== Converting Data Sizes
14551475
Spring Framework has a `DataSize` value type that expresses a size in bytes.

spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/fieldvalues/javac/JavaCompilerFieldValuesParser.java

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,19 @@ private static class FieldCollector implements TreeVisitor {
116116
DURATION_SUFFIX = Collections.unmodifiableMap(values);
117117
}
118118

119+
private static final String PERIOD_OF = "Period.of";
120+
121+
private static final Map<String, String> PERIOD_SUFFIX;
122+
123+
static {
124+
Map<String, String> values = new HashMap<>();
125+
values.put("Days", "d");
126+
values.put("Weeks", "w");
127+
values.put("Months", "m");
128+
values.put("Years", "y");
129+
PERIOD_SUFFIX = Collections.unmodifiableMap(values);
130+
}
131+
119132
private static final String DATA_SIZE_OF = "DataSize.of";
120133

121134
private static final Map<String, String> DATA_SIZE_SUFFIX;
@@ -130,19 +143,6 @@ private static class FieldCollector implements TreeVisitor {
130143
DATA_SIZE_SUFFIX = Collections.unmodifiableMap(values);
131144
}
132145

133-
private static final String PERIOD_OF = "Period.of";
134-
135-
private static final Map<String, String> PERIOD_SUFFIX;
136-
137-
static {
138-
Map<String, String> values = new HashMap<>();
139-
values.put("Days", "d");
140-
values.put("Weeks", "w");
141-
values.put("Months", "m");
142-
values.put("Years", "y");
143-
PERIOD_SUFFIX = Collections.unmodifiableMap(values);
144-
}
145-
146146
private final Map<String, Object> fieldValues = new HashMap<>();
147147

148148
private final Map<String, Object> staticFinals = new HashMap<>();

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/convert/ApplicationConversionService.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,12 @@ public static void configure(FormatterRegistry registry) {
110110
public static void addApplicationConverters(ConverterRegistry registry) {
111111
addDelimitedStringConverters(registry);
112112
registry.addConverter(new StringToDurationConverter());
113-
registry.addConverter(new StringToPeriodConverter());
114113
registry.addConverter(new DurationToStringConverter());
115-
registry.addConverter(new PeriodToStringConverter());
116114
registry.addConverter(new NumberToDurationConverter());
117-
registry.addConverter(new NumberToPeriodConverter());
118115
registry.addConverter(new DurationToNumberConverter());
116+
registry.addConverter(new StringToPeriodConverter());
117+
registry.addConverter(new PeriodToStringConverter());
118+
registry.addConverter(new NumberToPeriodConverter());
119119
registry.addConverter(new StringToDataSizeConverter());
120120
registry.addConverter(new NumberToDataSizeConverter());
121121
registry.addConverter(new StringToFileConverter());

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/convert/PeriodStyle.java

Lines changed: 66 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
import java.util.regex.Pattern;
2424

2525
import org.springframework.util.Assert;
26-
import org.springframework.util.StringUtils;
2726

2827
/**
2928
* A standard set of {@link Period} units.
@@ -38,33 +37,72 @@ public enum PeriodStyle {
3837
/**
3938
* Simple formatting, for example '1d'.
4039
*/
41-
SIMPLE("^([\\+\\-]?\\d+)([a-zA-Z]{0,2})$") {
40+
SIMPLE("^" + "(?:([-+]?[0-9]+)Y)?" + "(?:([-+]?[0-9]+)M)?" + "(?:([-+]?[0-9]+)W)?" + "(?:([-+]?[0-9]+)D)?" + "$",
41+
Pattern.CASE_INSENSITIVE) {
4242

4343
@Override
4444
public Period parse(String value, ChronoUnit unit) {
4545
try {
46+
if (NUMERIC.matcher(value).matches()) {
47+
return Unit.fromChronoUnit(unit).parse(value);
48+
}
4649
Matcher matcher = matcher(value);
4750
Assert.state(matcher.matches(), "Does not match simple period pattern");
48-
String suffix = matcher.group(2);
49-
return (StringUtils.hasLength(suffix) ? Unit.fromSuffix(suffix) : Unit.fromChronoUnit(unit))
50-
.parse(matcher.group(1));
51+
Assert.isTrue(hasAtLeastOneGroupValue(matcher), "'" + value + "' is not a valid simple period");
52+
int years = parseInt(matcher, 1);
53+
int months = parseInt(matcher, 2);
54+
int weeks = parseInt(matcher, 3);
55+
int days = parseInt(matcher, 4);
56+
return Period.of(years, months, Math.addExact(Math.multiplyExact(weeks, 7), days));
5157
}
5258
catch (Exception ex) {
5359
throw new IllegalArgumentException("'" + value + "' is not a valid simple period", ex);
5460
}
5561
}
5662

63+
boolean hasAtLeastOneGroupValue(Matcher matcher) {
64+
for (int i = 0; i < matcher.groupCount(); i++) {
65+
if (matcher.group(i + 1) != null) {
66+
return true;
67+
}
68+
}
69+
return false;
70+
}
71+
72+
private int parseInt(Matcher matcher, int group) {
73+
String value = matcher.group(group);
74+
return (value != null) ? Integer.parseInt(value) : 0;
75+
}
76+
77+
@Override
78+
protected boolean matches(String value) {
79+
return NUMERIC.matcher(value).matches() || matcher(value).matches();
80+
}
81+
5782
@Override
5883
public String print(Period value, ChronoUnit unit) {
59-
return Unit.fromChronoUnit(unit).print(value);
84+
if (value.isZero()) {
85+
return Unit.fromChronoUnit(unit).print(value);
86+
}
87+
StringBuilder result = new StringBuilder();
88+
append(result, value, Unit.YEARS);
89+
append(result, value, Unit.MONTHS);
90+
append(result, value, Unit.DAYS);
91+
return result.toString();
92+
}
93+
94+
private void append(StringBuilder result, Period value, Unit unit) {
95+
if (!unit.isZero(value)) {
96+
result.append(unit.print(value));
97+
}
6098
}
6199

62100
},
63101

64102
/**
65103
* ISO-8601 formatting.
66104
*/
67-
ISO8601("^[\\+\\-]?P.*$") {
105+
ISO8601("^[\\+\\-]?P.*$", 0) {
68106

69107
@Override
70108
public Period parse(String value, ChronoUnit unit) {
@@ -83,13 +121,15 @@ public String print(Period value, ChronoUnit unit) {
83121

84122
};
85123

124+
private static final Pattern NUMERIC = Pattern.compile("^[-+]?[0-9]+$");
125+
86126
private final Pattern pattern;
87127

88-
PeriodStyle(String pattern) {
89-
this.pattern = Pattern.compile(pattern);
128+
PeriodStyle(String pattern, int flags) {
129+
this.pattern = Pattern.compile(pattern, flags);
90130
}
91131

92-
protected final boolean matches(String value) {
132+
protected boolean matches(String value) {
93133
return this.pattern.matcher(value).matches();
94134
}
95135

@@ -175,69 +215,47 @@ enum Unit {
175215
/**
176216
* Days, represented by suffix {@code d}.
177217
*/
178-
DAYS(ChronoUnit.DAYS, "d", Period::getDays),
218+
DAYS(ChronoUnit.DAYS, "d", Period::getDays, Period::ofDays),
179219

180220
/**
181221
* Months, represented by suffix {@code m}.
182222
*/
183-
MONTHS(ChronoUnit.MONTHS, "m", Period::getMonths),
223+
MONTHS(ChronoUnit.MONTHS, "m", Period::getMonths, Period::ofMonths),
184224

185225
/**
186226
* Years, represented by suffix {@code y}.
187227
*/
188-
YEARS(ChronoUnit.YEARS, "y", Period::getYears);
228+
YEARS(ChronoUnit.YEARS, "y", Period::getYears, Period::ofYears);
189229

190230
private final ChronoUnit chronoUnit;
191231

192232
private final String suffix;
193233

194234
private final Function<Period, Integer> intValue;
195235

196-
Unit(ChronoUnit chronoUnit, String suffix, Function<Period, Integer> intValue) {
236+
private final Function<Integer, Period> factory;
237+
238+
Unit(ChronoUnit chronoUnit, String suffix, Function<Period, Integer> intValue,
239+
Function<Integer, Period> factory) {
197240
this.chronoUnit = chronoUnit;
198241
this.suffix = suffix;
199242
this.intValue = intValue;
243+
this.factory = factory;
200244
}
201245

202-
/**
203-
* Return the {@link Unit} matching the specified {@code suffix}.
204-
* @param suffix one of the standard suffixes
205-
* @return the {@link Unit} matching the specified {@code suffix}
206-
* @throws IllegalArgumentException if the suffix does not match the suffix of any
207-
* of this enum's constants
208-
*/
209-
public static Unit fromSuffix(String suffix) {
210-
for (Unit candidate : values()) {
211-
if (candidate.suffix.equalsIgnoreCase(suffix)) {
212-
return candidate;
213-
}
214-
}
215-
throw new IllegalArgumentException("Unknown unit suffix '" + suffix + "'");
246+
private Period parse(String value) {
247+
return this.factory.apply(Integer.parseInt(value));
216248
}
217249

218-
public Period parse(String value) {
219-
int intValue = Integer.parseInt(value);
220-
221-
if (ChronoUnit.DAYS == this.chronoUnit) {
222-
return Period.ofDays(intValue);
223-
}
224-
else if (ChronoUnit.WEEKS == this.chronoUnit) {
225-
return Period.ofWeeks(intValue);
226-
}
227-
else if (ChronoUnit.MONTHS == this.chronoUnit) {
228-
return Period.ofMonths(intValue);
229-
}
230-
else if (ChronoUnit.YEARS == this.chronoUnit) {
231-
return Period.ofYears(intValue);
232-
}
233-
throw new IllegalArgumentException("Unknow unit '" + this.chronoUnit + "'");
250+
private String print(Period value) {
251+
return intValue(value) + this.suffix;
234252
}
235253

236-
public String print(Period value) {
237-
return longValue(value) + this.suffix;
254+
public boolean isZero(Period value) {
255+
return intValue(value) == 0;
238256
}
239257

240-
public long longValue(Period value) {
258+
public int intValue(Period value) {
241259
return this.intValue.apply(value);
242260
}
243261

@@ -250,7 +268,7 @@ public static Unit fromChronoUnit(ChronoUnit chronoUnit) {
250268
return candidate;
251269
}
252270
}
253-
throw new IllegalArgumentException("Unknown unit " + chronoUnit);
271+
throw new IllegalArgumentException("Unsupported unit " + chronoUnit);
254272
}
255273

256274
}

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/PeriodStyleTests.java

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ void detectAndParseWhenSimpleDaysShouldReturnPeriod() {
5656
assertThat(PeriodStyle.detectAndParse("-10D")).isEqualTo(Period.ofDays(-10));
5757
}
5858

59+
@Test
60+
void detectAndParseWhenSimpleWeeksShouldReturnPeriod() {
61+
assertThat(PeriodStyle.detectAndParse("10w")).isEqualTo(Period.ofWeeks(10));
62+
assertThat(PeriodStyle.detectAndParse("10W")).isEqualTo(Period.ofWeeks(10));
63+
assertThat(PeriodStyle.detectAndParse("+10w")).isEqualTo(Period.ofWeeks(10));
64+
assertThat(PeriodStyle.detectAndParse("-10W")).isEqualTo(Period.ofWeeks(-10));
65+
}
66+
5967
@Test
6068
void detectAndParseWhenSimpleMonthsShouldReturnPeriod() {
6169
assertThat(PeriodStyle.detectAndParse("10m")).isEqualTo(Period.ofMonths(10));
@@ -86,6 +94,16 @@ void detectAndParseWhenSimpleWithoutSuffixButWithChronoUnitShouldReturnPeriod()
8694
assertThat(PeriodStyle.detectAndParse("-10", ChronoUnit.MONTHS)).isEqualTo(Period.ofMonths(-10));
8795
}
8896

97+
@Test
98+
void detectAndParseWhenComplexShouldReturnPeriod() {
99+
assertThat(PeriodStyle.detectAndParse("1y2m")).isEqualTo(Period.of(1, 2, 0));
100+
assertThat(PeriodStyle.detectAndParse("1y2m3d")).isEqualTo(Period.of(1, 2, 3));
101+
assertThat(PeriodStyle.detectAndParse("2m3d")).isEqualTo(Period.of(0, 2, 3));
102+
assertThat(PeriodStyle.detectAndParse("1y3d")).isEqualTo(Period.of(1, 0, 3));
103+
assertThat(PeriodStyle.detectAndParse("-1y3d")).isEqualTo(Period.of(-1, 0, 3));
104+
assertThat(PeriodStyle.detectAndParse("-1y-3d")).isEqualTo(Period.of(-1, 0, -3));
105+
}
106+
89107
@Test
90108
void detectAndParseWhenBadFormatShouldThrowException() {
91109
assertThatIllegalArgumentException().isThrownBy(() -> PeriodStyle.detectAndParse("10foo"))
@@ -161,8 +179,8 @@ void parseSimpleWithUnitShouldUseUnitAsFallback() {
161179

162180
@Test
163181
void parseSimpleWhenUnknownUnitShouldThrowException() {
164-
assertThatIllegalArgumentException().isThrownBy(() -> PeriodStyle.SIMPLE.parse("10mb"))
165-
.satisfies((ex) -> assertThat(ex.getCause().getMessage()).isEqualTo("Unknown unit suffix 'mb'"));
182+
assertThatIllegalArgumentException().isThrownBy(() -> PeriodStyle.SIMPLE.parse("10x")).satisfies(
183+
(ex) -> assertThat(ex.getCause().getMessage()).isEqualTo("Does not match simple period pattern"));
166184
}
167185

168186
@Test
@@ -184,15 +202,21 @@ void printIso8601ShouldIgnoreUnit() {
184202
}
185203

186204
@Test
187-
void printSimpleWithoutUnitShouldPrintInDays() {
188-
Period period = Period.ofMonths(1);
205+
void printSimpleWhenZeroWithoutUnitShouldPrintInDays() {
206+
Period period = Period.ofMonths(0);
189207
assertThat(PeriodStyle.SIMPLE.print(period)).isEqualTo("0d");
190208
}
191209

192210
@Test
193-
void printSimpleWithUnitShouldPrintInUnit() {
194-
Period period = Period.ofYears(1000);
195-
assertThat(PeriodStyle.SIMPLE.print(period, ChronoUnit.YEARS)).isEqualTo("1000y");
211+
void printSimpleWhenZeroWithUnitShouldPrintInUnit() {
212+
Period period = Period.ofYears(0);
213+
assertThat(PeriodStyle.SIMPLE.print(period, ChronoUnit.YEARS)).isEqualTo("0y");
214+
}
215+
216+
@Test
217+
void printSimpleWhenNonZeroShouldIgnoreUnit() {
218+
Period period = Period.of(1, 2, 3);
219+
assertThat(PeriodStyle.SIMPLE.print(period, ChronoUnit.YEARS)).isEqualTo("1y2m3d");
196220
}
197221

198222
}

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/PeriodToStringConverterTests.java

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,33 @@ void convertWithoutStyleShouldReturnIso8601(ConversionService conversionService)
4242
}
4343

4444
@ConversionServiceTest
45-
void convertWithFormatShouldUseFormatAndDays(ConversionService conversionService) {
46-
String converted = (String) conversionService.convert(Period.ofMonths(1),
45+
void convertWithFormatWhenZeroShouldUseFormatAndDays(ConversionService conversionService) {
46+
String converted = (String) conversionService.convert(Period.ofMonths(0),
4747
MockPeriodTypeDescriptor.get(null, PeriodStyle.SIMPLE), TypeDescriptor.valueOf(String.class));
4848
assertThat(converted).isEqualTo("0d");
4949
}
5050

5151
@ConversionServiceTest
52-
void convertWithFormatAndUnitShouldUseFormatAndUnit(ConversionService conversionService) {
53-
String converted = (String) conversionService.convert(Period.ofYears(1),
52+
void convertWithFormatShouldUseFormat(ConversionService conversionService) {
53+
String converted = (String) conversionService.convert(Period.of(1, 2, 3),
54+
MockPeriodTypeDescriptor.get(null, PeriodStyle.SIMPLE), TypeDescriptor.valueOf(String.class));
55+
assertThat(converted).isEqualTo("1y2m3d");
56+
}
57+
58+
@ConversionServiceTest
59+
void convertWithFormatAndUnitWhenZeroShouldUseFormatAndUnit(ConversionService conversionService) {
60+
String converted = (String) conversionService.convert(Period.ofYears(0),
61+
MockPeriodTypeDescriptor.get(ChronoUnit.YEARS, PeriodStyle.SIMPLE),
62+
TypeDescriptor.valueOf(String.class));
63+
assertThat(converted).isEqualTo("0y");
64+
}
65+
66+
@ConversionServiceTest
67+
void convertWithFormatAndUnitWhenNonZeroShouldUseFormatAndIgnoreUnit(ConversionService conversionService) {
68+
String converted = (String) conversionService.convert(Period.of(1, 0, 3),
5469
MockPeriodTypeDescriptor.get(ChronoUnit.YEARS, PeriodStyle.SIMPLE),
5570
TypeDescriptor.valueOf(String.class));
56-
assertThat(converted).isEqualTo("1y");
71+
assertThat(converted).isEqualTo("1y3d");
5772
}
5873

5974
static Stream<? extends Arguments> conversionServices() throws Exception {

0 commit comments

Comments
 (0)