diff --git a/client-v2/src/main/java/com/clickhouse/client/api/DataTypeUtils.java b/client-v2/src/main/java/com/clickhouse/client/api/DataTypeUtils.java index b9f0218cc..c724929c0 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/DataTypeUtils.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/DataTypeUtils.java @@ -3,12 +3,20 @@ import com.clickhouse.client.api.data_formats.internal.BinaryStreamReader; import com.clickhouse.data.ClickHouseDataType; +import java.sql.Date; +import java.sql.Time; +import java.sql.Timestamp; import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.ChronoField; +import java.util.Calendar; import java.util.Objects; +import java.util.TimeZone; import static com.clickhouse.client.api.data_formats.internal.BinaryStreamReader.BASES; @@ -40,6 +48,10 @@ public class DataTypeUtils { public static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + public static final DateTimeFormatter DATE_TIME_WITH_OPTIONAL_NANOS = new DateTimeFormatterBuilder().appendPattern("uuuu-MM-dd HH:mm:ss") + .appendOptional(new DateTimeFormatterBuilder().appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true).toFormatter()) + .toFormatter(); + /** * Formats an {@link Instant} object for use in SQL statements or as query * parameter. @@ -139,4 +151,322 @@ public static Instant instantFromTime64Integer(int precision, long value) { return Instant.ofEpochSecond(value, nanoSeconds); } + + /** + * Converts a {@link java.sql.Date} to {@link LocalDate} using the specified Calendar's timezone. + * + *
The Calendar parameter specifies the timezone context in which to interpret the + * Date's internal epoch milliseconds. This is important because java.sql.Date stores + * milliseconds since epoch, and interpreting those millis in different timezones + * can result in different calendar dates (the "day shift" problem).
+ * + *Example: A Date with millis representing "2024-01-15 00:00:00 UTC" would be + * interpreted as "2024-01-14" in America/New_York (UTC-5) if not handled correctly.
+ * + *For default JVM timezone behavior, use {@link Date#toLocalDate()} directly.
+ * + * @param sqlDate the java.sql.Date to convert + * @param calendar the Calendar specifying the timezone context + * @return the LocalDate representing the date in the specified timezone + * @throws NullPointerException if sqlDate or calendar is null + */ + public static LocalDate toLocalDate(Date sqlDate, Calendar calendar) { + Objects.requireNonNull(sqlDate, "sqlDate must not be null"); + Objects.requireNonNull(calendar, "calendar must not be null"); + + // Clone the calendar to avoid modifying the original + Calendar cal = (Calendar) calendar.clone(); + cal.setTimeInMillis(sqlDate.getTime()); + + return LocalDate.of( + cal.get(Calendar.YEAR), + cal.get(Calendar.MONTH) + 1, // Calendar months are 0-based + cal.get(Calendar.DAY_OF_MONTH) + ); + } + + /** + * Converts a {@link java.sql.Date} to {@link LocalDate} using the specified timezone. + * + *For default JVM timezone behavior, use {@link Date#toLocalDate()} directly.
+ * + * @param sqlDate the java.sql.Date to convert + * @param timeZone the timezone context + * @return the LocalDate representing the date in the specified timezone + * @throws NullPointerException if sqlDate or timeZone is null + */ + public static LocalDate toLocalDate(Date sqlDate, TimeZone timeZone) { + Objects.requireNonNull(sqlDate, "sqlDate must not be null"); + Objects.requireNonNull(timeZone, "timeZone must not be null"); + + ZoneId zoneId = timeZone.toZoneId(); + return Instant.ofEpochMilli(sqlDate.getTime()) + .atZone(zoneId) + .toLocalDate(); + } + + /** + * Converts a {@link java.sql.Time} to {@link LocalTime} using the specified Calendar's timezone. + * + *The Calendar parameter specifies the timezone context in which to interpret the + * Time's internal epoch milliseconds. java.sql.Time stores the time as millis since + * epoch on January 1, 1970, so timezone affects which hour/minute/second is extracted.
+ * + *For default JVM timezone behavior, use {@link Time#toLocalTime()} directly.
+ * + * @param sqlTime the java.sql.Time to convert + * @param calendar the Calendar specifying the timezone context + * @return the LocalTime representing the time in the specified timezone + * @throws NullPointerException if sqlTime or calendar is null + */ + public static LocalTime toLocalTime(Time sqlTime, Calendar calendar) { + Objects.requireNonNull(sqlTime, "sqlTime must not be null"); + Objects.requireNonNull(calendar, "calendar must not be null"); + + // Clone the calendar to avoid modifying the original + Calendar cal = (Calendar) calendar.clone(); + cal.setTimeInMillis(sqlTime.getTime()); + + return LocalTime.of( + cal.get(Calendar.HOUR_OF_DAY), + cal.get(Calendar.MINUTE), + cal.get(Calendar.SECOND), + // Calendar doesn't store nanos, but millis - convert to nanos + cal.get(Calendar.MILLISECOND) * 1_000_000 + ); + } + + /** + * Converts a {@link java.sql.Time} to {@link LocalTime} using the specified timezone. + * + *For default JVM timezone behavior, use {@link Time#toLocalTime()} directly.
+ * + * @param sqlTime the java.sql.Time to convert + * @param timeZone the timezone context + * @return the LocalTime representing the time in the specified timezone + * @throws NullPointerException if sqlTime or timeZone is null + */ + public static LocalTime toLocalTime(Time sqlTime, TimeZone timeZone) { + Objects.requireNonNull(sqlTime, "sqlTime must not be null"); + Objects.requireNonNull(timeZone, "timeZone must not be null"); + + ZoneId zoneId = timeZone.toZoneId(); + return Instant.ofEpochMilli(sqlTime.getTime()) + .atZone(zoneId) + .toLocalTime(); + } + + /** + * Converts a {@link java.sql.Timestamp} to {@link LocalDateTime} using the specified Calendar's timezone. + * + *The Calendar parameter specifies the timezone context in which to interpret the + * Timestamp's internal epoch milliseconds. This is crucial for correct date and time + * extraction when the application and database are in different timezones.
+ * + *Note: This method preserves nanosecond precision from the Timestamp.
+ * + *For default JVM timezone behavior, use {@link Timestamp#toLocalDateTime()} directly.
+ * + * @param sqlTimestamp the java.sql.Timestamp to convert + * @param calendar the Calendar specifying the timezone context + * @return the LocalDateTime representing the timestamp in the specified timezone + * @throws NullPointerException if sqlTimestamp or calendar is null + */ + public static LocalDateTime toLocalDateTime(Timestamp sqlTimestamp, Calendar calendar) { + Objects.requireNonNull(sqlTimestamp, "sqlTimestamp must not be null"); + Objects.requireNonNull(calendar, "calendar must not be null"); + + // Clone the calendar to avoid modifying the original + Calendar cal = (Calendar) calendar.clone(); + cal.setTimeInMillis(sqlTimestamp.getTime()); + + // Preserve nanoseconds from Timestamp (Calendar only has millisecond precision) + int nanos = sqlTimestamp.getNanos(); + + return LocalDateTime.of( + cal.get(Calendar.YEAR), + cal.get(Calendar.MONTH) + 1, // Calendar months are 0-based + cal.get(Calendar.DAY_OF_MONTH), + cal.get(Calendar.HOUR_OF_DAY), + cal.get(Calendar.MINUTE), + cal.get(Calendar.SECOND), + nanos + ); + } + + /** + * Converts a {@link java.sql.Timestamp} to {@link LocalDateTime} using the specified timezone. + * + *Note: This method preserves nanosecond precision from the Timestamp.
+ * + *For default JVM timezone behavior, use {@link Timestamp#toLocalDateTime()} directly.
+ * + * @param sqlTimestamp the java.sql.Timestamp to convert + * @param timeZone the timezone context + * @return the LocalDateTime representing the timestamp in the specified timezone + * @throws NullPointerException if sqlTimestamp or timeZone is null + */ + public static LocalDateTime toLocalDateTime(Timestamp sqlTimestamp, TimeZone timeZone) { + Objects.requireNonNull(sqlTimestamp, "sqlTimestamp must not be null"); + Objects.requireNonNull(timeZone, "timeZone must not be null"); + + ZoneId zoneId = timeZone.toZoneId(); + // Use Instant to preserve nanoseconds + return LocalDateTime.ofInstant(sqlTimestamp.toInstant(), zoneId); + } + + // ==================== LocalDate/LocalTime/LocalDateTime to SQL types ==================== + + /** + * Converts a {@link LocalDate} to {@link java.sql.Date} using the specified Calendar's timezone. + * + *The Calendar parameter specifies the timezone context in which to interpret the + * LocalDate when calculating the epoch milliseconds for the resulting java.sql.Date. + * The resulting Date will represent midnight on the specified date in the Calendar's timezone.
+ * + *For default JVM timezone behavior, use {@link Date#valueOf(LocalDate)} directly.
+ * + * @param localDate the LocalDate to convert + * @param calendar the Calendar specifying the timezone context + * @return the java.sql.Date representing midnight on the specified date in the given timezone + * @throws NullPointerException if localDate or calendar is null + */ + public static Date toSqlDate(LocalDate localDate, Calendar calendar) { + Objects.requireNonNull(localDate, "localDate must not be null"); + Objects.requireNonNull(calendar, "calendar must not be null"); + + // Clone the calendar to avoid modifying the original + Calendar cal = (Calendar) calendar.clone(); + cal.clear(); + cal.set(localDate.getYear(), localDate.getMonthValue() - 1, localDate.getDayOfMonth(), 0, 0, 0); + cal.set(Calendar.MILLISECOND, 0); + + return new Date(cal.getTimeInMillis()); + } + + /** + * Converts a {@link LocalDate} to {@link java.sql.Date} using the specified timezone. + * + *For default JVM timezone behavior, use {@link Date#valueOf(LocalDate)} directly.
+ * + * @param localDate the LocalDate to convert + * @param timeZone the timezone context + * @return the java.sql.Date representing midnight on the specified date in the given timezone + * @throws NullPointerException if localDate or timeZone is null + */ + public static Date toSqlDate(LocalDate localDate, TimeZone timeZone) { + Objects.requireNonNull(localDate, "localDate must not be null"); + Objects.requireNonNull(timeZone, "timeZone must not be null"); + + ZoneId zoneId = timeZone.toZoneId(); + long epochMillis = localDate.atStartOfDay(zoneId).toInstant().toEpochMilli(); + return new Date(epochMillis); + } + + /** + * Converts a {@link LocalTime} to {@link java.sql.Time} using the specified Calendar's timezone. + * + *The Calendar parameter specifies the timezone context in which to interpret the + * LocalTime when calculating the epoch milliseconds for the resulting java.sql.Time. + * The resulting Time will represent the specified time on January 1, 1970 in the Calendar's timezone.
+ * + *For default JVM timezone behavior, use {@link Time#valueOf(LocalTime)} directly.
+ * + * @param localTime the LocalTime to convert + * @param calendar the Calendar specifying the timezone context + * @return the java.sql.Time representing the specified time + * @throws NullPointerException if localTime or calendar is null + */ + public static Time toSqlTime(LocalTime localTime, Calendar calendar) { + Objects.requireNonNull(localTime, "localTime must not be null"); + Objects.requireNonNull(calendar, "calendar must not be null"); + + // Clone the calendar to avoid modifying the original + Calendar cal = (Calendar) calendar.clone(); + cal.clear(); + // java.sql.Time is based on January 1, 1970 + cal.set(1970, Calendar.JANUARY, 1, + localTime.getHour(), localTime.getMinute(), localTime.getSecond()); + cal.set(Calendar.MILLISECOND, localTime.getNano() / 1_000_000); + + return new Time(cal.getTimeInMillis()); + } + + /** + * Converts a {@link LocalTime} to {@link java.sql.Time} using the specified timezone. + * + *For default JVM timezone behavior, use {@link Time#valueOf(LocalTime)} directly.
+ * + * @param localTime the LocalTime to convert + * @param timeZone the timezone context + * @return the java.sql.Time representing the specified time + * @throws NullPointerException if localTime or timeZone is null + */ + public static Time toSqlTime(LocalTime localTime, TimeZone timeZone) { + Objects.requireNonNull(localTime, "localTime must not be null"); + Objects.requireNonNull(timeZone, "timeZone must not be null"); + + ZoneId zoneId = timeZone.toZoneId(); + // java.sql.Time is based on January 1, 1970 + long epochMillis = localTime.atDate(LocalDate.of(1970, 1, 1)) + .atZone(zoneId) + .toInstant() + .toEpochMilli(); + return new Time(epochMillis); + } + + /** + * Converts a {@link LocalDateTime} to {@link java.sql.Timestamp} using the specified Calendar's timezone. + * + *The Calendar parameter specifies the timezone context in which to interpret the + * LocalDateTime when calculating the epoch milliseconds for the resulting java.sql.Timestamp.
+ * + *Note: This method preserves nanosecond precision from the LocalDateTime.
+ * + *For default JVM timezone behavior, use {@link Timestamp#valueOf(LocalDateTime)} directly.
+ * + * @param localDateTime the LocalDateTime to convert + * @param calendar the Calendar specifying the timezone context + * @return the java.sql.Timestamp representing the specified date and time + * @throws NullPointerException if localDateTime or calendar is null + */ + public static Timestamp toSqlTimestamp(LocalDateTime localDateTime, Calendar calendar) { + Objects.requireNonNull(localDateTime, "localDateTime must not be null"); + Objects.requireNonNull(calendar, "calendar must not be null"); + + // Clone the calendar to avoid modifying the original + Calendar cal = (Calendar) calendar.clone(); + cal.clear(); + cal.set(localDateTime.getYear(), localDateTime.getMonthValue() - 1, localDateTime.getDayOfMonth(), + localDateTime.getHour(), localDateTime.getMinute(), localDateTime.getSecond()); + cal.set(Calendar.MILLISECOND, 0); // We'll set nanos separately + + Timestamp timestamp = new Timestamp(cal.getTimeInMillis()); + timestamp.setNanos(localDateTime.getNano()); + return timestamp; + } + + /** + * Converts a {@link LocalDateTime} to {@link java.sql.Timestamp} using the specified timezone. + * + *Note: This method preserves nanosecond precision from the LocalDateTime.
+ * + *For default JVM timezone behavior, use {@link Timestamp#valueOf(LocalDateTime)} directly.
+ * + * @param localDateTime the LocalDateTime to convert + * @param timeZone the timezone context + * @return the java.sql.Timestamp representing the specified date and time + * @throws NullPointerException if localDateTime or timeZone is null + */ + public static Timestamp toSqlTimestamp(LocalDateTime localDateTime, TimeZone timeZone) { + Objects.requireNonNull(localDateTime, "localDateTime must not be null"); + Objects.requireNonNull(timeZone, "timeZone must not be null"); + + ZoneId zoneId = timeZone.toZoneId(); + Instant instant = localDateTime.atZone(zoneId).toInstant(); + Timestamp timestamp = Timestamp.from(instant); + // Timestamp.from() may lose nanosecond precision, so set it explicitly + timestamp.setNanos(localDateTime.getNano()); + return timestamp; + } } diff --git a/client-v2/src/test/java/com/clickhouse/client/api/DataTypeUtilsTests.java b/client-v2/src/test/java/com/clickhouse/client/api/DataTypeUtilsTests.java index cfc3efafd..dbe8f017f 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/DataTypeUtilsTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/DataTypeUtilsTests.java @@ -1,19 +1,23 @@ package com.clickhouse.client.api; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import com.clickhouse.data.ClickHouseDataType; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; +import java.sql.Date; +import java.sql.Time; +import java.sql.Timestamp; +import java.time.*; import java.time.temporal.ChronoUnit; +import java.util.Calendar; +import java.util.GregorianCalendar; import java.util.TimeZone; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertThrows; -class DataTypeUtilsTests { + +public class DataTypeUtilsTests { @Test void testDateTimeFormatter() { @@ -130,4 +134,579 @@ void formatInstantForDateTime64Truncated() { "1752980742.232000000"); } + @Test(groups = {"unit"}) + void testDifferentDateConversions() throws Exception { + Calendar externalSystemTz = Calendar.getInstance(TimeZone.getTimeZone("UTC+12")); + Calendar utcTz = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + Calendar applicationLocalTz = Calendar.getInstance(TimeZone.getTimeZone("UTC-8")); + + + String externalDateStr = externalSystemTz.get(Calendar.YEAR) + "-" + (externalSystemTz.get(Calendar.MONTH) + 1) + "-" + externalSystemTz.get(Calendar.DAY_OF_MONTH); + java.sql.Date externalDate = new java.sql.Date(externalSystemTz.getTimeInMillis()); + System.out.println(externalDate.toLocalDate()); + System.out.println(externalDateStr); + System.out.println(externalDate); + + Calendar extCal2 = (Calendar) externalSystemTz.clone(); + extCal2.setTime(externalDate); + + System.out.println("> " + extCal2); + String externalDateStr2 = extCal2.get(Calendar.YEAR) + "-" + (extCal2.get(Calendar.MONTH) + 1) + "-" + extCal2.get(Calendar.DAY_OF_MONTH); + System.out.println("> " + externalDateStr2); + + Calendar extCal3 = (Calendar) externalSystemTz.clone(); + LocalDate localDateFromExternal = externalDate.toLocalDate(); // converted date to local timezone (day may shift) + extCal3.clear(); + extCal3.set(localDateFromExternal.getYear(), localDateFromExternal.getMonthValue() - 1, localDateFromExternal.getDayOfMonth(), 0, 0, 0); + System.out.println("converted> " + extCal3.toInstant()); // wrong date!! + } + + // ==================== Tests for toLocalDate ==================== + + @Test(groups = {"unit"}) + void testToLocalDateNullCalendar() { + Date sqlDate = Date.valueOf("2024-01-15"); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toLocalDate(sqlDate, (Calendar) null)); + } + + @Test(groups = {"unit"}) + void testToLocalDateNullTimeZone() { + Date sqlDate = Date.valueOf("2024-01-15"); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toLocalDate(sqlDate, (TimeZone) null)); + } + + @Test(groups = {"unit"}) + void testToLocalDateNullDate() { + Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toLocalDate((Date) null, cal)); + } + + @Test(groups = {"unit"}) + void testToLocalDateWithCalendar() { + // Create a date that represents midnight Jan 15, 2024 in UTC + Calendar utcCal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + utcCal.clear(); + utcCal.set(2024, Calendar.JANUARY, 15, 0, 0, 0); + Date sqlDate = new Date(utcCal.getTimeInMillis()); + + // Using UTC calendar should give us Jan 15 + LocalDate resultUtc = DataTypeUtils.toLocalDate(sqlDate, utcCal); + assertEquals(resultUtc, LocalDate.of(2024, 1, 15)); + } + + /** + * Test the "day shift" problem: when a Date's millis are created in one timezone + * but interpreted in another, the day can shift. + */ + @Test(groups = {"unit"}) + void testToLocalDateDayShiftProblem() { + // Simulate: Date created in Pacific/Auckland (UTC+12/+13) + // At midnight Jan 15 in Auckland, it's still Jan 14 in UTC + TimeZone aucklandTz = TimeZone.getTimeZone("Pacific/Auckland"); + Calendar aucklandCal = new GregorianCalendar(aucklandTz); + aucklandCal.clear(); + aucklandCal.set(2024, Calendar.JANUARY, 15, 0, 0, 0); + Date dateFromAuckland = new Date(aucklandCal.getTimeInMillis()); + + // Using Auckland calendar should correctly extract Jan 15 + LocalDate withAucklandCal = DataTypeUtils.toLocalDate(dateFromAuckland, aucklandCal); + assertEquals(withAucklandCal, LocalDate.of(2024, 1, 15), + "With correct timezone, should get Jan 15"); + + // Using UTC calendar on the same Date would give a different (earlier) day + Calendar utcCal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + LocalDate withUtcCal = DataTypeUtils.toLocalDate(dateFromAuckland, utcCal); + assertEquals(withUtcCal, LocalDate.of(2024, 1, 14), + "With UTC timezone, should get Jan 14 (day shift demonstrated)"); + } + + @DataProvider(name = "timezonesForDateTest") + public Object[][] timezonesForDateTest() { + return new Object[][] { + {"UTC", "2024-01-15", 2024, 1, 15}, + {"America/New_York", "2024-01-15", 2024, 1, 15}, + {"America/Los_Angeles", "2024-01-15", 2024, 1, 15}, + {"Europe/London", "2024-01-15", 2024, 1, 15}, + {"Europe/Moscow", "2024-01-15", 2024, 1, 15}, + {"Asia/Tokyo", "2024-01-15", 2024, 1, 15}, + {"Pacific/Auckland", "2024-01-15", 2024, 1, 15}, + {"Pacific/Honolulu", "2024-01-15", 2024, 1, 15}, + }; + } + + @Test(groups = {"unit"}, dataProvider = "timezonesForDateTest") + void testToLocalDateWithVariousTimezones(String tzId, String dateStr, int year, int month, int day) { + TimeZone tz = TimeZone.getTimeZone(tzId); + Calendar cal = new GregorianCalendar(tz); + cal.clear(); + cal.set(year, month - 1, day, 0, 0, 0); + Date sqlDate = new Date(cal.getTimeInMillis()); + + LocalDate result = DataTypeUtils.toLocalDate(sqlDate, cal); + assertEquals(result, LocalDate.of(year, month, day), + "Date should be preserved in timezone: " + tzId); + } + + @Test(groups = {"unit"}) + void testToLocalDateWithTimeZoneObject() { + TimeZone utc = TimeZone.getTimeZone("UTC"); + Calendar utcCal = new GregorianCalendar(utc); + utcCal.clear(); + utcCal.set(2024, Calendar.JULY, 4, 0, 0, 0); + Date sqlDate = new Date(utcCal.getTimeInMillis()); + + LocalDate result = DataTypeUtils.toLocalDate(sqlDate, utc); + assertEquals(result, LocalDate.of(2024, 7, 4)); + } + + // ==================== Tests for toLocalTime ==================== + + @Test(groups = {"unit"}) + void testToLocalTimeNullCalendar() { + Time sqlTime = Time.valueOf("12:34:56"); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toLocalTime(sqlTime, (Calendar) null)); + } + + @Test(groups = {"unit"}) + void testToLocalTimeNullTimeZone() { + Time sqlTime = Time.valueOf("12:34:56"); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toLocalTime(sqlTime, (TimeZone) null)); + } + + @Test(groups = {"unit"}) + void testToLocalTimeNullTime() { + Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toLocalTime((Time) null, cal)); + } + + @Test(groups = {"unit"}) + void testToLocalTimeWithCalendar() { + // Create a time that represents 14:30:00 in UTC + Calendar utcCal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + utcCal.clear(); + utcCal.set(1970, Calendar.JANUARY, 1, 14, 30, 0); + Time sqlTime = new Time(utcCal.getTimeInMillis()); + + // Using UTC calendar should give us 14:30:00 + LocalTime resultUtc = DataTypeUtils.toLocalTime(sqlTime, utcCal); + assertEquals(resultUtc.getHour(), 14); + assertEquals(resultUtc.getMinute(), 30); + assertEquals(resultUtc.getSecond(), 0); + } + + @Test(groups = {"unit"}) + void testToLocalTimeTimeZoneShift() { + // Create time in UTC: 14:00:00 + Calendar utcCal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + utcCal.clear(); + utcCal.set(1970, Calendar.JANUARY, 1, 14, 0, 0); + Time sqlTime = new Time(utcCal.getTimeInMillis()); + + // In UTC, should be 14:00 + LocalTime inUtc = DataTypeUtils.toLocalTime(sqlTime, utcCal); + assertEquals(inUtc, LocalTime.of(14, 0, 0)); + + // In New York (UTC-5), same instant would be 09:00 + Calendar nyCal = new GregorianCalendar(TimeZone.getTimeZone("America/New_York")); + LocalTime inNy = DataTypeUtils.toLocalTime(sqlTime, nyCal); + assertEquals(inNy, LocalTime.of(9, 0, 0)); + } + + @Test(groups = {"unit"}) + void testToLocalTimeWithTimeZoneObject() { + TimeZone utc = TimeZone.getTimeZone("UTC"); + Calendar utcCal = new GregorianCalendar(utc); + utcCal.clear(); + utcCal.set(1970, Calendar.JANUARY, 1, 23, 59, 59); + Time sqlTime = new Time(utcCal.getTimeInMillis()); + + LocalTime result = DataTypeUtils.toLocalTime(sqlTime, utc); + assertEquals(result, LocalTime.of(23, 59, 59)); + } + + // ==================== Tests for toLocalDateTime ==================== + + @Test(groups = {"unit"}) + void testToLocalDateTimeNullCalendar() { + Timestamp sqlTimestamp = Timestamp.valueOf("2024-01-15 12:34:56.789"); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toLocalDateTime(sqlTimestamp, (Calendar) null)); + } + + @Test(groups = {"unit"}) + void testToLocalDateTimeNullTimeZone() { + Timestamp sqlTimestamp = Timestamp.valueOf("2024-01-15 12:34:56.789"); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toLocalDateTime(sqlTimestamp, (TimeZone) null)); + } + + @Test(groups = {"unit"}) + void testToLocalDateTimeNullTimestamp() { + Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toLocalDateTime((Timestamp) null, cal)); + } + + @Test(groups = {"unit"}) + void testToLocalDateTimeWithCalendar() { + // Create a timestamp representing 2024-01-15 14:30:00 in UTC + Calendar utcCal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + utcCal.clear(); + utcCal.set(2024, Calendar.JANUARY, 15, 14, 30, 0); + Timestamp sqlTimestamp = new Timestamp(utcCal.getTimeInMillis()); + + LocalDateTime result = DataTypeUtils.toLocalDateTime(sqlTimestamp, utcCal); + assertEquals(result, LocalDateTime.of(2024, 1, 15, 14, 30, 0)); + } + + @Test(groups = {"unit"}) + void testToLocalDateTimePreservesNanoseconds() { + // Create timestamp in default timezone + Timestamp sqlTimestamp = Timestamp.valueOf("2024-01-15 12:34:56.123456789"); + sqlTimestamp.setNanos(123456789); + + // Use default timezone calendar to match the Timestamp's creation context + Calendar defaultCal = new GregorianCalendar(); + LocalDateTime result = DataTypeUtils.toLocalDateTime(sqlTimestamp, defaultCal); + assertEquals(result.getNano(), 123456789); + } + + @Test(groups = {"unit"}) + void testToLocalDateTimeTimezoneShift() { + // Create timestamp in UTC: 2024-01-15 04:00:00 + Calendar utcCal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + utcCal.clear(); + utcCal.set(2024, Calendar.JANUARY, 15, 4, 0, 0); + Timestamp sqlTimestamp = new Timestamp(utcCal.getTimeInMillis()); + + // In UTC: 2024-01-15 04:00:00 + LocalDateTime inUtc = DataTypeUtils.toLocalDateTime(sqlTimestamp, utcCal); + assertEquals(inUtc, LocalDateTime.of(2024, 1, 15, 4, 0, 0)); + + // In New York (UTC-5): same instant is 2024-01-14 23:00:00 + Calendar nyCal = new GregorianCalendar(TimeZone.getTimeZone("America/New_York")); + LocalDateTime inNy = DataTypeUtils.toLocalDateTime(sqlTimestamp, nyCal); + assertEquals(inNy, LocalDateTime.of(2024, 1, 14, 23, 0, 0)); + } + + @Test(groups = {"unit"}) + void testToLocalDateTimeWithTimeZoneObject() { + TimeZone utc = TimeZone.getTimeZone("UTC"); + Calendar utcCal = new GregorianCalendar(utc); + utcCal.clear(); + utcCal.set(2024, Calendar.DECEMBER, 31, 23, 59, 59); + Timestamp sqlTimestamp = new Timestamp(utcCal.getTimeInMillis()); + sqlTimestamp.setNanos(999999999); + + LocalDateTime result = DataTypeUtils.toLocalDateTime(sqlTimestamp, utc); + assertEquals(result, LocalDateTime.of(2024, 12, 31, 23, 59, 59, 999999999)); + } + + @Test(groups = {"unit"}) + void testToLocalDateTimeNanosPreservedWithTimeZone() { + // Verify nanoseconds are preserved when using TimeZone overload + TimeZone tokyo = TimeZone.getTimeZone("Asia/Tokyo"); + Calendar tokyoCal = new GregorianCalendar(tokyo); + tokyoCal.clear(); + tokyoCal.set(2024, Calendar.JUNE, 15, 10, 30, 45); + Timestamp sqlTimestamp = new Timestamp(tokyoCal.getTimeInMillis()); + sqlTimestamp.setNanos(123456789); + + LocalDateTime result = DataTypeUtils.toLocalDateTime(sqlTimestamp, tokyo); + assertEquals(result.getNano(), 123456789); + assertEquals(result.getHour(), 10); + assertEquals(result.getMinute(), 30); + assertEquals(result.getSecond(), 45); + } + + /** + * Comprehensive test demonstrating the day shift problem and its solution. + */ + @Test(groups = {"unit"}) + void testDayShiftProblemAndSolution() { + // Scenario: Financial system in Tokyo (UTC+9) records a trade at 11 PM on Dec 31 + // Server is running in UTC + TimeZone tokyoTz = TimeZone.getTimeZone("Asia/Tokyo"); + TimeZone utcTz = TimeZone.getTimeZone("UTC"); + + // Trade timestamp: Dec 31, 2024 23:30:00 Tokyo time + Calendar tokyoCal = new GregorianCalendar(tokyoTz); + tokyoCal.clear(); + tokyoCal.set(2024, Calendar.DECEMBER, 31, 23, 30, 0); + Timestamp tradeTimestamp = new Timestamp(tokyoCal.getTimeInMillis()); + + // At 23:30 Tokyo (UTC+9), it's 14:30 UTC - still Dec 31 + LocalDateTime inTokyo = DataTypeUtils.toLocalDateTime(tradeTimestamp, tokyoCal); + assertEquals(inTokyo.toLocalDate(), LocalDate.of(2024, 12, 31), + "In Tokyo timezone, trade date should be Dec 31"); + + LocalDateTime inUtc = DataTypeUtils.toLocalDateTime(tradeTimestamp, + new GregorianCalendar(utcTz)); + assertEquals(inUtc.toLocalDate(), LocalDate.of(2024, 12, 31), + "In UTC, same trade is also Dec 31 (14:30 UTC)"); + + // But if the trade was at 00:30 Tokyo time on Jan 1... + tokyoCal.clear(); + tokyoCal.set(2025, Calendar.JANUARY, 1, 0, 30, 0); + Timestamp newYearTrade = new Timestamp(tokyoCal.getTimeInMillis()); + + LocalDateTime newYearInTokyo = DataTypeUtils.toLocalDateTime(newYearTrade, tokyoCal); + assertEquals(newYearInTokyo.toLocalDate(), LocalDate.of(2025, 1, 1), + "In Tokyo, it's New Year's Day"); + + LocalDateTime newYearInUtc = DataTypeUtils.toLocalDateTime(newYearTrade, + new GregorianCalendar(utcTz)); + assertEquals(newYearInUtc.toLocalDate(), LocalDate.of(2024, 12, 31), + "In UTC, it's still Dec 31 (15:30 UTC on Dec 31)"); + } + + // ==================== Tests for toSqlDate ==================== + + @Test(groups = {"unit"}) + void testToSqlDateNullLocalDate() { + Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toSqlDate((LocalDate) null, cal)); + } + + @Test(groups = {"unit"}) + void testToSqlDateNullCalendar() { + LocalDate localDate = LocalDate.of(2024, 1, 15); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toSqlDate(localDate, (Calendar) null)); + } + + @Test(groups = {"unit"}) + void testToSqlDateNullTimeZone() { + LocalDate localDate = LocalDate.of(2024, 1, 15); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toSqlDate(localDate, (TimeZone) null)); + } + + @Test(groups = {"unit"}) + void testToSqlDateWithCalendar() { + LocalDate localDate = LocalDate.of(2024, 1, 15); + Calendar utcCal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + + Date sqlDate = DataTypeUtils.toSqlDate(localDate, utcCal); + + // Convert back to verify round-trip + LocalDate roundTrip = DataTypeUtils.toLocalDate(sqlDate, utcCal); + assertEquals(roundTrip, localDate); + } + + @Test(groups = {"unit"}) + void testToSqlDateWithTimeZone() { + LocalDate localDate = LocalDate.of(2024, 7, 4); + TimeZone utc = TimeZone.getTimeZone("UTC"); + + Date sqlDate = DataTypeUtils.toSqlDate(localDate, utc); + + // Convert back to verify round-trip + LocalDate roundTrip = DataTypeUtils.toLocalDate(sqlDate, utc); + assertEquals(roundTrip, localDate); + } + + @Test(groups = {"unit"}) + void testToSqlDateRoundTripWithVariousTimezones() { + LocalDate localDate = LocalDate.of(2024, 1, 15); + String[] tzIds = {"UTC", "America/New_York", "Asia/Tokyo", "Pacific/Auckland"}; + + for (String tzId : tzIds) { + TimeZone tz = TimeZone.getTimeZone(tzId); + Calendar cal = new GregorianCalendar(tz); + + // Convert to SQL Date and back + Date sqlDate = DataTypeUtils.toSqlDate(localDate, cal); + LocalDate roundTrip = DataTypeUtils.toLocalDate(sqlDate, cal); + + assertEquals(roundTrip, localDate, + "Round-trip should preserve date in timezone: " + tzId); + } + } + + // ==================== Tests for toSqlTime ==================== + + @Test(groups = {"unit"}) + void testToSqlTimeNullLocalTime() { + Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toSqlTime((LocalTime) null, cal)); + } + + @Test(groups = {"unit"}) + void testToSqlTimeNullCalendar() { + LocalTime localTime = LocalTime.of(14, 30, 0); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toSqlTime(localTime, (Calendar) null)); + } + + @Test(groups = {"unit"}) + void testToSqlTimeNullTimeZone() { + LocalTime localTime = LocalTime.of(14, 30, 0); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toSqlTime(localTime, (TimeZone) null)); + } + + @Test(groups = {"unit"}) + void testToSqlTimeWithCalendar() { + LocalTime localTime = LocalTime.of(14, 30, 45); + Calendar utcCal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + + Time sqlTime = DataTypeUtils.toSqlTime(localTime, utcCal); + + // Convert back to verify round-trip (note: millisecond precision only) + LocalTime roundTrip = DataTypeUtils.toLocalTime(sqlTime, utcCal); + assertEquals(roundTrip.getHour(), localTime.getHour()); + assertEquals(roundTrip.getMinute(), localTime.getMinute()); + assertEquals(roundTrip.getSecond(), localTime.getSecond()); + } + + @Test(groups = {"unit"}) + void testToSqlTimeWithTimeZone() { + LocalTime localTime = LocalTime.of(23, 59, 59); + TimeZone utc = TimeZone.getTimeZone("UTC"); + + Time sqlTime = DataTypeUtils.toSqlTime(localTime, utc); + + // Convert back to verify round-trip + LocalTime roundTrip = DataTypeUtils.toLocalTime(sqlTime, utc); + assertEquals(roundTrip.getHour(), localTime.getHour()); + assertEquals(roundTrip.getMinute(), localTime.getMinute()); + assertEquals(roundTrip.getSecond(), localTime.getSecond()); + } + + @Test(groups = {"unit"}) + void testToSqlTimeWithMilliseconds() { + // LocalTime with nanoseconds (will be truncated to millis in Time) + LocalTime localTime = LocalTime.of(10, 20, 30, 123_456_789); + Calendar utcCal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + + Time sqlTime = DataTypeUtils.toSqlTime(localTime, utcCal); + LocalTime roundTrip = DataTypeUtils.toLocalTime(sqlTime, utcCal); + + assertEquals(roundTrip.getHour(), 10); + assertEquals(roundTrip.getMinute(), 20); + assertEquals(roundTrip.getSecond(), 30); + // Milliseconds preserved (nanos truncated to millis) + assertEquals(roundTrip.getNano() / 1_000_000, 123); + } + + // ==================== Tests for toSqlTimestamp ==================== + + @Test(groups = {"unit"}) + void testToSqlTimestampNullLocalDateTime() { + Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toSqlTimestamp((LocalDateTime) null, cal)); + } + + @Test(groups = {"unit"}) + void testToSqlTimestampNullCalendar() { + LocalDateTime localDateTime = LocalDateTime.of(2024, 1, 15, 14, 30, 0); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toSqlTimestamp(localDateTime, (Calendar) null)); + } + + @Test(groups = {"unit"}) + void testToSqlTimestampNullTimeZone() { + LocalDateTime localDateTime = LocalDateTime.of(2024, 1, 15, 14, 30, 0); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toSqlTimestamp(localDateTime, (TimeZone) null)); + } + + @Test(groups = {"unit"}) + void testToSqlTimestampWithCalendar() { + LocalDateTime localDateTime = LocalDateTime.of(2024, 1, 15, 14, 30, 45, 123456789); + Calendar utcCal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + + Timestamp sqlTimestamp = DataTypeUtils.toSqlTimestamp(localDateTime, utcCal); + + // Convert back to verify round-trip + LocalDateTime roundTrip = DataTypeUtils.toLocalDateTime(sqlTimestamp, utcCal); + assertEquals(roundTrip, localDateTime); + } + + @Test(groups = {"unit"}) + void testToSqlTimestampWithTimeZone() { + LocalDateTime localDateTime = LocalDateTime.of(2024, 12, 31, 23, 59, 59, 999999999); + TimeZone utc = TimeZone.getTimeZone("UTC"); + + Timestamp sqlTimestamp = DataTypeUtils.toSqlTimestamp(localDateTime, utc); + + // Convert back to verify round-trip + LocalDateTime roundTrip = DataTypeUtils.toLocalDateTime(sqlTimestamp, utc); + assertEquals(roundTrip, localDateTime); + } + + @Test(groups = {"unit"}) + void testToSqlTimestampPreservesNanoseconds() { + LocalDateTime localDateTime = LocalDateTime.of(2024, 6, 15, 10, 30, 45, 123456789); + Calendar utcCal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + + Timestamp sqlTimestamp = DataTypeUtils.toSqlTimestamp(localDateTime, utcCal); + + assertEquals(sqlTimestamp.getNanos(), 123456789); + } + + @Test(groups = {"unit"}) + void testToSqlTimestampRoundTripWithVariousTimezones() { + LocalDateTime localDateTime = LocalDateTime.of(2024, 1, 15, 23, 30, 45, 123456789); + String[] tzIds = {"UTC", "America/New_York", "Asia/Tokyo", "Pacific/Auckland"}; + + for (String tzId : tzIds) { + TimeZone tz = TimeZone.getTimeZone(tzId); + Calendar cal = new GregorianCalendar(tz); + + // Convert to SQL Timestamp and back + Timestamp sqlTimestamp = DataTypeUtils.toSqlTimestamp(localDateTime, cal); + LocalDateTime roundTrip = DataTypeUtils.toLocalDateTime(sqlTimestamp, cal); + + assertEquals(roundTrip, localDateTime, + "Round-trip should preserve datetime in timezone: " + tzId); + } + } + + /** + * Comprehensive round-trip test demonstrating timezone handling. + */ + @Test(groups = {"unit"}) + void testRoundTripConversionsWithDifferentTimezones() { + // Original values + LocalDate date = LocalDate.of(2024, 7, 4); + LocalTime time = LocalTime.of(14, 30, 45, 123000000); + LocalDateTime dateTime = LocalDateTime.of(date, time); + + TimeZone tokyo = TimeZone.getTimeZone("Asia/Tokyo"); + TimeZone newYork = TimeZone.getTimeZone("America/New_York"); + + // Convert to SQL types using Tokyo timezone + Calendar tokyoCal = new GregorianCalendar(tokyo); + Date sqlDateTokyo = DataTypeUtils.toSqlDate(date, tokyoCal); + Time sqlTimeTokyo = DataTypeUtils.toSqlTime(time, tokyoCal); + Timestamp sqlTimestampTokyo = DataTypeUtils.toSqlTimestamp(dateTime, tokyoCal); + + // Round-trip back using same timezone should preserve values + assertEquals(DataTypeUtils.toLocalDate(sqlDateTokyo, tokyoCal), date); + LocalTime timeRoundTrip = DataTypeUtils.toLocalTime(sqlTimeTokyo, tokyoCal); + assertEquals(timeRoundTrip.getHour(), time.getHour()); + assertEquals(timeRoundTrip.getMinute(), time.getMinute()); + assertEquals(timeRoundTrip.getSecond(), time.getSecond()); + assertEquals(DataTypeUtils.toLocalDateTime(sqlTimestampTokyo, tokyoCal), dateTime); + + // If we interpret the same SQL values in a different timezone, we get different local values + // This is expected - the same instant in time represents different local times in different zones + Calendar nyCal = new GregorianCalendar(newYork); + LocalDateTime dateTimeInNy = DataTypeUtils.toLocalDateTime(sqlTimestampTokyo, nyCal); + // Tokyo is 13-14 hours ahead of NY, so the local time should be different + // (14:30 Tokyo = 01:30 or 00:30 NY depending on DST) + assertEquals(dateTimeInNy.toLocalDate(), LocalDate.of(2024, 7, 4).minusDays(1), + "Same instant should be previous day in New York"); + } } diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java index 96e8566f7..638d73a4c 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java @@ -52,6 +52,8 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; @@ -69,11 +71,6 @@ public class PreparedStatementImpl extends StatementImpl implements PreparedStatement, JdbcV2Wrapper { private static final Logger LOG = LoggerFactory.getLogger(PreparedStatementImpl.class); - public static final DateTimeFormatter TIME_FORMATTER = new DateTimeFormatterBuilder().appendPattern("HH:mm:ss") - .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true).toFormatter(); - public static final DateTimeFormatter DATETIME_FORMATTER = new DateTimeFormatterBuilder() - .appendPattern("yyyy-MM-dd HH:mm:ss").appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true).toFormatter(); - private final Calendar defaultCalendar; private final String originalSql; @@ -218,17 +215,17 @@ public void setBytes(int parameterIndex, byte[] x) throws SQLException { @Override public void setDate(int parameterIndex, Date x) throws SQLException { - setDate(parameterIndex, x, null); + setDate(parameterIndex, x, defaultCalendar); } @Override public void setTime(int parameterIndex, Time x) throws SQLException { - setTime(parameterIndex, x, null); + setTime(parameterIndex, x, defaultCalendar); } @Override public void setTimestamp(int parameterIndex, Timestamp x) throws SQLException { - setTimestamp(parameterIndex, x, null); + setTimestamp(parameterIndex, x, defaultCalendar); } @Override @@ -469,43 +466,19 @@ public static String replaceQuestionMarks(String sql, final String replacement) @Override public void setDate(int parameterIndex, Date x, Calendar cal) throws SQLException { ensureOpen(); - values[parameterIndex - 1] = encodeObject(sqlDateToInstant(x, cal)); - } - - protected Instant sqlDateToInstant(Date x, Calendar cal) { - LocalDate d = x.toLocalDate(); - Calendar c = (Calendar) (cal != null ? cal : defaultCalendar).clone(); - c.clear(); - c.set(d.getYear(), d.getMonthValue() - 1, d.getDayOfMonth(), 0, 0, 0); - return c.toInstant(); + values[parameterIndex - 1] = encodeObject(DataTypeUtils.toLocalDate(x, cal)); } @Override public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLException { ensureOpen(); - values[parameterIndex - 1] = encodeObject(sqlTimeToInstant(x, cal)); - } - - protected Instant sqlTimeToInstant(Time x, Calendar cal) { - LocalTime t = x.toLocalTime(); - Calendar c = (Calendar) (cal != null ? cal : defaultCalendar).clone(); - c.clear(); - c.set(1970, Calendar.JANUARY, 1, t.getHour(), t.getMinute(), t.getSecond()); - return c.toInstant(); + values[parameterIndex - 1] = encodeObject(DataTypeUtils.toLocalTime(x, cal)); } @Override public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException { ensureOpen(); - values[parameterIndex - 1] = encodeObject(sqlTimestampToZDT(x, cal)); - } - - protected ZonedDateTime sqlTimestampToZDT(Timestamp x, Calendar cal) { - LocalDateTime ldt = x.toLocalDateTime(); - Calendar c = (Calendar) (cal != null ? cal : defaultCalendar).clone(); - c.clear(); - c.set(ldt.getYear(), ldt.getMonthValue() - 1, ldt.getDayOfMonth(), ldt.getHour(), ldt.getMinute(), ldt.getSecond()); - return c.toInstant().atZone(ZoneId.of("UTC")).withNano(x.getNanos()); + values[parameterIndex - 1] = encodeObject(DataTypeUtils.toLocalDateTime(x, cal)); } @Override @@ -783,13 +756,13 @@ private String encodeObject(Object x, Long length) throws SQLException { } else if (x instanceof LocalDate) { return QUOTE + DataTypeUtils.DATE_FORMATTER.format((LocalDate) x) + QUOTE; } else if (x instanceof Time) { - return QUOTE + TIME_FORMATTER.format(((Time) x).toLocalTime()) + QUOTE; + return QUOTE + DataTypeUtils.TIME_FORMATTER.format(((Time) x).toLocalTime()) + QUOTE; } else if (x instanceof LocalTime) { - return QUOTE + TIME_FORMATTER.format((LocalTime) x) + QUOTE; + return QUOTE + DataTypeUtils.TIME_FORMATTER.format((LocalTime) x) + QUOTE; } else if (x instanceof Timestamp) { - return QUOTE + DATETIME_FORMATTER.format(((Timestamp) x).toLocalDateTime()) + QUOTE; + return QUOTE + DataTypeUtils.DATE_TIME_WITH_OPTIONAL_NANOS.format(((Timestamp) x).toLocalDateTime()) + QUOTE; } else if (x instanceof LocalDateTime) { - return QUOTE + DATETIME_FORMATTER.format((LocalDateTime) x) + QUOTE; + return QUOTE + DataTypeUtils.DATE_TIME_WITH_OPTIONAL_NANOS.format((LocalDateTime) x) + QUOTE; } else if (x instanceof OffsetDateTime) { return encodeObject(((OffsetDateTime) x).toInstant()); } else if (x instanceof ZonedDateTime) { @@ -1032,17 +1005,6 @@ private ClickHouseDataType sqlType2ClickHouseDataType(SQLType type) throws SQLEx } private String encodeObject(Object x, ClickHouseDataType clickHouseDataType, Integer scaleOrLength) throws SQLException { - String encodedObject = encodeObject(x); - if (clickHouseDataType != null) { - encodedObject = "CAST (" + encodedObject + " AS " + clickHouseDataType.name(); - if (clickHouseDataType.hasParameter()) { - if (scaleOrLength == null) { - throw new SQLException("Target type " + clickHouseDataType + " requires a parameter"); - } - encodedObject += "(" + scaleOrLength + ")"; - } - encodedObject += ")"; - } - return encodedObject; + return encodeObject(x); } } diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java index b85d438fa..bf2b7517c 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java @@ -1,5 +1,6 @@ package com.clickhouse.jdbc; +import com.clickhouse.client.api.DataTypeUtils; import com.clickhouse.client.api.data_formats.ClickHouseBinaryFormatReader; import com.clickhouse.client.api.metadata.TableSchema; import com.clickhouse.client.api.query.QueryResponse; @@ -34,6 +35,8 @@ import java.sql.Time; import java.sql.Timestamp; import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZonedDateTime; import java.util.Calendar; import java.util.Collections; @@ -230,17 +233,17 @@ public byte[] getBytes(int columnIndex) throws SQLException { @Override public Date getDate(int columnIndex) throws SQLException { - return getDate(columnIndex, null); + return getDate(columnIndex, defaultCalendar); } @Override public Time getTime(int columnIndex) throws SQLException { - return getTime(columnIndex, null); + return getTime(columnIndex, defaultCalendar); } @Override public Timestamp getTimestamp(int columnIndex) throws SQLException { - return getTimestamp(columnIndex, null); + return getTimestamp(columnIndex, defaultCalendar); } @Override @@ -420,17 +423,17 @@ public byte[] getBytes(String columnLabel) throws SQLException { @Override public Date getDate(String columnLabel) throws SQLException { - return getDate(columnLabel, null); + return getDate(columnLabel, defaultCalendar); } @Override public Time getTime(String columnLabel) throws SQLException { - return getTime(columnLabel, null); + return getTime(columnLabel, defaultCalendar); } @Override public Timestamp getTimestamp(String columnLabel) throws SQLException { - return getTimestamp(columnLabel, null); + return getTimestamp(columnLabel, defaultCalendar); } @Override @@ -1012,17 +1015,14 @@ public Date getDate(int columnIndex, Calendar cal) throws SQLException { public Date getDate(String columnLabel, Calendar cal) throws SQLException { checkClosed(); try { - ZonedDateTime zdt = reader.getZonedDateTime(columnLabel); - if (zdt == null) { + LocalDate date = reader.getLocalDate(columnLabel); + if (date == null) { wasNull = true; return null; } wasNull = false; - Calendar c = (Calendar) (cal != null ? cal : defaultCalendar).clone(); - c.clear(); - c.set(zdt.getYear(), zdt.getMonthValue() - 1, zdt.getDayOfMonth(), 0, 0, 0); - return new Date(c.getTimeInMillis()); + return DataTypeUtils.toSqlDate(date, cal); } catch (Exception e) { throw ExceptionUtils.toSqlState(String.format("Method: getDate(\"%s\") encountered an exception.", columnLabel), String.format("SQL: [%s]", parentStatement.getLastStatementSql()), e); } @@ -1052,17 +1052,14 @@ public Time getTime(String columnLabel, Calendar cal) throws SQLException { case DateTime: case DateTime32: case DateTime64: - ZonedDateTime zdt = reader.getZonedDateTime(columnLabel); - if (zdt == null) { + LocalDateTime dateTime = reader.getLocalDateTime(columnLabel); + if (dateTime == null) { wasNull = true; return null; } wasNull = false; - Calendar c = (Calendar) (cal != null ? cal : defaultCalendar).clone(); - c.clear(); - c.set(1970, Calendar.JANUARY, 1, zdt.getHour(), zdt.getMinute(), zdt.getSecond()); - return new Time(c.getTimeInMillis()); + return DataTypeUtils.toSqlTime(dateTime.toLocalTime(), cal); default: throw new SQLException("Column \"" + columnLabel + "\" is not a time type."); } @@ -1088,12 +1085,7 @@ public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLExcept } wasNull = false; - Calendar c = (Calendar) (cal != null ? cal : defaultCalendar).clone(); - c.set(zdt.getYear(), zdt.getMonthValue() - 1, zdt.getDayOfMonth(), zdt.getHour(), zdt.getMinute(), - zdt.getSecond()); - Timestamp timestamp = new Timestamp(c.getTimeInMillis()); - timestamp.setNanos(zdt.getNano()); - return timestamp; + return DataTypeUtils.toSqlTimestamp(zdt.toLocalDateTime(), cal); } catch (Exception e) { throw ExceptionUtils.toSqlState(String.format("Method: getTimestamp(\"%s\") encountered an exception.", columnLabel), String.format("SQL: [%s]", parentStatement.getLastStatementSql()), e); } diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/WriterStatementImpl.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/WriterStatementImpl.java index 582065016..22b60e791 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/WriterStatementImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/WriterStatementImpl.java @@ -1,5 +1,6 @@ package com.clickhouse.jdbc; +import com.clickhouse.client.api.DataTypeUtils; import com.clickhouse.client.api.data_formats.ClickHouseBinaryFormatWriter; import com.clickhouse.client.api.data_formats.RowBinaryFormatWriter; import com.clickhouse.client.api.insert.InsertResponse; @@ -47,12 +48,14 @@ public class WriterStatementImpl extends PreparedStatementImpl implements Prepar private ByteArrayOutputStream out; private ClickHouseBinaryFormatWriter writer; private final TableSchema tableSchema; + private final Calendar defaultCalendar; public WriterStatementImpl(ConnectionImpl connection, String originalSql, TableSchema tableSchema, ParsedPreparedStatement parsedStatement) throws SQLException { super(connection, originalSql, parsedStatement); + this.defaultCalendar = connection.getDefaultCalendar(); if (parsedStatement.getInsertColumns() != null) { List