Skip to content

Refactor datetime arithmetics #489

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

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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
8 changes: 8 additions & 0 deletions core/api/kotlinx-datetime.api
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ public final class kotlinx/datetime/InstantKt {
public static final fun isDistantFuture (Lkotlinx/datetime/Instant;)Z
public static final fun isDistantPast (Lkotlinx/datetime/Instant;)Z
public static final fun minus (Lkotlinx/datetime/Instant;ILkotlinx/datetime/DateTimeUnit$TimeBased;)Lkotlinx/datetime/Instant;
public static final fun minus (Lkotlinx/datetime/Instant;ILkotlinx/datetime/DateTimeUnit;Lkotlinx/datetime/TimeZone;)Lkotlinx/datetime/Instant;
public static final fun minus (Lkotlinx/datetime/Instant;JLkotlinx/datetime/DateTimeUnit$TimeBased;)Lkotlinx/datetime/Instant;
public static final fun minus (Lkotlinx/datetime/Instant;JLkotlinx/datetime/DateTimeUnit;Lkotlinx/datetime/TimeZone;)Lkotlinx/datetime/Instant;
public static final fun minus (Lkotlinx/datetime/Instant;Lkotlinx/datetime/DateTimePeriod;Lkotlinx/datetime/TimeZone;)Lkotlinx/datetime/Instant;
Expand All @@ -265,10 +266,17 @@ public final class kotlinx/datetime/InstantKt {
public static final fun minus (Lkotlinx/datetime/Instant;Lkotlinx/datetime/Instant;Lkotlinx/datetime/DateTimeUnit;Lkotlinx/datetime/TimeZone;)J
public static final fun minus (Lkotlinx/datetime/Instant;Lkotlinx/datetime/Instant;Lkotlinx/datetime/TimeZone;)Lkotlinx/datetime/DateTimePeriod;
public static final fun monthsUntil (Lkotlinx/datetime/Instant;Lkotlinx/datetime/Instant;Lkotlinx/datetime/TimeZone;)I
public static final fun periodUntil (Lkotlinx/datetime/Instant;Lkotlinx/datetime/Instant;Lkotlinx/datetime/TimeZone;)Lkotlinx/datetime/DateTimePeriod;
public static final fun plus (Lkotlinx/datetime/Instant;ILkotlinx/datetime/DateTimeUnit$TimeBased;)Lkotlinx/datetime/Instant;
public static final fun plus (Lkotlinx/datetime/Instant;ILkotlinx/datetime/DateTimeUnit;Lkotlinx/datetime/TimeZone;)Lkotlinx/datetime/Instant;
public static final fun plus (Lkotlinx/datetime/Instant;JLkotlinx/datetime/DateTimeUnit$TimeBased;)Lkotlinx/datetime/Instant;
public static final fun plus (Lkotlinx/datetime/Instant;JLkotlinx/datetime/DateTimeUnit;Lkotlinx/datetime/TimeZone;)Lkotlinx/datetime/Instant;
public static final fun plus (Lkotlinx/datetime/Instant;Lkotlinx/datetime/DateTimePeriod;Lkotlinx/datetime/TimeZone;)Lkotlinx/datetime/Instant;
public static final fun plus (Lkotlinx/datetime/Instant;Lkotlinx/datetime/DateTimeUnit$TimeBased;)Lkotlinx/datetime/Instant;
public static final fun plus (Lkotlinx/datetime/Instant;Lkotlinx/datetime/DateTimeUnit;Lkotlinx/datetime/TimeZone;)Lkotlinx/datetime/Instant;
public static final fun toInstant (Ljava/lang/String;)Lkotlinx/datetime/Instant;
public static final fun until (Lkotlinx/datetime/Instant;Lkotlinx/datetime/Instant;Lkotlinx/datetime/DateTimeUnit$TimeBased;)J
public static final fun until (Lkotlinx/datetime/Instant;Lkotlinx/datetime/Instant;Lkotlinx/datetime/DateTimeUnit;Lkotlinx/datetime/TimeZone;)J
public static final fun yearsUntil (Lkotlinx/datetime/Instant;Lkotlinx/datetime/Instant;Lkotlinx/datetime/TimeZone;)I
}

Expand Down
139 changes: 131 additions & 8 deletions core/common/src/Instant.kt
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,37 @@ public fun String.toInstant(): Instant = Instant.parse(this)
* [LocalDateTime].
* @sample kotlinx.datetime.test.samples.InstantSamples.plusPeriod
*/
public expect fun Instant.plus(period: DateTimePeriod, timeZone: TimeZone): Instant
public fun Instant.plus(period: DateTimePeriod, timeZone: TimeZone): Instant = try {
with(period) {
val initialOffset = offsetIn(timeZone)
val initialLdt = toLocalDateTimeFailing(initialOffset)
val instantAfterMonths: Instant
val offsetAfterMonths: UtcOffset
val ldtAfterMonths: LocalDateTime
if (totalMonths != 0L) {
val unresolvedLdtWithMonths = initialLdt.plus(totalMonths, DateTimeUnit.MONTH)
instantAfterMonths = localDateTimeToInstant(unresolvedLdtWithMonths, timeZone, preferred = initialOffset)
offsetAfterMonths = instantAfterMonths.offsetIn(timeZone)
ldtAfterMonths = instantAfterMonths.toLocalDateTime(offsetAfterMonths)
} else {
instantAfterMonths = this@plus
offsetAfterMonths = initialOffset
ldtAfterMonths = initialLdt
}
val instantAfterMonthsAndDays = if (days != 0) {
val unresolvedLdtWithDays = ldtAfterMonths.plus(days, DateTimeUnit.DAY)
localDateTimeToInstant(unresolvedLdtWithDays, timeZone, preferred = offsetAfterMonths)
} else {
instantAfterMonths
}
instantAfterMonthsAndDays
.run { if (totalNanoseconds != 0L) plus(0, totalNanoseconds).check(timeZone) else this }
}.check(timeZone)
} catch (e: ArithmeticException) {
throw DateTimeArithmeticException("Arithmetic overflow when adding CalendarPeriod to an Instant", e)
} catch (e: IllegalArgumentException) {
throw DateTimeArithmeticException("Boundaries of Instant exceeded when adding CalendarPeriod", e)
}

/**
* Returns an instant that is the result of subtracting components of [DateTimePeriod] from this instant. The components
Expand Down Expand Up @@ -489,7 +519,25 @@ public fun Instant.minus(period: DateTimePeriod, timeZone: TimeZone): Instant =
* @throws DateTimeArithmeticException if `this` or [other] instant is too large to fit in [LocalDateTime].
* @sample kotlinx.datetime.test.samples.InstantSamples.periodUntil
*/
public expect fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateTimePeriod
public fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateTimePeriod {
val initialOffset = offsetIn(timeZone)
val initialLdt = toLocalDateTimeFailing(initialOffset)
val otherLdt = other.toLocalDateTimeFailing(other.offsetIn(timeZone))

val months = initialLdt.until(otherLdt, DateTimeUnit.MONTH) // `until` on dates never fails
val unresolvedLdtWithMonths = initialLdt.plus(months, DateTimeUnit.MONTH)
// won't throw: thisLdt + months <= otherLdt, which is known to be valid
val instantWithMonths = localDateTimeToInstant(unresolvedLdtWithMonths, timeZone, preferred = initialOffset)
val offsetWithMonths = instantWithMonths.offsetIn(timeZone)
val ldtWithMonths = instantWithMonths.toLocalDateTime(offsetWithMonths)
val days = ldtWithMonths.until(otherLdt, DateTimeUnit.DAY) // `until` on dates never fails
val unresolvedLdtWithDays = ldtWithMonths.plus(days, DateTimeUnit.DAY)
val newInstant = localDateTimeToInstant(unresolvedLdtWithDays, timeZone, preferred = initialOffset)
// won't throw: thisLdt + days <= otherLdt
val nanoseconds = newInstant.until(other, DateTimeUnit.NANOSECOND) // |otherLdt - thisLdt| < 24h

return buildDateTimePeriod(months, days.toInt(), nanoseconds)
}

/**
* Returns the whole number of the specified date or time [units][unit] between `this` and [other] instants
Expand All @@ -505,7 +553,15 @@ public expect fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateT
* @throws DateTimeArithmeticException if `this` or [other] instant is too large to fit in [LocalDateTime].
* @sample kotlinx.datetime.test.samples.InstantSamples.untilAsDateTimeUnit
*/
public expect fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: TimeZone): Long
public fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: TimeZone): Long =
when (unit) {
is DateTimeUnit.DateBased ->
toLocalDateTimeFailing(offsetIn(timeZone)).until(other.toLocalDateTimeFailing(other.offsetIn(timeZone)), unit)
is DateTimeUnit.TimeBased -> {
check(timeZone); other.check(timeZone)
until(other, unit)
}
}

/**
* Returns the whole number of the specified time [units][unit] between `this` and [other] instants.
Expand Down Expand Up @@ -592,7 +648,8 @@ public fun Instant.minus(other: Instant, timeZone: TimeZone): DateTimePeriod =
* @throws DateTimeArithmeticException if this value or the result is too large to fit in [LocalDateTime].
*/
@Deprecated("Use the plus overload with an explicit number of units", ReplaceWith("this.plus(1, unit, timeZone)"))
public expect fun Instant.plus(unit: DateTimeUnit, timeZone: TimeZone): Instant
public fun Instant.plus(unit: DateTimeUnit, timeZone: TimeZone): Instant =
plus(1L, unit, timeZone)

/**
* Returns an instant that is the result of subtracting one [unit] from this instant
Expand Down Expand Up @@ -641,7 +698,8 @@ public fun Instant.minus(unit: DateTimeUnit.TimeBased): Instant =
* @throws DateTimeArithmeticException if this value or the result is too large to fit in [LocalDateTime].
* @sample kotlinx.datetime.test.samples.InstantSamples.plusDateTimeUnit
*/
public expect fun Instant.plus(value: Int, unit: DateTimeUnit, timeZone: TimeZone): Instant
public fun Instant.plus(value: Int, unit: DateTimeUnit, timeZone: TimeZone): Instant =
plus(value.toLong(), unit, timeZone)

/**
* Returns an instant that is the result of subtracting the [value] number of the specified [unit] from this instant
Expand All @@ -659,7 +717,8 @@ public expect fun Instant.plus(value: Int, unit: DateTimeUnit, timeZone: TimeZon
* @throws DateTimeArithmeticException if this value or the result is too large to fit in [LocalDateTime].
* @sample kotlinx.datetime.test.samples.InstantSamples.minusDateTimeUnit
*/
public expect fun Instant.minus(value: Int, unit: DateTimeUnit, timeZone: TimeZone): Instant
public fun Instant.minus(value: Int, unit: DateTimeUnit, timeZone: TimeZone): Instant =
plus(-value.toLong(), unit, timeZone)

/**
* Returns an instant that is the result of adding the [value] number of the specified [unit] to this instant.
Expand Down Expand Up @@ -700,7 +759,21 @@ public fun Instant.minus(value: Int, unit: DateTimeUnit.TimeBased): Instant =
* @throws DateTimeArithmeticException if this value or the result is too large to fit in [LocalDateTime].
* @sample kotlinx.datetime.test.samples.InstantSamples.plusDateTimeUnit
*/
public expect fun Instant.plus(value: Long, unit: DateTimeUnit, timeZone: TimeZone): Instant
public fun Instant.plus(value: Long, unit: DateTimeUnit, timeZone: TimeZone): Instant = try {
when (unit) {
is DateTimeUnit.DateBased -> {
val initialOffset = offsetIn(timeZone)
val initialLdt = toLocalDateTimeFailing(initialOffset)
localDateTimeToInstant(initialLdt.plus(value, unit), timeZone, preferred = initialOffset)
}
is DateTimeUnit.TimeBased ->
check(timeZone).plus(value, unit).check(timeZone)
}
} catch (e: ArithmeticException) {
throw DateTimeArithmeticException("Arithmetic overflow when adding to an Instant", e)
} catch (e: IllegalArgumentException) {
throw DateTimeArithmeticException("Boundaries of Instant exceeded when adding a value", e)
}

/**
* Returns an instant that is the result of subtracting the [value] number of the specified [unit] from this instant
Expand Down Expand Up @@ -732,7 +805,17 @@ public fun Instant.minus(value: Long, unit: DateTimeUnit, timeZone: TimeZone): I
*
* @sample kotlinx.datetime.test.samples.InstantSamples.plusTimeBasedUnit
*/
public expect fun Instant.plus(value: Long, unit: DateTimeUnit.TimeBased): Instant
public fun Instant.plus(value: Long, unit: DateTimeUnit.TimeBased): Instant =
try {
multiplyAndDivide(value, unit.nanoseconds, NANOS_PER_ONE.toLong()).let { (seconds, nanoseconds) ->
plus(seconds, nanoseconds)
}
} catch (_: ArithmeticException) {
if (value > 0) Instant.MAX else Instant.MIN
} catch (_: IllegalArgumentException) {
if (value > 0) Instant.MAX else Instant.MIN
}


/**
* Returns an instant that is the result of subtracting the [value] number of the specified [unit] from this instant.
Expand Down Expand Up @@ -800,3 +883,43 @@ public fun Instant.format(format: DateTimeFormat<DateTimeComponents>, offset: Ut

internal const val DISTANT_PAST_SECONDS = -3217862419201
internal const val DISTANT_FUTURE_SECONDS = 3093527980800

private fun Instant.toLocalDateTimeFailing(offset: UtcOffset): LocalDateTime = try {
toLocalDateTime(offset)
} catch (e: IllegalArgumentException) {
throw DateTimeArithmeticException("Can not convert instant $this to LocalDateTime to perform computations", e)
}

/** Check that [Instant] fits in [LocalDateTime].
* This is done on the results of computations for consistency with other platforms.
*/
private fun Instant.check(zone: TimeZone): Instant = [email protected] {
toLocalDateTimeFailing(offsetIn(zone))
}

private fun LocalDateTime.plus(value: Long, unit: DateTimeUnit.DateBased) =
date.plus(value, unit).atTime(time)

private fun LocalDateTime.plus(value: Int, unit: DateTimeUnit.DateBased) =
date.plus(value, unit).atTime(time)

/**
* @throws ArithmeticException if arithmetic overflow occurs
* @throws IllegalArgumentException if the boundaries of Instant are overflown
*/
internal expect fun Instant.plus(secondsToAdd: Long, nanosToAdd: Long): Instant

// org.threeten.bp.LocalDateTime#until
internal fun LocalDateTime.until(other: LocalDateTime, unit: DateTimeUnit.DateBased): Long {
val otherDate = other.date
val delta = when {
otherDate > date && other.time < time -> -1 // addition won't throw: endDate - date >= 1
otherDate < date && other.time > time -> 1 // addition won't throw: date - endDate >= 1
else -> 0
}
val endDate = otherDate.plus(delta, DateTimeUnit.DAY)
return when (unit) {
is DateTimeUnit.MonthBased -> date.until(endDate, DateTimeUnit.MONTH) / unit.months
is DateTimeUnit.DayBased -> date.until(endDate, DateTimeUnit.DAY) / unit.days
}
}
4 changes: 4 additions & 0 deletions core/common/src/TimeZone.kt
Original file line number Diff line number Diff line change
Expand Up @@ -325,3 +325,7 @@ public expect fun LocalDateTime.toInstant(offset: UtcOffset): Instant
* @sample kotlinx.datetime.test.samples.TimeZoneSamples.atStartOfDayIn
*/
public expect fun LocalDate.atStartOfDayIn(timeZone: TimeZone): Instant

internal expect fun localDateTimeToInstant(
dateTime: LocalDateTime, timeZone: TimeZone, preferred: UtcOffset? = null
): Instant
6 changes: 3 additions & 3 deletions core/commonJs/src/internal/Platform.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,21 +96,21 @@ private object SystemTimeZone: TimeZone() {

/* https://github.com/js-joda/js-joda/blob/8c1a7448db92ca014417346049fb64b55f7b1ac1/packages/core/src/LocalDate.js#L1404-L1416 +
* https://github.com/js-joda/js-joda/blob/8c1a7448db92ca014417346049fb64b55f7b1ac1/packages/core/src/zone/SystemDefaultZoneRules.js#L69-L71 */
override fun atStartOfDay(date: LocalDate): Instant = atZone(date.atTime(LocalTime.MIN)).toInstant()
override fun atStartOfDay(date: LocalDate): Instant = localDateTimeToInstant(date.atTime(LocalTime.MIN))

/* https://github.com/js-joda/js-joda/blob/8c1a7448db92ca014417346049fb64b55f7b1ac1/packages/core/src/zone/SystemDefaultZoneRules.js#L21-L24 */
override fun offsetAtImpl(instant: Instant): UtcOffset =
UtcOffset(minutes = -Date(instant.toEpochMilliseconds().toDouble()).getTimezoneOffset().toInt())

/* https://github.com/js-joda/js-joda/blob/8c1a7448db92ca014417346049fb64b55f7b1ac1/packages/core/src/zone/SystemDefaultZoneRules.js#L49-L55 */
override fun atZone(dateTime: LocalDateTime, preferred: UtcOffset?): ZonedDateTime {
override fun localDateTimeToInstant(dateTime: LocalDateTime, preferred: UtcOffset?): Instant {
val epochMilli = dateTime.toInstant(UTC).toEpochMilliseconds()
val offsetInMinutesBeforePossibleTransition = Date(epochMilli.toDouble()).getTimezoneOffset().toInt()
val epochMilliSystemZone = epochMilli +
offsetInMinutesBeforePossibleTransition * SECONDS_PER_MINUTE * MILLIS_PER_ONE
val offsetInMinutesAfterPossibleTransition = Date(epochMilliSystemZone.toDouble()).getTimezoneOffset().toInt()
val offset = UtcOffset(minutes = -offsetInMinutesAfterPossibleTransition)
return ZonedDateTime(dateTime, this, offset)
return dateTime.toInstant(offset)
}

override fun equals(other: Any?): Boolean = other === this
Expand Down
Loading