-
Notifications
You must be signed in to change notification settings - Fork 29.3k
[SPARK-57033][SQL] Add java.time LocalDateTime/Instant conversion and Dataset roundtrip for nanosecond timestamps #56158
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
Changes from 10 commits
845c0c9
2d57886
9176040
359e42c
2b58090
fb6e3db
9b826cd
2cc5f85
364835a
466ad92
beff171
9092075
d008377
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,7 +21,7 @@ import scala.collection.mutable | |
| import scala.reflect.classTag | ||
|
|
||
| import org.apache.spark.sql.{AnalysisException, Row} | ||
| import org.apache.spark.sql.catalyst.encoders.AgnosticEncoders.{BinaryEncoder, BoxedBooleanEncoder, BoxedByteEncoder, BoxedDoubleEncoder, BoxedFloatEncoder, BoxedIntEncoder, BoxedLongEncoder, BoxedShortEncoder, CalendarIntervalEncoder, CharEncoder, DateEncoder, DayTimeIntervalEncoder, EncoderField, GeographyEncoder, GeometryEncoder, InstantEncoder, IterableEncoder, JavaDecimalEncoder, LocalDateEncoder, LocalDateTimeEncoder, LocalTimeEncoder, MapEncoder, NullEncoder, RowEncoder => AgnosticRowEncoder, StringEncoder, TimestampEncoder, UDTEncoder, VarcharEncoder, VariantEncoder, YearMonthIntervalEncoder} | ||
| import org.apache.spark.sql.catalyst.encoders.AgnosticEncoders.{BinaryEncoder, BoxedBooleanEncoder, BoxedByteEncoder, BoxedDoubleEncoder, BoxedFloatEncoder, BoxedIntEncoder, BoxedLongEncoder, BoxedShortEncoder, CalendarIntervalEncoder, CharEncoder, DateEncoder, DayTimeIntervalEncoder, EncoderField, GeographyEncoder, GeometryEncoder, InstantEncoder, InstantNanosEncoder, IterableEncoder, JavaDecimalEncoder, LocalDateEncoder, LocalDateTimeEncoder, LocalDateTimeNanosEncoder, LocalTimeEncoder, MapEncoder, NullEncoder, RowEncoder => AgnosticRowEncoder, StringEncoder, TimestampEncoder, UDTEncoder, VarcharEncoder, VariantEncoder, YearMonthIntervalEncoder} | ||
| import org.apache.spark.sql.errors.DataTypeErrorsBase | ||
| import org.apache.spark.sql.internal.SqlApiConf | ||
| import org.apache.spark.sql.types._ | ||
|
|
@@ -50,6 +50,8 @@ import org.apache.spark.util.ArrayImplicits._ | |
| * TimestampType -> java.time.Instant if spark.sql.datetime.java8API.enabled is true | ||
| * | ||
| * TimestampNTZType -> java.time.LocalDateTime | ||
| * TimestampNTZNanosType -> java.time.LocalDateTime | ||
| * TimestampLTZNanosType -> java.time.Instant | ||
| * TimeType -> java.time.LocalTime | ||
| * | ||
| * DayTimeIntervalType -> java.time.Duration | ||
|
|
@@ -97,6 +99,10 @@ object RowEncoder extends DataTypeErrorsBase { | |
| case TimestampType if SqlApiConf.get.datetimeJava8ApiEnabled => InstantEncoder(lenient) | ||
| case TimestampType => TimestampEncoder(lenient) | ||
| case TimestampNTZType => LocalDateTimeEncoder | ||
| // Nano timestamp types intentionally do not honor `lenient`: legacy `java.sql.Timestamp` / | ||
| // `java.sql.Date` external types are out of scope for nanosecond precision (SPARK-57033). | ||
| case t: TimestampNTZNanosType => LocalDateTimeNanosEncoder(t.precision) | ||
| case t: TimestampLTZNanosType => InstantNanosEncoder(t.precision) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can inject types here also? Or are you waiting for a refactor PR? Myabe it would be a better idea to do it now so places like this one don't pile up in the future.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's do that by https://issues.apache.org/jira/browse/SPARK-57101 |
||
| case DateType if SqlApiConf.get.datetimeJava8ApiEnabled => LocalDateEncoder(lenient) | ||
| case DateType => DateEncoder(lenient) | ||
| case _: TimeType => LocalTimeEncoder | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,7 +36,7 @@ import org.apache.spark.sql.internal.SQLConf | |
| import org.apache.spark.sql.types._ | ||
| import org.apache.spark.sql.types.DayTimeIntervalType._ | ||
| import org.apache.spark.sql.types.YearMonthIntervalType._ | ||
| import org.apache.spark.unsafe.types.{GeographyVal, GeometryVal, UTF8String} | ||
| import org.apache.spark.unsafe.types.{GeographyVal, GeometryVal, TimestampNanosVal, UTF8String} | ||
| import org.apache.spark.util.ArrayImplicits._ | ||
| import org.apache.spark.util.collection.Utils | ||
|
|
||
|
|
@@ -88,6 +88,8 @@ object CatalystTypeConverters { | |
| case TimestampType if SQLConf.get.datetimeJava8ApiEnabled => InstantConverter | ||
| case TimestampType => TimestampConverter | ||
| case TimestampNTZType => TimestampNTZConverter | ||
| case t: TimestampNTZNanosType => new TimestampNTZNanosConverter(t) | ||
| case t: TimestampLTZNanosType => new TimestampLTZNanosConverter(t) | ||
| case dt: DecimalType => new DecimalConverter(dt) | ||
| case BooleanType => BooleanConverter | ||
| case ByteType => ByteConverter | ||
|
|
@@ -298,7 +300,7 @@ object CatalystTypeConverters { | |
| } | ||
| new GenericInternalRow(ar) | ||
| case other => throw new SparkIllegalArgumentException( | ||
| errorClass = "_LEGACY_ERROR_TEMP_3219", | ||
| errorClass = "INVALID_EXTERNAL_VALUE", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why rename the legacy error in this PR?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do believe it is related to the changes. We postpone this issue every time while adding new types. I think it is worth to assign proper name now. |
||
| messageParameters = scala.collection.immutable.Map( | ||
| "other" -> other.toString, | ||
| "otherClass" -> other.getClass.getCanonicalName, | ||
|
|
@@ -357,7 +359,7 @@ object CatalystTypeConverters { | |
| case chr: Char => UTF8String.fromString(chr.toString) | ||
| case ac: Array[Char] => UTF8String.fromString(String.valueOf(ac)) | ||
| case other => throw new SparkIllegalArgumentException( | ||
| errorClass = "_LEGACY_ERROR_TEMP_3219", | ||
| errorClass = "INVALID_EXTERNAL_VALUE", | ||
| messageParameters = scala.collection.immutable.Map( | ||
| "other" -> other.toString, | ||
| "otherClass" -> other.getClass.getCanonicalName, | ||
|
|
@@ -383,7 +385,7 @@ object CatalystTypeConverters { | |
| case g: org.apache.spark.sql.types.Geometry if SQLConf.get.geospatialEnabled => | ||
| STUtils.serializeGeomFromWKB(g, dataType) | ||
| case other => throw new SparkIllegalArgumentException( | ||
| errorClass = "_LEGACY_ERROR_TEMP_3219", | ||
| errorClass = "INVALID_EXTERNAL_VALUE", | ||
| messageParameters = scala.collection.immutable.Map( | ||
| "other" -> other.toString, | ||
| "otherClass" -> other.getClass.getCanonicalName, | ||
|
|
@@ -408,7 +410,7 @@ object CatalystTypeConverters { | |
| case g: org.apache.spark.sql.types.Geography if SQLConf.get.geospatialEnabled => | ||
| STUtils.serializeGeogFromWKB(g, dataType) | ||
| case other => throw new SparkIllegalArgumentException( | ||
| errorClass = "_LEGACY_ERROR_TEMP_3219", | ||
| errorClass = "INVALID_EXTERNAL_VALUE", | ||
| messageParameters = scala.collection.immutable.Map( | ||
| "other" -> other.toString, | ||
| "otherClass" -> other.getClass.getCanonicalName, | ||
|
|
@@ -432,7 +434,7 @@ object CatalystTypeConverters { | |
| case d: Date => DateTimeUtils.fromJavaDate(d) | ||
| case l: LocalDate => DateTimeUtils.localDateToDays(l) | ||
| case other => throw new SparkIllegalArgumentException( | ||
| errorClass = "_LEGACY_ERROR_TEMP_3219", | ||
| errorClass = "INVALID_EXTERNAL_VALUE", | ||
| messageParameters = scala.collection.immutable.Map( | ||
| "other" -> other.toString, | ||
| "otherClass" -> other.getClass.getCanonicalName, | ||
|
|
@@ -472,7 +474,7 @@ object CatalystTypeConverters { | |
| case t: Timestamp => DateTimeUtils.fromJavaTimestamp(t) | ||
| case i: Instant => DateTimeUtils.instantToMicros(i) | ||
| case other => throw new SparkIllegalArgumentException( | ||
| errorClass = "_LEGACY_ERROR_TEMP_3219", | ||
| errorClass = "INVALID_EXTERNAL_VALUE", | ||
| messageParameters = scala.collection.immutable.Map( | ||
| "other" -> other.toString, | ||
| "otherClass" -> other.getClass.getCanonicalName, | ||
|
|
@@ -500,7 +502,7 @@ object CatalystTypeConverters { | |
| override def toCatalystImpl(scalaValue: Any): Any = scalaValue match { | ||
| case l: LocalDateTime => DateTimeUtils.localDateTimeToMicros(l) | ||
| case other => throw new SparkIllegalArgumentException( | ||
| errorClass = "_LEGACY_ERROR_TEMP_3219", | ||
| errorClass = "INVALID_EXTERNAL_VALUE", | ||
| messageParameters = scala.collection.immutable.Map( | ||
| "other" -> other.toString, | ||
| "otherClass" -> other.getClass.getCanonicalName, | ||
|
|
@@ -515,6 +517,50 @@ object CatalystTypeConverters { | |
| DateTimeUtils.microsToLocalDateTime(row.getLong(column)) | ||
| } | ||
|
|
||
| private class TimestampNTZNanosConverter(dataType: TimestampNTZNanosType) | ||
| extends CatalystTypeConverter[Any, LocalDateTime, TimestampNanosVal] { | ||
| override def toCatalystImpl(scalaValue: Any): TimestampNanosVal = scalaValue match { | ||
| case l: LocalDateTime => DateTimeUtils.localDateTimeToTimestampNanos(l, dataType.precision) | ||
| case other => throw new SparkIllegalArgumentException( | ||
| errorClass = "INVALID_EXTERNAL_VALUE", | ||
| messageParameters = scala.collection.immutable.Map( | ||
| "other" -> other.toString, | ||
| "otherClass" -> other.getClass.getCanonicalName, | ||
| "dataType" -> dataType.sql)) | ||
| } | ||
|
|
||
| override def toScala(catalystValue: TimestampNanosVal): LocalDateTime = | ||
| if (catalystValue == null) null | ||
| else DateTimeUtils.timestampNanosToLocalDateTime(catalystValue) | ||
|
|
||
| override def toScalaImpl(row: InternalRow, column: Int): LocalDateTime = | ||
| DateTimeUtils.timestampNanosToLocalDateTime(row.getTimestampNTZNanos(column)) | ||
| } | ||
|
|
||
| // Always maps `TimestampLTZNanosType` to `java.time.Instant`. Unlike micro `TimestampType`, | ||
| // the mapping does not consult `spark.sql.datetime.java8API.enabled`: the nanos LTZ type is | ||
| // post-Java-8 and the legacy `java.sql.Timestamp` external type is intentionally out of scope | ||
| // here. See SPARK-57033. | ||
| private class TimestampLTZNanosConverter(dataType: TimestampLTZNanosType) | ||
| extends CatalystTypeConverter[Any, Instant, TimestampNanosVal] { | ||
| override def toCatalystImpl(scalaValue: Any): TimestampNanosVal = scalaValue match { | ||
| case i: Instant => DateTimeUtils.instantToTimestampNanos(i, dataType.precision) | ||
| case other => throw new SparkIllegalArgumentException( | ||
| errorClass = "INVALID_EXTERNAL_VALUE", | ||
| messageParameters = scala.collection.immutable.Map( | ||
| "other" -> other.toString, | ||
| "otherClass" -> other.getClass.getCanonicalName, | ||
| "dataType" -> dataType.sql)) | ||
| } | ||
|
|
||
| override def toScala(catalystValue: TimestampNanosVal): Instant = | ||
| if (catalystValue == null) null | ||
| else DateTimeUtils.timestampNanosToInstant(catalystValue) | ||
|
|
||
| override def toScalaImpl(row: InternalRow, column: Int): Instant = | ||
| DateTimeUtils.timestampNanosToInstant(row.getTimestampLTZNanos(column)) | ||
| } | ||
|
|
||
| private class DecimalConverter(dataType: DecimalType) | ||
| extends CatalystTypeConverter[Any, JavaBigDecimal, Decimal] { | ||
|
|
||
|
|
@@ -527,7 +573,7 @@ object CatalystTypeConverters { | |
| case d: JavaBigInteger => Decimal(d) | ||
| case d: Decimal => d | ||
| case other => throw new SparkIllegalArgumentException( | ||
| errorClass = "_LEGACY_ERROR_TEMP_3219", | ||
| errorClass = "INVALID_EXTERNAL_VALUE", | ||
| messageParameters = scala.collection.immutable.Map( | ||
| "other" -> other.toString, | ||
| "otherClass" -> other.getClass.getCanonicalName, | ||
|
|
@@ -655,6 +701,9 @@ object CatalystTypeConverters { | |
| case ld: LocalDate => LocalDateConverter.toCatalyst(ld) | ||
| case t: LocalTime => TimeConverter.toCatalyst(t) | ||
| case t: Timestamp => TimestampConverter.toCatalyst(t) | ||
| // SPARK-57033: schema-less convertToCatalyst keeps bare `Instant` / `LocalDateTime` on the | ||
| // microsecond converters. The nanosecond path is schema-driven only - users opt in via an | ||
| // explicit `TimestampLTZNanosType` / `TimestampNTZNanosType` column in the schema. | ||
| case i: Instant => InstantConverter.toCatalyst(i) | ||
| case l: LocalDateTime => TimestampNTZConverter.toCatalyst(l) | ||
| case d: BigDecimal => | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks like it bypasses the
spark.sql.timestampNanosTypes.enabledgate.CatalystTypeConvertersstill callsTypeUtils.failUnsupportedDataType(...), so schema-driven converter paths reject nanos timestamp types when the feature is disabled. ButRowEncodernow mapsTimestampNTZNanosType/TimestampLTZNanosTypeto nanos encoders unconditionally, which meansEncoders.row(schema)/ExpressionEncoder(schema)can succeed with the flag off.Can we add the same gate here (or centralize the unsupported-type check) and add a negative test for the flag-off case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
addressed