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

Statically distinguish date-only and date+time units and periods #7

Merged
merged 10 commits into from
Jul 20, 2020
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,21 @@ package kotlinx.datetime
import kotlin.time.Duration
import kotlin.time.ExperimentalTime

typealias Period = CalendarPeriod

class CalendarPeriod(val years: Int = 0, val months: Int = 0, val days: Int = 0,
val hours: Int = 0, val minutes: Int = 0, val seconds: Long = 0, val nanoseconds: Long = 0) {
object Builder {
val Int.years get() = CalendarPeriod(years = this)
val Int.months get() = CalendarPeriod(months = this)
val Int.days get() = CalendarPeriod(days = this)
}

// TODO: could be error-prone without explicitly named params
sealed class DateTimePeriod {
abstract val years: Int
abstract val months: Int
abstract val days: Int
abstract val hours: Int
abstract val minutes: Int
abstract val seconds: Long
abstract val nanoseconds: Long

private fun allNotPositive() =
years <= 0 && months <= 0 && days <= 0 && hours <= 0 && minutes <= 0 && seconds <= 0 && nanoseconds <= 0 &&
(years or months or days or hours or minutes != 0 || seconds or nanoseconds != 0L)


override fun toString(): String = buildString {
val sign = if (allNotPositive()) { append('-'); -1 } else 1
append('P')
Expand All @@ -43,7 +43,7 @@ class CalendarPeriod(val years: Int = 0, val months: Int = 0, val days: Int = 0,

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is CalendarPeriod) return false
if (other !is DateTimePeriod) return false

if (years != other.years) return false
if (months != other.months) return false
Expand All @@ -66,21 +66,50 @@ class CalendarPeriod(val years: Int = 0, val months: Int = 0, val days: Int = 0,
result = 31 * result + nanoseconds.hashCode()
return result
}
}

inline fun period(builder: CalendarPeriod.Builder.() -> CalendarPeriod): CalendarPeriod = CalendarPeriod.Builder.builder()
// TODO: parsing from iso string
}

val Int.calendarDays: CalendarPeriod get() = CalendarPeriod(days = this)
val Int.calendarMonths: CalendarPeriod get() = CalendarPeriod(months = this)
val Int.calendarYears: CalendarPeriod get() = CalendarPeriod(years = this)
class DatePeriod(
override val years: Int = 0,
override val months: Int = 0,
override val days: Int = 0
) : DateTimePeriod() {
override val hours: Int get() = 0
override val minutes: Int get() = 0
override val seconds: Long get() = 0
override val nanoseconds: Long get() = 0
}

private class DateTimePeriodImpl(
override val years: Int = 0,
override val months: Int = 0,
override val days: Int = 0,
override val hours: Int = 0,
override val minutes: Int = 0,
override val seconds: Long = 0,
override val nanoseconds: Long = 0
) : DateTimePeriod()

fun DateTimePeriod(
years: Int = 0,
months: Int = 0,
days: Int = 0,
hours: Int = 0,
minutes: Int = 0,
seconds: Long = 0,
nanoseconds: Long = 0
): DateTimePeriod = if (hours or minutes != 0 || seconds or nanoseconds != 0L)
DateTimePeriodImpl(years, months, days, hours, minutes, seconds, nanoseconds)
else
DatePeriod(years, months, days)

@OptIn(ExperimentalTime::class)
fun Duration.toCalendarPeriod(): CalendarPeriod = toComponents { hours, minutes, seconds, nanoseconds ->
CalendarPeriod(hours = hours, minutes = minutes, seconds = seconds.toLong(), nanoseconds = nanoseconds.toLong())
fun Duration.toDateTimePeriod(): DateTimePeriod = toComponents { hours, minutes, seconds, nanoseconds ->
DateTimePeriod(hours = hours, minutes = minutes, seconds = seconds.toLong(), nanoseconds = nanoseconds.toLong())
}

operator fun CalendarPeriod.plus(other: CalendarPeriod): CalendarPeriod = CalendarPeriod(
operator fun DateTimePeriod.plus(other: DateTimePeriod): DateTimePeriod = DateTimePeriod(
this.years + other.years,
this.months + other.months,
this.days + other.days,
Expand All @@ -90,13 +119,9 @@ operator fun CalendarPeriod.plus(other: CalendarPeriod): CalendarPeriod = Calend
this.nanoseconds + other.nanoseconds
)

enum class CalendarUnit {
YEAR,
MONTH,
WEEK,
DAY,
HOUR,
MINUTE,
SECOND,
NANOSECOND
}
operator fun DatePeriod.plus(other: DatePeriod): DatePeriod = DatePeriod(
this.years + other.years,
this.months + other.months,
this.days + other.days
)

142 changes: 142 additions & 0 deletions core/commonMain/src/DateTimeUnit.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* 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

import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import kotlin.time.nanoseconds

sealed class DateTimeUnit {

abstract operator fun times(scalar: Int): DateTimeUnit

internal abstract val calendarUnit: CalendarUnit
internal abstract val calendarScale: Long

class TimeBased(val nanoseconds: Long) : DateTimeUnit() {
internal override val calendarUnit: CalendarUnit
internal override val calendarScale: Long

init {
require(nanoseconds > 0) { "Unit duration must be positive, but was $nanoseconds ns." }
when {
nanoseconds % 3600_000_000_000 == 0L -> {
calendarUnit = CalendarUnit.HOUR
calendarScale = nanoseconds / 3600_000_000_000
}
nanoseconds % 60_000_000_000 == 0L -> {
calendarUnit = CalendarUnit.MINUTE
calendarScale = nanoseconds / 60_000_000_000
}
nanoseconds % 1_000_000_000 == 0L -> {
calendarUnit = CalendarUnit.SECOND
calendarScale = nanoseconds / 1_000_000_000
}
nanoseconds % 1_000_000 == 0L -> {
calendarUnit = CalendarUnit.MILLISECOND
calendarScale = nanoseconds / 1_000_000
}
nanoseconds % 1_000 == 0L -> {
calendarUnit = CalendarUnit.MICROSECOND
calendarScale = nanoseconds / 1_000
}
else -> {
calendarUnit = CalendarUnit.NANOSECOND
calendarScale = nanoseconds
}
}
}

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

@ExperimentalTime
val duration: Duration = nanoseconds.nanoseconds

override fun equals(other: Any?): Boolean =
this === other || (other is TimeBased && this.nanoseconds == other.nanoseconds)

override fun hashCode(): Int = nanoseconds.toInt() xor (nanoseconds shr Int.SIZE_BITS).toInt()

override fun toString(): String = formatToString(calendarScale, calendarUnit.toString())
}

sealed class DateBased : DateTimeUnit() {
// TODO: investigate how to move subclasses to ChronoUnit scope
class DayBased(val days: Int) : DateBased() {
init {
require(days > 0) { "Unit duration must be positive, but was $days days." }
}

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

internal override val calendarUnit: CalendarUnit get() = CalendarUnit.DAY
internal override val calendarScale: Long get() = days.toLong()

override fun equals(other: Any?): Boolean =
this === other || (other is DayBased && this.days == other.days)

override fun hashCode(): Int = days xor 0x10000

override fun toString(): String = if (days % 7 == 0)
formatToString(days / 7, "WEEK")
else
formatToString(days, "DAY")
}
class MonthBased(val months: Int) : DateBased() {
init {
require(months > 0) { "Unit duration must be positive, but was $months months." }
}

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

internal override val calendarUnit: CalendarUnit get() = CalendarUnit.MONTH
internal override val calendarScale: Long get() = months.toLong()

override fun equals(other: Any?): Boolean =
this === other || (other is MonthBased && this.months == other.months)

override fun hashCode(): Int = months xor 0x20000

override fun toString(): String = when {
months % 12_00 == 0 -> formatToString(months / 12_00, "CENTURY")
months % 12 == 0 -> formatToString(months / 12, "YEAR")
months % 3 == 0 -> formatToString(months / 3, "QUARTER")
else -> formatToString(months, "MONTH")
}
}
}

protected fun formatToString(value: Int, unit: String): String = if (value == 1) unit else "$value-$unit"
protected fun formatToString(value: Long, unit: String): String = if (value == 1L) unit else "$value-$unit"

companion object {
val NANOSECOND = TimeBased(nanoseconds = 1)
val MICROSECOND = NANOSECOND * 1000
val MILLISECOND = MICROSECOND * 1000
val SECOND = MILLISECOND * 1000
val MINUTE = SECOND * 60
val HOUR = MINUTE * 60
val DAY = DateBased.DayBased(days = 1)
val WEEK = DAY * 7
val MONTH = DateBased.MonthBased(months = 1)
val QUARTER = MONTH * 3
val YEAR = MONTH * 12
val CENTURY = YEAR * 100
}
}


internal enum class CalendarUnit {
YEAR,
MONTH,
DAY,
HOUR,
MINUTE,
SECOND,
MILLISECOND,
MICROSECOND,
NANOSECOND
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think splitting CalendarUnit into a similar date/time hierarchy of sealed classes here could simplify some of our implementation code, but I don't know whether this approach has some other downsides, performance etc.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to migrate our implementation code to DateTimeUnit. CalendarUnit is left mostly for conversion to ChronoUnit when interfacing with external implementation.

47 changes: 34 additions & 13 deletions core/commonMain/src/Instant.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,30 +74,25 @@ public fun String.toInstant(): Instant = Instant.parse(this)
* @throws DateTimeArithmeticException if this value or the results of intermediate computations are too large to fit in
* [LocalDateTime].
*/
public expect fun Instant.plus(period: CalendarPeriod, zone: TimeZone): Instant
public expect fun Instant.plus(period: DateTimePeriod, zone: TimeZone): Instant

/**
* @throws DateTimeArithmeticException if this value or the result is too large to fit in [LocalDateTime].
*/
public expect fun Instant.plus(value: Int, unit: CalendarUnit, zone: TimeZone): Instant

/**
* @throws DateTimeArithmeticException if this value or the result is too large to fit in [LocalDateTime].
*/
public expect fun Instant.plus(value: Long, unit: CalendarUnit, zone: TimeZone): Instant
internal expect fun Instant.plus(value: Long, unit: CalendarUnit, zone: TimeZone): Instant

/**
* @throws DateTimeArithmeticException if this [Instant] or [other] is too large to fit in [LocalDateTime].
*/
public expect fun Instant.periodUntil(other: Instant, zone: TimeZone): CalendarPeriod
public expect fun Instant.periodUntil(other: Instant, zone: TimeZone): DateTimePeriod

/**
* The return value is clamped to [Long.MAX_VALUE] or [Long.MIN_VALUE] if [unit] is more granular than
* [CalendarUnit.DAY] and the result is too large.
*
* @throws DateTimeArithmeticException if this [Instant] or [other] is too large to fit in [LocalDateTime].
*/
public expect fun Instant.until(other: Instant, unit: CalendarUnit, zone: TimeZone): Long
public expect fun Instant.until(other: Instant, unit: DateTimeUnit, zone: TimeZone): Long

/**
* The return value is clamped to [Int.MAX_VALUE] or [Int.MIN_VALUE] if the result would otherwise cause an arithmetic
Expand All @@ -106,7 +101,7 @@ public expect fun Instant.until(other: Instant, unit: CalendarUnit, zone: TimeZo
* @throws DateTimeArithmeticException if this [Instant] or [other] is too large to fit in [LocalDateTime].
*/
public fun Instant.daysUntil(other: Instant, zone: TimeZone): Int =
until(other, CalendarUnit.DAY, zone).clampToInt()
until(other, DateTimeUnit.DAY, zone).clampToInt()

/**
* The return value is clamped to [Int.MAX_VALUE] or [Int.MIN_VALUE] if the result would otherwise cause an arithmetic
Expand All @@ -115,7 +110,7 @@ public fun Instant.daysUntil(other: Instant, zone: TimeZone): Int =
* @throws DateTimeArithmeticException if this [Instant] or [other] is too large to fit in [LocalDateTime].
*/
public fun Instant.monthsUntil(other: Instant, zone: TimeZone): Int =
until(other, CalendarUnit.MONTH, zone).clampToInt()
until(other, DateTimeUnit.MONTH, zone).clampToInt()

/**
* The return value is clamped to [Int.MAX_VALUE] or [Int.MIN_VALUE] if the result would otherwise cause an arithmetic
Expand All @@ -124,7 +119,33 @@ public fun Instant.monthsUntil(other: Instant, zone: TimeZone): Int =
* @throws DateTimeArithmeticException if this [Instant] or [other] is too large to fit in [LocalDateTime].
*/
public fun Instant.yearsUntil(other: Instant, zone: TimeZone): Int =
until(other, CalendarUnit.YEAR, zone).clampToInt()
until(other, DateTimeUnit.YEAR, zone).clampToInt()

private fun Long.clampToInt(): Int =
// 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)


/**
* @throws DateTimeArithmeticException if this value or the result is too large to fit in [LocalDateTime].
*/
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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible to provide an example of how this method could throw (assuming safeAdd) even when the result and the intermediate value fits in LocalDateTime:

Clock.System.now().plus(Long.MAX_VALUE, DateTimeUnit.TimeBased(2), TimeZone.UTC)

In effect, we're asking the system to add (2^63 - 1) * 2 nanoseconds, which is less than 600 years: https://www.wolframalpha.com/input/?i=%282%5E63+-+1%29+*+2+nanoseconds+in+years. The result would fit in a LocalDateTime here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we can specialize plus implementation later for different platforms for better handling this situation.


/**
* @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)


public fun Instant.minus(other: Instant, unit: DateTimeUnit, zone: TimeZone): Long = other.until(this, unit, zone)
26 changes: 21 additions & 5 deletions core/commonMain/src/LocalDate.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,39 @@ public fun String.toLocalDate(): LocalDate = LocalDate.parse(this)
* @throws IllegalArgumentException if the calendar unit is not date-based.
* @throws DateTimeArithmeticException if the result exceeds the boundaries of [LocalDate].
*/
expect fun LocalDate.plus(value: Long, unit: CalendarUnit): LocalDate
internal expect fun LocalDate.plus(value: Long, unit: CalendarUnit): LocalDate

/**
* @throws IllegalArgumentException if the calendar unit is not date-based.
* @throws DateTimeArithmeticException if the result exceeds the boundaries of [LocalDate].
*/
expect fun LocalDate.plus(value: Int, unit: CalendarUnit): LocalDate
internal expect fun LocalDate.plus(value: Int, unit: CalendarUnit): LocalDate

/**
* @throws IllegalArgumentException if [period] has non-zero time (as opposed to date) components.
* @throws DateTimeArithmeticException if arithmetic overflow occurs or the boundaries of [LocalDate] are exceeded at
* any point in intermediate computations.
*/
expect operator fun LocalDate.plus(period: CalendarPeriod): LocalDate
expect operator fun LocalDate.plus(period: DatePeriod): LocalDate

/** */
expect fun LocalDate.periodUntil(other: LocalDate): CalendarPeriod
expect fun LocalDate.periodUntil(other: LocalDate): DatePeriod

/** */
operator fun LocalDate.minus(other: LocalDate): CalendarPeriod = other.periodUntil(this)
operator fun LocalDate.minus(other: LocalDate): DatePeriod = other.periodUntil(this)

public expect fun LocalDate.daysUntil(other: LocalDate): Int
public expect fun LocalDate.monthsUntil(other: LocalDate): Int
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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, too, safeMultiply or the like is needed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will be used later, after commonizing that function.

public fun LocalDate.plus(value: Long, unit: DateTimeUnit.DateBased): LocalDate =
plus(value * unit.calendarScale, unit.calendarUnit)

public fun LocalDate.until(other: LocalDate, unit: DateTimeUnit.DateBased): Int = when(unit) {
is DateTimeUnit.DateBased.MonthBased -> (monthsUntil(other) / unit.months).toInt()
is DateTimeUnit.DateBased.DayBased -> (daysUntil(other) / unit.days).toInt()
}
Loading