Skip to content

Make JVM and JS implementations conform to exception contract #23

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 27, 2020
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
20 changes: 10 additions & 10 deletions core/commonMain/src/DateTimePeriod.kt
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,18 @@ fun Duration.toDateTimePeriod(): DateTimePeriod = toComponents { hours, minutes,
}

operator fun DateTimePeriod.plus(other: DateTimePeriod): DateTimePeriod = DateTimePeriod(
this.years + other.years,
this.months + other.months,
this.days + other.days,
this.hours + other.hours,
this.minutes + other.minutes,
this.seconds + other.seconds,
this.nanoseconds + other.nanoseconds
safeAdd(this.years, other.years),
safeAdd(this.months, other.months),
safeAdd(this.days, other.days),
safeAdd(this.hours, other.hours),
safeAdd(this.minutes, other.minutes),
safeAdd(this.seconds, other.seconds),
safeAdd(this.nanoseconds, other.nanoseconds)
)

operator fun DatePeriod.plus(other: DatePeriod): DatePeriod = DatePeriod(
this.years + other.years,
this.months + other.months,
this.days + other.days
safeAdd(this.years, other.years),
safeAdd(this.months, other.months),
safeAdd(this.days, other.days)
)

6 changes: 3 additions & 3 deletions core/commonMain/src/DateTimeUnit.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ sealed class DateTimeUnit {
}
}

override fun times(scalar: Int): TimeBased = TimeBased(nanoseconds * scalar) // TODO: prevent overflow
override fun times(scalar: Int): TimeBased = TimeBased(safeMultiply(nanoseconds, scalar.toLong()))

@ExperimentalTime
val duration: Duration = nanoseconds.nanoseconds
Expand All @@ -70,7 +70,7 @@ sealed class DateTimeUnit {
require(days > 0) { "Unit duration must be positive, but was $days days." }
}

override fun times(scalar: Int): DayBased = DayBased(days * scalar)
override fun times(scalar: Int): DayBased = DayBased(safeMultiply(days, scalar))

internal override val calendarUnit: CalendarUnit get() = CalendarUnit.DAY
internal override val calendarScale: Long get() = days.toLong()
Expand All @@ -90,7 +90,7 @@ sealed class DateTimeUnit {
require(months > 0) { "Unit duration must be positive, but was $months months." }
}

override fun times(scalar: Int): MonthBased = MonthBased(months * scalar)
override fun times(scalar: Int): MonthBased = MonthBased(safeMultiply(months, scalar))

internal override val calendarUnit: CalendarUnit get() = CalendarUnit.MONTH
internal override val calendarScale: Long get() = months.toLong()
Expand Down
20 changes: 13 additions & 7 deletions core/commonMain/src/Instant.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ public expect class Instant : Comparable<Instant> {
* @throws DateTimeFormatException if the text cannot be parsed or the boundaries of [Instant] are exceeded.
*/
fun parse(isoString: String): Instant

internal val MIN: Instant
internal val MAX: Instant
}
}

Expand Down Expand Up @@ -121,10 +124,6 @@ public fun Instant.monthsUntil(other: Instant, zone: TimeZone): Int =
public fun Instant.yearsUntil(other: Instant, zone: TimeZone): Int =
until(other, DateTimeUnit.YEAR, zone).clampToInt()

// TODO: move to internal utils
internal fun Long.clampToInt(): Int =
if (this > Int.MAX_VALUE) Int.MAX_VALUE else if (this < Int.MIN_VALUE) Int.MIN_VALUE else toInt()

public fun Instant.minus(other: Instant, zone: TimeZone): DateTimePeriod = other.periodUntil(this, zone)


Expand All @@ -134,18 +133,25 @@ public fun Instant.minus(other: Instant, zone: TimeZone): DateTimePeriod = other
public fun Instant.plus(unit: DateTimeUnit, zone: TimeZone): Instant =
plus(unit.calendarScale, unit.calendarUnit, zone)

// TODO: safeMultiply
/**
* @throws DateTimeArithmeticException if this value or the result is too large to fit in [LocalDateTime].
*/
public fun Instant.plus(value: Int, unit: DateTimeUnit, zone: TimeZone): Instant =
plus(value * unit.calendarScale, unit.calendarUnit, zone)
try {
plus(safeMultiply(value.toLong(), unit.calendarScale), unit.calendarUnit, zone)
} catch (e: ArithmeticException) {
throw DateTimeArithmeticException(e)
}

/**
* @throws DateTimeArithmeticException if this value or the result is too large to fit in [LocalDateTime].
*/
public fun Instant.plus(value: Long, unit: DateTimeUnit, zone: TimeZone): Instant =
plus(value * unit.calendarScale, unit.calendarUnit, zone)
try {
plus(safeMultiply(value, unit.calendarScale), unit.calendarUnit, zone)
} catch (e: ArithmeticException) {
throw DateTimeArithmeticException(e)
}


public fun Instant.minus(other: Instant, unit: DateTimeUnit, zone: TimeZone): Long = other.until(this, unit, zone)
17 changes: 15 additions & 2 deletions core/commonMain/src/LocalDate.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ public expect class LocalDate : Comparable<LocalDate> {
* @throws DateTimeFormatException if the text cannot be parsed or the boundaries of [LocalDate] are exceeded.
*/
public fun parse(isoString: String): LocalDate

internal val MIN: LocalDate
internal val MAX: LocalDate
}

/**
Expand Down Expand Up @@ -66,9 +69,19 @@ public expect fun LocalDate.yearsUntil(other: LocalDate): Int
public fun LocalDate.plus(unit: DateTimeUnit.DateBased): LocalDate =
plus(unit.calendarScale, unit.calendarUnit)
public fun LocalDate.plus(value: Int, unit: DateTimeUnit.DateBased): LocalDate =
plus(value * unit.calendarScale, unit.calendarUnit)
try {
plus(safeMultiply(value.toLong(), unit.calendarScale), unit.calendarUnit)
} catch (e: Exception) {
if (e !is ArithmeticException) throw e
throw DateTimeArithmeticException(e)
}
public fun LocalDate.plus(value: Long, unit: DateTimeUnit.DateBased): LocalDate =
plus(value * unit.calendarScale, unit.calendarUnit)
try {
plus(safeMultiply(value, unit.calendarScale), unit.calendarUnit)
} catch (e: Exception) {
if (e !is ArithmeticException) throw e
throw DateTimeArithmeticException(e)
}

public fun LocalDate.until(other: LocalDate, unit: DateTimeUnit.DateBased): Int = when(unit) {
is DateTimeUnit.DateBased.MonthBased -> (monthsUntil(other) / unit.months).toInt()
Expand Down
3 changes: 3 additions & 0 deletions core/commonMain/src/LocalDateTime.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public expect class LocalDateTime : Comparable<LocalDateTime> {
* exceeded.
*/
public fun parse(isoString: String): LocalDateTime

internal val MIN: LocalDateTime
internal val MAX: LocalDateTime
}

/**
Expand Down
19 changes: 19 additions & 0 deletions core/commonMain/src/math.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright 2019-2020 JetBrains s.r.o.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package kotlinx.datetime

internal fun Long.clampToInt(): Int =
when {
this > Int.MAX_VALUE -> Int.MAX_VALUE
this < Int.MIN_VALUE -> Int.MIN_VALUE
else -> toInt()
}


internal expect fun safeMultiply(a: Long, b: Long): Long
internal expect fun safeMultiply(a: Int, b: Int): Int
internal expect fun safeAdd(a: Long, b: Long): Long
internal expect fun safeAdd(a: Int, b: Int): Int
201 changes: 199 additions & 2 deletions core/commonTest/src/InstantTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class InstantTest {
* Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos
*/
@Test
fun instantParsing() {
fun parseIsoString() {
val instants = arrayOf(
Triple("1970-01-01T00:00:00Z", 0, 0),
Triple("1970-01-01t00:00:00Z", 0, 0),
Expand All @@ -78,8 +78,12 @@ class InstantTest {
val instant = Instant.parse(str)
assertEquals(seconds.toLong() * 1000 + nanos / 1000000, instant.toEpochMilliseconds())
}
}

assertInvalidFormat { Instant.parse("x") }
assertInvalidFormat { Instant.parse("12020-12-31T23:59:59.000000000Z") }
// this string represents an Instant that is currently larger than Instant.MAX any of the implementations:
assertInvalidFormat { Instant.parse("+1000000001-12-31T23:59:59.000000000Z") }
}

@OptIn(ExperimentalTime::class)
@Test
Expand Down Expand Up @@ -133,6 +137,29 @@ class InstantTest {
assertEquals(0, instant6.minus(instant1, DateTimeUnit.DAY, zone))
}

@OptIn(ExperimentalTime::class)
@Test
fun unitMultiplesUntil() {
val unit1000days = DateTimeUnit.DAY * 1000
val unit4years = DateTimeUnit.YEAR * 4 // longer than 1000-DAY

val zone = TimeZone.UTC
val min = LocalDateTime.MIN.toInstant(zone)
val max = LocalDateTime.MAX.toInstant(zone)
val diffDays = min.until(max, unit1000days, zone)
val diffYears = min.until(max, unit4years, zone)
assertTrue(diffDays in 0..Int.MAX_VALUE, "difference in $unit1000days should fit in Int, was $diffDays")
assertTrue(diffDays > diffYears, "difference in $unit1000days unit must be more than in $unit4years unit, was $diffDays $diffYears")

val unit500ns = DateTimeUnit.NANOSECOND * 500
val start = Instant.parse("1700-01-01T00:00:00Z")
val end = start.plus(300, DateTimeUnit.YEAR, zone)
val diffNs = start.until(end, unit500ns, zone)
val diffUs = start.until(end, DateTimeUnit.MICROSECOND, zone)
// TODO: avoid clamping/overflowing in intermediate results
// assertEquals(diffUs * 2, diffNs)
}

@OptIn(ExperimentalTime::class)
@Test
fun instantOffset() {
Expand Down Expand Up @@ -297,3 +324,173 @@ class InstantTest {
}

}

@OptIn(ExperimentalTime::class)
class InstantRangeTest {
private val UTC = TimeZone.UTC
private val maxValidInstant = LocalDateTime.MAX.toInstant(UTC)
private val minValidInstant = LocalDateTime.MIN.toInstant(UTC)

private val largePositiveLongs = listOf(Long.MAX_VALUE, Long.MAX_VALUE - 1, Long.MAX_VALUE - 50)
private val largeNegativeLongs = listOf(Long.MIN_VALUE, Long.MIN_VALUE + 1, Long.MIN_VALUE + 50)

private val largePositiveInstants = listOf(Instant.MAX, Instant.MAX - 1.seconds, Instant.MAX - 50.seconds)
private val largeNegativeInstants = listOf(Instant.MIN, Instant.MIN + 1.seconds, Instant.MIN + 50.seconds)

private val smallInstants = listOf(
Instant.fromEpochMilliseconds(0),
Instant.fromEpochMilliseconds(1003),
Instant.fromEpochMilliseconds(253112)
)


@Test
fun epochMillisecondsClamping() {
// toEpochMilliseconds()/fromEpochMilliseconds()
// assuming that ranges of Long (representing a number of milliseconds) and Instant are not just overlapping,
// but one is included in the other.
if (Instant.MAX.epochSeconds > Long.MAX_VALUE / 1000) {
/* Any number of milliseconds in Long is representable as an Instant */
for (instant in largePositiveInstants) {
assertEquals(Long.MAX_VALUE, instant.toEpochMilliseconds(), "$instant")
}
for (instant in largeNegativeInstants) {
assertEquals(Long.MIN_VALUE, instant.toEpochMilliseconds(), "$instant")
}
for (milliseconds in largePositiveLongs + largeNegativeLongs) {
assertEquals(milliseconds, Instant.fromEpochMilliseconds(milliseconds).toEpochMilliseconds(),
"$milliseconds")
}
} else {
/* Any Instant is representable as a number of milliseconds in Long */
for (milliseconds in largePositiveLongs) {
assertEquals(Instant.MAX, Instant.fromEpochMilliseconds(milliseconds), "$milliseconds")
}
for (milliseconds in largeNegativeLongs) {
assertEquals(Instant.MIN, Instant.fromEpochMilliseconds(milliseconds), "$milliseconds")
}
for (instant in largePositiveInstants + smallInstants + largeNegativeInstants) {
assertEquals(instant.epochSeconds,
Instant.fromEpochMilliseconds(instant.toEpochMilliseconds()).epochSeconds, "$instant")
}
}
}

@Test
fun epochSecondsClamping() {
// fromEpochSeconds
// On all platforms Long.MAX_VALUE of seconds is not a valid instant.
for (seconds in largePositiveLongs) {
assertEquals(Instant.MAX, Instant.fromEpochSeconds(seconds, 35))
}
for (seconds in largeNegativeLongs) {
assertEquals(Instant.MIN, Instant.fromEpochSeconds(seconds, 35))
}
for (instant in largePositiveInstants + smallInstants + largeNegativeInstants) {
assertEquals(instant, Instant.fromEpochSeconds(instant.epochSeconds, instant.nanosecondsOfSecond.toLong()))
}
}

@Test
fun durationArithmeticClamping() {
val longDurations = listOf(Duration.INFINITE, Double.MAX_VALUE.nanoseconds, Long.MAX_VALUE.seconds)

for (duration in longDurations) {
for (instant in smallInstants + largeNegativeInstants + largePositiveInstants) {
assertEquals(Instant.MAX, instant + duration)
}
for (instant in smallInstants + largeNegativeInstants + largePositiveInstants) {
assertEquals(Instant.MIN, instant - duration)
}
}
assertEquals(Instant.MAX, (Instant.MAX - 4.seconds) + 5.seconds)
assertEquals(Instant.MIN, (Instant.MIN + 10.seconds) - 12.seconds)
}

@Test
fun periodArithmeticOutOfRange() {
// Instant.plus(DateTimePeriod(), TimeZone)
// Arithmetic overflow
for (instant in smallInstants + largeNegativeInstants + largePositiveInstants) {
assertArithmeticFails("$instant") { instant.plus(DateTimePeriod(seconds = Long.MAX_VALUE), UTC) }
assertArithmeticFails("$instant") { instant.plus(DateTimePeriod(seconds = Long.MIN_VALUE), UTC) }
}
// Overflowing a LocalDateTime in input
maxValidInstant.plus(DateTimePeriod(nanoseconds = -1), UTC)
minValidInstant.plus(DateTimePeriod(nanoseconds = 1), UTC)
assertArithmeticFails { (maxValidInstant + 1.nanoseconds).plus(DateTimePeriod(nanoseconds = -2), UTC) }
assertArithmeticFails { (minValidInstant - 1.nanoseconds).plus(DateTimePeriod(nanoseconds = 2), UTC) }
// Overflowing a LocalDateTime in result
assertArithmeticFails { maxValidInstant.plus(DateTimePeriod(nanoseconds = 1), UTC) }
assertArithmeticFails { minValidInstant.plus(DateTimePeriod(nanoseconds = -1), UTC) }
// Overflowing a LocalDateTime in intermediate computations
assertArithmeticFails { maxValidInstant.plus(DateTimePeriod(seconds = 1, nanoseconds = -1_000_000_001), UTC) }
assertArithmeticFails { maxValidInstant.plus(DateTimePeriod(hours = 1, minutes = -61), UTC) }
assertArithmeticFails { maxValidInstant.plus(DateTimePeriod(days = 1, hours = -48), UTC) }
}

@Test
fun unitArithmeticOutOfRange() {
// Instant.plus(Long, DateTimeUnit, TimeZone)
// Arithmetic overflow
for (instant in smallInstants + largeNegativeInstants + largePositiveInstants) {
assertArithmeticFails("$instant") { instant.plus(Long.MAX_VALUE, DateTimeUnit.SECOND, UTC) }
assertArithmeticFails("$instant") { instant.plus(Long.MIN_VALUE, DateTimeUnit.SECOND, UTC) }
assertArithmeticFails("$instant") { instant.plus(Long.MAX_VALUE, DateTimeUnit.YEAR, UTC) }
assertArithmeticFails("$instant") { instant.plus(Long.MIN_VALUE, DateTimeUnit.YEAR, UTC) }
}
// Overflowing a LocalDateTime in input
maxValidInstant.plus(-1, DateTimeUnit.NANOSECOND, UTC)
minValidInstant.plus(1, DateTimeUnit.NANOSECOND, UTC)
assertArithmeticFails { (maxValidInstant + 1.nanoseconds).plus(-2, DateTimeUnit.NANOSECOND, UTC) }
assertArithmeticFails { (minValidInstant - 1.nanoseconds).plus(2, DateTimeUnit.NANOSECOND, UTC) }
// Overflowing a LocalDateTime in result
assertArithmeticFails { maxValidInstant.plus(1, DateTimeUnit.NANOSECOND, UTC) }
assertArithmeticFails { maxValidInstant.plus(1, DateTimeUnit.YEAR, UTC) }
assertArithmeticFails { minValidInstant.plus(-1, DateTimeUnit.NANOSECOND, UTC) }
assertArithmeticFails { minValidInstant.plus(-1, DateTimeUnit.YEAR, UTC) }
}

@Test
fun periodUntilOutOfRange() {
// Instant.periodUntil
maxValidInstant.periodUntil(minValidInstant, UTC)
assertArithmeticFails { (maxValidInstant + 1.nanoseconds).periodUntil(minValidInstant, UTC) }
assertArithmeticFails { maxValidInstant.periodUntil(minValidInstant - 1.nanoseconds, UTC) }
}

@Test
fun unitsUntilClamping() {
// Arithmetic overflow of the resulting number
assertEquals(Long.MAX_VALUE, minValidInstant.until(maxValidInstant, DateTimeUnit.NANOSECOND, UTC))
assertEquals(Long.MIN_VALUE, maxValidInstant.until(minValidInstant, DateTimeUnit.NANOSECOND, UTC))
}

@Test
fun unitsUntilOutOfRange() {
// Instant.until
// Overflowing a LocalDateTime in input
assertArithmeticFails { (maxValidInstant + 1.nanoseconds).until(maxValidInstant, DateTimeUnit.NANOSECOND, UTC) }
assertArithmeticFails { maxValidInstant.until(maxValidInstant + 1.nanoseconds, DateTimeUnit.NANOSECOND, UTC) }
}
}


@Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
@kotlin.internal.InlineOnly
inline fun <T> assertArithmeticFails(message: String? = null, f: () -> T) {
assertFailsWith<DateTimeArithmeticException>(message) {
val result = f()
fail(result.toString())
}
}

@Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
@kotlin.internal.InlineOnly
inline fun <T> assertInvalidFormat(message: String? = null, f: () -> T) {
assertFailsWith<DateTimeFormatException>(message) {
val result = f()
fail(result.toString())
}
}

Loading