diff --git a/docs/src/main/sphinx/connector/exasol.md b/docs/src/main/sphinx/connector/exasol.md index 5f0c29a89f4b..6064dc36717a 100644 --- a/docs/src/main/sphinx/connector/exasol.md +++ b/docs/src/main/sphinx/connector/exasol.md @@ -99,7 +99,13 @@ Trino data type mapping: - * - `DATE` - `DATE` + - +* - `TIMESTAMP(n)` + - `TIMESTAMP(n)` - +* - `TIMESTAMP(n) WITH LOCAL TIME ZONE` + - `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..005fa8e7cef7 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,27 @@ 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.createTimestampType; import static io.trino.spi.type.VarbinaryType.VARBINARY; import static java.lang.String.format; import static java.util.Locale.ENGLISH; @@ -79,11 +94,15 @@ public class ExasolClient extends BaseJdbcClient { + private static final int EXASOL_TIMESTAMP_WITH_TIMEZONE = 124; + private static final Set INTERNAL_SCHEMAS = ImmutableSet.builder() .add("EXA_STATISTICS") .add("SYS") .build(); + private static final int MAX_EXASOL_TIMESTAMP_PRECISION = 9; + @Inject public ExasolClient( BaseJdbcConfig config, @@ -239,8 +258,13 @@ 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, TIMESTAMP and TIMESTAMP WITH LOCAL TIME ZONE 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: + case EXASOL_TIMESTAMP_WITH_TIMEZONE: + return Optional.of(timestampColumnMapping(typeHandle)); } if (getUnsupportedTypeHandling(session) == CONVERT_TO_VARCHAR) { @@ -256,6 +280,128 @@ private boolean isHashType(JdbcTypeHandle typeHandle) && typeHandle.jdbcTypeName().get().equalsIgnoreCase("HASHTYPE"); } + 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) + { + return (resultSet, columnIndex) -> { + Timestamp timestamp = resultSet.getTimestamp(columnIndex); + return toTrinoTimestamp(timestampType, timestamp.toLocalDateTime()); + }; + } + + private static LongWriteFunction longTimestampWriteFunction(TimestampType 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.VARCHAR); + } + }; + } + + 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.VARCHAR); + } + }; + } + + private static void verifyObjectTimestampPrecision(TimestampType timestampType) + { + int precision = timestampType.getPrecision(); + checkArgument(precision > TimestampType.MAX_SHORT_PRECISION && precision <= MAX_EXASOL_TIMESTAMP_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); + } + private static ColumnMapping dateColumnMapping() { // Exasol driver does not support LocalDate 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..da38ba1811c5 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,10 +279,238 @@ private void testDate(ZoneId sessionZone) .execute(getQueryRunner(), session, exasolCreateAndInsert(TEST_SCHEMA + "." + "test_date")); } - // See for more details: https://docs.exasol.com/saas/microcontent/Resources/MicroContent/general/hash-data-type.htm + @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 testTimestampWithTimeZone() + { + testTimestampWithTimeZone(UTC); + testTimestampWithTimeZone(jvmZone); + // using two non-JVM zones so that we don't need to worry what Exasol system zone is + testTimestampWithTimeZone(vilnius); + testTimestampWithTimeZone(kathmandu); + testTimestampWithTimeZone(TestingSession.DEFAULT_TIME_ZONE_KEY.getZoneId()); + } + + /** + * Exasol {@code TIMESTAMP WITH LOCAL TIME ZONE} does not persist any time zone information; + * it stores values normalized to the database session time zone. + * In Trino, this type is represented as {@code TIMESTAMP}. + *

+ * See for more details: Date and time data types + *

+ */ + private void testTimestampWithTimeZone(ZoneId sessionZone) + { + Session session = Session.builder(getSession()) + .setTimeZoneKey(TimeZoneKey.getTimeZoneKey(sessionZone.getId())) + .build(); + + SqlDataTypeTest.create() + .addRoundTrip("timestamp with local time zone", "NULL", createTimestampType(3), "CAST(NULL AS TIMESTAMP)") + + // timestamp with precision 3 examples + .addRoundTrip("timestamp with local time zone", "TIMESTAMP '2019-03-18 10:01:17.123'", createTimestampType(3), "TIMESTAMP '2019-03-18 10:01:17.123'") + .addRoundTrip("timestamp(3) with local time zone", "TIMESTAMP '2018-10-27 19:33:17.456'", createTimestampType(3), "TIMESTAMP '2018-10-27 19:33:17.456'") + .addRoundTrip("timestamp(3) with local time zone", "TIMESTAMP '2018-10-28 03:33:33.333'", createTimestampType(3), "TIMESTAMP '2018-10-28 03:33:33.333'") + .addRoundTrip("timestamp(3) with local time zone", "TIMESTAMP '2018-10-28 01:13:55.1234'", createTimestampType(3), "TIMESTAMP '2018-10-28 01:13:55.123'") + .addRoundTrip("timestamp(3) with local time zone", "TIMESTAMP '2018-10-28 01:13:55.12345'", createTimestampType(3), "TIMESTAMP '2018-10-28 01:13:55.123'") + .addRoundTrip("timestamp(3) with local time zone", "TIMESTAMP '1970-01-01 00:13:42.000'", createTimestampType(3), "TIMESTAMP '1970-01-01 00:13:42.000'") + .addRoundTrip("timestamp(3) with local time zone", "TIMESTAMP '2020-09-27 12:34:56.999'", createTimestampType(3), "TIMESTAMP '2020-09-27 12:34:56.999'") + .addRoundTrip("timestamp(3) with local time zone", "TIMESTAMP '2018-03-25 03:17:17.000'", createTimestampType(3), "TIMESTAMP '2018-03-25 03:17:17.000'") + .addRoundTrip("timestamp(3) with local time zone", "TIMESTAMP '1986-01-01 00:13:07.000'", createTimestampType(3), "TIMESTAMP '1986-01-01 00:13:07.000'") + + // timestamp with precision 6-9 examples + .addRoundTrip("timestamp(6) with local time zone", "TIMESTAMP '2019-03-18 10:01:17.987654'", createTimestampType(6), "TIMESTAMP '2019-03-18 10:01:17.987654'") + .addRoundTrip("timestamp(6) with local time zone", "TIMESTAMP '2018-10-28 01:33:17.456789'", createTimestampType(6), "TIMESTAMP '2018-10-28 01:33:17.456789'") + .addRoundTrip("timestamp(6) with local time zone", "TIMESTAMP '2018-10-28 03:33:33.333333'", createTimestampType(6), "TIMESTAMP '2018-10-28 03:33:33.333333'") + .addRoundTrip("timestamp(6) with local time zone", "TIMESTAMP '1970-01-01 00:13:42.000000'", createTimestampType(6), "TIMESTAMP '1970-01-01 00:13:42.000000'") + .addRoundTrip("timestamp(6) with local time zone", "TIMESTAMP '2018-03-25 03:17:17.000000'", createTimestampType(6), "TIMESTAMP '2018-03-25 03:17:17.000000'") + .addRoundTrip("timestamp(6) with local time zone", "TIMESTAMP '1986-01-01 00:13:07.000000'", createTimestampType(6), "TIMESTAMP '1986-01-01 00:13:07.000000'") + .addRoundTrip("timestamp(6) with local time zone", "TIMESTAMP '1986-01-01 00:13:07.123456789'", createTimestampType(6), "TIMESTAMP '1986-01-01 00:13:07.123456'") + .addRoundTrip("timestamp(7) with local time zone", "TIMESTAMP '1986-01-01 00:13:07.1234567'", createTimestampType(7), "TIMESTAMP '1986-01-01 00:13:07.1234567'") + .addRoundTrip("timestamp(8) with local time zone", "TIMESTAMP '1986-01-01 00:13:07.12345678'", createTimestampType(8), "TIMESTAMP '1986-01-01 00:13:07.12345678'") + .addRoundTrip("timestamp(9) with local time zone", "TIMESTAMP '1986-01-01 00:13:07.123456789'", createTimestampType(9), "TIMESTAMP '1986-01-01 00:13:07.123456789'") + + // tests for other precisions (0-6 and some 1's) + .addRoundTrip("timestamp(0) with local time zone", "TIMESTAMP '1970-01-01 00:00:01'", createTimestampType(0), "TIMESTAMP '1970-01-01 00:00:01'") + .addRoundTrip("timestamp(1) with local time zone", "TIMESTAMP '1970-01-01 00:00:01.1'", createTimestampType(1), "TIMESTAMP '1970-01-01 00:00:01.1'") + .addRoundTrip("timestamp(1) with local time zone", "TIMESTAMP '1970-01-01 00:00:01.9'", createTimestampType(1), "TIMESTAMP '1970-01-01 00:00:01.9'") + .addRoundTrip("timestamp(2) with local time zone", "TIMESTAMP '1970-01-01 00:00:01.12'", createTimestampType(2), "TIMESTAMP '1970-01-01 00:00:01.12'") + .addRoundTrip("timestamp(3) with local time zone", "TIMESTAMP '1970-01-01 00:00:01.123'", createTimestampType(3), "TIMESTAMP '1970-01-01 00:00:01.123'") + .addRoundTrip("timestamp(3) with local time zone", "TIMESTAMP '1970-01-01 00:00:01.999'", createTimestampType(3), "TIMESTAMP '1970-01-01 00:00:01.999'") + .addRoundTrip("timestamp(4) with local time zone", "TIMESTAMP '1970-01-01 00:00:01.1234'", createTimestampType(4), "TIMESTAMP '1970-01-01 00:00:01.1234'") + .addRoundTrip("timestamp(5) with local time zone", "TIMESTAMP '1970-01-01 00:00:01.12345'", createTimestampType(5), "TIMESTAMP '1970-01-01 00:00:01.12345'") + .addRoundTrip("timestamp(1) with local time zone", "TIMESTAMP '2020-09-27 12:34:56.1'", createTimestampType(1), "TIMESTAMP '2020-09-27 12:34:56.1'") + .addRoundTrip("timestamp(1) with local time zone", "TIMESTAMP '2020-09-27 12:34:56.9'", createTimestampType(1), "TIMESTAMP '2020-09-27 12:34:56.9'") + .addRoundTrip("timestamp(3) with local time zone", "TIMESTAMP '2020-09-27 12:34:56.123'", createTimestampType(3), "TIMESTAMP '2020-09-27 12:34:56.123'") + .addRoundTrip("timestamp(6) with local time zone", "TIMESTAMP '2020-09-27 12:34:56.123456'", createTimestampType(6), "TIMESTAMP '2020-09-27 12:34:56.123456'") + + //test cases for timestamp with zero precision and with non-zero seconds + .addRoundTrip("timestamp(0) with local time zone", "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) with local time zone", "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) with local time zone", "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) with local time zone", "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) with local time zone", "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) with local time zone", "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) with local time zone", "TIMESTAMP '2018-10-28 01:13:55.123'", createTimestampType(3), "TIMESTAMP '2018-10-28 01:13:55.123'") + .addRoundTrip("timestamp(6) with local time zone", "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) with local time zone", resolveInvalidDstGapValue("2018-04-01 02:13:55.123"), createTimestampType(3), "TIMESTAMP '2018-04-01 03:13:55.123'") + .addRoundTrip("timestamp(3) with local time zone", 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) with local time zone", "TIMESTAMP '2018-04-01 03:13:55.123'", createTimestampType(3), "TIMESTAMP '2018-04-01 03:13:55.123'") + .addRoundTrip("timestamp(6) with local time zone", "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_with_local_timezone")); + } + + @Test + void testUnsupportedTimestampValues() + { + // See for more details: + // https://docs.exasol.com/db/latest/sql_references/data_types/datatypedetails.htm + + // Below minimum supported TIMESTAMP value (must be >= 0001-01-01) + 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'"); + + // Above maximum supported TIMESTAMP value (must be <= 9999-12-31) + 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"); + } + + @Test + void testUnsupportedTimestampWithLocalTimeZoneValues() + { + // See for more details: + // https://docs.exasol.com/db/latest/sql_references/data_types/datatypedetails.htm + + // Below minimum supported TIMESTAMP WITH LOCAL TIME ZONE value (must be >= 0001-01-01) + testUnsupportedInsertValue( + "TIMESTAMP WITH LOCAL TIME ZONE", + "TIMESTAMP '10000-01-01 00:00:00.000000'", + "data exception - invalid character value for cast; Value: '10000-01-01 00:00:00.000000'"); + + // Above maximum supported TIMESTAMP WITH LOCAL TIME ZONE value (must be <= 9999-12-31) + testUnsupportedInsertValue( + "TIMESTAMP WITH LOCAL TIME ZONE", + "TIMESTAMP '0000-12-31 23:59:59.999999'", + "data exception - invalid date value; Value: '0000-12-31 23:59:59.999999'"); + + // Exceeds TIMESTAMP WITH LOCAL TIME ZONE maximum supported fractional seconds precision (9 digits) + testUnsupportedInsertValue( + "TIMESTAMP WITH LOCAL TIME ZONE", + "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) WITH LOCAL TIME ZONE", + "syntax error, unexpected '-', expecting UNSIGNED_INTEGER"); + } + @Test void testHashtype() { + // See for more details: + // https://docs.exasol.com/saas/microcontent/Resources/MicroContent/general/hash-data-type.htm + SqlDataTypeTest.create() // Null .addRoundTrip("hashtype", "NULL", VARBINARY, "from_hex(NULL)") @@ -307,88 +536,92 @@ void testHashtype() .execute(getQueryRunner(), exasolCreateAndInsert(TEST_SCHEMA + "." + "test_hashtype_as_varbinary_mapping")); } - // See for more details: https://docs.exasol.com/saas/microcontent/Resources/MicroContent/general/hash-data-type.htm @Test void testUnsupportedHashTypeDefinitions() { + // See for more details: + // https://docs.exasol.com/saas/microcontent/Resources/MicroContent/general/hash-data-type.htm + // Too few bytes (< 1) - testUnsupportedHashTypeDefinition( + testUnsupportedDefinition( "HASHTYPE(0 BYTE)", "the given size of HASHTYPE is too small. A minimum of 1 bytes are required"); // Too many bytes (> 1024) - testUnsupportedHashTypeDefinition( + testUnsupportedDefinition( "HASHTYPE(1025 BYTE)", "the given size of HASHTYPE is too large. At most 1024 bytes are allowed"); // Too few bits (< 8) - testUnsupportedHashTypeDefinition( + testUnsupportedDefinition( "HASHTYPE(7 BIT)", "the given size of HASHTYPE is too small. A minimum of 8 bits are required"); // Too many bits (> 8192) - testUnsupportedHashTypeDefinition( + testUnsupportedDefinition( "HASHTYPE(8193 BIT)", "the given size of HASHTYPE is too large. At most 8192 bits are allowed"); // Bits not divisible by 8 - testUnsupportedHashTypeDefinition( + testUnsupportedDefinition( "HASHTYPE(9 BIT)", "Bit size of HASHTYPE has to be a multiple of 8"); } - private void testUnsupportedHashTypeDefinition( + private void testUnsupportedDefinition( String exasolType, String expectedException) { - String tableName = "test_unsupported_hashtype_" + randomNameSuffix(); + String tableName = "test_unsupported_definition_" + randomNameSuffix(); assertExasolSqlQueryFails( "CREATE TABLE %s.%s (col %s)".formatted(TEST_SCHEMA, tableName, exasolType), expectedException); } - // See for more details: https://docs.exasol.com/saas/microcontent/Resources/MicroContent/general/hash-data-type.htm @Test void testUnsupportedHashTypeInsertValues() { + // See for more details: + // https://docs.exasol.com/saas/microcontent/Resources/MicroContent/general/hash-data-type.htm + // Invalid hex character - testUnsupportedHashTypeInsertValue( + testUnsupportedInsertValue( "HASHTYPE(4 BYTE)", "'GGGGGGGG'", "data exception - Invalid hash format"); // Too short for declared size (expecting 4 bytes = 8 hex chars, got 6) - testUnsupportedHashTypeInsertValue( + testUnsupportedInsertValue( "HASHTYPE(4 BYTE)", "'AABBCC'", "data exception - Invalid hash format"); // Too short for declared size (expecting 16 bytes = 32 hex chars, got 31) - testUnsupportedHashTypeInsertValue( + testUnsupportedInsertValue( "HASHTYPE(16 BYTE)", "'550e8400-e29b-11d4-a716-44665544000'", "data exception - Invalid hash format"); // Too long for declared size (expecting 4 bytes = 8 hex chars, got 10) - testUnsupportedHashTypeInsertValue( + testUnsupportedInsertValue( "HASHTYPE(4 BYTE)", "'AABBCCDDEE'", "data exception - Invalid hash format"); // Unexpected symbol inside - testUnsupportedHashTypeInsertValue( + testUnsupportedInsertValue( "HASHTYPE(4 BYTE)", "'AABB-CCZZ'", "data exception - Invalid hash format"); // Parentheses instead of curly brackets - testUnsupportedHashTypeInsertValue( + testUnsupportedInsertValue( "HASHTYPE(4 BYTE)", "'(AABB-CCCC)'", "data exception - Invalid hash format"); } - private void testUnsupportedHashTypeInsertValue( + private void testUnsupportedInsertValue( String exasolType, String inputLiteral, String expectedException) @@ -420,6 +653,33 @@ private DataSetup exasolCreateAndInsert(String tableNamePrefix) return new CreateAndInsertDataSetup(exasolServer.getSqlExecutor(), tableNamePrefix); } + // 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); + } + private static void checkIsGap(ZoneId zone, LocalDateTime dateTime) { verify(isGap(zone, dateTime), "Expected %s to be a gap in %s", dateTime, zone);