From ddbdfb982d27c9a445ebfe1d450e7dbc2f16ccff Mon Sep 17 00:00:00 2001 From: "mykhailo.skliar" Date: Wed, 15 Oct 2025 11:14:00 +0200 Subject: [PATCH] Add support for TIMESTAMP type in exasol connector --- docs/src/main/sphinx/connector/exasol.md | 3 + .../io/trino/plugin/exasol/ExasolClient.java | 153 +++++++++++++++++ .../plugin/exasol/TestExasolTypeMapping.java | 156 ++++++++++++++++++ 3 files changed, 312 insertions(+) diff --git a/docs/src/main/sphinx/connector/exasol.md b/docs/src/main/sphinx/connector/exasol.md index 5f0c29a89f4b..36844d34e0cd 100644 --- a/docs/src/main/sphinx/connector/exasol.md +++ b/docs/src/main/sphinx/connector/exasol.md @@ -100,6 +100,9 @@ Trino data type mapping: * - `DATE` - `DATE` - +* - `TIMESTAMP(n)` + - `TIMESTAMP(n)` + - * - `HASHTYPE` - `VARBINARY` - diff --git a/plugin/trino-exasol/src/main/java/io/trino/plugin/exasol/ExasolClient.java b/plugin/trino-exasol/src/main/java/io/trino/plugin/exasol/ExasolClient.java index 8675aa509435..331f0841c6cc 100644 --- a/plugin/trino-exasol/src/main/java/io/trino/plugin/exasol/ExasolClient.java +++ b/plugin/trino-exasol/src/main/java/io/trino/plugin/exasol/ExasolClient.java @@ -30,6 +30,8 @@ import io.trino.plugin.jdbc.JdbcTypeHandle; import io.trino.plugin.jdbc.LongReadFunction; import io.trino.plugin.jdbc.LongWriteFunction; +import io.trino.plugin.jdbc.ObjectReadFunction; +import io.trino.plugin.jdbc.ObjectWriteFunction; import io.trino.plugin.jdbc.QueryBuilder; import io.trino.plugin.jdbc.SliceReadFunction; import io.trino.plugin.jdbc.SliceWriteFunction; @@ -43,12 +45,18 @@ import io.trino.spi.connector.ColumnPosition; import io.trino.spi.connector.ConnectorSession; import io.trino.spi.connector.ConnectorTableMetadata; +import io.trino.spi.type.LongTimestamp; +import io.trino.spi.type.TimestampType; import io.trino.spi.type.Type; import java.sql.Connection; import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; import java.sql.Types; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.HexFormat; import java.util.List; import java.util.Map; @@ -57,20 +65,28 @@ import java.util.Set; import java.util.function.BiFunction; +import static com.google.common.base.Preconditions.checkArgument; +import static io.trino.plugin.jdbc.PredicatePushdownController.FULL_PUSHDOWN; import static io.trino.plugin.jdbc.StandardColumnMappings.bigintColumnMapping; import static io.trino.plugin.jdbc.StandardColumnMappings.booleanColumnMapping; import static io.trino.plugin.jdbc.StandardColumnMappings.decimalColumnMapping; import static io.trino.plugin.jdbc.StandardColumnMappings.defaultCharColumnMapping; import static io.trino.plugin.jdbc.StandardColumnMappings.defaultVarcharColumnMapping; import static io.trino.plugin.jdbc.StandardColumnMappings.doubleColumnMapping; +import static io.trino.plugin.jdbc.StandardColumnMappings.fromLongTrinoTimestamp; +import static io.trino.plugin.jdbc.StandardColumnMappings.fromTrinoTimestamp; import static io.trino.plugin.jdbc.StandardColumnMappings.integerColumnMapping; import static io.trino.plugin.jdbc.StandardColumnMappings.smallintColumnMapping; +import static io.trino.plugin.jdbc.StandardColumnMappings.toLongTrinoTimestamp; +import static io.trino.plugin.jdbc.StandardColumnMappings.toTrinoTimestamp; import static io.trino.plugin.jdbc.TypeHandlingJdbcSessionProperties.getUnsupportedTypeHandling; import static io.trino.plugin.jdbc.UnsupportedTypeHandling.CONVERT_TO_VARCHAR; import static io.trino.spi.StandardErrorCode.NOT_SUPPORTED; import static io.trino.spi.connector.ConnectorMetadata.MODIFYING_ROWS_MESSAGE; import static io.trino.spi.type.DateType.DATE; import static io.trino.spi.type.DecimalType.createDecimalType; +import static io.trino.spi.type.TimestampType.MAX_SHORT_PRECISION; +import static io.trino.spi.type.TimestampType.createTimestampType; import static io.trino.spi.type.VarbinaryType.VARBINARY; import static java.lang.String.format; import static java.util.Locale.ENGLISH; @@ -84,6 +100,8 @@ public class ExasolClient .add("SYS") .build(); + private static final int MAX_EXASOL_TIMESTAMP_PRECISION = 9; + @Inject public ExasolClient( BaseJdbcConfig config, @@ -239,8 +257,12 @@ public Optional toColumnMapping(ConnectorSession session, Connect // String data is sorted by its binary representation. // https://docs.exasol.com/db/latest/sql/select.htm#UsageNotes return Optional.of(defaultVarcharColumnMapping(typeHandle.requiredColumnSize(), true)); + // DATE and TIMESTAMP types are described here in more details: + // https://docs.exasol.com/db/latest/sql_references/data_types/datatypedetails.htm case Types.DATE: return Optional.of(dateColumnMapping()); + case Types.TIMESTAMP: + return Optional.of(timestampColumnMapping(typeHandle)); } if (getUnsupportedTypeHandling(session) == CONVERT_TO_VARCHAR) { @@ -307,6 +329,137 @@ private static SliceWriteFunction hashTypeWriteFunction() }); } + private static ColumnMapping timestampColumnMapping(JdbcTypeHandle typeHandle) + { + int timestampPrecision = typeHandle.requiredDecimalDigits(); + TimestampType timestampType = createTimestampType(timestampPrecision); + if (timestampType.isShort()) { + return ColumnMapping.longMapping( + timestampType, + longTimestampReadFunction(timestampType), + longTimestampWriteFunction(timestampType), + FULL_PUSHDOWN); + } + return ColumnMapping.objectMapping( + timestampType, + objectTimestampReadFunction(timestampType), + objectTimestampWriteFunction(timestampType), + FULL_PUSHDOWN); + } + + private static LongReadFunction longTimestampReadFunction(TimestampType timestampType) + { + verifyLongTimestampPrecision(timestampType); + return (resultSet, columnIndex) -> { + Timestamp timestamp = resultSet.getTimestamp(columnIndex); + return toTrinoTimestamp(timestampType, timestamp.toLocalDateTime()); + }; + } + + private static LongWriteFunction longTimestampWriteFunction(TimestampType timestampType) + { + verifyLongTimestampPrecision(timestampType); + return new LongWriteFunction() + { + @Override + public String getBindExpression() + { + return getTimestampBindExpression(timestampType.getPrecision()); + } + + @Override + public void set(PreparedStatement statement, int index, long epochMicros) + throws SQLException + { + LocalDateTime localDateTime = fromTrinoTimestamp(epochMicros); + Timestamp timestampValue = Timestamp.valueOf(localDateTime); + statement.setTimestamp(index, timestampValue); + } + + @Override + public void setNull(PreparedStatement statement, int index) + throws SQLException + { + statement.setNull(index, Types.TIMESTAMP); + } + }; + } + + private static ObjectReadFunction objectTimestampReadFunction(TimestampType timestampType) + { + verifyObjectTimestampPrecision(timestampType); + return ObjectReadFunction.of( + LongTimestamp.class, + (resultSet, columnIndex) -> { + Timestamp timestamp = resultSet.getTimestamp(columnIndex); + return toLongTrinoTimestamp(timestampType, timestamp.toLocalDateTime()); + }); + } + + private static ObjectWriteFunction objectTimestampWriteFunction(TimestampType timestampType) + { + int precision = timestampType.getPrecision(); + verifyObjectTimestampPrecision(timestampType); + + return new ObjectWriteFunction() { + @Override + public Class getJavaType() + { + return LongTimestamp.class; + } + + @Override + public void set(PreparedStatement statement, int index, Object value) + throws SQLException + { + LocalDateTime localDateTime = fromLongTrinoTimestamp((LongTimestamp) value, precision); + Timestamp timestamp = Timestamp.valueOf(localDateTime); + statement.setTimestamp(index, timestamp); + } + + @Override + public String getBindExpression() + { + return getTimestampBindExpression(timestampType.getPrecision()); + } + + @Override + public void setNull(PreparedStatement statement, int index) + throws SQLException + { + statement.setNull(index, Types.TIMESTAMP); + } + }; + } + + private static void verifyObjectTimestampPrecision(TimestampType timestampType) + { + int precision = timestampType.getPrecision(); + checkArgument(precision > MAX_SHORT_PRECISION && precision <= MAX_EXASOL_TIMESTAMP_PRECISION, + "Precision is out of range: %s", precision); + } + + private static void verifyLongTimestampPrecision(TimestampType timestampType) + { + int precision = timestampType.getPrecision(); + checkArgument(precision >= 0 && precision <= MAX_SHORT_PRECISION, + "Precision is out of range: %s", precision); + } + + /** + * Returns a {@code TO_TIMESTAMP} bind expression using the appropriate format model + * based on the given fractional seconds precision. + * See for more details: Date/time format models + */ + private static String getTimestampBindExpression(int precision) + { + checkArgument(precision >= 0, "Precision is negative: %s", precision); + if (precision == 0) { + return "TO_TIMESTAMP(?, 'YYYY-MM-DD HH24:MI:SS')"; + } + return format("TO_TIMESTAMP(?, 'YYYY-MM-DD HH24:MI:SS.FF%d')", precision); + } + @Override public WriteMapping toWriteMapping(ConnectorSession session, Type type) { diff --git a/plugin/trino-exasol/src/test/java/io/trino/plugin/exasol/TestExasolTypeMapping.java b/plugin/trino-exasol/src/test/java/io/trino/plugin/exasol/TestExasolTypeMapping.java index ebe4a0da53da..ef5dd15c867d 100644 --- a/plugin/trino-exasol/src/test/java/io/trino/plugin/exasol/TestExasolTypeMapping.java +++ b/plugin/trino-exasol/src/test/java/io/trino/plugin/exasol/TestExasolTypeMapping.java @@ -40,6 +40,7 @@ import static io.trino.spi.type.DateType.DATE; import static io.trino.spi.type.DecimalType.createDecimalType; import static io.trino.spi.type.DoubleType.DOUBLE; +import static io.trino.spi.type.TimestampType.createTimestampType; import static io.trino.spi.type.VarbinaryType.VARBINARY; import static io.trino.spi.type.VarcharType.createVarcharType; import static io.trino.testing.TestingNames.randomNameSuffix; @@ -278,6 +279,112 @@ private void testDate(ZoneId sessionZone) .execute(getQueryRunner(), session, exasolCreateAndInsert(TEST_SCHEMA + "." + "test_date")); } + @Test + void testTimestamp() + { + // See for more details: + // https://docs.exasol.com/db/latest/sql_references/data_types/datatypedetails.htm + + testTimestamp(UTC); + testTimestamp(jvmZone); + // using two non-JVM zones so that we don't need to worry what Exasol system zone is + testTimestamp(vilnius); + testTimestamp(kathmandu); + testTimestamp(TestingSession.DEFAULT_TIME_ZONE_KEY.getZoneId()); + } + + private void testTimestamp(ZoneId sessionZone) + { + Session session = Session.builder(getSession()) + .setTimeZoneKey(TimeZoneKey.getTimeZoneKey(sessionZone.getId())) + .build(); + + SqlDataTypeTest.create() + .addRoundTrip("timestamp", "NULL", createTimestampType(3), "CAST(NULL AS TIMESTAMP)") + .addRoundTrip("timestamp", "TIMESTAMP '2019-03-18 10:01:17.987'", createTimestampType(3), "TIMESTAMP '2019-03-18 10:01:17.987'") + .addRoundTrip("timestamp", "TIMESTAMP '2013-03-11 17:30:15.123'", createTimestampType(3), "TIMESTAMP '2013-03-11 17:30:15.123'") + .addRoundTrip("timestamp", "TIMESTAMP '2018-10-28 01:33:17.456'", createTimestampType(3), "TIMESTAMP '2018-10-28 01:33:17.456'") + .addRoundTrip("timestamp", "TIMESTAMP '2018-10-28 01:13:55.1234'", createTimestampType(3), "TIMESTAMP '2018-10-28 01:13:55.123'") + .addRoundTrip("timestamp", "TIMESTAMP '2018-10-28 01:13:55.12345'", createTimestampType(3), "TIMESTAMP '2018-10-28 01:13:55.123'") + .addRoundTrip("timestamp", "TIMESTAMP '2018-10-28 03:33:33.333'", createTimestampType(3), "TIMESTAMP '2018-10-28 03:33:33.333'") + .addRoundTrip("timestamp", "TIMESTAMP '1970-01-01 00:13:42.000'", createTimestampType(3), "TIMESTAMP '1970-01-01 00:13:42.000'") + .addRoundTrip("timestamp", "TIMESTAMP '2020-09-27 12:34:56.999'", createTimestampType(3), "TIMESTAMP '2020-09-27 12:34:56.999'") + .addRoundTrip("timestamp", "TIMESTAMP '2018-03-25 03:17:17.000'", createTimestampType(3), "TIMESTAMP '2018-03-25 03:17:17.000'") + .addRoundTrip("timestamp", "TIMESTAMP '1986-01-01 00:13:07.000'", createTimestampType(3), "TIMESTAMP '1986-01-01 00:13:07.000'") + .addRoundTrip("timestamp(6)", "TIMESTAMP '2013-03-11 17:30:15.123456'", createTimestampType(6), "TIMESTAMP '2013-03-11 17:30:15.123456'") + .addRoundTrip("timestamp(6)", "TIMESTAMP '2013-03-11 17:30:15.123456789'", createTimestampType(6), "TIMESTAMP '2013-03-11 17:30:15.123456'") + .addRoundTrip("timestamp(9)", "TIMESTAMP '2013-03-11 17:30:15.123456789'", createTimestampType(9), "TIMESTAMP '2013-03-11 17:30:15.123456789'") + .addRoundTrip("timestamp(1)", "TIMESTAMP '2016-08-19 19:28:05.0'", createTimestampType(1), "TIMESTAMP '2016-08-19 19:28:05.0'") + .addRoundTrip("timestamp(2)", "TIMESTAMP '2016-08-19 19:28:05.01'", createTimestampType(2), "TIMESTAMP '2016-08-19 19:28:05.01'") + .addRoundTrip("timestamp", "TIMESTAMP '3030-03-03 12:34:56.123'", createTimestampType(3), "TIMESTAMP '3030-03-03 12:34:56.123'") + .addRoundTrip("timestamp(4)", "TIMESTAMP '3030-03-03 12:34:56.1234'", createTimestampType(4), "TIMESTAMP '3030-03-03 12:34:56.1234'") + .addRoundTrip("timestamp(5)", "TIMESTAMP '3030-03-03 12:34:56.12345'", createTimestampType(5), "TIMESTAMP '3030-03-03 12:34:56.12345'") + .addRoundTrip("timestamp(9)", "TIMESTAMP '3030-03-03 12:34:56.123456789'", createTimestampType(9), "TIMESTAMP '3030-03-03 12:34:56.123456789'") + .addRoundTrip("timestamp(6)", "TIMESTAMP '3030-03-03 12:34:56.123456'", createTimestampType(6), "TIMESTAMP '3030-03-03 12:34:56.123456'") + .addRoundTrip("timestamp(7)", "TIMESTAMP '3030-03-03 12:34:56.1234567'", createTimestampType(7), "TIMESTAMP '3030-03-03 12:34:56.1234567'") + .addRoundTrip("timestamp(8)", "TIMESTAMP '3030-03-03 12:34:56.12345678'", createTimestampType(8), "TIMESTAMP '3030-03-03 12:34:56.12345678'") + .addRoundTrip("timestamp(9)", "TIMESTAMP '3030-03-03 12:34:56.123456789'", createTimestampType(9), "TIMESTAMP '3030-03-03 12:34:56.123456789'") + .addRoundTrip("timestamp(0)", "TIMESTAMP '2017-07-01'", createTimestampType(0), "TIMESTAMP '2017-07-01'") // summer on northern hemisphere (possible DST) + .addRoundTrip("timestamp(0)", "TIMESTAMP '2017-01-01'", createTimestampType(0), "TIMESTAMP '2017-01-01'") // winter on northern hemisphere (possible DST on southern hemisphere) + .addRoundTrip("timestamp(0)", "TIMESTAMP '1970-01-01'", createTimestampType(0), "TIMESTAMP '1970-01-01'") // change forward at midnight in JVM + .addRoundTrip("timestamp(0)", "TIMESTAMP '1983-04-01'", createTimestampType(0), "TIMESTAMP '1983-04-01'") // change forward at midnight in Vilnius + .addRoundTrip("timestamp(0)", "TIMESTAMP '1983-10-01'", createTimestampType(0), "TIMESTAMP '1983-10-01'") // change backward at midnight in Vilnius + .addRoundTrip("timestamp(0)", "TIMESTAMP '9999-12-31'", createTimestampType(0), "TIMESTAMP '9999-12-31'") // max value in Exasol + + //test cases for timestamp with zero precision and with non-zero seconds + .addRoundTrip("timestamp(0)", "TIMESTAMP '2017-07-01 00:00:01'", createTimestampType(0), "TIMESTAMP '2017-07-01 00:00:01'") // summer on northern hemisphere (possible DST) + .addRoundTrip("timestamp(0)", "TIMESTAMP '2017-01-01 00:00:02'", createTimestampType(0), "TIMESTAMP '2017-01-01 00:00:02'") // winter on northern hemisphere (possible DST on southern hemisphere) + .addRoundTrip("timestamp(0)", "TIMESTAMP '1970-01-01 00:00:03'", createTimestampType(0), "TIMESTAMP '1970-01-01 00:00:03'") // change forward at midnight in JVM + .addRoundTrip("timestamp(0)", "TIMESTAMP '1983-04-01 00:00:04'", createTimestampType(0), "TIMESTAMP '1983-04-01 00:00:04'") // change forward at midnight in Vilnius + .addRoundTrip("timestamp(0)", "TIMESTAMP '1983-10-01 00:00:05'", createTimestampType(0), "TIMESTAMP '1983-10-01 00:00:05'") // change backward at midnight in Vilnius + .addRoundTrip("timestamp(0)", "TIMESTAMP '9999-12-31 00:00:59'", createTimestampType(0), "TIMESTAMP '9999-12-31 00:00:59'") // max value in Exasol + + //DST ambiguity (overlap) time for "America/Bahia_Banderas" time zone + .addRoundTrip("timestamp(3)", "TIMESTAMP '2018-10-28 01:13:55.123'", createTimestampType(3), "TIMESTAMP '2018-10-28 01:13:55.123'") + .addRoundTrip("timestamp(6)", "TIMESTAMP '2018-10-28 01:13:55.123456'", createTimestampType(6), "TIMESTAMP '2018-10-28 01:13:55.123456'") + + // Invalid DST gap time in "America/Bahia_Banderas" JVM time zone. + // The value '2018-04-01 02:13:55.123' is invalid in JVM time zone, because this local time + // never occurs: the clock jumps from 01:59 to 03:00 during DST + .addRoundTrip("timestamp(3)", resolveInvalidDstGapValue("2018-04-01 02:13:55.123"), createTimestampType(3), "TIMESTAMP '2018-04-01 03:13:55.123'") + .addRoundTrip("timestamp(3)", resolveInvalidDstGapValue("2018-04-01 02:13:55.123"), createTimestampType(3), "TIMESTAMP '2018-04-01 03:13:55.123'") + + // Valid shifted DST gap time in "America/Bahia_Banderas" JVM timezone + .addRoundTrip("timestamp(3)", "TIMESTAMP '2018-04-01 03:13:55.123'", createTimestampType(3), "TIMESTAMP '2018-04-01 03:13:55.123'") + .addRoundTrip("timestamp(6)", "TIMESTAMP '2018-04-01 03:13:55.123456'", createTimestampType(6), "TIMESTAMP '2018-04-01 03:13:55.123456'") + .execute(getQueryRunner(), session, exasolCreateAndInsert(TEST_SCHEMA + "." + "test_timestamp")); + } + + @Test + void testUnsupportedTimestampValues() + { + // See for more details: + // https://docs.exasol.com/db/latest/sql_references/data_types/datatypedetails.htm + + // Above maximum supported TIMESTAMP value (must be <= 9999-12-31) + testUnsupportedInsertValue( + "TIMESTAMP", + "TIMESTAMP '10000-01-01 00:00:00.000000'", + "data exception - invalid character value for cast; Value: '10000-01-01 00:00:00.000000'"); + + // Below minimum supported TIMESTAMP value (must be >= 0001-01-01) + testUnsupportedInsertValue( + "TIMESTAMP", + "TIMESTAMP '0000-12-31 23:59:59.999999'", + "data exception - invalid date value; Value: '0000-12-31 23:59:59.999999'"); + + // Exceeds TIMESTAMP maximum supported fractional seconds precision (9 digits) + testUnsupportedInsertValue( + "TIMESTAMP", + "TIMESTAMP '2024-01-01 12:34:56.1234567890'", + "data exception - invalid character value for cast; Value: '2024-01-01 12:34:56.1234567890'"); + + // Negative precisions are not supported + testUnsupportedDefinition( + "TIMESTAMP(-1)", + "syntax error, unexpected '-', expecting UNSIGNED_INTEGER"); + } + // See for more details: https://docs.exasol.com/saas/microcontent/Resources/MicroContent/general/hash-data-type.htm @Test void testHashtype() @@ -445,4 +552,53 @@ private SqlExecutor onRemoteDatabase() { return exasolServer::execute; } + + private void testUnsupportedDefinition( + String exasolType, + String expectedException) + { + String tableName = "test_unsupported_definition_" + randomNameSuffix(); + assertExasolSqlQueryFails( + "CREATE TABLE %s.%s (col %s)".formatted(TEST_SCHEMA, tableName, exasolType), + expectedException); + } + + private void testUnsupportedInsertValue( + String exasolType, + String inputLiteral, + String expectedException) + { + try (TestTable table = new TestTable(onRemoteDatabase(), + TEST_SCHEMA + ".test_unsupported_hashtype_" + randomNameSuffix(), + "(col %s)".formatted(exasolType))) { + assertExasolSqlQueryFails("INSERT INTO %s VALUES (%s)".formatted(table.getName(), inputLiteral), expectedException); + } + } + + // Resolves DST-gap values that are invalid in the JVM zone ("America/Bahia_Banderas") + // but may be valid in the Exasol DB zone. We convert via UTC to avoid ambiguity, + // ensuring the value maps back to the intended Exasol time zone representation. + // + // Example: "2018-04-01 02:13:55.123" is invalid in "America/Bahia_Banderas" (JVM) + // but valid in "Europe/Berlin" (Exasol). + // Without converting to UTC and back, it would be incorrectly mapped + // to "2018-04-01 03:13:55.123" in "Europe/Berlin" (Exasol), + // not to expected "2018-04-01 02:13:55.123" + // Converting through UTC guarantees the correct match. + private static String resolveInvalidDstGapValue(String invalidJvmZoneDstGapString) + { + return """ + CONVERT_TZ( + CONVERT_TZ( + TIMESTAMP '%s', + 'America/Bahia_Banderas', + 'UTC', + 'INVALID SHIFT AMBIGUOUS REJECT' + ), + 'UTC', + 'America/Bahia_Banderas', + 'INVALID SHIFT AMBIGUOUS REJECT' + ) + """.formatted(invalidJvmZoneDstGapString); + } }