Skip to content

Commit c0748c6

Browse files
authored
Orthogonal DateTimePeriod implementation (#80)
Change DateTimePeriod to be normalized Now, it only has three components: months, days, and nanoseconds, and all the other properties are just representations of these ones. This way, for each DateTimePeriod there exists a well-defined ISO-8601 representation, and `toString()` behaves correctly. Fixes #79 Fixes #81 Additionally, parsing from ISO-8601 strings was added.
1 parent 978ae2f commit c0748c6

13 files changed

+438
-122
lines changed

core/common/src/DateTimePeriod.kt

+250-59
Large diffs are not rendered by default.

core/common/src/Instant.kt

+8-10
Original file line numberDiff line numberDiff line change
@@ -192,19 +192,15 @@ public fun Instant.minus(period: DateTimePeriod, timeZone: TimeZone): Instant =
192192
/* An overflow can happen for any component, but we are only worried about nanoseconds, as having an overflow in
193193
any other component means that `plus` will throw due to the minimum value of the numeric type overflowing the
194194
platform-specific limits. */
195-
if (period.nanoseconds != Long.MIN_VALUE) {
196-
val negatedPeriod = with(period) {
197-
DateTimePeriod(-years, -months, -days, -hours, -minutes, -seconds, -nanoseconds)
198-
}
195+
if (period.totalNanoseconds != Long.MIN_VALUE) {
196+
val negatedPeriod = with(period) { buildDateTimePeriod(-totalMonths, -days, -totalNanoseconds) }
199197
plus(negatedPeriod, timeZone)
200198
} else {
201-
val negatedPeriod = with(period) {
202-
DateTimePeriod(-years, -months, -days, -hours, -minutes, -seconds, -(nanoseconds+1))
203-
}
199+
val negatedPeriod = with(period) { buildDateTimePeriod(-totalMonths, -days, -(totalNanoseconds+1)) }
204200
plus(negatedPeriod, timeZone).plus(DateTimeUnit.NANOSECOND)
205201
}
206202

207-
/**
203+
/**
208204
* Returns a [DateTimePeriod] representing the difference between `this` and [other] instants.
209205
*
210206
* The components of [DateTimePeriod] are calculated so that adding it to `this` instant results in the [other] instant.
@@ -214,7 +210,8 @@ public fun Instant.minus(period: DateTimePeriod, timeZone: TimeZone): Instant =
214210
* - negative or zero if this instant is later than the other,
215211
* - exactly zero if this instant is equal to the other.
216212
*
217-
* @throws DateTimeArithmeticException if `this` or [other] instant is too large to fit in [LocalDateTime].
213+
* @throws DateTimeArithmeticException if `this` or [other] instant is too large to fit in [LocalDateTime]. Also (only
214+
* on JVM) if the number of months between the two dates exceeds an Int.
218215
*/
219216
public expect fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateTimePeriod
220217

@@ -296,7 +293,8 @@ public fun Instant.yearsUntil(other: Instant, timeZone: TimeZone): Int =
296293
* - positive or zero if this instant is later than the other,
297294
* - exactly zero if this instant is equal to the other.
298295
*
299-
* @throws DateTimeArithmeticException if `this` or [other] instant is too large to fit in [LocalDateTime].
296+
* @throws DateTimeArithmeticException if `this` or [other] instant is too large to fit in [LocalDateTime]. Also (only
297+
* on JVM) if the number of months between the two dates exceeds an Int.
300298
* @see Instant.periodUntil
301299
*/
302300
public fun Instant.minus(other: Instant, timeZone: TimeZone): DateTimePeriod =

core/common/src/LocalDate.kt

+4
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ public operator fun LocalDate.minus(period: DatePeriod): LocalDate =
137137
* - negative or zero if this date is later than the other,
138138
* - exactly zero if this date is equal to the other.
139139
*
140+
* @throws DateTimeArithmeticException if the number of months between the two dates exceeds an Int (JVM only).
141+
*
140142
* @see LocalDate.minus
141143
*/
142144
expect fun LocalDate.periodUntil(other: LocalDate): DatePeriod
@@ -151,6 +153,8 @@ expect fun LocalDate.periodUntil(other: LocalDate): DatePeriod
151153
* - positive or zero if this date is later than the other,
152154
* - exactly zero if this date is equal to the other.
153155
*
156+
* @throws DateTimeArithmeticException if the number of months between the two dates exceeds an Int (JVM only).
157+
*
154158
* @see LocalDate.periodUntil
155159
*/
156160
operator fun LocalDate.minus(other: LocalDate): DatePeriod = other.periodUntil(this)

core/common/src/math.kt

+19
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,22 @@ internal fun multiplyAddAndDivide(d: Long, n: Long, r: Long, m: Long): Long {
161161
val (rd, rr) = multiplyAndDivide(md, n, m)
162162
return safeAdd(rd, safeAdd(mr / m, safeAdd(mr % m, rr) / m))
163163
}
164+
165+
/**
166+
* Calculates [d] * [n] + [r], where [n] > 0 and |[r]| <= [n].
167+
*
168+
* @throws ArithmeticException if the result overflows a long
169+
*/
170+
internal fun multiplyAndAdd(d: Long, n: Long, r: Long): Long {
171+
var md = d
172+
var mr = r
173+
// make sure [md] and [mr] have the same sign
174+
if (d > 0 && r < 0) {
175+
md--
176+
mr += n
177+
} else if (d < 0 && r > 0) {
178+
md++
179+
mr -= n
180+
}
181+
return safeAdd(safeMultiply(md, n), mr)
182+
}

core/common/test/DateTimePeriodTest.kt

+114-3
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,45 @@ import kotlin.time.*
1212

1313
class DateTimePeriodTest {
1414

15+
@Test
16+
fun normalization() {
17+
assertPeriodComponents(DateTimePeriod(years = 1) as DatePeriod, years = 1)
18+
assertPeriodComponents(DateTimePeriod(years = 1, months = 1) as DatePeriod, years = 1, months = 1)
19+
assertPeriodComponents(DateTimePeriod(years = 1, months = -1) as DatePeriod, months = 11)
20+
assertPeriodComponents(DateTimePeriod(years = -1, months = 1) as DatePeriod, months = -11)
21+
assertPeriodComponents(DateTimePeriod(years = -1, months = -1) as DatePeriod, years = -1, months = -1)
22+
assertPeriodComponents(DateTimePeriod(months = 11) as DatePeriod, months = 11)
23+
assertPeriodComponents(DateTimePeriod(months = 14) as DatePeriod, years = 1, months = 2)
24+
assertPeriodComponents(DateTimePeriod(months = -14) as DatePeriod, years = -1, months = -2)
25+
assertPeriodComponents(DateTimePeriod(months = 10, days = 5) as DatePeriod, months = 10, days = 5)
26+
assertPeriodComponents(DateTimePeriod(years = 1, days = 40) as DatePeriod, years = 1, days = 40)
27+
assertPeriodComponents(DateTimePeriod(years = 1, days = -40) as DatePeriod, years = 1, days = -40)
28+
assertPeriodComponents(DateTimePeriod(days = 5) as DatePeriod, days = 5)
29+
30+
assertPeriodComponents(DateTimePeriod(hours = 3), hours = 3)
31+
assertPeriodComponents(DateTimePeriod(hours = 1, minutes = 120), hours = 3)
32+
assertPeriodComponents(DateTimePeriod(hours = 1, minutes = 119, seconds = 60), hours = 3)
33+
assertPeriodComponents(DateTimePeriod(hours = 1, minutes = 119, seconds = 59, nanoseconds = 1_000_000_000), hours = 3)
34+
assertPeriodComponents(DateTimePeriod(hours = 1, minutes = 121, seconds = -59, nanoseconds = -1_000_000_000), hours = 3)
35+
assertPeriodComponents(DateTimePeriod())
36+
assertPeriodComponents(DatePeriod())
37+
38+
assertPeriodComponents(DateTimePeriod(days = 1, hours = -1), days = 1, hours = -1)
39+
assertPeriodComponents(DateTimePeriod(days = -1, hours = -1), days = -1, hours = -1)
40+
41+
assertPeriodComponents(DateTimePeriod(years = -1, months = -2, days = -3, hours = -4, minutes = -5, seconds = 0, nanoseconds = 500_000_000),
42+
years = -1, months = -2, days = -3, hours = -4, minutes = -4, seconds = -59, nanoseconds = -500_000_000)
43+
44+
assertPeriodComponents(DateTimePeriod(nanoseconds = 999_999_999_999_999L), hours = 277, minutes = 46, seconds = 39, nanoseconds = 999_999_999)
45+
assertPeriodComponents(DateTimePeriod(nanoseconds = -999_999_999_999_999L), hours = -277, minutes = -46, seconds = -39, nanoseconds = -999_999_999)
46+
}
47+
1548
@Test
1649
fun toStringConversion() {
1750
assertEquals("P1Y", DateTimePeriod(years = 1).toString())
1851
assertEquals("P1Y1M", DatePeriod(years = 1, months = 1).toString())
1952
assertEquals("P11M", DateTimePeriod(months = 11).toString())
20-
assertEquals("P14M", DateTimePeriod(months = 14).toString()) // TODO: normalize or not
53+
assertEquals("P1Y2M", DateTimePeriod(months = 14).toString())
2154
assertEquals("P10M5D", DateTimePeriod(months = 10, days = 5).toString())
2255
assertEquals("P1Y40D", DateTimePeriod(years = 1, days = 40).toString())
2356

@@ -29,8 +62,74 @@ class DateTimePeriodTest {
2962
assertEquals("-P1DT1H", DateTimePeriod(days = -1, hours = -1).toString())
3063
assertEquals("-P1M", DateTimePeriod(months = -1).toString())
3164

32-
assertEquals("P-1Y-2M-3DT-4H-5M0.500000000S",
65+
assertEquals("-P1Y2M3DT4H4M59.500000000S",
3366
DateTimePeriod(years = -1, months = -2, days = -3, hours = -4, minutes = -5, seconds = 0, nanoseconds = 500_000_000).toString())
67+
68+
assertEquals("PT277H46M39.999999999S", DateTimePeriod(nanoseconds = 999_999_999_999_999L).toString())
69+
assertEquals("PT0.999999999S", DateTimePeriod(seconds = 1, nanoseconds = -1L).toString())
70+
assertEquals("-PT0.000000001S", DateTimePeriod(nanoseconds = -1L).toString())
71+
assertEquals("P1DT-0.000000001S", DateTimePeriod(days = 1, nanoseconds = -1L).toString())
72+
assertEquals("-PT0.999999999S", DateTimePeriod(seconds = -1, nanoseconds = 1L).toString())
73+
assertEquals("P1DT-0.999999999S", DateTimePeriod(days = 1, seconds = -1, nanoseconds = 1L).toString())
74+
}
75+
76+
@Test
77+
fun parseIsoString() {
78+
assertEquals(DateTimePeriod(years = 1), DateTimePeriod.parse("P1Y"))
79+
assertEquals(DatePeriod(years = 1, months = 1), DateTimePeriod.parse("P1Y1M"))
80+
assertEquals(DateTimePeriod(months = 11), DateTimePeriod.parse("P11M"))
81+
assertEquals(DateTimePeriod(months = 10, days = 5), DateTimePeriod.parse("P10M5D"))
82+
assertEquals(DateTimePeriod(years = 1, days = 40), DateTimePeriod.parse("P1Y40D"))
83+
84+
assertEquals(DateTimePeriod(months = 14), DateTimePeriod.parse("P14M"))
85+
assertPeriodComponents(DateTimePeriod.parse("P14M") as DatePeriod, years = 1, months = 2)
86+
87+
assertEquals(DateTimePeriod(hours = 1), DateTimePeriod.parse("PT1H"))
88+
assertEquals(DateTimePeriod(), DateTimePeriod.parse("P0D"))
89+
assertEquals(DatePeriod(), DateTimePeriod.parse("P0D"))
90+
91+
assertEquals(DateTimePeriod(days = 1, hours = -1), DateTimePeriod.parse("P1DT-1H"))
92+
assertEquals(DateTimePeriod(days = -1, hours = -1), DateTimePeriod.parse("-P1DT1H"))
93+
assertEquals(DateTimePeriod(months = -1), DateTimePeriod.parse("-P1M"))
94+
95+
assertEquals(DateTimePeriod(years = -1, months = -2, days = -3, hours = -4, minutes = -5, seconds = 0, nanoseconds = 500_000_000),
96+
DateTimePeriod.parse("P-1Y-2M-3DT-4H-5M0.500000000S"))
97+
assertPeriodComponents(DateTimePeriod.parse("P-1Y-2M-3DT-4H-5M0.500000000S"),
98+
years = -1, months = -2, days = -3, hours = -4, minutes = -4, seconds = -59, nanoseconds = -500_000_000)
99+
100+
assertEquals(DateTimePeriod(nanoseconds = 999_999_999_999_999L), DateTimePeriod.parse("PT277H46M39.999999999S"))
101+
assertPeriodComponents(DateTimePeriod.parse("PT277H46M39.999999999S"),
102+
hours = 277, minutes = 46, seconds = 39, nanoseconds = 999_999_999)
103+
104+
assertEquals(DateTimePeriod(nanoseconds = 999_999_999), DateTimePeriod.parse("PT0.999999999S"))
105+
assertEquals(DateTimePeriod(nanoseconds = -1), DateTimePeriod.parse("-PT0.000000001S"))
106+
assertEquals(DateTimePeriod(days = 1, nanoseconds = -1), DateTimePeriod.parse("P1DT-0.000000001S"))
107+
assertEquals(DateTimePeriod(nanoseconds = -999_999_999), DateTimePeriod.parse("-PT0.999999999S"))
108+
assertEquals(DateTimePeriod(days = 1, nanoseconds = -999_999_999), DateTimePeriod.parse("P1DT-0.999999999S"))
109+
assertPeriodComponents(DateTimePeriod.parse("P1DT-0.999999999S"), days = 1, nanoseconds = -999_999_999)
110+
111+
// overflow of `Int.MAX_VALUE` months
112+
assertFailsWith<IllegalArgumentException> { DateTimePeriod.parse("P2000000000Y") }
113+
assertFailsWith<IllegalArgumentException> { DateTimePeriod.parse("P1Y2147483640M") }
114+
115+
// too large a number in a field
116+
assertFailsWith<DateTimeFormatException> { DateTimePeriod.parse("P3000000000Y") }
117+
assertFailsWith<DateTimeFormatException> { DateTimePeriod.parse("P3000000000M") }
118+
assertFailsWith<DateTimeFormatException> { DateTimePeriod.parse("P3000000000D") }
119+
assertFailsWith<DateTimeFormatException> { DateTimePeriod.parse("P3000000000H") }
120+
assertFailsWith<DateTimeFormatException> { DateTimePeriod.parse("P3000000000M") }
121+
assertFailsWith<DateTimeFormatException> { DateTimePeriod.parse("P3000000000S") }
122+
123+
// wrong order of signifiers
124+
assertFailsWith<DateTimeFormatException> { DateTimePeriod.parse("P1Y2D3M") }
125+
assertFailsWith<DateTimeFormatException> { DateTimePeriod.parse("P0DT1M2H") }
126+
127+
// loss of precision in fractional seconds
128+
assertFailsWith<DateTimeFormatException> { DateTimePeriod.parse("P0.000000000001S") }
129+
130+
// non-zero time components when parsing DatePeriod
131+
assertFailsWith<IllegalArgumentException> { DatePeriod.parse("P1DT1H") }
132+
DatePeriod.parse("P1DT0H")
34133
}
35134

36135
@Test
@@ -69,4 +168,16 @@ class DateTimePeriodTest {
69168
assertEquals(period, duration.toDateTimePeriod())
70169
}
71170
}
72-
}
171+
172+
private fun assertPeriodComponents(period: DateTimePeriod,
173+
years: Int = 0, months: Int = 0, days: Int = 0,
174+
hours: Int = 0, minutes: Int = 0, seconds: Int = 0, nanoseconds: Int = 0) {
175+
assertEquals(years, period.years)
176+
assertEquals(months, period.months)
177+
assertEquals(days, period.days)
178+
assertEquals(hours, period.hours)
179+
assertEquals(minutes, period.minutes)
180+
assertEquals(seconds, period.seconds)
181+
assertEquals(nanoseconds, period.nanoseconds)
182+
}
183+
}

core/common/test/InstantTest.kt

+10-9
Original file line numberDiff line numberDiff line change
@@ -468,9 +468,11 @@ class InstantRangeTest {
468468
fun periodArithmeticOutOfRange() {
469469
// Instant.plus(DateTimePeriod(), TimeZone)
470470
// Arithmetic overflow
471-
for (instant in smallInstants + largeNegativeInstants + largePositiveInstants) {
472-
assertArithmeticFails("$instant") { instant.plus(DateTimePeriod(seconds = Long.MAX_VALUE), UTC) }
473-
assertArithmeticFails("$instant") { instant.plus(DateTimePeriod(seconds = Long.MIN_VALUE), UTC) }
471+
for (instant in largePositiveInstants) {
472+
assertArithmeticFails("$instant") { instant.plus(DateTimePeriod(nanoseconds = Long.MAX_VALUE), UTC) }
473+
}
474+
for (instant in largeNegativeInstants) {
475+
assertArithmeticFails("$instant") { instant.plus(DateTimePeriod(nanoseconds = Long.MIN_VALUE), UTC) }
474476
}
475477
// Arithmetic overflow in an Int
476478
for (instant in smallInstants + listOf(maxValidInstant)) {
@@ -494,9 +496,8 @@ class InstantRangeTest {
494496
assertArithmeticFails { maxValidInstant.plus(DateTimePeriod(nanoseconds = 1), UTC) }
495497
assertArithmeticFails { minValidInstant.plus(DateTimePeriod(nanoseconds = -1), UTC) }
496498
// Overflowing a LocalDateTime in intermediate computations
497-
assertArithmeticFails { maxValidInstant.plus(DateTimePeriod(seconds = 1, nanoseconds = -1_000_000_001), UTC) }
498-
assertArithmeticFails { maxValidInstant.plus(DateTimePeriod(hours = 1, minutes = -61), UTC) }
499-
assertArithmeticFails { maxValidInstant.plus(DateTimePeriod(days = 1, hours = -48), UTC) }
499+
assertArithmeticFails { maxValidInstant.plus(DateTimePeriod(days = 1, nanoseconds = -1_000_000_001), UTC) }
500+
assertArithmeticFails { maxValidInstant.plus(DateTimePeriod(months = 1, days = -48), UTC) }
500501
}
501502

502503
@Test
@@ -540,9 +541,9 @@ class InstantRangeTest {
540541
@Test
541542
fun periodUntilOutOfRange() {
542543
// Instant.periodUntil
543-
maxValidInstant.periodUntil(minValidInstant, UTC)
544-
assertArithmeticFails { (maxValidInstant + 1.nanoseconds).periodUntil(minValidInstant, UTC) }
545-
assertArithmeticFails { maxValidInstant.periodUntil(minValidInstant - 1.nanoseconds, UTC) }
544+
maxValidInstant.periodUntil(maxValidInstant, UTC)
545+
assertArithmeticFails { (maxValidInstant + 1.nanoseconds).periodUntil(maxValidInstant, UTC) }
546+
assertArithmeticFails { minValidInstant.periodUntil(minValidInstant - 1.nanoseconds, UTC) }
546547
}
547548

548549
@Test

core/js/src/Instant.kt

+5-8
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,12 @@ public actual fun Instant.plus(period: DateTimePeriod, timeZone: TimeZone): Inst
111111
val thisZdt = this.value.atZone(timeZone.zoneId)
112112
with(period) {
113113
thisZdt
114-
.run { if (years != 0 && months == 0) plusYears(years) else this }
115-
.run { if (months != 0) plusMonths(years * 12.0 + months) else this }
114+
.run { if (totalMonths != 0) plusMonths(totalMonths) else this }
116115
.run { if (days != 0) plusDays(days) as ZonedDateTime else this }
117116
.run { if (hours != 0) plusHours(hours) else this }
118117
.run { if (minutes != 0) plusMinutes(minutes) else this }
119-
.run { if (seconds != 0L) plusSeconds(seconds.toDouble()) else this }
120-
.run { if (nanoseconds != 0L) plusNanos(nanoseconds.toDouble()) else this }
118+
.run { if (seconds != 0) plusSeconds(seconds) else this }
119+
.run { if (nanoseconds != 0) plusNanos(nanoseconds.toDouble()) else this }
121120
}.toInstant().let(::Instant)
122121
} catch (e: Throwable) {
123122
if (e.isJodaDateTimeException()) throw DateTimeArithmeticException(e)
@@ -188,11 +187,9 @@ public actual fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateT
188187

189188
val months = thisZdt.until(otherZdt, ChronoUnit.MONTHS).toDouble(); thisZdt = thisZdt.plusMonths(months)
190189
val days = thisZdt.until(otherZdt, ChronoUnit.DAYS).toDouble(); thisZdt = thisZdt.plusDays(days) as ZonedDateTime
191-
val time = thisZdt.until(otherZdt, ChronoUnit.NANOS).toDouble().nanoseconds
190+
val nanoseconds = thisZdt.until(otherZdt, ChronoUnit.NANOS).toDouble()
192191

193-
time.toComponents { hours, minutes, seconds, nanoseconds ->
194-
return DateTimePeriod((months / 12).toInt(), (months % 12).toInt(), days.toInt(), hours, minutes, seconds.toLong(), nanoseconds.toLong())
195-
}
192+
buildDateTimePeriod(months.toInt(), days.toInt(), nanoseconds.toLong())
196193
} catch (e: Throwable) {
197194
if (e.isJodaDateTimeException()) throw DateTimeArithmeticException(e) else throw e
198195
}

core/js/src/LocalDate.kt

+2-3
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,7 @@ private fun LocalDate.plusNumber(value: Number, unit: DateTimeUnit.DateBased): L
6868
public actual operator fun LocalDate.plus(period: DatePeriod): LocalDate = try {
6969
with(period) {
7070
return@with value
71-
.run { if (years != 0 && months == 0) plusYears(years) else this }
72-
.run { if (months != 0) plusMonths(years.toDouble() * 12 + months) else this }
71+
.run { if (totalMonths != 0) plusMonths(totalMonths) else this }
7372
.run { if (days != 0) plusDays(days) else this }
7473

7574
}.let(::LocalDate)
@@ -86,7 +85,7 @@ public actual fun LocalDate.periodUntil(other: LocalDate): DatePeriod {
8685
val months = startD.until(endD, ChronoUnit.MONTHS).toInt(); startD = startD.plusMonths(months)
8786
val days = startD.until(endD, ChronoUnit.DAYS).toInt()
8887

89-
return DatePeriod(months / 12, months % 12, days)
88+
return DatePeriod(totalMonths = months, days)
9089
}
9190

9291
public actual fun LocalDate.until(other: LocalDate, unit: DateTimeUnit.DateBased): Int = when(unit) {

0 commit comments

Comments
 (0)