Skip to content

Commit a100ce8

Browse files
authored
Fix Instant.parse succeeding even when seconds are omitted on the JVM and JS (#370)
Fixes #369
1 parent cff3fdd commit a100ce8

File tree

5 files changed

+25
-58
lines changed

5 files changed

+25
-58
lines changed

core/common/src/format/DateTimeComponents.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public class DateTimeComponents internal constructor(internal val contents: Date
6767
* ISO 8601 extended format for dates and times with UTC offset.
6868
*
6969
* For specifying the time zone offset, the format uses the [UtcOffset.Formats.ISO] format, except that during
70-
* parsing, specifying the minutes of the offset is optional.
70+
* parsing, specifying the minutes of the offset is optional (so offsets like `+03` are also allowed).
7171
*
7272
* This format differs from [LocalTime.Formats.ISO] in its time part in that
7373
* specifying the seconds is *not* optional.

core/common/test/InstantTest.kt

+1
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class InstantTest {
8686

8787
assertInvalidFormat { Instant.parse("1970-01-01T23:59:60Z")}
8888
assertInvalidFormat { Instant.parse("1970-01-01T24:00:00Z")}
89+
assertInvalidFormat { Instant.parse("1970-01-01T23:59Z")}
8990
assertInvalidFormat { Instant.parse("x") }
9091
assertInvalidFormat { Instant.parse("12020-12-31T23:59:59.000000000Z") }
9192
// this string represents an Instant that is currently larger than Instant.MAX any of the implementations:

core/commonJs/src/Instant.kt

+7-27
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ package kotlinx.datetime
77

88
import kotlinx.datetime.format.*
99
import kotlinx.datetime.internal.JSJoda.Instant as jtInstant
10-
import kotlinx.datetime.internal.JSJoda.OffsetDateTime as jtOffsetDateTime
1110
import kotlinx.datetime.internal.JSJoda.Duration as jtDuration
1211
import kotlinx.datetime.internal.JSJoda.Clock as jtClock
1312
import kotlinx.datetime.internal.JSJoda.ChronoUnit as jtChronoUnit
@@ -74,36 +73,17 @@ public actual class Instant internal constructor(internal val value: jtInstant)
7473
if (epochMilliseconds > 0) MAX else MIN
7574
}
7675

77-
public actual fun parse(input: CharSequence, format: DateTimeFormat<DateTimeComponents>): Instant =
78-
if (format === DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET) {
79-
try {
80-
Instant(jsTry { jtOffsetDateTime.parse(fixOffsetRepresentation(input.toString())) }.toInstant())
81-
} catch (e: Throwable) {
82-
if (e.isJodaDateTimeParseException()) throw DateTimeFormatException(e)
83-
throw e
84-
}
85-
} else {
86-
try {
87-
format.parse(input).toInstantUsingOffset()
88-
} catch (e: IllegalArgumentException) {
89-
throw DateTimeFormatException("Failed to parse an instant from '$input'", e)
90-
}
91-
}
76+
// TODO: implement a custom parser to 1) help DCE get rid of the formatting machinery 2) move Instant to stdlib
77+
public actual fun parse(input: CharSequence, format: DateTimeFormat<DateTimeComponents>): Instant = try {
78+
// This format is not supported properly by Joda-Time, so we can't delegate to it.
79+
format.parse(input).toInstantUsingOffset()
80+
} catch (e: IllegalArgumentException) {
81+
throw DateTimeFormatException("Failed to parse an instant from '$input'", e)
82+
}
9283

9384
@Deprecated("This overload is only kept for binary compatibility", level = DeprecationLevel.HIDDEN)
9485
public fun parse(isoString: String): Instant = parse(input = isoString)
9586

96-
/** A workaround for the string representations of Instant that have an offset of the form
97-
* "+XX" not being recognized by [jtOffsetDateTime.parse], while "+XX:XX" work fine. */
98-
private fun fixOffsetRepresentation(isoString: String): String {
99-
val time = isoString.indexOf('T', ignoreCase = true)
100-
if (time == -1) return isoString // the string is malformed
101-
val offset = isoString.indexOfLast { c -> c == '+' || c == '-' }
102-
if (offset < time) return isoString // the offset is 'Z' and not +/- something else
103-
val separator = isoString.indexOf(':', offset) // if there is a ':' in the offset, no changes needed
104-
return if (separator != -1) isoString else "$isoString:00"
105-
}
106-
10787
public actual fun fromEpochSeconds(epochSeconds: Long, nanosecondAdjustment: Long): Instant = try {
10888
/* Performing normalization here because otherwise this fails:
10989
assertEquals((Long.MAX_VALUE % 1_000_000_000).toInt(),

core/js/test/JsConverterTest.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import kotlin.test.*
1212
class JsConverterTest {
1313
@Test
1414
fun toJSDateTest() {
15-
val releaseInstant = "2016-02-15T00:00Z".toInstant()
15+
val releaseInstant = Instant.parse("2016-02-15T00:00:00Z")
1616
val releaseDate = releaseInstant.toJSDate()
1717
assertEquals(2016, releaseDate.getUTCFullYear())
1818
assertEquals(1, releaseDate.getUTCMonth())
@@ -23,7 +23,7 @@ class JsConverterTest {
2323
fun toInstantTest() {
2424
val kotlinReleaseEpochMilliseconds = 1455494400000
2525
val releaseDate = Date(milliseconds = kotlinReleaseEpochMilliseconds)
26-
val releaseInstant = "2016-02-15T00:00Z".toInstant()
26+
val releaseInstant = Instant.parse("2016-02-15T00:00:00Z")
2727
assertEquals(releaseInstant, releaseDate.toKotlinInstant())
2828
}
2929
}

core/jvm/src/Instant.kt

+14-28
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,11 @@ import kotlinx.datetime.internal.*
1212
import kotlinx.datetime.serializers.InstantIso8601Serializer
1313
import kotlinx.serialization.Serializable
1414
import java.time.DateTimeException
15-
import java.time.format.DateTimeParseException
16-
import java.time.temporal.ChronoUnit
15+
import java.time.temporal.*
1716
import kotlin.time.*
1817
import kotlin.time.Duration.Companion.nanoseconds
1918
import kotlin.time.Duration.Companion.seconds
2019
import java.time.Instant as jtInstant
21-
import java.time.OffsetDateTime as jtOffsetDateTime
2220
import java.time.Clock as jtClock
2321

2422
@Serializable(with = InstantIso8601Serializer::class)
@@ -67,35 +65,23 @@ public actual class Instant internal constructor(internal val value: jtInstant)
6765
public actual fun fromEpochMilliseconds(epochMilliseconds: Long): Instant =
6866
Instant(jtInstant.ofEpochMilli(epochMilliseconds))
6967

70-
public actual fun parse(input: CharSequence, format: DateTimeFormat<DateTimeComponents>): Instant =
71-
if (format === DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET) {
72-
try {
73-
Instant(jtOffsetDateTime.parse(fixOffsetRepresentation(input)).toInstant())
74-
} catch (e: DateTimeParseException) {
75-
throw DateTimeFormatException(e)
76-
}
77-
} else {
78-
try {
79-
format.parse(input).toInstantUsingOffset()
80-
} catch (e: IllegalArgumentException) {
81-
throw DateTimeFormatException("Failed to parse an instant from '$input'", e)
82-
}
83-
}
68+
// TODO: implement a custom parser to 1) help DCE get rid of the formatting machinery 2) move Instant to stdlib
69+
public actual fun parse(input: CharSequence, format: DateTimeFormat<DateTimeComponents>): Instant = try {
70+
/**
71+
* Can't use built-in Java Time's handling of `Instant.parse` because it supports 24:00:00 and
72+
* 23:59:60, and also doesn't support non-`Z` UTC offsets on older JDKs.
73+
* Can't use custom Java Time's formats because Java 8 doesn't support the UTC offset format with
74+
* optional minutes and seconds and `:` between them:
75+
* https://docs.oracle.com/javase/8/docs/api/java/time/format/DateTimeFormatterBuilder.html#appendOffset-java.lang.String-java.lang.String-
76+
*/
77+
format.parse(input).toInstantUsingOffset()
78+
} catch (e: IllegalArgumentException) {
79+
throw DateTimeFormatException("Failed to parse an instant from '$input'", e)
80+
}
8481

8582
@Deprecated("This overload is only kept for binary compatibility", level = DeprecationLevel.HIDDEN)
8683
public fun parse(isoString: String): Instant = parse(input = isoString)
8784

88-
/** A workaround for a quirk of the JDKs older than 11 where the string representations of Instant that have an
89-
* offset of the form "+XX" are not recognized by [jtOffsetDateTime.parse], while "+XX:XX" work fine. */
90-
private fun fixOffsetRepresentation(isoString: CharSequence): CharSequence {
91-
val time = isoString.indexOf('T', ignoreCase = true)
92-
if (time == -1) return isoString // the string is malformed
93-
val offset = isoString.indexOfLast { c -> c == '+' || c == '-' }
94-
if (offset < time) return isoString // the offset is 'Z' and not +/- something else
95-
val separator = isoString.indexOf(':', offset) // if there is a ':' in the offset, no changes needed
96-
return if (separator != -1) isoString else "$isoString:00"
97-
}
98-
9985
public actual fun fromEpochSeconds(epochSeconds: Long, nanosecondAdjustment: Long): Instant = try {
10086
Instant(jtInstant.ofEpochSecond(epochSeconds, nanosecondAdjustment))
10187
} catch (e: Exception) {

0 commit comments

Comments
 (0)