diff --git a/HOCON.md b/HOCON.md index 168a0a33..f85c4b05 100644 --- a/HOCON.md +++ b/HOCON.md @@ -1296,7 +1296,27 @@ must be lowercase. Exactly these strings are supported: - `m`, `minute`, `minutes` - `h`, `hour`, `hours` - `d`, `day`, `days` + +### Period Format + +Similar to the `getDuration()` method, there is a `getPeriod()` method +available for getting time units as a `java.time.Period`. +This can use the general "units format" described above; bare +numbers are taken to be in days, while strings are +parsed as a number plus an optional unit string. + +The supported unit strings for period are case sensitive and +must be lowercase. Exactly these strings are supported: + + - `d`, `day`, `days` + - `w`, `week`, `weeks` + - `m`, `mo`, `month`, `months` (note that if you are using `getTemporal()` + which may return either a `java.time.Duration` or a `java.time.Period` + you will want to use `mo` rather than `m` to prevent your unit being + parsed as minutes) + - `y`, `year`, `years` + ### Size in bytes format Implementations may wish to support a `getBytes()` returning a diff --git a/config/src/main/java/com/typesafe/config/Config.java b/config/src/main/java/com/typesafe/config/Config.java index 0097eed6..68a36e78 100644 --- a/config/src/main/java/com/typesafe/config/Config.java +++ b/config/src/main/java/com/typesafe/config/Config.java @@ -4,6 +4,8 @@ package com.typesafe.config; import java.time.Duration; +import java.time.Period; +import java.time.temporal.TemporalAmount; import java.util.List; import java.util.Map; import java.util.Set; @@ -793,6 +795,44 @@ public interface Config extends ConfigMergeable { */ Duration getDuration(String path); + /** + * Gets a value as a java.time.Period. If the value is + * already a number, then it's taken as days; if it's + * a string, it's parsed understanding units suffixes like + * "10d" or "5w" as documented in the the + * spec. This method never returns null. + * + * @since 1.3.0 + * + * @param path + * path expression + * @return the period value at the requested path + * @throws ConfigException.Missing + * if value is absent or null + * @throws ConfigException.WrongType + * if value is not convertible to Long or String + * @throws ConfigException.BadValue + * if value cannot be parsed as a number of the given TimeUnit + */ + Period getPeriod(String path); + + /** + * Gets a value as a java.time.temporal.TemporalAmount. + * This method will first try get get the value as a java.time.Duration, and if unsuccessful, + * then as a java.time.Period. + * This means that values like "5m" will be parsed as 5 minutes rather than 5 months + * @param path path expression + * @return the temporal value at the requested path + * @throws ConfigException.Missing + * if value is absent or null + * @throws ConfigException.WrongType + * if value is not convertible to Long or String + * @throws ConfigException.BadValue + * if value cannot be parsed as a TemporalAmount + */ + TemporalAmount getTemporal(String path); + /** * Gets a list value (with any element type) as a {@link ConfigList}, which * implements {@code java.util.List}. Throws if the path is diff --git a/config/src/main/java/com/typesafe/config/impl/SimpleConfig.java b/config/src/main/java/com/typesafe/config/impl/SimpleConfig.java index c3147124..ce1913ad 100644 --- a/config/src/main/java/com/typesafe/config/impl/SimpleConfig.java +++ b/config/src/main/java/com/typesafe/config/impl/SimpleConfig.java @@ -7,7 +7,11 @@ import java.io.ObjectStreamException; import java.io.Serializable; import java.math.BigDecimal; import java.math.BigInteger; +import java.time.DateTimeException; import java.time.Duration; +import java.time.Period; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAmount; import java.util.AbstractMap; import java.util.ArrayList; import java.util.HashMap; @@ -322,6 +326,21 @@ final class SimpleConfig implements Config, MergeableValue, Serializable { return Duration.ofNanos(nanos); } + @Override + public Period getPeriod(String path){ + ConfigValue v = find(path, ConfigValueType.STRING); + return parsePeriod((String) v.unwrapped(), v.origin(), path); + } + + @Override + public TemporalAmount getTemporal(String path){ + try{ + return getDuration(path); + } catch (ConfigException.BadValue e){ + return getPeriod(path); + } + } + @SuppressWarnings("unchecked") private List getHomogeneousUnwrappedList(String path, ConfigValueType expected) { @@ -583,6 +602,90 @@ final class SimpleConfig implements Config, MergeableValue, Serializable { return s.substring(i + 1); } + /** + * Parses a period string. If no units are specified in the string, it is + * assumed to be in days. The returned period is in days. + * The purpose of this function is to implement the period-related methods + * in the ConfigObject interface. + * + * @param input + * the string to parse + * @param originForException + * origin of the value being parsed + * @param pathForException + * path to include in exceptions + * @return duration in days + * @throws ConfigException + * if string is invalid + */ + public static Period parsePeriod(String input, + ConfigOrigin originForException, String pathForException) { + String s = ConfigImplUtil.unicodeTrim(input); + String originalUnitString = getUnits(s); + String unitString = originalUnitString; + String numberString = ConfigImplUtil.unicodeTrim(s.substring(0, s.length() + - unitString.length())); + ChronoUnit units; + + // this would be caught later anyway, but the error message + // is more helpful if we check it here. + if (numberString.length() == 0) + throw new ConfigException.BadValue(originForException, + pathForException, "No number in period value '" + input + + "'"); + + if (unitString.length() > 2 && !unitString.endsWith("s")) + unitString = unitString + "s"; + + // note that this is deliberately case-sensitive + if (unitString.equals("") || unitString.equals("d") || unitString.equals("days")) { + units = ChronoUnit.DAYS; + + } else if (unitString.equals("w") || unitString.equals("weeks")) { + units = ChronoUnit.WEEKS; + + } else if (unitString.equals("m") || unitString.equals("mo") || unitString.equals("months")) { + units = ChronoUnit.MONTHS; + + } else if (unitString.equals("y") || unitString.equals("years")) { + units = ChronoUnit.YEARS; + + } else { + throw new ConfigException.BadValue(originForException, + pathForException, "Could not parse time unit '" + + originalUnitString + + "' (try d, w, mo, y)"); + } + + try { + return periodOf(Integer.parseInt(numberString), units); + } catch (NumberFormatException e) { + throw new ConfigException.BadValue(originForException, + pathForException, "Could not parse duration number '" + + numberString + "'"); + } + } + + + private static Period periodOf(int n, ChronoUnit unit){ + if(unit.isTimeBased()){ + throw new DateTimeException(unit + " cannot be converted to a java.time.Period"); + } + + switch (unit){ + case DAYS: + return Period.ofDays(n); + case WEEKS: + return Period.ofWeeks(n); + case MONTHS: + return Period.ofMonths(n); + case YEARS: + return Period.ofYears(n); + default: + throw new DateTimeException(unit + " cannot be converted to a java.time.Period"); + } + } + /** * Parses a duration string. If no units are specified in the string, it is * assumed to be in milliseconds. The returned duration is in nanoseconds. diff --git a/config/src/test/resources/test01.conf b/config/src/test/resources/test01.conf index 1370bf85..e40eb7f5 100644 --- a/config/src/test/resources/test01.conf +++ b/config/src/test/resources/test01.conf @@ -65,6 +65,14 @@ "minusLargeNanos" : -4878955355435272204ns }, + "periods" : { + "day" : 1d, + "dayAsNumber": 2, + "week": 3 weeks, + "month": 5 mo, + "year": 8y + }, + "memsizes" : { "meg" : 1M, "megsList" : [1M, 1024K, 1048576], diff --git a/config/src/test/scala/com/typesafe/config/impl/ConfigTest.scala b/config/src/test/scala/com/typesafe/config/impl/ConfigTest.scala index 96b55835..0becca4b 100644 --- a/config/src/test/scala/com/typesafe/config/impl/ConfigTest.scala +++ b/config/src/test/scala/com/typesafe/config/impl/ConfigTest.scala @@ -3,13 +3,16 @@ */ package com.typesafe.config.impl +import java.time.temporal.{ ChronoUnit, TemporalUnit } + import org.junit.Assert._ import org.junit._ import com.typesafe.config._ import java.util.concurrent.TimeUnit + import scala.collection.JavaConverters._ import com.typesafe.config.ConfigResolveOptions -import java.util.concurrent.TimeUnit.{ SECONDS, NANOSECONDS, MICROSECONDS, MILLISECONDS, MINUTES, DAYS, HOURS } +import java.util.concurrent.TimeUnit.{ DAYS, HOURS, MICROSECONDS, MILLISECONDS, MINUTES, NANOSECONDS, SECONDS } class ConfigTest extends TestUtils { @@ -811,6 +814,13 @@ class ConfigTest extends TestUtils { assertDurationAsTimeUnit(HOURS) assertDurationAsTimeUnit(DAYS) + // periods + assertEquals(1, conf.getPeriod("periods.day").get(ChronoUnit.DAYS)) + assertEquals(2, conf.getPeriod("periods.dayAsNumber").getDays) + assertEquals(3 * 7, conf.getTemporal("periods.week").get(ChronoUnit.DAYS)) + assertEquals(5, conf.getTemporal("periods.month").get(ChronoUnit.MONTHS)) + assertEquals(8, conf.getTemporal("periods.year").get(ChronoUnit.YEARS)) + // should get size in bytes assertEquals(1024 * 1024L, conf.getBytes("memsizes.meg")) assertEquals(1024 * 1024L, conf.getBytes("memsizes.megAsNumber")) diff --git a/config/src/test/scala/com/typesafe/config/impl/UnitParserTest.scala b/config/src/test/scala/com/typesafe/config/impl/UnitParserTest.scala index b4ee9a1b..f03593d1 100644 --- a/config/src/test/scala/com/typesafe/config/impl/UnitParserTest.scala +++ b/config/src/test/scala/com/typesafe/config/impl/UnitParserTest.scala @@ -3,6 +3,9 @@ */ package com.typesafe.config.impl +import java.time.{ LocalDate, Period } +import java.time.temporal.ChronoUnit + import org.junit.Assert._ import org.junit._ import com.typesafe.config._ @@ -40,6 +43,33 @@ class UnitParserTest extends TestUtils { assertTrue(e2.getMessage.contains("duration number")) } + @Test + def parsePeriod() = { + val oneYears = List( + "1y", "1 y", "1year", "1 years", " 1y ", " 1 y ", + "365", "365d", "365 d", "365 days", " 365 days ", "365day", + "12m", "12mo", "12 m", " 12 mo ", "12 months", "12month") + val epochDate = LocalDate.ofEpochDay(0) + val oneYear = ChronoUnit.DAYS.between(epochDate, epochDate.plus(Period.ofYears(1))) + for (y <- oneYears) { + val period = SimpleConfig.parsePeriod(y, fakeOrigin(), "test") + val dayCount = ChronoUnit.DAYS.between(epochDate, epochDate.plus(period)) + assertEquals(oneYear, dayCount) + } + + // bad units + val e = intercept[ConfigException.BadValue] { + SimpleConfig.parsePeriod("100 dollars", fakeOrigin(), "test") + } + assertTrue(s"${e.getMessage} was not the expected error message", e.getMessage.contains("time unit")) + + // bad number + val e2 = intercept[ConfigException.BadValue] { + SimpleConfig.parsePeriod("1 00 seconds", fakeOrigin(), "test") + } + assertTrue(s"${e2.getMessage} was not the expected error message", e2.getMessage.contains("time unit 'seconds'")) + } + // https://github.com/typesafehub/config/issues/117 // this broke because "1d" is a valid double for parseDouble @Test