diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ddf9808..658eb0f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Client + +- Support ISO 8601 duration parsing and formatting for `Interval` + ## [1.7.1] - 2026-08-31 ### Dependencies diff --git a/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/Interval.java b/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/Interval.java index 708f65e9..c7cba667 100644 --- a/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/Interval.java +++ b/tarantool-jackson-mapping/src/main/java/io/tarantool/mapping/Interval.java @@ -13,6 +13,8 @@ import static java.time.temporal.ChronoUnit.SECONDS; import static java.time.temporal.ChronoUnit.WEEKS; import static java.time.temporal.ChronoUnit.YEARS; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; import java.time.temporal.ChronoField; import java.time.temporal.ChronoUnit; @@ -24,6 +26,9 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.function.LongConsumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import io.tarantool.core.Assert; @@ -59,6 +64,20 @@ public class Interval implements TemporalAmount { Collections.unmodifiableList( Arrays.asList(YEARS, MONTHS, WEEKS, DAYS, HOURS, MINUTES, SECONDS, NANOS)); + // per-field sign, not one leading sign, e.g. "P-1Y-2M" (a lenient variant beyond strict ISO 8601) + private static final Pattern ISO_8601_PATTERN = + Pattern.compile( + "P" + + "(?:(?[+-]?\\d+)Y)?" + + "(?:(?[+-]?\\d+)M)?" + + "(?:(?[+-]?\\d+)W)?" + + "(?:(?[+-]?\\d+)D)?" + + "(?:T" + + "(?:(?[+-]?\\d+)H)?" + + "(?:(?[+-]?\\d+)M)?" + + "(?:(?[+-]?\\d+(?:\\.\\d+)?)S)?" + + ")?"); + public static final long MAX_NSEC_RANGE = Integer.MAX_VALUE; public static final long MAX_YEAR_RANGE = MAX_DATE_YEAR - MIN_DATE_YEAR; public static final long MAX_MONTH_RANGE = MAX_YEAR_RANGE * 12; @@ -337,6 +356,81 @@ public Interval setAdjust(Adjust adjust) { return this; } + public static Interval parse(String text) { + Assert.state(text != null, "Text to parse must not be null"); + Matcher matcher = ISO_8601_PATTERN.matcher(text); + Assert.state(matcher.matches(), "'" + text + "' is not a valid ISO 8601 duration"); + + Interval interval = new Interval(); + boolean hasAnyField = false; + hasAnyField |= applyField(matcher, "year", interval::setYear, text); + hasAnyField |= applyField(matcher, "month", interval::setMonth, text); + hasAnyField |= applyField(matcher, "week", interval::setWeek, text); + hasAnyField |= applyField(matcher, "day", interval::setDay, text); + hasAnyField |= applyField(matcher, "hour", interval::setHour, text); + hasAnyField |= applyField(matcher, "minute", interval::setMin, text); + + String second = matcher.group("second"); + if (second != null) { + BigDecimal value = new BigDecimal(second); + // longValueExact throws on overflow; setScale drops the fraction first + long wholeSeconds; + try { + wholeSeconds = value.setScale(0, RoundingMode.DOWN).longValueExact(); + } catch (ArithmeticException e) { + throw new IllegalArgumentException("'" + text + "' is not a valid ISO 8601 duration", e); + } + interval.setSec(wholeSeconds); + interval.setNsec( + value.subtract(BigDecimal.valueOf(wholeSeconds)).movePointRight(9).longValue()); + hasAnyField = true; + } + + Assert.state(hasAnyField, "'" + text + "' does not contain any duration fields"); + return interval; + } + + private static boolean applyField( + Matcher matcher, String group, LongConsumer setter, String text) { + String value = matcher.group(group); + if (value == null) { + return false; + } + try { + setter.accept(Long.parseLong(value)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("'" + text + "' is not a valid ISO 8601 duration", e); + } + return true; + } + + public String toIsoString() { + StringBuilder sb = threadLocalStringBuilder.get(); + sb.delete(0, sb.length()); + sb.append('P').append(year).append('Y').append(month).append('M'); + if (week != 0) { + sb.append(week).append('W'); + } + sb.append(day).append('D'); + sb.append('T').append(hour).append('H').append(min).append('M'); + sb.append(formatSeconds()).append('S'); + return sb.toString(); + } + + private String formatSeconds() { + if (nsec == 0) { + return Long.toString(sec); + } + // ISO 8601 has one sign per number; sec and nsec of opposite sign can't be combined into one + Assert.state( + sec == 0 || (sec > 0) == (nsec > 0), + "sec=" + sec + " and nsec=" + nsec + " have different signs; cannot format as ISO 8601"); + return BigDecimal.valueOf(sec) + .add(BigDecimal.valueOf(nsec, 9)) + .stripTrailingZeros() + .toPlainString(); + } + @Override public int hashCode() { int result = (int) (year ^ (year >>> 32)); diff --git a/tarantool-jackson-mapping/src/test/java/io/tarantool/mapping/datetime/IntervalTest.java b/tarantool-jackson-mapping/src/test/java/io/tarantool/mapping/datetime/IntervalTest.java index 95b4d6ea..9fe3f539 100644 --- a/tarantool-jackson-mapping/src/test/java/io/tarantool/mapping/datetime/IntervalTest.java +++ b/tarantool-jackson-mapping/src/test/java/io/tarantool/mapping/datetime/IntervalTest.java @@ -307,6 +307,88 @@ void testToStringWithThreads() throws ExecutionException, InterruptedException { } } + public static Stream dataForIsoRoundTrip() { + return Stream.of( + Arguments.of( + new Interval() + .setYear(1) + .setMonth(2) + .setDay(3) + .setHour(4) + .setMin(5) + .setSec(6) + .setNsec(789_000_000), + "P1Y2M3DT4H5M6.789S"), + Arguments.of(new Interval(), "P0Y0M0DT0H0M0S"), + Arguments.of( + new Interval() + .setYear(-1) + .setMonth(-2) + .setDay(-3) + .setHour(-4) + .setMin(-5) + .setSec(-6) + .setNsec(-789_000_000), + "P-1Y-2M-3DT-4H-5M-6.789S"), + Arguments.of(new Interval().setYear(1).setMonth(-2).setDay(3), "P1Y-2M3DT0H0M0S"), + Arguments.of(new Interval().setMonth(14), "P0Y14M0DT0H0M0S"), + Arguments.of(new Interval().setSec(30), "P0Y0M0DT0H0M30S"), + Arguments.of( + new Interval().setHour(2).setMin(30).setSec(45).setNsec(500_000_000), + "P0Y0M0DT2H30M45.5S"), + Arguments.of(new Interval().setWeek(2), "P0Y0M2W0DT0H0M0S"), + Arguments.of(new Interval().setNsec(-500_000_000), "P0Y0M0DT0H0M-0.5S")); + } + + @ParameterizedTest + @MethodSource("dataForIsoRoundTrip") + void testToIsoStringProducesExpectedFormat(Interval interval, String iso) { + assertEquals(iso, interval.toIsoString()); + } + + @ParameterizedTest + @MethodSource("dataForIsoRoundTrip") + void testParseIsInverseOfToIsoString(Interval interval, String iso) { + assertEquals(interval, Interval.parse(iso)); + } + + public static Stream dataForMixedSignSecNsec() { + return Stream.of( + new Interval().setSec(6).setNsec(-500_000_000), + new Interval().setSec(-6).setNsec(500_000_000)); + } + + @ParameterizedTest + @MethodSource("dataForMixedSignSecNsec") + void testToIsoStringRejectsMixedSignSecNsec(Interval interval) { + assertThrows(IllegalArgumentException.class, interval::toIsoString); + } + + public static Stream dataForInvalidIso() { + return Stream.of( + "", + "garbage", + "P", + "PT", + "1Y2M3D", + "P1X", + // overflows long even after truncating the fractional part + "P0Y0M0DT0H0M99999999999999999999999S", + // overflow outside the seconds field + "P99999999999999999999Y"); + } + + @ParameterizedTest + @MethodSource("dataForInvalidIso") + void testParseRejectsInvalidIso(String text) { + assertThrows(IllegalArgumentException.class, () -> Interval.parse(text)); + } + + @Test + void testParseRejectsNull() { + assertThrows(IllegalArgumentException.class, () -> Interval.parse(null)); + } + @AfterEach public void cleanUp() { futures.clear();