Skip to content

Commit c3792e5

Browse files
dkhalanskyjbilya-g
andauthored
Implement zone-agnostic time-based arithmetic on Instants (#45)
Co-authored-by: ilya-g <[email protected]>
1 parent 4f679a1 commit c3792e5

File tree

5 files changed

+128
-37
lines changed

5 files changed

+128
-37
lines changed

core/commonMain/src/Instant.kt

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,13 +195,33 @@ public expect fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateT
195195
* - positive or zero if this instant is earlier than the other,
196196
* - negative or zero if this instant is later than the other,
197197
* - zero if this instant is equal to the other.
198-
198+
*
199199
* If the result does not fit in [Long], returns [Long.MAX_VALUE] for a positive result or [Long.MIN_VALUE] for a negative result.
200200
*
201201
* @throws DateTimeArithmeticException if `this` or [other] instant is too large to fit in [LocalDateTime].
202202
*/
203203
public expect fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: TimeZone): Long
204204

205+
/**
206+
* Returns the whole number of the specified time [units][unit] between `this` and [other] instants.
207+
*
208+
* The value returned is:
209+
* - positive or zero if this instant is earlier than the other,
210+
* - negative or zero if this instant is later than the other,
211+
* - zero if this instant is equal to the other.
212+
*
213+
* If the result does not fit in [Long], returns [Long.MAX_VALUE] for a positive result or [Long.MIN_VALUE] for a negative result.
214+
*/
215+
public fun Instant.until(other: Instant, unit: DateTimeUnit.TimeBased): Long =
216+
try {
217+
multiplyAddAndDivide(other.epochSeconds - epochSeconds,
218+
NANOS_PER_ONE.toLong(),
219+
(other.nanosecondsOfSecond - nanosecondsOfSecond).toLong(),
220+
unit.nanoseconds)
221+
} catch (e: ArithmeticException) {
222+
if (this < other) Long.MAX_VALUE else Long.MIN_VALUE
223+
}
224+
205225
/**
206226
* Returns the number of whole days between two instants in the specified [timeZone].
207227
*
@@ -262,6 +282,16 @@ public fun Instant.minus(other: Instant, timeZone: TimeZone): DateTimePeriod =
262282
*/
263283
public expect fun Instant.plus(unit: DateTimeUnit, timeZone: TimeZone): Instant
264284

285+
/**
286+
* Returns an instant that is the result of adding one [unit] to this instant.
287+
*
288+
* The returned instant is later than this instant.
289+
*
290+
* The return value is clamped to the platform-specific boundaries for [Instant] if the result exceeds them.
291+
*/
292+
public fun Instant.plus(unit: DateTimeUnit.TimeBased): Instant =
293+
plus(1L, unit)
294+
265295
/**
266296
* Returns an instant that is the result of adding the [value] number of the specified [unit] to this instant
267297
* in the specified [timeZone].
@@ -273,6 +303,17 @@ public expect fun Instant.plus(unit: DateTimeUnit, timeZone: TimeZone): Instant
273303
*/
274304
public expect fun Instant.plus(value: Int, unit: DateTimeUnit, timeZone: TimeZone): Instant
275305

306+
/**
307+
* Returns an instant that is the result of adding the [value] number of the specified [unit] to this instant.
308+
*
309+
* If the [value] is positive, the returned instant is later than this instant.
310+
* If the [value] is negative, the returned instant is earlier than this instant.
311+
*
312+
* The return value is clamped to the platform-specific boundaries for [Instant] if the result exceeds them.
313+
*/
314+
public fun Instant.plus(value: Int, unit: DateTimeUnit.TimeBased): Instant =
315+
plus(value.toLong(), unit)
316+
276317
/**
277318
* Returns an instant that is the result of adding the [value] number of the specified [unit] to this instant
278319
* in the specified [timeZone].
@@ -284,6 +325,16 @@ public expect fun Instant.plus(value: Int, unit: DateTimeUnit, timeZone: TimeZon
284325
*/
285326
public expect fun Instant.plus(value: Long, unit: DateTimeUnit, timeZone: TimeZone): Instant
286327

328+
/**
329+
* Returns an instant that is the result of adding the [value] number of the specified [unit] to this instant.
330+
*
331+
* If the [value] is positive, the returned instant is later than this instant.
332+
* If the [value] is negative, the returned instant is earlier than this instant.
333+
*
334+
* The return value is clamped to the platform-specific boundaries for [Instant] if the result exceeds them.
335+
*/
336+
public expect fun Instant.plus(value: Long, unit: DateTimeUnit.TimeBased): Instant
337+
287338
/**
288339
* Returns the whole number of the specified date or time [units][unit] between [other] and `this` instants
289340
* in the specified [timeZone].
@@ -299,5 +350,18 @@ public expect fun Instant.plus(value: Long, unit: DateTimeUnit, timeZone: TimeZo
299350
public fun Instant.minus(other: Instant, unit: DateTimeUnit, timeZone: TimeZone): Long =
300351
other.until(this, unit, timeZone)
301352

353+
/**
354+
* Returns the whole number of the specified time [units][unit] between [other] and `this` instants.
355+
*
356+
* The value returned is negative or zero if this instant is earlier than the other,
357+
* and positive or zero if this instant is later than the other.
358+
*
359+
* If the result does not fit in [Long], returns [Long.MAX_VALUE] for a positive result or [Long.MIN_VALUE] for a negative result.
360+
*
361+
* @see Instant.until
362+
*/
363+
public fun Instant.minus(other: Instant, unit: DateTimeUnit.TimeBased): Long =
364+
other.until(this, unit)
365+
302366
internal const val DISTANT_PAST_SECONDS = -3217862419201
303367
internal const val DISTANT_FUTURE_SECONDS = 3093527980800

core/commonTest/src/InstantTest.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,22 @@ class InstantRangeTest {
489489
assertArithmeticFails { minValidInstant.plus(-1, DateTimeUnit.YEAR, UTC) }
490490
}
491491

492+
@Test
493+
fun timeBasedUnitArithmeticOutOfRange() {
494+
// Instant.plus(Long, DateTimeUnit.TimeBased)
495+
// Arithmetic overflow
496+
for (instant in smallInstants + largeNegativeInstants + largePositiveInstants) {
497+
assertEquals(Instant.MAX, instant.plus(Long.MAX_VALUE, DateTimeUnit.SECOND))
498+
assertEquals(Instant.MIN, instant.plus(Long.MIN_VALUE, DateTimeUnit.SECOND))
499+
}
500+
// Overflow of Instant boundaries
501+
for (instant in smallInstants + largeNegativeInstants + largePositiveInstants) {
502+
assertEquals(Instant.MAX, instant.plus(Instant.MAX.epochSeconds - instant.epochSeconds + 1, DateTimeUnit.SECOND))
503+
assertEquals(Instant.MIN, instant.plus(Instant.MIN.epochSeconds - instant.epochSeconds - 1, DateTimeUnit.SECOND))
504+
}
505+
}
506+
507+
492508
@Test
493509
fun periodUntilOutOfRange() {
494510
// Instant.periodUntil
@@ -502,6 +518,8 @@ class InstantRangeTest {
502518
// Arithmetic overflow of the resulting number
503519
assertEquals(Long.MAX_VALUE, minValidInstant.until(maxValidInstant, DateTimeUnit.NANOSECOND, UTC))
504520
assertEquals(Long.MIN_VALUE, maxValidInstant.until(minValidInstant, DateTimeUnit.NANOSECOND, UTC))
521+
assertEquals(Long.MAX_VALUE, minValidInstant.until(maxValidInstant, DateTimeUnit.NANOSECOND))
522+
assertEquals(Long.MIN_VALUE, maxValidInstant.until(minValidInstant, DateTimeUnit.NANOSECOND))
505523
}
506524

507525
@Test
@@ -510,6 +528,9 @@ class InstantRangeTest {
510528
// Overflowing a LocalDateTime in input
511529
assertArithmeticFails { (maxValidInstant + 1.nanoseconds).until(maxValidInstant, DateTimeUnit.NANOSECOND, UTC) }
512530
assertArithmeticFails { maxValidInstant.until(maxValidInstant + 1.nanoseconds, DateTimeUnit.NANOSECOND, UTC) }
531+
// Overloads without a TimeZone should not fail on overflowing a LocalDateTime
532+
(maxValidInstant + 1.nanoseconds).until(maxValidInstant, DateTimeUnit.NANOSECOND)
533+
maxValidInstant.until(maxValidInstant + 1.nanoseconds, DateTimeUnit.NANOSECOND)
513534
}
514535
}
515536

core/jsMain/src/Instant.kt

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,7 @@ public actual fun Instant.plus(value: Long, unit: DateTimeUnit, timeZone: TimeZo
150150
val thisZdt = this.atZone(timeZone)
151151
when (unit) {
152152
is DateTimeUnit.TimeBased -> {
153-
multiplyAndDivide(value, unit.nanoseconds, NANOS_PER_ONE.toLong()).let {
154-
(d, r) -> this.plusFix(d.toDouble(), r.toInt()).checkZone(timeZone)
155-
}
153+
plus(value, unit).value.checkZone(timeZone)
156154
}
157155
is DateTimeUnit.DateBased.DayBased ->
158156
(thisZdt.plusDays(value.toDouble() * unit.days) as ZonedDateTime).toInstant()
@@ -168,11 +166,8 @@ public actual fun Instant.plus(value: Int, unit: DateTimeUnit, timeZone: TimeZon
168166
try {
169167
val thisZdt = this.atZone(timeZone)
170168
when (unit) {
171-
is DateTimeUnit.TimeBased -> {
172-
multiplyAndDivide(value.toLong(), unit.nanoseconds, NANOS_PER_ONE.toLong()).let {
173-
(d, r) -> this.plusFix(d.toDouble(), r.toInt()).checkZone(timeZone)
174-
}
175-
}
169+
is DateTimeUnit.TimeBased ->
170+
plus(value.toLong(), unit).value.checkZone(timeZone)
176171
is DateTimeUnit.DateBased.DayBased ->
177172
(thisZdt.plusDays(value.toDouble() * unit.days) as ZonedDateTime).toInstant()
178173
is DateTimeUnit.DateBased.MonthBased ->
@@ -183,6 +178,18 @@ public actual fun Instant.plus(value: Int, unit: DateTimeUnit, timeZone: TimeZon
183178
throw e
184179
}
185180

181+
actual fun Instant.plus(value: Long, unit: DateTimeUnit.TimeBased): Instant =
182+
try {
183+
multiplyAndDivide(value, unit.nanoseconds, NANOS_PER_ONE.toLong()).let { (d, r) ->
184+
Instant(plusFix(d.toDouble(), r.toInt()))
185+
}
186+
} catch (e: Throwable) {
187+
if (!e.isJodaDateTimeException()) {
188+
throw e
189+
}
190+
if (value > 0) Instant.MAX else Instant.MIN
191+
}
192+
186193
@OptIn(ExperimentalTime::class)
187194
public actual fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateTimePeriod = try {
188195
var thisZdt = this.value.atZone(timeZone.zoneId)
@@ -203,12 +210,7 @@ public actual fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: Ti
203210
val thisZdt = this.atZone(timeZone)
204211
val otherZdt = other.atZone(timeZone)
205212
when(unit) {
206-
is DateTimeUnit.TimeBased -> {
207-
multiplyAddAndDivide(other.epochSeconds - epochSeconds,
208-
NANOS_PER_ONE.toLong(),
209-
(other.nanosecondsOfSecond - nanosecondsOfSecond).toLong(),
210-
unit.nanoseconds)
211-
}
213+
is DateTimeUnit.TimeBased -> until(other, unit)
212214
is DateTimeUnit.DateBased.DayBased -> (thisZdt.until(otherZdt, ChronoUnit.DAYS).toDouble() / unit.days).toLong()
213215
is DateTimeUnit.DateBased.MonthBased -> (thisZdt.until(otherZdt, ChronoUnit.MONTHS).toDouble() / unit.months).toLong()
214216
}

core/jvmMain/src/Instant.kt

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,8 @@ public actual fun Instant.plus(value: Long, unit: DateTimeUnit, timeZone: TimeZo
114114
try {
115115
val thisZdt = atZone(timeZone)
116116
when (unit) {
117-
is DateTimeUnit.TimeBased -> {
118-
multiplyAndDivide(value, unit.nanoseconds, NANOS_PER_ONE.toLong()).let {
119-
(d, r) -> this.value.plusSeconds(d).plusNanos(r).also { it.atZone(timeZone.zoneId) }
120-
}
121-
}
117+
is DateTimeUnit.TimeBased ->
118+
plus(value, unit).value.also { it.atZone(timeZone.zoneId) }
122119
is DateTimeUnit.DateBased.DayBased ->
123120
thisZdt.plusDays(safeMultiply(value, unit.days.toLong())).toInstant()
124121
is DateTimeUnit.DateBased.MonthBased ->
@@ -129,6 +126,15 @@ public actual fun Instant.plus(value: Long, unit: DateTimeUnit, timeZone: TimeZo
129126
throw DateTimeArithmeticException("Instant $this cannot be represented as local date when adding $value $unit to it", e)
130127
}
131128

129+
actual fun Instant.plus(value: Long, unit: DateTimeUnit.TimeBased): Instant =
130+
try {
131+
multiplyAndDivide(value, unit.nanoseconds, NANOS_PER_ONE.toLong()).let { (d, r) ->
132+
Instant(this.value.plusSeconds(d).plusNanos(r))
133+
}
134+
} catch (e: Exception) {
135+
if (e !is DateTimeException && e !is ArithmeticException) throw e
136+
if (value > 0) Instant.MAX else Instant.MIN
137+
}
132138

133139
@OptIn(ExperimentalTime::class)
134140
public actual fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateTimePeriod {
@@ -148,12 +154,7 @@ public actual fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: Ti
148154
val thisZdt = this.atZone(timeZone)
149155
val otherZdt = other.atZone(timeZone)
150156
when(unit) {
151-
is DateTimeUnit.TimeBased -> {
152-
multiplyAddAndDivide(other.epochSeconds - epochSeconds,
153-
NANOS_PER_ONE.toLong(),
154-
(other.nanosecondsOfSecond - nanosecondsOfSecond).toLong(),
155-
unit.nanoseconds)
156-
}
157+
is DateTimeUnit.TimeBased -> until(other, unit)
157158
is DateTimeUnit.DateBased.DayBased -> thisZdt.until(otherZdt, ChronoUnit.DAYS) / unit.days
158159
is DateTimeUnit.DateBased.MonthBased -> thisZdt.until(otherZdt, ChronoUnit.MONTHS) / unit.months
159160
}

core/nativeMain/src/Instant.kt

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -304,16 +304,26 @@ public actual fun Instant.plus(value: Long, unit: DateTimeUnit, timeZone: TimeZo
304304
throw ArithmeticException("Can't add a Long date-based value, as it would cause an overflow")
305305
toZonedLocalDateTimeFailing(timeZone).plus(value.toInt(), unit).toInstant()
306306
}
307-
is DateTimeUnit.TimeBased -> multiplyAndDivide(value, unit.nanoseconds, NANOS_PER_ONE.toLong()).let { (seconds, nanoseconds) ->
308-
check(timeZone).plus(seconds, nanoseconds).check(timeZone)
309-
}
307+
is DateTimeUnit.TimeBased ->
308+
check(timeZone).plus(value, unit).check(timeZone)
310309
}
311310
} catch (e: ArithmeticException) {
312311
throw DateTimeArithmeticException("Arithmetic overflow when adding to an Instant", e)
313312
} catch (e: IllegalArgumentException) {
314313
throw DateTimeArithmeticException("Boundaries of Instant exceeded when adding a value", e)
315314
}
316315

316+
public actual fun Instant.plus(value: Long, unit: DateTimeUnit.TimeBased): Instant =
317+
try {
318+
multiplyAndDivide(value, unit.nanoseconds, NANOS_PER_ONE.toLong()).let { (seconds, nanoseconds) ->
319+
plus(seconds, nanoseconds)
320+
}
321+
} catch (e: ArithmeticException) {
322+
if (value > 0) Instant.MAX else Instant.MIN
323+
} catch (e: IllegalArgumentException) {
324+
if (value > 0) Instant.MAX else Instant.MIN
325+
}
326+
317327
@OptIn(ExperimentalTime::class)
318328
actual fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateTimePeriod {
319329
var thisLdt = toZonedLocalDateTimeFailing(timeZone)
@@ -337,13 +347,6 @@ public actual fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: Ti
337347
.toLong()
338348
is DateTimeUnit.TimeBased -> {
339349
check(timeZone); other.check(timeZone)
340-
try {
341-
multiplyAddAndDivide(other.epochSeconds - epochSeconds,
342-
NANOS_PER_ONE.toLong(),
343-
(other.nanosecondsOfSecond - nanosecondsOfSecond).toLong(),
344-
unit.nanoseconds)
345-
} catch (e: ArithmeticException) {
346-
if (this < other) Long.MAX_VALUE else Long.MIN_VALUE
347-
}
350+
until(other, unit)
348351
}
349352
}

0 commit comments

Comments
 (0)