Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Client

- Support ISO 8601 duration parsing and formatting for `Interval`

## [1.7.1] - 2026-08-31

### Dependencies
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -59,6 +64,20 @@ public class Interval implements TemporalAmount {
Collections.unmodifiableList(
Arrays.<TemporalUnit>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"
+ "(?:(?<year>[+-]?\\d+)Y)?"
+ "(?:(?<month>[+-]?\\d+)M)?"
+ "(?:(?<week>[+-]?\\d+)W)?"
+ "(?:(?<day>[+-]?\\d+)D)?"
+ "(?:T"
+ "(?:(?<hour>[+-]?\\d+)H)?"
+ "(?:(?<minute>[+-]?\\d+)M)?"
+ "(?:(?<second>[+-]?\\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;
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,88 @@ void testToStringWithThreads() throws ExecutionException, InterruptedException {
}
}

public static Stream<Arguments> 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<Interval> 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<String> 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();
Expand Down
Loading