Skip to content
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

Refactor datetime arithmetics #489

Open
wants to merge 4 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
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 @@ -274,3 +274,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
118 changes: 12 additions & 106 deletions core/commonKotlin/src/Instant.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,6 @@ public actual class Instant internal constructor(public actual val epochSeconds:
if (epochSeconds > 0) Long.MAX_VALUE else Long.MIN_VALUE
}

// org.threeten.bp.Instant#plus(long, long)
/**
* @throws ArithmeticException if arithmetic overflow occurs
* @throws IllegalArgumentException if the boundaries of Instant are overflown
*/
internal fun plus(secondsToAdd: Long, nanosToAdd: Long): Instant {
if ((secondsToAdd or nanosToAdd) == 0L) {
return this
}
val newEpochSeconds: Long = safeAdd(safeAdd(epochSeconds, secondsToAdd), (nanosToAdd / NANOS_PER_ONE))
val newNanosToAdd = nanosToAdd % NANOS_PER_ONE
val nanoAdjustment = (nanosecondsOfSecond + newNanosToAdd) // safe int+NANOS_PER_ONE
return fromEpochSecondsThrowing(newEpochSeconds, nanoAdjustment)
}

public actual operator fun plus(duration: Duration): Instant = duration.toComponents { secondsToAdd, nanosecondsToAdd ->
try {
plus(secondsToAdd, nanosecondsToAdd.toLong())
Expand Down Expand Up @@ -129,7 +114,7 @@ public actual class Instant internal constructor(public actual val epochSeconds:
* @throws ArithmeticException if arithmetic overflow occurs
* @throws IllegalArgumentException if the boundaries of Instant are overflown
*/
private fun fromEpochSecondsThrowing(epochSeconds: Long, nanosecondAdjustment: Long): Instant {
internal fun fromEpochSecondsThrowing(epochSeconds: Long, nanosecondAdjustment: Long): Instant {
val secs = safeAdd(epochSeconds, nanosecondAdjustment.floorDiv(NANOS_PER_ONE.toLong()))
val nos = nanosecondAdjustment.mod(NANOS_PER_ONE.toLong()).toInt()
return Instant(secs, nos)
Expand Down Expand Up @@ -164,96 +149,6 @@ public actual class Instant internal constructor(public actual val epochSeconds:

}

private fun Instant.toZonedDateTimeFailing(zone: TimeZone): ZonedDateTime = try {
toZonedDateTime(zone)
} catch (e: IllegalArgumentException) {
throw DateTimeArithmeticException("Can not convert instant $this to LocalDateTime to perform computations", e)
}

/**
* @throws IllegalArgumentException if the [Instant] exceeds the boundaries of [LocalDateTime]
*/
private fun Instant.toZonedDateTime(zone: TimeZone): ZonedDateTime {
val currentOffset = zone.offsetAt(this)
return ZonedDateTime(toLocalDateTimeImpl(currentOffset), zone, currentOffset)
}

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

public actual fun Instant.plus(period: DateTimePeriod, timeZone: TimeZone): Instant = try {
with(period) {
val withDate = toZonedDateTimeFailing(timeZone)
.run { if (totalMonths != 0L) plus(totalMonths, DateTimeUnit.MONTH) else this }
.run { if (days != 0) plus(days.toLong(), DateTimeUnit.DAY) else this }
withDate.toInstant()
.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)
}

@Deprecated("Use the plus overload with an explicit number of units", ReplaceWith("this.plus(1, unit, timeZone)"))
public actual fun Instant.plus(unit: DateTimeUnit, timeZone: TimeZone): Instant =
plus(1L, unit, timeZone)
public actual fun Instant.plus(value: Int, unit: DateTimeUnit, timeZone: TimeZone): Instant =
plus(value.toLong(), unit, timeZone)
public actual fun Instant.minus(value: Int, unit: DateTimeUnit, timeZone: TimeZone): Instant =
plus(-value.toLong(), unit, timeZone)
public actual fun Instant.plus(value: Long, unit: DateTimeUnit, timeZone: TimeZone): Instant = try {
when (unit) {
is DateTimeUnit.DateBased ->
toZonedDateTimeFailing(timeZone).plus(value, unit).toInstant()
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)
}

public actual 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
}

public actual fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateTimePeriod {
var thisLdt = toZonedDateTimeFailing(timeZone)
val otherLdt = other.toZonedDateTimeFailing(timeZone)

val months = thisLdt.until(otherLdt, DateTimeUnit.MONTH) // `until` on dates never fails
thisLdt = thisLdt.plus(months, DateTimeUnit.MONTH) // won't throw: thisLdt + months <= otherLdt, which is known to be valid
val days = thisLdt.until(otherLdt, DateTimeUnit.DAY) // `until` on dates never fails
thisLdt = thisLdt.plus(days, DateTimeUnit.DAY) // won't throw: thisLdt + days <= otherLdt
val nanoseconds = thisLdt.until(otherLdt, DateTimeUnit.NANOSECOND) // |otherLdt - thisLdt| < 24h

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

public actual fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: TimeZone): Long =
when (unit) {
is DateTimeUnit.DateBased ->
toZonedDateTimeFailing(timeZone).dateTime.until(other.toZonedDateTimeFailing(timeZone).dateTime, unit)
.toLong()
is DateTimeUnit.TimeBased -> {
check(timeZone); other.check(timeZone)
until(other, unit)
}
}

private val ISO_DATE_TIME_OFFSET_WITH_TRAILING_ZEROS = DateTimeComponents.Format {
date(ISO_DATE)
alternativeParsing({
Expand All @@ -277,3 +172,14 @@ private val ISO_DATE_TIME_OFFSET_WITH_TRAILING_ZEROS = DateTimeComponents.Format
outputSecond = WhenToOutput.IF_NONZERO
)
}

// org.threeten.bp.Instant#plus(long, long)
internal actual fun Instant.plus(secondsToAdd: Long, nanosToAdd: Long): Instant {
if ((secondsToAdd or nanosToAdd) == 0L) {
return this
}
val newEpochSeconds: Long = safeAdd(safeAdd(epochSeconds, secondsToAdd), (nanosToAdd / NANOS_PER_ONE))
val newNanosToAdd = nanosToAdd % NANOS_PER_ONE
val nanoAdjustment = (nanosecondsOfSecond + newNanosToAdd) // safe int+NANOS_PER_ONE
return Instant.fromEpochSecondsThrowing(newEpochSeconds, nanoAdjustment)
}
Loading