Skip to content

Implement parsing Instant with offset #107

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 6 commits into from
Apr 16, 2021
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ kotlin {
val jsMain by getting {
dependencies {
api("org.jetbrains.kotlin:kotlin-stdlib-js")
implementation(npm("@js-joda/core", "3.1.0"))
implementation(npm("@js-joda/core", "3.2.0"))
}
}

Expand Down
15 changes: 12 additions & 3 deletions core/common/src/Instant.kt
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,19 @@ public expect class Instant : Comparable<Instant> {

/**
* Parses a string that represents an instant in ISO-8601 format including date and time components and
* the mandatory `Z` designator of the UTC+0 time zone and returns the parsed [Instant] value.
* the mandatory time zone offset and returns the parsed [Instant] value.
*
* Examples of instants in ISO-8601 format:
* Supports the following ways of specifying the time zone offset:
* - the `Z` designator for the UTC+0 time zone,
* - a custom time zone offset specified with `+hh`, or `+hh:mm`, or `+hh:mm:ss`
* (with `+` being replaced with `-` for the negative offsets)
*
* Examples of instants in the ISO-8601 format:
* - `2020-08-30T18:43:00Z`
* - `2020-08-30T18:43:00.500Z`
* - `2020-08-30T18:43:00.123456789Z`
* - `2020-08-30T18:40.00+03:00`
* - `2020-08-30T18:40.00+03:30:20`
*
* @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [Instant] are exceeded.
*/
Expand Down Expand Up @@ -166,7 +173,7 @@ public val Instant.isDistantFuture: Boolean

/**
* Converts this string representing an instant in ISO-8601 format including date and time components and
* the mandatory `Z` designator of the UTC+0 time zone to an [Instant] value.
* the time zone offset to an [Instant] value.
*
* See [Instant.parse] for examples of instant string representations.
*
Expand Down Expand Up @@ -470,3 +477,5 @@ public fun Instant.minus(other: Instant, unit: DateTimeUnit.TimeBased): Long =

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

internal expect fun Instant.toStringWithOffset(offset: ZoneOffset): String
45 changes: 45 additions & 0 deletions core/common/test/InstantTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,51 @@ class InstantTest {
assertInvalidFormat { Instant.parse("+1000000001-12-31T23:59:59.000000000Z") }
}

@Test
fun parseStringsWithOffsets() {
val strings = arrayOf(
Pair("2020-01-01T00:01:01.02+18:00", "2019-12-31T06:01:01.020Z"),
Pair("2020-01-01T00:01:01.123456789-17:59:59", "2020-01-01T18:01:00.123456789Z"),
Pair("2020-01-01T00:01:01.010203040+17:59:59", "2019-12-31T06:01:02.010203040Z"),
Pair("2020-01-01T00:01:01.010203040+17:59", "2019-12-31T06:02:01.010203040Z"),
Pair("2020-01-01T00:01:01+00", "2020-01-01T00:01:01Z"),
)
strings.forEach {
val (str, strInZ) = it
val instant = Instant.parse(str)
assertEquals(Instant.parse(strInZ), instant, str)
assertEquals(strInZ, instant.toString(), str)
}
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+18:01") }
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+1801") }
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+0") }
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+") }
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01") }
assertInvalidFormat { Instant.parse("2020-01-01T00:01:01+000000") }

val instants = listOf(
Instant.DISTANT_FUTURE,
Instant.DISTANT_PAST,
Instant.fromEpochSeconds(0, 0))

val offsets = listOf(
TimeZone.of("Z") as ZoneOffset,
TimeZone.of("+03:12:14") as ZoneOffset,
TimeZone.of("-03:12:14") as ZoneOffset,
TimeZone.of("+02:35") as ZoneOffset,
TimeZone.of("-02:35") as ZoneOffset,
TimeZone.of("+04") as ZoneOffset,
TimeZone.of("-04") as ZoneOffset,
)

for (instant in instants) {
for (offset in offsets) {
val str = instant.toStringWithOffset(TimeZone.of("+03:12:14") as ZoneOffset)
assertEquals(instant, Instant.parse(str))
}
}
}

@OptIn(ExperimentalTime::class)
@Test
fun instantCalendarArithmetic() {
Expand Down
15 changes: 14 additions & 1 deletion core/js/src/Instant.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import kotlin.time.ExperimentalTime
import kotlin.time.nanoseconds
import kotlin.time.seconds
import kotlinx.datetime.internal.JSJoda.Instant as jtInstant
import kotlinx.datetime.internal.JSJoda.OffsetDateTime as jtOffsetDateTime
import kotlinx.datetime.internal.JSJoda.Duration as jtDuration
import kotlinx.datetime.internal.JSJoda.Clock as jtClock
import kotlinx.datetime.internal.JSJoda.ChronoUnit
Expand Down Expand Up @@ -76,12 +77,21 @@ public actual class Instant internal constructor(internal val value: jtInstant)
}

public actual fun parse(isoString: String): Instant = try {
Instant(jtInstant.parse(isoString))
Instant(jtOffsetDateTime.parse(fixOffsetRepresentation(isoString)).toInstant())
} catch (e: Throwable) {
if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e)
throw e
}

/** A workaround for a bug where the string representations of Instant that have an offset of the form
* "+XX" are not recognized by [jtOffsetDateTime.parse], while "+XX:XX" work fine.
* See [the Github issue](https://github.com/js-joda/js-joda/issues/492). */
private fun fixOffsetRepresentation(isoString: String): String {
val time = isoString.split("T").elementAtOrNull(1) ?: return isoString
val offset = time.split("+", "-").elementAtOrNull(1) ?: return isoString
return if (offset.contains(":")) isoString else "$isoString:00"
}

public actual fun fromEpochSeconds(epochSeconds: Long, nanosecondAdjustment: Long): Instant = try {
/* Performing normalization here because otherwise this fails:
assertEquals((Long.MAX_VALUE % 1_000_000_000).toInt(),
Expand Down Expand Up @@ -210,3 +220,6 @@ public actual fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: Ti
} catch (e: Throwable) {
if (e.isJodaDateTimeException()) throw DateTimeArithmeticException(e) else throw e
}

internal actual fun Instant.toStringWithOffset(offset: ZoneOffset): String =
jtOffsetDateTime.ofInstant(this.value, offset.zoneId).toString()
Loading