Skip to content

Commit d3ea6de

Browse files
committed
Fix rounding in Duration formatting. Stolen from @Rollczi <3
1 parent 4a52d73 commit d3ea6de

File tree

5 files changed

+192
-68
lines changed

5 files changed

+192
-68
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.eternalcode.commons;
2+
3+
import java.util.function.Supplier;
4+
5+
public class Lazy<T> implements Supplier<T> {
6+
7+
private Supplier<T> supplier;
8+
private boolean initialized;
9+
private T value;
10+
private Exception exception;
11+
12+
public Lazy(T value) {
13+
this.initialized = true;
14+
this.value = value;
15+
}
16+
17+
public Lazy(Supplier<T> supplier) {
18+
this.supplier = supplier;
19+
}
20+
21+
public static Lazy<Void> ofRunnable(Runnable runnable) {
22+
return new Lazy<>(() -> {
23+
runnable.run();
24+
return null;
25+
});
26+
}
27+
28+
@Override
29+
public synchronized T get() {
30+
if (exception != null) {
31+
throw new RuntimeException("Lazy value has been already initialized with exception", exception);
32+
}
33+
34+
if (initialized) {
35+
return value;
36+
}
37+
38+
this.initialized = true;
39+
40+
try {
41+
return this.value = supplier.get();
42+
}
43+
catch (Exception exception) {
44+
this.exception = exception;
45+
throw new RuntimeException("Cannot initialize lazy value", exception);
46+
}
47+
}
48+
49+
public boolean isInitialized() {
50+
return initialized;
51+
}
52+
53+
public boolean hasFailed() {
54+
return exception != null;
55+
}
56+
57+
}

eternalcode-commons-shared/src/main/java/com/eternalcode/commons/time/DurationParser.java

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import java.time.LocalDateTime;
55
import java.time.temporal.ChronoUnit;
66
import java.util.Map;
7+
import java.util.Set;
78

89
public class DurationParser extends TemporalAmountParser<Duration> {
910

@@ -26,20 +27,24 @@ public class DurationParser extends TemporalAmountParser<Duration> {
2627
.withUnit("y", ChronoUnit.YEARS);
2728

2829
public DurationParser() {
29-
super(LocalDateTimeProvider.now());
30+
super(ChronoUnit.SECONDS, LocalDateTimeProvider.now());
3031
}
3132

32-
public DurationParser(LocalDateTimeProvider localDateTimeProvider) {
33-
super(localDateTimeProvider);
33+
public DurationParser(ChronoUnit defaultZero) {
34+
super(defaultZero, LocalDateTimeProvider.now());
3435
}
3536

36-
private DurationParser(Map<String, ChronoUnit> units, LocalDateTimeProvider baseForTimeEstimation) {
37-
super(units, baseForTimeEstimation);
37+
public DurationParser(ChronoUnit defaultZero, LocalDateTimeProvider localDateTimeProvider) {
38+
super(defaultZero, localDateTimeProvider);
39+
}
40+
41+
private DurationParser(ChronoUnit defaultZero, Map<String, ChronoUnit> units, Set<TimeModifier> modifiers, LocalDateTimeProvider baseForTimeEstimation) {
42+
super(defaultZero, units, modifiers, baseForTimeEstimation);
3843
}
3944

4045
@Override
41-
protected TemporalAmountParser<Duration> clone(Map<String, ChronoUnit> units, LocalDateTimeProvider baseForTimeEstimation) {
42-
return new DurationParser(units, baseForTimeEstimation);
46+
protected TemporalAmountParser<Duration> clone(ChronoUnit defaultZero, Map<String, ChronoUnit> units, Set<TimeModifier> modifiers, LocalDateTimeProvider baseForTimeEstimation) {
47+
return new DurationParser(defaultZero, units, modifiers, baseForTimeEstimation);
4348
}
4449

4550
@Override

eternalcode-commons-shared/src/main/java/com/eternalcode/commons/time/PeriodParser.java

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.time.Period;
66
import java.time.temporal.ChronoUnit;
77
import java.util.Map;
8+
import java.util.Set;
89

910
public class PeriodParser extends TemporalAmountParser<Period> {
1011

@@ -15,20 +16,29 @@ public class PeriodParser extends TemporalAmountParser<Period> {
1516
.withUnit("y", ChronoUnit.YEARS);
1617

1718
public PeriodParser() {
18-
super(LocalDateTimeProvider.now());
19+
super(ChronoUnit.DAYS, LocalDateTimeProvider.now());
20+
}
21+
22+
public PeriodParser(ChronoUnit defaultZero) {
23+
super(defaultZero, LocalDateTimeProvider.now());
1924
}
2025

2126
public PeriodParser(LocalDateTimeProvider baseForTimeEstimation) {
22-
super(baseForTimeEstimation);
27+
super(ChronoUnit.DAYS, baseForTimeEstimation);
28+
}
29+
30+
31+
public PeriodParser(ChronoUnit defaultZero, LocalDateTimeProvider baseForTimeEstimation) {
32+
super(defaultZero, baseForTimeEstimation);
2333
}
2434

25-
private PeriodParser(Map<String, ChronoUnit> units, LocalDateTimeProvider baseForTimeEstimation) {
26-
super(units, baseForTimeEstimation);
35+
private PeriodParser(ChronoUnit defaultZero, Map<String, ChronoUnit> units, Set<TimeModifier> modifiers, LocalDateTimeProvider baseForTimeEstimation) {
36+
super(defaultZero, units, modifiers, baseForTimeEstimation);
2737
}
2838

2939
@Override
30-
protected TemporalAmountParser<Period> clone(Map<String, ChronoUnit> units, LocalDateTimeProvider baseForTimeEstimation) {
31-
return new PeriodParser(units, baseForTimeEstimation);
40+
protected TemporalAmountParser<Period> clone(ChronoUnit defaultZero, Map<String, ChronoUnit> units, Set<TimeModifier> modifiers, LocalDateTimeProvider baseForTimeEstimation) {
41+
return new PeriodParser(defaultZero, units, modifiers, baseForTimeEstimation);
3242
}
3343

3444
@Override

eternalcode-commons-shared/src/main/java/com/eternalcode/commons/time/TemporalAmountParser.java

Lines changed: 105 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.eternalcode.commons.time;
22

3+
import com.eternalcode.commons.Lazy;
34
import java.math.BigInteger;
5+
import java.math.RoundingMode;
46
import java.time.Duration;
57
import java.time.LocalDate;
68
import java.time.LocalDateTime;
@@ -40,51 +42,44 @@
4042
*/
4143
public abstract class TemporalAmountParser<T extends TemporalAmount> {
4244

43-
private static final Map<ChronoUnit, Long> UNIT_TO_NANO = new LinkedHashMap<>();
44-
private static final Map<ChronoUnit, Integer> PART_TIME_UNITS = new LinkedHashMap<>();
45-
46-
private Set<ChronoUnit> roundedUnits = new HashSet<>();
47-
48-
public TemporalAmountParser<T> roundOff(ChronoUnit unit) {
49-
this.roundedUnits.add(unit);
50-
return this;
51-
}
45+
private static final Map<ChronoUnit, BigInteger> UNIT_TO_NANO = new LinkedHashMap<>();
5246

5347
static {
54-
UNIT_TO_NANO.put(ChronoUnit.NANOS, 1L);
55-
UNIT_TO_NANO.put(ChronoUnit.MICROS, 1_000L);
56-
UNIT_TO_NANO.put(ChronoUnit.MILLIS, 1_000_000L);
57-
UNIT_TO_NANO.put(ChronoUnit.SECONDS, 1_000_000_000L);
58-
UNIT_TO_NANO.put(ChronoUnit.MINUTES, 60 * 1_000_000_000L);
59-
UNIT_TO_NANO.put(ChronoUnit.HOURS, 60 * 60 * 1_000_000_000L);
60-
UNIT_TO_NANO.put(ChronoUnit.DAYS, 24 * 60 * 60 * 1_000_000_000L);
61-
UNIT_TO_NANO.put(ChronoUnit.WEEKS, 7 * 24 * 60 * 60 * 1_000_000_000L);
62-
UNIT_TO_NANO.put(ChronoUnit.MONTHS, 30 * 24 * 60 * 60 * 1_000_000_000L);
63-
UNIT_TO_NANO.put(ChronoUnit.YEARS, 365 * 24 * 60 * 60 * 1_000_000_000L);
64-
UNIT_TO_NANO.put(ChronoUnit.DECADES, 10 * 365 * 24 * 60 * 60 * 1_000_000_000L);
65-
66-
PART_TIME_UNITS.put(ChronoUnit.NANOS, 1000);
67-
PART_TIME_UNITS.put(ChronoUnit.MICROS, 1000);
68-
PART_TIME_UNITS.put(ChronoUnit.MILLIS, 1000);
69-
PART_TIME_UNITS.put(ChronoUnit.SECONDS, 60);
70-
PART_TIME_UNITS.put(ChronoUnit.MINUTES, 60);
71-
PART_TIME_UNITS.put(ChronoUnit.HOURS, 24);
72-
PART_TIME_UNITS.put(ChronoUnit.DAYS, 7);
73-
PART_TIME_UNITS.put(ChronoUnit.WEEKS, 4);
74-
PART_TIME_UNITS.put(ChronoUnit.MONTHS, 12);
75-
PART_TIME_UNITS.put(ChronoUnit.YEARS, Integer.MAX_VALUE);
48+
UNIT_TO_NANO.put(ChronoUnit.NANOS, BigInteger.valueOf(1L));
49+
UNIT_TO_NANO.put(ChronoUnit.MICROS, BigInteger.valueOf(1_000L));
50+
UNIT_TO_NANO.put(ChronoUnit.MILLIS, BigInteger.valueOf(1_000_000L));
51+
UNIT_TO_NANO.put(ChronoUnit.SECONDS, BigInteger.valueOf(1_000_000_000L));
52+
UNIT_TO_NANO.put(ChronoUnit.MINUTES, BigInteger.valueOf(60 * 1_000_000_000L));
53+
UNIT_TO_NANO.put(ChronoUnit.HOURS, BigInteger.valueOf(60 * 60 * 1_000_000_000L));
54+
UNIT_TO_NANO.put(ChronoUnit.DAYS, BigInteger.valueOf(24 * 60 * 60 * 1_000_000_000L));
55+
UNIT_TO_NANO.put(ChronoUnit.WEEKS, BigInteger.valueOf(7 * 24 * 60 * 60 * 1_000_000_000L));
56+
UNIT_TO_NANO.put(ChronoUnit.MONTHS, BigInteger.valueOf(30 * 24 * 60 * 60 * 1_000_000_000L));
57+
UNIT_TO_NANO.put(ChronoUnit.YEARS, BigInteger.valueOf(365 * 24 * 60 * 60 * 1_000_000_000L));
58+
UNIT_TO_NANO.put(ChronoUnit.DECADES, BigInteger.valueOf(10 * 365 * 24 * 60 * 60 * 1_000_000_000L));
7659
}
7760

61+
private final ChronoUnit defaultZero;
62+
private final Lazy<String> defaultZeroSymbol;
7863
private final Map<String, ChronoUnit> units = new LinkedHashMap<>();
64+
private final Set<TimeModifier> modifiers = new HashSet<>();
7965
private final LocalDateTimeProvider baseForTimeEstimation;
8066

81-
protected TemporalAmountParser(LocalDateTimeProvider baseForTimeEstimation) {
82-
this.baseForTimeEstimation = baseForTimeEstimation;
67+
protected TemporalAmountParser(ChronoUnit defaultZero, Map<String, ChronoUnit> units, Set<TimeModifier> modifiers, LocalDateTimeProvider baseForTimeEstimation) {
68+
this(defaultZero, baseForTimeEstimation);
69+
this.units.putAll(units);
70+
this.modifiers.addAll(modifiers);
8371
}
8472

85-
protected TemporalAmountParser(Map<String, ChronoUnit> units, LocalDateTimeProvider baseForTimeEstimation) {
73+
protected TemporalAmountParser(ChronoUnit defaultZero, LocalDateTimeProvider baseForTimeEstimation) {
74+
this.defaultZero = defaultZero;
8675
this.baseForTimeEstimation = baseForTimeEstimation;
87-
this.units.putAll(units);
76+
this.defaultZeroSymbol = new Lazy<>(() -> this.units.entrySet()
77+
.stream()
78+
.filter(entry -> entry.getValue() == defaultZero)
79+
.map(entry -> entry.getKey())
80+
.findFirst()
81+
.orElseThrow(() -> new IllegalStateException("Can not find default zero symbol for " + defaultZero))
82+
);
8883
}
8984

9085
public TemporalAmountParser<T> withUnit(String symbol, ChronoUnit chronoUnit) {
@@ -98,14 +93,61 @@ public TemporalAmountParser<T> withUnit(String symbol, ChronoUnit chronoUnit) {
9893

9994
Map<String, ChronoUnit> newUnits = new LinkedHashMap<>(this.units);
10095
newUnits.put(symbol, chronoUnit);
101-
return clone(newUnits, this.baseForTimeEstimation);
96+
return clone(this.defaultZero, newUnits, this.modifiers, this.baseForTimeEstimation);
10297
}
10398

10499
public TemporalAmountParser<T> withLocalDateTimeProvider(LocalDateTimeProvider baseForTimeEstimation) {
105-
return clone(this.units, baseForTimeEstimation);
100+
return clone(this.defaultZero, this.units, this.modifiers, baseForTimeEstimation);
101+
}
102+
103+
public TemporalAmountParser<T> withDefaultZero(ChronoUnit defaultZero) {
104+
return clone(defaultZero, this.units, this.modifiers, this.baseForTimeEstimation);
106105
}
107106

108-
protected abstract TemporalAmountParser<T> clone(Map<String, ChronoUnit> units, LocalDateTimeProvider baseForTimeEstimation);
107+
public TemporalAmountParser<T> withRounded(ChronoUnit unit, RoundingMode roundingMode) {
108+
return withTimeModifier(duration -> {
109+
BigInteger nanosInUnit = UNIT_TO_NANO.get(unit);
110+
BigInteger nanos = durationToNano(duration);
111+
BigInteger rounded = round(roundingMode, nanos, nanosInUnit);
112+
113+
return Duration.ofNanos(rounded.longValue());
114+
});
115+
}
116+
117+
private static BigInteger round(RoundingMode roundingMode, BigInteger nanos, BigInteger nanosInUnit) {
118+
BigInteger remainder = nanos.remainder(nanosInUnit);
119+
BigInteger subtract = nanos.subtract(remainder);
120+
BigInteger add = subtract.add(nanosInUnit);
121+
122+
BigInteger roundedUp = remainder.equals(BigInteger.ZERO) ? nanos : (nanos.signum() > 0 ? add : subtract.subtract(nanosInUnit));
123+
BigInteger roundedDown = remainder.equals(BigInteger.ZERO) ? nanos : (nanos.signum() > 0 ? subtract : add.subtract(nanosInUnit));
124+
125+
int compare = remainder.abs().multiply(BigInteger.valueOf(2)).compareTo(nanosInUnit);
126+
switch (roundingMode) {
127+
case UP:
128+
return roundedUp;
129+
case DOWN:
130+
return roundedDown;
131+
case CEILING:
132+
return nanos.signum() >= 0 ? roundedUp : roundedDown;
133+
case FLOOR:
134+
return nanos.signum() >= 0 ? roundedDown : roundedUp;
135+
case HALF_UP:
136+
return compare >= 0 ? roundedUp : roundedDown;
137+
case HALF_DOWN:
138+
return (compare > 0) ? roundedUp : roundedDown;
139+
default: throw new IllegalArgumentException("Unsupported rounding mode " + roundingMode);
140+
}
141+
}
142+
143+
144+
private TemporalAmountParser<T> withTimeModifier(TimeModifier modifier) {
145+
Set<TimeModifier> newRoundedUnits = new HashSet<>(this.modifiers);
146+
newRoundedUnits.add(modifier);
147+
return clone(this.defaultZero, this.units, newRoundedUnits, this.baseForTimeEstimation);
148+
}
149+
150+
protected abstract TemporalAmountParser<T> clone(ChronoUnit defaultZeroUnit, Map<String, ChronoUnit> units, Set<TimeModifier> modifiers, LocalDateTimeProvider baseForTimeEstimation);
109151

110152
private boolean validCharacters(String content, Predicate<Character> predicate) {
111153
for (int i = 0; i < content.length(); i++) {
@@ -274,6 +316,9 @@ public ChronoUnit getUnit() {
274316
public String format(T temporalAmount) {
275317
StringBuilder builder = new StringBuilder();
276318
Duration duration = this.toDuration(this.baseForTimeEstimation, temporalAmount);
319+
for (TimeModifier modifier : this.modifiers) {
320+
duration = modifier.modify(duration);
321+
}
277322

278323
if (duration.isNegative()) {
279324
builder.append('-');
@@ -284,31 +329,34 @@ public String format(T temporalAmount) {
284329
Collections.reverse(keys);
285330

286331
for (String key : keys) {
287-
ChronoUnit chronoUnit = this.units.get(key);
288-
289-
if (roundedUnits.contains(chronoUnit)) {
290-
continue;
291-
}
292-
293-
Long part = UNIT_TO_NANO.get(chronoUnit);
332+
ChronoUnit unit = this.units.get(key);
333+
BigInteger nanosInOneUnit = UNIT_TO_NANO.get(unit);
294334

295-
if (part == null) {
296-
throw new IllegalArgumentException("Unsupported unit " + chronoUnit);
335+
if (nanosInOneUnit == null) {
336+
throw new IllegalArgumentException("Unsupported unit " + unit);
297337
}
298338

299-
BigInteger currentCount = this.durationToNano(duration).divide(BigInteger.valueOf(part));
300-
BigInteger maxCount = BigInteger.valueOf(PART_TIME_UNITS.get(chronoUnit));
301-
BigInteger count = currentCount.equals(maxCount) ? BigInteger.ONE : currentCount.mod(maxCount);
339+
BigInteger nanosCount = this.durationToNano(duration);
340+
BigInteger count = nanosCount.divide(nanosInOneUnit);
302341

303342
if (count.equals(BigInteger.ZERO)) {
304343
continue;
305344
}
306345

346+
BigInteger nanosCountCleared = count.multiply(nanosInOneUnit);
347+
307348
builder.append(count).append(key);
308-
duration = duration.minusNanos(count.longValue() * part);
349+
duration = duration.minusNanos(nanosCountCleared.longValue());
350+
}
351+
352+
String result = builder.toString();
353+
354+
if (result.isEmpty()) {
355+
String defaultSymbol = this.defaultZeroSymbol.get();
356+
return "0" + defaultSymbol;
309357
}
310358

311-
return builder.toString();
359+
return result;
312360
}
313361

314362
protected abstract Duration toDuration(LocalDateTimeProvider baseForTimeEstimation, T temporalAmount);
@@ -340,11 +388,14 @@ static LocalDateTimeProvider of(LocalDate localDate) {
340388

341389
}
342390

343-
BigInteger durationToNano(Duration duration) {
391+
protected interface TimeModifier {
392+
Duration modify(Duration duration);
393+
}
394+
395+
private BigInteger durationToNano(Duration duration) {
344396
return BigInteger.valueOf(duration.getSeconds())
345397
.multiply(BigInteger.valueOf(1_000_000_000))
346398
.add(BigInteger.valueOf(duration.getNano()));
347399
}
348400

349401
}
350-

eternalcode-commons-shared/test/com/eternalcode/commons/time/TemporalAmountParserTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import static org.junit.jupiter.api.Assertions.assertThrows;
55

66
import com.eternalcode.commons.time.TemporalAmountParser.LocalDateTimeProvider;
7+
import java.math.RoundingMode;
78
import java.time.Duration;
89
import java.time.LocalDateTime;
910
import java.time.Month;
@@ -199,7 +200,7 @@ void testRoundOff() {
199200
.withUnit("m", ChronoUnit.MINUTES)
200201
.withUnit("h", ChronoUnit.HOURS)
201202
.withUnit("d", ChronoUnit.DAYS)
202-
.roundOff(ChronoUnit.MILLIS);
203+
.withRounded(ChronoUnit.MILLIS, RoundingMode.UP);
203204

204205
Duration temporalAmount = Duration.ofDays(1)
205206
.plus(Duration.ofHours(1))

0 commit comments

Comments
 (0)