From 60cedd500daf3e3635f855d5f88548e76706ea1c Mon Sep 17 00:00:00 2001 From: Alexander Radzin Date: Tue, 30 Apr 2024 21:20:00 +0300 Subject: [PATCH] FIR-32017: PreparedStatement: set date/time/timestamp with calendar (#396) --- .../tests/PreparedStatementTest.java | 33 +++++++++++ .../statements/prepared-statement/ddl.sql | 8 ++- .../FireboltPreparedStatement.java | 20 +++++-- .../type/JavaTypeToFireboltSQLString.java | 30 +++++++++- .../firebolt/jdbc/type/date/SqlDateUtil.java | 15 +++-- .../FireboltPreparedStatementTest.java | 57 ++++++++++++++++++- .../type/JavaTypeToFireboltSQLStringTest.java | 19 ++++++- 7 files changed, 162 insertions(+), 20 deletions(-) diff --git a/src/integrationTest/java/integration/tests/PreparedStatementTest.java b/src/integrationTest/java/integration/tests/PreparedStatementTest.java index 87b179a6c..70e99ce1d 100644 --- a/src/integrationTest/java/integration/tests/PreparedStatementTest.java +++ b/src/integrationTest/java/integration/tests/PreparedStatementTest.java @@ -28,10 +28,13 @@ import java.net.MalformedURLException; import java.net.URL; import java.sql.Connection; +import java.sql.Date; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.sql.Time; +import java.sql.Timestamp; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -340,6 +343,34 @@ void shouldInsertAndSelectStreams() throws SQLException, IOException { } } + @Test + void shouldInsertAndSelectDateTime() throws SQLException { + Car car1 = Car.builder().make("Ford").sales(12345).ts(new Timestamp(2)).d(new Date(3)).build(); + try (Connection connection = createConnection()) { + + try (PreparedStatement statement = connection + .prepareStatement("INSERT INTO prepared_statement_test (sales, make, ts, d) VALUES (?,?,?,?)")) { + statement.setLong(1, car1.getSales()); + statement.setString(2, car1.getMake()); + statement.setTimestamp(3, car1.getTs()); + statement.setDate(4, car1.getD()); + statement.executeUpdate(); + } + + try (Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SELECT sales, make, ts, d FROM prepared_statement_test")) { + assertTrue(rs.next()); + Car actual = Car.builder().sales(rs.getInt(1)).make(rs.getString(2)).ts(rs.getTimestamp(3)).d(rs.getDate(4)).build(); + assertFalse(rs.next()); + // Date type in DB does not really hold the time, so the time part is unpredictable and cannot be compared. + // This is the reason to compare string representation of the object: Date.toString() returns the date only + // without hours, minutes and seconds. + assertEquals(car1.toString(), actual.toString()); + } + } + } + + private QueryResult createExpectedResult(List> expectedRows) { return QueryResult.builder().databaseName(ConnectionInfo.getInstance().getDatabase()) .tableName("prepared_statement_test") @@ -385,6 +416,8 @@ private static class Car { Integer sales; String make; byte[] signature; + Timestamp ts; + Date d; } } diff --git a/src/integrationTest/resources/statements/prepared-statement/ddl.sql b/src/integrationTest/resources/statements/prepared-statement/ddl.sql index 98d9bec25..c78926ad4 100644 --- a/src/integrationTest/resources/statements/prepared-statement/ddl.sql +++ b/src/integrationTest/resources/statements/prepared-statement/ddl.sql @@ -1,8 +1,10 @@ DROP TABLE IF EXISTS prepared_statement_test; CREATE FACT TABLE IF NOT EXISTS prepared_statement_test ( -make STRING null, -sales bigint not null, -signature bytea null + make STRING null, + sales bigint not null, + ts timestamp NULL, + d date NULL, + signature bytea null ) PRIMARY INDEX make; \ No newline at end of file diff --git a/src/main/java/com/firebolt/jdbc/statement/preparedstatement/FireboltPreparedStatement.java b/src/main/java/com/firebolt/jdbc/statement/preparedstatement/FireboltPreparedStatement.java index 8589e4fd0..e6eafb6da 100644 --- a/src/main/java/com/firebolt/jdbc/statement/preparedstatement/FireboltPreparedStatement.java +++ b/src/main/java/com/firebolt/jdbc/statement/preparedstatement/FireboltPreparedStatement.java @@ -319,9 +319,8 @@ public void setClob(int parameterIndex, Clob clob) throws SQLException { } @Override - @NotImplemented - public void setDate(int parameterIndex, Date x, Calendar cal) throws SQLException { - throw new FireboltUnsupportedOperationException(); + public void setDate(int parameterIndex, Date date, Calendar calendar) throws SQLException { + setDateTime(parameterIndex, date, calendar, JavaTypeToFireboltSQLString.DATE); } @Override @@ -331,9 +330,18 @@ public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLExceptio } @Override - @NotImplemented - public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException { - throw new FireboltUnsupportedOperationException(); + public void setTimestamp(int parameterIndex, Timestamp timestamp, Calendar calendar) throws SQLException { + setDateTime(parameterIndex, timestamp, calendar, JavaTypeToFireboltSQLString.TIMESTAMP); + } + + private void setDateTime(int parameterIndex, T datetime, Calendar calendar, JavaTypeToFireboltSQLString type) throws SQLException { + validateStatementIsNotClosed(); + validateParamIndex(parameterIndex); + if (datetime == null || calendar == null) { + providedParameters.put(parameterIndex, type.transform(datetime)); + } else { + providedParameters.put(parameterIndex, type.transform(datetime, calendar.getTimeZone().getID())); + } } @Override diff --git a/src/main/java/com/firebolt/jdbc/type/JavaTypeToFireboltSQLString.java b/src/main/java/com/firebolt/jdbc/type/JavaTypeToFireboltSQLString.java index 7620c0482..ecdcac89e 100644 --- a/src/main/java/com/firebolt/jdbc/type/JavaTypeToFireboltSQLString.java +++ b/src/main/java/com/firebolt/jdbc/type/JavaTypeToFireboltSQLString.java @@ -1,5 +1,6 @@ package com.firebolt.jdbc.type; +import com.firebolt.jdbc.CheckedBiFunction; import com.firebolt.jdbc.CheckedFunction; import com.firebolt.jdbc.exception.FireboltException; import com.firebolt.jdbc.type.array.SqlArrayUtil; @@ -18,6 +19,7 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Optional; +import java.util.TimeZone; import java.util.function.Supplier; import java.util.stream.Stream; @@ -39,8 +41,8 @@ public enum JavaTypeToFireboltSQLString { BIG_INTEGER(BigInteger.class, value -> value instanceof BigInteger ? value.toString() : Long.toString(((Number)value).longValue())), FLOAT(Float.class, value -> Float.toString(((Number)value).floatValue())), DOUBLE(Double.class, value -> Double.toString(((Number)value).doubleValue())), - DATE(Date.class, date -> SqlDateUtil.transformFromDateToSQLStringFunction.apply((Date) date)), - TIMESTAMP(Timestamp.class, time -> SqlDateUtil.transformFromTimestampToSQLStringFunction.apply((Timestamp) time)), + DATE(Date.class, date -> SqlDateUtil.transformFromDateToSQLStringFunction.apply((Date) date), (date, tz) -> SqlDateUtil.transformFromDateWithTimezoneToSQLStringFunction.apply((Date) date, toTimeZone(tz))), + TIMESTAMP(Timestamp.class, time -> SqlDateUtil.transformFromTimestampToSQLStringFunction.apply((Timestamp) time), (ts, tz) -> SqlDateUtil.transformFromTimestampWithTimezoneToSQLStringFunction.apply((Timestamp) ts, toTimeZone(tz))), BIG_DECIMAL(BigDecimal.class, value -> value == null ? BaseType.NULL_VALUE : ((BigDecimal) value).toPlainString()), ARRAY(Array.class, SqlArrayUtil::arrayToString), BYTE_ARRAY(byte[].class, value -> ofNullable(byteArrayToHexString((byte[])value, true)).map(x -> format("E'%s'::BYTEA", x)).orElse(null)), @@ -80,13 +82,21 @@ public enum JavaTypeToFireboltSQLString { private final Class sourceType; private final CheckedFunction transformToJavaTypeFunction; + private final CheckedBiFunction transformToJavaTypeFunctionWithParameter; public static final String NULL_VALUE = "NULL"; private static final Map, JavaTypeToFireboltSQLString> classToType = Stream.of(JavaTypeToFireboltSQLString.values()) .collect(toMap(type -> type.sourceType, type -> type)); JavaTypeToFireboltSQLString(Class sourceType, CheckedFunction transformToSqlStringFunction) { + this(sourceType, transformToSqlStringFunction, null); + } + + JavaTypeToFireboltSQLString(Class sourceType, + CheckedFunction transformToSqlStringFunction, + CheckedBiFunction transformToJavaTypeFunctionWithParameter) { this.sourceType = sourceType; this.transformToJavaTypeFunction = transformToSqlStringFunction; + this.transformToJavaTypeFunctionWithParameter = transformToJavaTypeFunctionWithParameter; } public static String transformAny(Object object) throws FireboltException { @@ -131,15 +141,29 @@ public Class getSourceType() { return sourceType; } - public String transform(Object object) throws FireboltException { + public String transform(Object object, Object ... more) throws FireboltException { if (object == null) { return NULL_VALUE; } else { try { + if (more.length > 0) { + return transformToJavaTypeFunctionWithParameter.apply(object, more[0]); + } return transformToJavaTypeFunction.apply(object); } catch (Exception e) { throw new FireboltException("Could not convert object to a String ", e, TYPE_TRANSFORMATION_ERROR); } } } + + @SuppressWarnings("java:S6201") // Pattern Matching for "instanceof" was introduced in java 16 while we still try to be compliant with java 11 + private static TimeZone toTimeZone(Object tz) { + if (tz instanceof TimeZone) { + return (TimeZone)tz; + } + if (tz instanceof String) { + return TimeZone.getTimeZone((String)tz); + } + throw new IllegalArgumentException(format("Cannot convert %s to TimeZone", tz)); + } } diff --git a/src/main/java/com/firebolt/jdbc/type/date/SqlDateUtil.java b/src/main/java/com/firebolt/jdbc/type/date/SqlDateUtil.java index cc60a344a..5a77bfb10 100644 --- a/src/main/java/com/firebolt/jdbc/type/date/SqlDateUtil.java +++ b/src/main/java/com/firebolt/jdbc/type/date/SqlDateUtil.java @@ -1,20 +1,21 @@ package com.firebolt.jdbc.type.date; +import com.firebolt.jdbc.CheckedBiFunction; +import lombok.CustomLog; +import lombok.experimental.UtilityClass; + import java.sql.Date; import java.sql.Time; import java.sql.Timestamp; +import java.time.Instant; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.ChronoField; import java.util.TimeZone; +import java.util.function.BiFunction; import java.util.function.Function; -import com.firebolt.jdbc.CheckedBiFunction; - -import lombok.CustomLog; -import lombok.experimental.UtilityClass; - @UtilityClass @CustomLog public class SqlDateUtil { @@ -28,12 +29,16 @@ public class SqlDateUtil { public static final Function transformFromTimestampToSQLStringFunction = value -> String .format("'%s'", dateTimeFormatter.format(value.toLocalDateTime())); + public static final BiFunction transformFromTimestampWithTimezoneToSQLStringFunction = (ts, tz) -> String + .format("'%s'", dateTimeFormatter.format(ts.toInstant().atZone(tz.toZoneId()).toLocalDateTime())); private static final TimeZone DEFAULT_SERVER_TZ = TimeZone.getTimeZone("UTC"); private static final DateTimeFormatter dateFormatter = new DateTimeFormatterBuilder() .appendValue(ChronoField.YEAR, 4).parseDefaulting(ChronoField.YEAR, 0).appendPattern("[-]MM-dd") .toFormatter(); public static final Function transformFromDateToSQLStringFunction = value -> String.format("'%s'", dateFormatter.format(value.toLocalDate())); + public static final BiFunction transformFromDateWithTimezoneToSQLStringFunction = (date, tz) -> String.format("'%s'", + dateFormatter.format(Instant.ofEpochMilli(date.getTime()).atZone(tz.toZoneId()).toLocalDateTime())); public static final CheckedBiFunction transformToTimestampFunction = TimestampUtil::toTimestamp; public static final Function transformFromTimestampToOffsetDateTime = timestamp -> { diff --git a/src/test/java/com/firebolt/jdbc/statement/preparedstatement/FireboltPreparedStatementTest.java b/src/test/java/com/firebolt/jdbc/statement/preparedstatement/FireboltPreparedStatementTest.java index 51d82c350..0af897fb1 100644 --- a/src/test/java/com/firebolt/jdbc/statement/preparedstatement/FireboltPreparedStatementTest.java +++ b/src/test/java/com/firebolt/jdbc/statement/preparedstatement/FireboltPreparedStatementTest.java @@ -46,8 +46,11 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; +import java.text.ParseException; +import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Optional; +import java.util.TimeZone; import java.util.stream.Stream; import static java.lang.String.format; @@ -96,10 +99,8 @@ public SerialNClob(char[] ch) throws SQLException { private static Stream unsupported() { return Stream.of( Arguments.of("setRef", (Executable) () -> statement.setRef(1, mock(Ref.class))), - Arguments.of("setDate", (Executable) () -> statement.setDate(1, new Date(System.currentTimeMillis()), Calendar.getInstance())), Arguments.of("setTime", (Executable) () -> statement.setTime(1, new Time(System.currentTimeMillis()))), Arguments.of("setTime(calendar)", (Executable) () -> statement.setTime(1, new Time(System.currentTimeMillis()), Calendar.getInstance())), - Arguments.of("setTimestamp", (Executable) () -> statement.setTimestamp(1, new Timestamp(System.currentTimeMillis()), Calendar.getInstance())), Arguments.of("setRowId", (Executable) () -> statement.setRowId(1, mock(RowId.class))), Arguments.of("setSQLXML", (Executable) () -> statement.setSQLXML(1, mock(SQLXML.class))), @@ -423,6 +424,45 @@ void shouldSetDate() throws SQLException { queryInfoWrapperArgumentCaptor.getValue().getSql()); } + @Test + void shouldSetDateWithCalendar() throws SQLException, ParseException { + statement = createStatementWithSql("INSERT INTO cars(release_date) VALUES (?)"); + Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("JST")); + calendar.setTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z").parse("2024-04-18 20:00:00 GMT")); + statement.setDate(1, new Date(calendar.getTimeInMillis()), calendar); + statement.execute(); + + verify(fireboltStatementService).execute(queryInfoWrapperArgumentCaptor.capture(), eq(properties), anyBoolean(), any()); + assertEquals("INSERT INTO cars(release_date) VALUES ('2024-04-19')", + queryInfoWrapperArgumentCaptor.getValue().getSql()); + } + + @Test + @DefaultTimeZone("Europe/London") + void shouldSetDateWithNullCalendar() throws SQLException, ParseException { + statement = createStatementWithSql("INSERT INTO cars(release_date) VALUES (?)"); + + statement.setDate(1, new Date(1564527600000L), null); + statement.execute(); + + verify(fireboltStatementService).execute(queryInfoWrapperArgumentCaptor.capture(), eq(properties), anyBoolean(), any()); + assertEquals("INSERT INTO cars(release_date) VALUES ('2019-07-31')", + queryInfoWrapperArgumentCaptor.getValue().getSql()); + } + + @Test + void shouldSetTimeStampWithCalendar() throws SQLException, ParseException { + statement = createStatementWithSql("INSERT INTO cars(release_date) VALUES (?)"); + Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("JST")); + calendar.setTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z").parse("2024-04-18 20:11:01 GMT")); + statement.setTimestamp(1, new Timestamp(calendar.getTimeInMillis()), calendar); + statement.execute(); + + verify(fireboltStatementService).execute(queryInfoWrapperArgumentCaptor.capture(), eq(properties), anyBoolean(), any()); + assertEquals("INSERT INTO cars(release_date) VALUES ('2024-04-19 05:11:01')", + queryInfoWrapperArgumentCaptor.getValue().getSql()); + } + @Test @DefaultTimeZone("Europe/London") void shouldSetTimeStamp() throws SQLException { @@ -436,6 +476,19 @@ void shouldSetTimeStamp() throws SQLException { queryInfoWrapperArgumentCaptor.getValue().getSql()); } + @Test + void shouldSetNullTimeStampWithCalendar() throws SQLException, ParseException { + statement = createStatementWithSql("INSERT INTO cars(release_date) VALUES (?)"); + Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("JST")); + calendar.setTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z").parse("2024-04-18 20:11:01 GMT")); + statement.setTimestamp(1, null, calendar); + statement.execute(); + + verify(fireboltStatementService).execute(queryInfoWrapperArgumentCaptor.capture(), eq(properties), anyBoolean(), any()); + assertEquals("INSERT INTO cars(release_date) VALUES (NULL)", + queryInfoWrapperArgumentCaptor.getValue().getSql()); + } + @Test @DefaultTimeZone("Europe/London") void shouldSetAllObjects() throws SQLException { diff --git a/src/test/java/com/firebolt/jdbc/type/JavaTypeToFireboltSQLStringTest.java b/src/test/java/com/firebolt/jdbc/type/JavaTypeToFireboltSQLStringTest.java index 6b7c32d82..fa128d163 100644 --- a/src/test/java/com/firebolt/jdbc/type/JavaTypeToFireboltSQLStringTest.java +++ b/src/test/java/com/firebolt/jdbc/type/JavaTypeToFireboltSQLStringTest.java @@ -15,6 +15,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Map; +import java.util.TimeZone; import java.util.UUID; import static com.firebolt.jdbc.exception.ExceptionType.TYPE_NOT_SUPPORTED; @@ -123,7 +124,23 @@ void shouldTransformDateToString() throws FireboltException { Date d = Date.valueOf(LocalDate.of(2022, 5, 23)); String expectedDateString = "'2022-05-23'"; assertEquals(expectedDateString, JavaTypeToFireboltSQLString.DATE.transform(d)); - assertEquals(expectedDateString, JavaTypeToFireboltSQLString.transformAny((d))); + assertEquals(expectedDateString, JavaTypeToFireboltSQLString.transformAny(d)); + } + + @Test + void shouldTransformDateWithDefaultTimezoneToString() throws FireboltException { + assertEquals("'2022-05-23'", JavaTypeToFireboltSQLString.DATE.transform(Date.valueOf(LocalDate.of(2022, 5, 23)), TimeZone.getDefault())); + } + + @Test + void shouldTransformDateWithDefaultTimezoneIdToString() throws FireboltException { + assertEquals("'2022-05-23'", JavaTypeToFireboltSQLString.DATE.transform(Date.valueOf(LocalDate.of(2022, 5, 23)), TimeZone.getDefault().getID())); + } + + @Test + void shouldThrowExceptionWhenTransformingDateToStringWithWrongParameter() { + assertEquals(IllegalArgumentException.class, + assertThrows(SQLException.class, () -> JavaTypeToFireboltSQLString.DATE.transform(Date.valueOf(LocalDate.of(2022, 5, 23)), new Object())).getCause().getClass()); } @Test