From 6966916c39f590ed961385afc9cae9304cccf6c4 Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Tue, 16 Dec 2025 23:12:41 -0800 Subject: [PATCH 01/12] Added date time converters. --- .../clickhouse/client/api/DataTypeUtils.java | 330 ++++++++++ .../client/api/DataTypeUtilsTests.java | 589 +++++++++++++++++- .../jdbc/PreparedStatementImpl.java | 64 +- .../com/clickhouse/jdbc/ResultSetImpl.java | 40 +- .../clickhouse/jdbc/WriterStatementImpl.java | 15 +- .../jdbc/PreparedStatementTest.java | 212 ++++++- 6 files changed, 1147 insertions(+), 103 deletions(-) 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 insertColumns = new ArrayList<>(); for (String column : parsedStatement.getInsertColumns()) { @@ -202,17 +205,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 @@ -374,19 +377,19 @@ public void setArray(int parameterIndex, Array x) throws SQLException { @Override public void setDate(int parameterIndex, Date x, Calendar cal) throws SQLException { ensureOpen(); - writer.setValue(parameterIndex, sqlDateToInstant(x, cal)); + writer.setValue(parameterIndex, DataTypeUtils.toLocalDate(x, cal)); } @Override public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLException { ensureOpen(); - writer.setValue(parameterIndex, sqlTimeToInstant(x, cal)); + writer.setValue(parameterIndex, DataTypeUtils.toLocalTime(x, cal)); } @Override public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException { ensureOpen(); - writer.setDateTime(parameterIndex, sqlTimestampToZDT(x, cal)); + writer.setDateTime(parameterIndex, DataTypeUtils.toLocalDateTime(x, cal)); } @Override diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java index 2a28f8e67..0c46f4f1a 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java @@ -28,14 +28,8 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.GregorianCalendar; -import java.util.Properties; -import java.util.Random; -import java.util.TimeZone; -import java.util.UUID; +import java.util.*; +import java.util.concurrent.TimeUnit; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; @@ -220,7 +214,7 @@ public void testSetDate() throws Exception { @Test(groups = { "integration" }) public void testSetTime() throws Exception { try (Connection conn = getJdbcConnection()) { - try (PreparedStatement stmt = conn.prepareStatement("SELECT toDateTime(?)")) { + try (PreparedStatement stmt = conn.prepareStatement("SELECT toTime(?)")) { stmt.setTime(1, java.sql.Time.valueOf("12:34:56"), new GregorianCalendar(TimeZone.getTimeZone("UTC"))); try (ResultSet rs = stmt.executeQuery()) { assertTrue(rs.next()); @@ -233,11 +227,22 @@ public void testSetTime() throws Exception { @Test(groups = { "integration" }) public void testSetTimestamp() throws Exception { + final Calendar calendar = new GregorianCalendar(TimeZone.getTimeZone("UTC")); try (Connection conn = getJdbcConnection()) { try (PreparedStatement stmt = conn.prepareStatement("SELECT toDateTime64(?, 3)")) { - stmt.setTimestamp(1, java.sql.Timestamp.valueOf("2021-01-01 01:34:56.456"), new GregorianCalendar(TimeZone.getTimeZone("UTC"))); + stmt.setTimestamp(1, java.sql.Timestamp.valueOf("2021-01-01 01:34:56.456"), calendar); try (ResultSet rs = stmt.executeQuery()) { assertTrue(rs.next()); + assertEquals(rs.getTimestamp(1, calendar).toString(), "2021-01-01 01:34:56.456"); + assertFalse(rs.next()); + } + } + + try (PreparedStatement stmt = conn.prepareStatement("SELECT toDateTime64(?, 3)")) { + stmt.setObject(1, LocalDateTime.parse("2021-01-01T01:34:56").withNano((int) TimeUnit.MILLISECONDS.toNanos(456))); + try (ResultSet rs = stmt.executeQuery()) { + assertTrue(rs.next()); + assertEquals(rs.getTimestamp(1).getNanos(), TimeUnit.MILLISECONDS.toNanos(456)); assertEquals(rs.getTimestamp(1).toString(), "2021-01-01 01:34:56.456"); assertFalse(rs.next()); } @@ -435,7 +440,7 @@ void testWithClauseWithParams() throws Exception { stmt.execute("CREATE TABLE " + table + " (v1 String) Engine MergeTree ORDER BY ()"); stmt.execute("INSERT INTO " + table + " VALUES ('A'), ('B')"); } - final Timestamp target_time = Timestamp.valueOf(LocalDateTime.now()); + final Timestamp target_time = Timestamp.valueOf(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS)); try (PreparedStatement stmt = conn.prepareStatement("WITH " + " toDateTime(?) as target_time, " + " (SELECT 123) as magic_number" + @@ -886,7 +891,8 @@ void testBatchInsertWithRowBinary(String sql, Class implClass) throws Exception try (PreparedStatement stmt = conn.prepareStatement(String.format(sql, table))) { Assert.assertEquals(stmt.getClass(), implClass); for (int bI = 0; bI < nBatches; bI++) { - stmt.setTimestamp(1, Timestamp.valueOf(LocalDateTime.now())); + // truncate to seconds to make fit into DateTime + stmt.setTimestamp(1, Timestamp.valueOf(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS))); stmt.setInt(2, rnd.nextInt()); stmt.setFloat(3, rnd.nextFloat()); stmt.setInt(4, rnd.nextInt()); @@ -939,10 +945,11 @@ void testBatchInsertTextStatement(String sql) throws Exception { } final int nBatches = 10; + try (PreparedStatement stmt = conn.prepareStatement(String.format(sql, table))) { Assert.assertEquals(stmt.getClass(), PreparedStatementImpl.class); for (int bI = 0; bI < nBatches; bI++) { - stmt.setTimestamp(1, Timestamp.valueOf(LocalDateTime.now())); + stmt.setTimestamp(1, Timestamp.valueOf(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS))); stmt.setInt(2, rnd.nextInt()); stmt.setFloat(3, rnd.nextFloat()); stmt.setInt(4, rnd.nextInt()); @@ -1406,7 +1413,8 @@ public void testWithInClause() throws Exception { } } - @Test(groups = {"integration"}, dataProvider = "testTypeCastsDP") + // Disabled because implicit type casting is not a right way + @Test(groups = {"integration"}, dataProvider = "testTypeCastsDP", enabled = false) public void testTypeCastsWithoutArgument(Object value, SQLType targetType, ClickHouseDataType expectedType) throws Exception { try (Connection conn = getJdbcConnection()) { try (PreparedStatement stmt = conn.prepareStatement("select ?, toTypeName(?)")) { @@ -1446,7 +1454,8 @@ public static Object[][] testTypeCastsDP() { }; } - @Test(groups = {"integration"}, dataProvider = "testJDBCTypeCastDP") + // Disabled because implicit type casting is not a right way + @Test(groups = {"integration"}, dataProvider = "testJDBCTypeCastDP", enabled = false) public void testJDBCTypeCast(Object value, int targetType, ClickHouseDataType expectedType) throws Exception { try (Connection conn = getJdbcConnection()) { try (PreparedStatement stmt = conn.prepareStatement("select ?, toTypeName(?)")) { @@ -1478,7 +1487,8 @@ public static Object[][] testJDBCTypeCastDP() { }; } - @Test(groups = {"integration"}) + // Disabled because implicit type casting is not a right way + @Test(groups = {"integration"}, enabled = false) public void testTypesInvalidForCast() throws Exception { try (Connection conn = getJdbcConnection()) { try (PreparedStatement stmt = conn.prepareStatement("select ?, toTypeName(?)")) { @@ -1492,7 +1502,8 @@ public void testTypesInvalidForCast() throws Exception { } } - @Test(groups = {"integration"}, dataProvider = "testTypeCastWithScaleOrLengthDP") + // Disabled because implicit type casting is not a right way + @Test(groups = {"integration"}, dataProvider = "testTypeCastWithScaleOrLengthDP", enabled = false) public void testTypeCastWithScaleOrLength(Object value, SQLType targetType, Integer scaleOrLength, String expectedValue, String expectedType) throws Exception { try (Connection conn = getJdbcConnection()) { @@ -1626,4 +1637,171 @@ public void testEncodingArray() throws Exception { } } } + + + /** + * Tests the "day shift" bug that can occur when timezone differences cause dates to shift by a day. + * + * The issue: java.sql.Date internally stores milliseconds since epoch at midnight in some timezone. + * If the driver interprets that instant using a different timezone, the date can shift. + * + * Example: "2024-01-15 00:00:00" in America/New_York (UTC-5) is "2024-01-15 05:00:00" in UTC. + * But "2024-01-15 00:00:00" in Pacific/Auckland (UTC+13) is "2024-01-14 11:00:00" in UTC. + * If not handled correctly, dates near midnight can shift to the previous or next day. + */ + @Test(groups = {"integration"}) + void testDateDayShiftWithDifferentTimezones() throws Exception { + String table = "test_date_day_shift"; + try (Connection conn = getJdbcConnection()) { + try (Statement stmt = conn.createStatement()) { + stmt.execute("DROP TABLE IF EXISTS " + table); + stmt.execute("CREATE TABLE " + table + " (id Int32, d Date) Engine MergeTree ORDER BY id"); + } + + // Test dates that are prone to day shift issues (near year boundaries, month boundaries) + String[] testDates = { + "2024-01-01", // New Year - common edge case + "2024-12-31", // Year end + "2024-06-15", // Mid-year + "2024-03-10", // Near DST transition in US + "2024-11-03" // Near DST transition in US + }; + + // Timezones with significant offsets from UTC + TimeZone[] timezones = { + TimeZone.getTimeZone("UTC"), + TimeZone.getTimeZone("America/New_York"), // UTC-5 / UTC-4 (DST) + TimeZone.getTimeZone("America/Los_Angeles"), // UTC-8 / UTC-7 (DST) + TimeZone.getTimeZone("Europe/Moscow"), // UTC+3 + TimeZone.getTimeZone("Asia/Tokyo"), // UTC+9 + TimeZone.getTimeZone("Pacific/Auckland") // UTC+12 / UTC+13 (DST) + }; + + int id = 0; + for (String dateStr : testDates) { + java.sql.Date expectedDate = java.sql.Date.valueOf(dateStr); + + for (TimeZone tz : timezones) { + id++; + try (PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO " + table + " (id, d) VALUES (?, ?)")) { + stmt.setInt(1, id); + // Use Calendar to specify the timezone context for the date + stmt.setDate(2, expectedDate, new GregorianCalendar(tz)); + stmt.executeUpdate(); + } + + // Verify the date was stored correctly + try (PreparedStatement stmt = conn.prepareStatement( + "SELECT d FROM " + table + " WHERE id = ?")) { + stmt.setInt(1, id); + try (ResultSet rs = stmt.executeQuery()) { + assertTrue(rs.next(), "Expected row for id=" + id); + java.sql.Date actualDate = rs.getDate(1); + assertEquals(actualDate.toString(), expectedDate.toString(), + String.format("Date mismatch for %s with timezone %s: expected %s, got %s", + dateStr, tz.getID(), expectedDate, actualDate)); + } + } + } + } + + // Cleanup + try (Statement stmt = conn.createStatement()) { + stmt.execute("DROP TABLE " + table); + } + } + } + + /** + * Tests that setDate without Calendar uses the default timezone consistently. + * Compares behavior with and without explicit Calendar parameter. + */ + @Test(groups = {"integration"}) + void testDateWithAndWithoutCalendar() throws Exception { + String table = "test_date_calendar_comparison"; + try (Connection conn = getJdbcConnection()) { + try (Statement stmt = conn.createStatement()) { + stmt.execute("DROP TABLE IF EXISTS " + table); + stmt.execute("CREATE TABLE " + table + " (id Int32, d Date) Engine MergeTree ORDER BY id"); + } + + java.sql.Date testDate = java.sql.Date.valueOf("2024-06-15"); + + // Insert without Calendar (uses default timezone) + try (PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO " + table + " (id, d) VALUES (?, ?)")) { + stmt.setInt(1, 1); + stmt.setDate(2, testDate); // No Calendar - uses default + stmt.executeUpdate(); + } + + // Insert with explicit UTC Calendar + try (PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO " + table + " (id, d) VALUES (?, ?)")) { + stmt.setInt(1, 2); + stmt.setDate(2, testDate, new GregorianCalendar(TimeZone.getTimeZone("UTC"))); + stmt.executeUpdate(); + } + + // Both should retrieve the same date string + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT id, d FROM " + table + " ORDER BY id")) { + + assertTrue(rs.next()); + assertEquals(rs.getInt(1), 1); + java.sql.Date dateWithoutCalendar = rs.getDate(2); + + assertTrue(rs.next()); + assertEquals(rs.getInt(1), 2); + java.sql.Date dateWithCalendar = rs.getDate(2); + + // The string representation should be the same + assertEquals(dateWithoutCalendar.toString(), testDate.toString(), + "Date without Calendar should match original"); + assertEquals(dateWithCalendar.toString(), testDate.toString(), + "Date with Calendar should match original"); + } + + // Cleanup + try (Statement stmt = conn.createStatement()) { + stmt.execute("DROP TABLE " + table); + } + } + } + + /** + * Tests edge case: dates at the boundary of a day in extreme timezones. + * This is where day shift bugs are most likely to manifest. + */ + @Test(groups = {"integration"}) + void testDateBoundaryEdgeCases() throws Exception { + try (Connection conn = getJdbcConnection()) { + final java.sql.Date testDate = java.sql.Date.valueOf("2024-01-15"); + + // Test with a timezone that's far ahead of UTC (e.g., Pacific/Auckland UTC+12/+13) + // If there's a day shift bug, this would show as 2024-01-14 or 2024-01-16 + TimeZone auckland = TimeZone.getTimeZone("Pacific/Auckland"); + try (PreparedStatement stmt = conn.prepareStatement("SELECT toDate(?)")) { + stmt.setDate(1, testDate, new GregorianCalendar(auckland)); + try (ResultSet rs = stmt.executeQuery()) { + assertTrue(rs.next()); + assertEquals(rs.getDate(1).toString(), "2024-01-15", + "Date should not shift even with far-ahead timezone"); + } + } + + // Test with a timezone that's far behind UTC (e.g., Pacific/Honolulu UTC-10) + TimeZone honolulu = TimeZone.getTimeZone("Pacific/Honolulu"); + Calendar honoluluCal = new GregorianCalendar(honolulu); + try (PreparedStatement stmt = conn.prepareStatement("SELECT toDate(?)")) { + stmt.setDate(1, testDate, honoluluCal); + try (ResultSet rs = stmt.executeQuery()) { + assertTrue(rs.next()); + assertEquals(rs.getDate(1, honoluluCal).toString(), "2024-01-15", + "Date should not shift even with far-behind timezone"); + } + } + } + } } From 25231f6358425601e6e910da63d0f76fb659e1d3 Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Wed, 21 Jan 2026 13:48:01 -0800 Subject: [PATCH 02/12] Added small tests - date time conversion examples --- .../client/internal/SmallTests.java | 59 +++++++++++++++++++ .../jdbc/PreparedStatementImpl.java | 6 -- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/client-v2/src/test/java/com/clickhouse/client/internal/SmallTests.java b/client-v2/src/test/java/com/clickhouse/client/internal/SmallTests.java index 42be10846..5f03bbdee 100644 --- a/client-v2/src/test/java/com/clickhouse/client/internal/SmallTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/internal/SmallTests.java @@ -1,8 +1,67 @@ package com.clickhouse.client.internal; +import com.clickhouse.client.api.DataTypeUtils; +import org.testng.annotations.Test; + +import java.sql.Date; +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.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Calendar; +import java.util.TimeZone; + /** * Tests playground */ public class SmallTests { + + @Test + public void localDateTimeValues() { + String srcDate = "2024-12-31"; + String srcTime = "11:59:59"; + String srcTimestamp = srcDate + "T" + srcTime; + + + LocalDate localDate = LocalDate.parse(srcDate, DateTimeFormatter.ISO_LOCAL_DATE); + LocalTime localTime = LocalTime.parse(srcTime, DateTimeFormatter.ISO_LOCAL_TIME); + LocalDateTime localTimestamp = LocalDateTime.parse(srcTimestamp, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + + System.out.println("\n" + localDate + "\n" + localTime + "\n" +localTimestamp); + + Timestamp oldTimestamp = new Timestamp(ZonedDateTime.now(ZoneId.of("UTC")).toEpochSecond() * 1000); + System.out.println("oldTimestamp instant: " + oldTimestamp.toInstant()); + System.out.println("local datetime: " + LocalDateTime.now()); + System.out.println("local datetime from oldTS: " + oldTimestamp.toLocalDateTime()); + } + + + @Test + public void oldJDBCDateConversion() throws Exception { + // see com.clickhouse.jdbc.internal.SqlBasedPreparedStatement.setDate + + Calendar utc = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + utc.set(2026, 0, 1, 0, 0, 0); + System.out.println("utc: " + utc.toInstant()); + Date d1 = new Date(utc.getTimeInMillis()); + System.out.println("d1: " + d1.toLocalDate() + " (ts: " + d1.getTime() + ")"); + System.out.println("d1 converted: " + DataTypeUtils.toLocalDate(d1, utc)); + + // else + Calendar convertCal = (Calendar) utc.clone(); + convertCal.setTime(d1); + Instant convInst =convertCal.toInstant(); + System.out.println("converted Instant: " + convInst); + ZonedDateTime convZoned = convInst.atZone(utc.getTimeZone().toZoneId()).withZoneSameInstant(ZoneId.of("America/New_York")); + System.out.println("convZoned: " + convZoned); + LocalDate convDate = convZoned.toLocalDate(); + System.out.println("convDate: " + convDate); + System.out.println("local date time " + convZoned.toLocalDateTime()); + } + } 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 638d73a4c..f79540ea2 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java @@ -47,13 +47,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.OffsetDateTime; -import java.time.ZoneId; import java.time.ZonedDateTime; -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; From 3379f9f1be9b553c7cec13e347b0f881f2136d4a Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Mon, 9 Feb 2026 16:02:27 -0800 Subject: [PATCH 03/12] Updated converting of LocalDate and LocalTime to Date and Time. --- .../client/internal/SmallTests.java | 34 +++++++++++++++++++ .../com/clickhouse/jdbc/ResultSetImpl.java | 28 ++++++++------- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/client-v2/src/test/java/com/clickhouse/client/internal/SmallTests.java b/client-v2/src/test/java/com/clickhouse/client/internal/SmallTests.java index 57ef3cb5c..e0d84a17e 100644 --- a/client-v2/src/test/java/com/clickhouse/client/internal/SmallTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/internal/SmallTests.java @@ -2,11 +2,14 @@ import org.testng.annotations.Test; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZoneId; import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.TimeZone; import java.util.concurrent.TimeUnit; /** @@ -46,4 +49,35 @@ public void testInstantVsLocalTime() { System.out.println(maxTime); System.out.println(maxTime.getDayOfYear()); } + + @Test + public void testInstantFromUTC() { + + LocalDate ld = LocalDate.of(1970, 1, 1); + + ZonedDateTime atTokyo = ld.atStartOfDay(TimeZone.getTimeZone("Asia/Tokyo").toZoneId()); + Instant tokyoInstant = atTokyo.toInstant(); + + ZonedDateTime atUtc = ld.atStartOfDay(TimeZone.getTimeZone("UTC").toZoneId()); + Instant utcInstant = atUtc.toInstant(); + + System.out.println(ld); + System.out.println(atTokyo); + System.out.println(tokyoInstant); + System.out.println(atUtc); + System.out.println(utcInstant); + + } + + @Test + public void testTimezoneOffset() { + ZoneId tokyoTz = ZoneId.of("Asia/Tokyo"); + ZoneId losAngelesTz = ZoneId.of("America/Los_Angeles"); + + System.out.println(tokyoTz.getRules().getTransitionRules()); + System.out.println(losAngelesTz.getRules().getTransitionRules()); + + ZonedDateTime ld = LocalDate.of(1970, 3, 7).atStartOfDay(losAngelesTz); + System.out.println(ld.toOffsetDateTime()); + } } 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 72fb5466c..3a78afb1f 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java @@ -34,10 +34,13 @@ import java.sql.Statement; import java.sql.Time; import java.sql.Timestamp; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Calendar; import java.util.Collections; @@ -1025,10 +1028,9 @@ public Date getDate(String columnLabel, Calendar cal) throws SQLException { } wasNull = false; - Calendar c = (Calendar) (cal != null ? cal : defaultCalendar).clone(); - c.clear(); - c.set(ld.getYear(), ld.getMonthValue() - 1, ld.getDayOfMonth(), 0, 0, 0); - return new Date(c.getTimeInMillis()); + Calendar c = cal != null ? cal : defaultCalendar; + long time = ld.atStartOfDay(c.getTimeZone().toZoneId()).toEpochSecond() * 1000; + return new Date(time); } catch (Exception e) { ClickHouseColumn column = getSchema().getColumnByName(columnLabel); switch (column.getValueDataType()) { @@ -1083,14 +1085,9 @@ public Time getTime(String columnLabel, Calendar cal) throws SQLException { @Override public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException { - return getTimestamp(columnIndexToName(columnIndex), cal); - } - - @Override - public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLException { checkClosed(); try { - ZonedDateTime zdt = reader.getZonedDateTime(columnLabel); + ZonedDateTime zdt = reader.getZonedDateTime(columnIndex); if (zdt == null) { wasNull = true; return null; @@ -1099,7 +1096,7 @@ public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLExcept return DataTypeUtils.toSqlTimestamp(zdt.toLocalDateTime(), cal); } catch (Exception e) { - ClickHouseColumn column = getSchema().getColumnByName(columnLabel); + ClickHouseColumn column = getSchema().getColumnByIndex(columnIndex); switch (column.getValueDataType()) { case DateTime64: case DateTime: @@ -1109,8 +1106,15 @@ public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLExcept throw new SQLException("Value of " + column.getValueDataType() + " type cannot be converted to Timestamp value"); } - throw ExceptionUtils.toSqlState(String.format("Method: getTimestamp(\"%s\") encountered an exception.", columnLabel), String.format("SQL: [%s]", parentStatement.getLastStatementSql()), e); + throw ExceptionUtils.toSqlState(String.format("Method: getTimestamp(\"%s\") encountered an exception.", columnIndex), + String.format("SQL: [%s]", parentStatement.getLastStatementSql()), e); } + + } + + @Override + public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLException { + return getTimestamp(getSchema().nameToColumnIndex(columnLabel), cal); } @Override From b09a59ed734579308a66d93b932173f3ec40886a Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Mon, 9 Feb 2026 16:22:43 -0800 Subject: [PATCH 04/12] made proper covnersion from time to Duration --- .../internal/AbstractBinaryFormatReader.java | 25 ++++++++++++++++--- .../internal/MapBackedRecord.java | 25 ++++++++++++++++--- .../client/datatypes/DataTypeTests.java | 5 ++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/AbstractBinaryFormatReader.java b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/AbstractBinaryFormatReader.java index 2ebf2ffa0..a9b0269c2 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/AbstractBinaryFormatReader.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/AbstractBinaryFormatReader.java @@ -2,6 +2,7 @@ import com.clickhouse.client.api.ClientConfigProperties; import com.clickhouse.client.api.ClientException; +import com.clickhouse.client.api.DataTypeUtils; import com.clickhouse.client.api.data_formats.ClickHouseBinaryFormatReader; import com.clickhouse.client.api.internal.DataTypeConverter; import com.clickhouse.client.api.internal.MapUtils; @@ -680,13 +681,31 @@ public ZonedDateTime getZonedDateTime(int index) { @Override public Duration getDuration(int index) { - TemporalAmount temporalAmount = getTemporalAmount(index); - return temporalAmount == null ? null : Duration.from(temporalAmount); + Object value = readValue(index); + if (value == null) { + return null; + } + if (value instanceof LocalDateTime) { + return DataTypeUtils.localDateTimeToDuration((LocalDateTime) value); + } else if (value instanceof TemporalAmount) { + return Duration.from((TemporalAmount)value); + } + throw new ClientException("Column at index " + index + " cannot be converted to Duration"); } @Override public TemporalAmount getTemporalAmount(int index) { - return readValue(index); + Object value = readValue(index); + if (value == null) { + return null; + } + if (value instanceof LocalDateTime) { + return DataTypeUtils.localDateTimeToDuration((LocalDateTime) value); + } else if (value instanceof TemporalAmount) { + return (TemporalAmount) value; + } + + throw new ClientException("Column at index " + index + " cannot be converted to TemporalAmount"); } @Override diff --git a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/MapBackedRecord.java b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/MapBackedRecord.java index 35d138846..ef5be3c89 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/MapBackedRecord.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/MapBackedRecord.java @@ -1,6 +1,7 @@ package com.clickhouse.client.api.data_formats.internal; import com.clickhouse.client.api.ClientException; +import com.clickhouse.client.api.DataTypeUtils; import com.clickhouse.client.api.internal.DataTypeConverter; import com.clickhouse.client.api.metadata.TableSchema; import com.clickhouse.client.api.query.GenericRecord; @@ -173,13 +174,31 @@ public ZonedDateTime getZonedDateTime(String colName) { @Override public Duration getDuration(String colName) { - TemporalAmount temporalAmount = readValue(colName); - return temporalAmount == null ? null : Duration.from(temporalAmount); + Object value = readValue(colName); + if (value == null) { + return null; + } + if (value instanceof LocalDateTime) { + return DataTypeUtils.localDateTimeToDuration((LocalDateTime) value); + } else if (value instanceof TemporalAmount) { + return Duration.from((TemporalAmount)value); + } + throw new ClientException("Column " + colName + " cannot be converted to Duration"); } @Override public TemporalAmount getTemporalAmount(String colName) { - return readValue(colName); + Object value = readValue(colName); + if (value == null) { + return null; + } + if (value instanceof LocalDateTime) { + return DataTypeUtils.localDateTimeToDuration((LocalDateTime) value); + } else if (value instanceof TemporalAmount) { + return (TemporalAmount) value; + } + + throw new ClientException("Column " + colName + " cannot be converted to TemporalAmount"); } @Override diff --git a/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java b/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java index bb606592b..9e7087745 100644 --- a/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java @@ -35,6 +35,7 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.sql.Connection; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; @@ -868,22 +869,26 @@ public void testTimeDataType() throws Exception { Assert.assertEquals(record.getInteger("o_num"), 1); Assert.assertEquals(record.getLocalDateTime("time").toEpochSecond(ZoneOffset.UTC), TimeUnit.HOURS.toSeconds(999)); Assert.assertEquals(record.getInstant("time"), Instant.ofEpochSecond(TimeUnit.HOURS.toSeconds(999))); + Assert.assertEquals(record.getDuration("time"), Duration.ofHours(999)); record = records.get(1); Assert.assertEquals(record.getInteger("o_num"), 2); Assert.assertEquals(record.getLocalDateTime("time").toEpochSecond(ZoneOffset.UTC), TimeUnit.HOURS.toSeconds(999) + TimeUnit.MINUTES.toSeconds(59) + 59); Assert.assertEquals(record.getInstant("time"), Instant.ofEpochSecond(TimeUnit.HOURS.toSeconds(999) + TimeUnit.MINUTES.toSeconds(59) + 59)); + Assert.assertEquals(record.getDuration("time"), Duration.ofHours(999).plusMinutes(59).plusSeconds(59)); record = records.get(2); Assert.assertEquals(record.getInteger("o_num"), 3); Assert.assertEquals(record.getLocalDateTime("time").toEpochSecond(ZoneOffset.UTC), 0); Assert.assertEquals(record.getInstant("time"), Instant.ofEpochSecond(0)); + Assert.assertEquals(record.getDuration("time"), Duration.ofHours(0)); record = records.get(3); Assert.assertEquals(record.getInteger("o_num"), 4); Assert.assertEquals(record.getLocalDateTime("time").toEpochSecond(ZoneOffset.UTC), - (TimeUnit.HOURS.toSeconds(999) + TimeUnit.MINUTES.toSeconds(59) + 59)); Assert.assertEquals(record.getInstant("time"), Instant.ofEpochSecond(- (TimeUnit.HOURS.toSeconds(999) + TimeUnit.MINUTES.toSeconds(59) + 59))); + Assert.assertEquals(record.getDuration("time"), Duration.ofHours(999).plusMinutes(59).plusSeconds(59).negated()); } @Test(groups = {"integration"}, dataProvider = "testTimeData") From 6683a1e1b272319a0caff923819d152e94771ebf Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Wed, 18 Feb 2026 20:19:12 -0800 Subject: [PATCH 05/12] Updated DATE read, write and cleaned up tests. Added test for read and write back with different timezone --- .../clickhouse/client/api/DataTypeUtils.java | 193 +------------ .../client/api/DataTypeUtilsTests.java | 258 ++---------------- .../jdbc/PreparedStatementImpl.java | 6 +- .../com/clickhouse/jdbc/ResultSetImpl.java | 7 +- .../clickhouse/jdbc/WriterStatementImpl.java | 6 +- .../jdbc/DetachedResultSetTest.java | 2 +- .../clickhouse/jdbc/JDBCDateTimeTests.java | 83 ++++-- .../jdbc/PreparedStatementTest.java | 35 --- 8 files changed, 104 insertions(+), 486 deletions(-) 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 d4fdab102..06d0bd95d 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 @@ -13,6 +13,7 @@ import java.time.LocalTime; import java.time.ZoneId; import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.ChronoField; @@ -229,38 +230,6 @@ public static Duration localDateTimeToDuration(LocalDateTime localDateTime) { .plusNanos(localDateTime.getNano()); } - /** - * 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. @@ -282,37 +251,6 @@ public static LocalDate toLocalDate(Date sqlDate, TimeZone timeZone) { .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. * @@ -333,44 +271,6 @@ public static LocalTime toLocalTime(Time sqlTime, TimeZone timeZone) { .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. * @@ -394,33 +294,6 @@ public static LocalDateTime toLocalDateTime(Timestamp sqlTimestamp, TimeZone tim // ==================== 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. * @@ -435,38 +308,8 @@ 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()); + long time = ZonedDateTime.of(localDate, LocalTime.MIDNIGHT, timeZone.toZoneId()).toEpochSecond() * 1000; + return new Date(time); } /** @@ -492,36 +335,6 @@ public static Time toSqlTime(LocalTime localTime, TimeZone timeZone) { 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. 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 dbe8f017f..f7f6126df 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 @@ -160,16 +160,6 @@ void testDifferentDateConversions() throws Exception { 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"); @@ -177,12 +167,6 @@ void testToLocalDateNullTimeZone() { () -> 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() { @@ -193,7 +177,7 @@ void testToLocalDateWithCalendar() { Date sqlDate = new Date(utcCal.getTimeInMillis()); // Using UTC calendar should give us Jan 15 - LocalDate resultUtc = DataTypeUtils.toLocalDate(sqlDate, utcCal); + LocalDate resultUtc = DataTypeUtils.toLocalDate(sqlDate, utcCal.getTimeZone()); assertEquals(resultUtc, LocalDate.of(2024, 1, 15)); } @@ -212,13 +196,13 @@ void testToLocalDateDayShiftProblem() { Date dateFromAuckland = new Date(aucklandCal.getTimeInMillis()); // Using Auckland calendar should correctly extract Jan 15 - LocalDate withAucklandCal = DataTypeUtils.toLocalDate(dateFromAuckland, aucklandCal); + LocalDate withAucklandCal = DataTypeUtils.toLocalDate(dateFromAuckland, aucklandCal.getTimeZone()); 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); + LocalDate withUtcCal = DataTypeUtils.toLocalDate(dateFromAuckland, utcCal.getTimeZone()); assertEquals(withUtcCal, LocalDate.of(2024, 1, 14), "With UTC timezone, should get Jan 14 (day shift demonstrated)"); } @@ -245,7 +229,7 @@ void testToLocalDateWithVariousTimezones(String tzId, String dateStr, int year, cal.set(year, month - 1, day, 0, 0, 0); Date sqlDate = new Date(cal.getTimeInMillis()); - LocalDate result = DataTypeUtils.toLocalDate(sqlDate, cal); + LocalDate result = DataTypeUtils.toLocalDate(sqlDate, tz); assertEquals(result, LocalDate.of(year, month, day), "Date should be preserved in timezone: " + tzId); } @@ -264,27 +248,6 @@ void testToLocalDateWithTimeZoneObject() { // ==================== 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 @@ -294,7 +257,7 @@ void testToLocalTimeWithCalendar() { Time sqlTime = new Time(utcCal.getTimeInMillis()); // Using UTC calendar should give us 14:30:00 - LocalTime resultUtc = DataTypeUtils.toLocalTime(sqlTime, utcCal); + LocalTime resultUtc = DataTypeUtils.toLocalTime(sqlTime, utcCal.getTimeZone()); assertEquals(resultUtc.getHour(), 14); assertEquals(resultUtc.getMinute(), 30); assertEquals(resultUtc.getSecond(), 0); @@ -309,12 +272,12 @@ void testToLocalTimeTimeZoneShift() { Time sqlTime = new Time(utcCal.getTimeInMillis()); // In UTC, should be 14:00 - LocalTime inUtc = DataTypeUtils.toLocalTime(sqlTime, utcCal); + LocalTime inUtc = DataTypeUtils.toLocalTime(sqlTime, utcCal.getTimeZone()); 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); + LocalTime inNy = DataTypeUtils.toLocalTime(sqlTime, nyCal.getTimeZone()); assertEquals(inNy, LocalTime.of(9, 0, 0)); } @@ -332,51 +295,6 @@ void testToLocalTimeWithTimeZoneObject() { // ==================== 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 @@ -386,12 +304,12 @@ void testToLocalDateTimeTimezoneShift() { Timestamp sqlTimestamp = new Timestamp(utcCal.getTimeInMillis()); // In UTC: 2024-01-15 04:00:00 - LocalDateTime inUtc = DataTypeUtils.toLocalDateTime(sqlTimestamp, utcCal); + LocalDateTime inUtc = DataTypeUtils.toLocalDateTime(sqlTimestamp, utcCal.getTimeZone()); 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); + LocalDateTime inNy = DataTypeUtils.toLocalDateTime(sqlTimestamp, nyCal.getTimeZone()); assertEquals(inNy, LocalDateTime.of(2024, 1, 14, 23, 0, 0)); } @@ -442,12 +360,12 @@ void testDayShiftProblemAndSolution() { 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); + LocalDateTime inTokyo = DataTypeUtils.toLocalDateTime(tradeTimestamp, tokyoCal.getTimeZone()); 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)); + new GregorianCalendar(utcTz).getTimeZone()); assertEquals(inUtc.toLocalDate(), LocalDate.of(2024, 12, 31), "In UTC, same trade is also Dec 31 (14:30 UTC)"); @@ -456,32 +374,18 @@ void testDayShiftProblemAndSolution() { tokyoCal.set(2025, Calendar.JANUARY, 1, 0, 30, 0); Timestamp newYearTrade = new Timestamp(tokyoCal.getTimeInMillis()); - LocalDateTime newYearInTokyo = DataTypeUtils.toLocalDateTime(newYearTrade, tokyoCal); + LocalDateTime newYearInTokyo = DataTypeUtils.toLocalDateTime(newYearTrade, tokyoCal.getTimeZone()); assertEquals(newYearInTokyo.toLocalDate(), LocalDate.of(2025, 1, 1), "In Tokyo, it's New Year's Day"); LocalDateTime newYearInUtc = DataTypeUtils.toLocalDateTime(newYearTrade, - new GregorianCalendar(utcTz)); + new GregorianCalendar(utcTz).getTimeZone()); 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); @@ -489,18 +393,6 @@ void testToSqlDateNullTimeZone() { () -> 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); @@ -523,116 +415,16 @@ void testToSqlDateRoundTripWithVariousTimezones() { Calendar cal = new GregorianCalendar(tz); // Convert to SQL Date and back - Date sqlDate = DataTypeUtils.toSqlDate(localDate, cal); - LocalDate roundTrip = DataTypeUtils.toLocalDate(sqlDate, cal); + Date sqlDate = DataTypeUtils.toSqlDate(localDate, cal.getTimeZone()); + LocalDate roundTrip = DataTypeUtils.toLocalDate(sqlDate, cal.getTimeZone()); 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); @@ -650,7 +442,7 @@ 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); + Timestamp sqlTimestamp = DataTypeUtils.toSqlTimestamp(localDateTime, utcCal.getTimeZone()); assertEquals(sqlTimestamp.getNanos(), 123456789); } @@ -665,8 +457,8 @@ void testToSqlTimestampRoundTripWithVariousTimezones() { Calendar cal = new GregorianCalendar(tz); // Convert to SQL Timestamp and back - Timestamp sqlTimestamp = DataTypeUtils.toSqlTimestamp(localDateTime, cal); - LocalDateTime roundTrip = DataTypeUtils.toLocalDateTime(sqlTimestamp, cal); + Timestamp sqlTimestamp = DataTypeUtils.toSqlTimestamp(localDateTime, cal.getTimeZone()); + LocalDateTime roundTrip = DataTypeUtils.toLocalDateTime(sqlTimestamp, cal.getTimeZone()); assertEquals(roundTrip, localDateTime, "Round-trip should preserve datetime in timezone: " + tzId); @@ -688,22 +480,22 @@ void testRoundTripConversionsWithDifferentTimezones() { // 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); + Date sqlDateTokyo = DataTypeUtils.toSqlDate(date, tokyoCal.getTimeZone()); + Time sqlTimeTokyo = DataTypeUtils.toSqlTime(time, tokyoCal.getTimeZone()); + Timestamp sqlTimestampTokyo = DataTypeUtils.toSqlTimestamp(dateTime, tokyoCal.getTimeZone()); // Round-trip back using same timezone should preserve values - assertEquals(DataTypeUtils.toLocalDate(sqlDateTokyo, tokyoCal), date); - LocalTime timeRoundTrip = DataTypeUtils.toLocalTime(sqlTimeTokyo, tokyoCal); + assertEquals(DataTypeUtils.toLocalDate(sqlDateTokyo, tokyoCal.getTimeZone()), date); + LocalTime timeRoundTrip = DataTypeUtils.toLocalTime(sqlTimeTokyo, tokyoCal.getTimeZone()); assertEquals(timeRoundTrip.getHour(), time.getHour()); assertEquals(timeRoundTrip.getMinute(), time.getMinute()); assertEquals(timeRoundTrip.getSecond(), time.getSecond()); - assertEquals(DataTypeUtils.toLocalDateTime(sqlTimestampTokyo, tokyoCal), dateTime); + assertEquals(DataTypeUtils.toLocalDateTime(sqlTimestampTokyo, tokyoCal.getTimeZone()), 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); + LocalDateTime dateTimeInNy = DataTypeUtils.toLocalDateTime(sqlTimestampTokyo, nyCal.getTimeZone()); // 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), 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 7d865007f..024a9af54 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java @@ -460,19 +460,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(DataTypeUtils.toLocalDate(x, cal)); + values[parameterIndex - 1] = encodeObject(DataTypeUtils.toLocalDate(x, cal.getTimeZone())); } @Override public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLException { ensureOpen(); - values[parameterIndex - 1] = encodeObject(DataTypeUtils.toLocalTime(x, cal)); + values[parameterIndex - 1] = encodeObject(DataTypeUtils.toLocalTime(x, cal.getTimeZone())); } @Override public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException { ensureOpen(); - values[parameterIndex - 1] = encodeObject(DataTypeUtils.toLocalDateTime(x, cal)); + values[parameterIndex - 1] = encodeObject(DataTypeUtils.toLocalDateTime(x, cal.getTimeZone())); } @Override 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 3a78afb1f..4d8454e31 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java @@ -1021,6 +1021,7 @@ public Date getDate(int columnIndex, Calendar cal) throws SQLException { public Date getDate(String columnLabel, Calendar cal) throws SQLException { checkClosed(); try { + LocalDate ld = reader.getLocalDate(columnLabel); if (ld == null) { wasNull = true; @@ -1028,9 +1029,7 @@ public Date getDate(String columnLabel, Calendar cal) throws SQLException { } wasNull = false; - Calendar c = cal != null ? cal : defaultCalendar; - long time = ld.atStartOfDay(c.getTimeZone().toZoneId()).toEpochSecond() * 1000; - return new Date(time); + return DataTypeUtils.toSqlDate(ld, cal.getTimeZone()); } catch (Exception e) { ClickHouseColumn column = getSchema().getColumnByName(columnLabel); switch (column.getValueDataType()) { @@ -1094,7 +1093,7 @@ public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException } wasNull = false; - return DataTypeUtils.toSqlTimestamp(zdt.toLocalDateTime(), cal); + return DataTypeUtils.toSqlTimestamp(zdt.toLocalDateTime(), cal.getTimeZone()); } catch (Exception e) { ClickHouseColumn column = getSchema().getColumnByIndex(columnIndex); switch (column.getValueDataType()) { 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 778c45d8b..18bc4564d 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/WriterStatementImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/WriterStatementImpl.java @@ -379,19 +379,19 @@ public void setArray(int parameterIndex, Array x) throws SQLException { @Override public void setDate(int parameterIndex, Date x, Calendar cal) throws SQLException { ensureOpen(); - writer.setValue(parameterIndex, DataTypeUtils.toLocalDate(x, cal)); + writer.setValue(parameterIndex, DataTypeUtils.toLocalDate(x, cal.getTimeZone())); } @Override public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLException { ensureOpen(); - writer.setValue(parameterIndex, DataTypeUtils.toLocalTime(x, cal)); + writer.setValue(parameterIndex, DataTypeUtils.toLocalTime(x, cal.getTimeZone())); } @Override public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException { ensureOpen(); - writer.setDateTime(parameterIndex, DataTypeUtils.toLocalDateTime(x, cal)); + writer.setDateTime(parameterIndex, DataTypeUtils.toLocalDateTime(x, cal.getTimeZone())); } @Override diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/DetachedResultSetTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/DetachedResultSetTest.java index 94fee56db..0751afdee 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/DetachedResultSetTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/DetachedResultSetTest.java @@ -356,7 +356,7 @@ public void testGetMetadata() throws SQLException { } @Test(groups = { "integration" }) - public void testDateTypes() throws SQLException { + public void testDateTimeTypes() throws SQLException { runQuery("CREATE TABLE detached_rs_test_dates (order Int8, " + "date Date, date32 Date32, " + "dateTime DateTime, dateTime32 DateTime32, " + diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java index 7cb29d587..8e33a0fdf 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java @@ -7,16 +7,21 @@ import org.testng.annotations.Test; 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.time.Duration; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.Month; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalAmount; import java.time.temporal.TemporalUnit; @@ -36,6 +41,13 @@ void testDaysBeforeBirthdayParty() throws SQLException { LocalDate now = LocalDate.now(); int daysBeforeParty = 10; LocalDate birthdate = now.plusDays(daysBeforeParty); + Instant birthdateInstant = birthdate.atStartOfDay(ZoneOffset.systemDefault()).toInstant(); + + Object[] dataset = new Object[] { + birthdate, + java.sql.Date.valueOf(birthdate), + birthdate.format(DataTypeUtils.DATE_FORMATTER) + }; Properties props = new Properties(); @@ -43,36 +55,73 @@ void testDaysBeforeBirthdayParty() throws SQLException { props.put(ClientConfigProperties.serverSetting("session_timezone"), "Asia/Tokyo"); try (Connection conn = getJdbcConnection(props); Statement stmt = conn.createStatement()) { - stmt.executeUpdate("CREATE TABLE test_days_before_birthday_party (id Int32, birthdate Date32) Engine MergeTree ORDER BY()"); - final String birthdateStr = birthdate.format(DataTypeUtils.DATE_FORMATTER); - stmt.executeUpdate("INSERT INTO test_days_before_birthday_party VALUES (1, '" + birthdateStr + "')"); - try (ResultSet rs = stmt.executeQuery("SELECT id, birthdate, birthdate::String, timezone() FROM test_days_before_birthday_party")) { - Assert.assertTrue(rs.next()); - LocalDate dateFromDb = rs.getObject(2, LocalDate.class); - Assert.assertEquals(dateFromDb, birthdate); - Assert.assertEquals(now.toEpochDay() - dateFromDb.toEpochDay(), -daysBeforeParty); - Assert.assertEquals(rs.getString(4), "Asia/Tokyo"); + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO test_days_before_birthday_party VALUES (?, ?)")) { + for (int i = 0; i < dataset.length; i++) { + ps.setInt(1, i + 1); + ps.setObject(2, dataset[i]); + ps.addBatch(); + } + + ps.executeBatch(); + } + + try (ResultSet rs = stmt.executeQuery("SELECT id, birthdate, birthdate::String, timezone() FROM test_days_before_birthday_party ORDER BY id")) { + + for (int i = 0; i < dataset.length; i++) { + Assert.assertTrue(rs.next()); + Assert.assertEquals(rs.getInt(1), i + 1); + + java.sql.Date sqlDate = rs.getDate(2); +// Instant sqlDateInstant = sqlDate.toInstant(); // throws "operation not supported". why then getTime() ok?! + long sqlDateTime = sqlDate.getTime(); + Assert.assertEquals(sqlDateTime, birthdateInstant.toEpochMilli()); + + LocalDate ld = rs.getObject(2, LocalDate.class); + Assert.assertEquals(ld, birthdate); + String dateStr = rs.getObject(2, String.class); + Assert.assertEquals(dateStr, birthdate.format(DataTypeUtils.DATE_FORMATTER)); + } + } + } + } + + @Test(groups = {"integration"}) + void testDateWithTimezone() throws Exception { + + // 2026-02-01 Midnight in Tokyo as utc timestamp + long utcTsForDayInTokyo = (1769904000000L) - TimeUnit.HOURS.toMillis(9); + java.sql.Date dateInTokyo = new Date(utcTsForDayInTokyo); + + java.sql.Date dateToWrite = Date.valueOf("2026-02-01"); // local date + Calendar tokyoCal = Calendar.getInstance(TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo"))); + + LocalDate ldInTokyo = DataTypeUtils.toLocalDate(dateToWrite, tokyoCal.getTimeZone()); + try (Connection conn = getJdbcConnection()) { + try (PreparedStatement p = conn.prepareStatement("SELECT toDate(?)")) { + p.setDate(1, dateToWrite); + try (ResultSet rs = p.executeQuery()) { + Assert.assertTrue(rs.next()); - Assert.assertEquals(rs.getString(2), rs.getString(3)); + // Application MUST use calendar of the same timezone as it for write. + java.sql.Date dataFromDb = rs.getDate(1, tokyoCal); - java.sql.Date sqlDate = rs.getDate(2); // in local timezone + // dateToWrite is local date before conversion to Tokyo date. + Assert.assertEquals(dataFromDb, dateInTokyo); + Assert.assertEquals(dataFromDb.getTime(), utcTsForDayInTokyo); - String zoneId = "Asia/Tokyo"; - Calendar tzCalendar = Calendar.getInstance(TimeZone.getTimeZone(ZoneId.of(zoneId))); // TimeZone.getTimeZone() doesn't throw exception but fallback to GMT - java.sql.Date tzSqlDate = rs.getDate(2, tzCalendar); // Calendar tells from what timezone convert to local - Assert.assertEquals(Math.abs(sqlDate.toLocalDate().toEpochDay() - tzSqlDate.toLocalDate().toEpochDay()), 1, - "tzCalendar " + tzCalendar + " default " + Calendar.getInstance().getTimeZone().getID()); + Assert.assertEquals(rs.getObject(1, LocalDate.class), ldInTokyo); + } } } } @Test(groups = {"integration"}) - void testWalkTime() throws SQLException { + void testWalkTime() throws Exception { if (isVersionMatch("(,25.5]")) { return; // time64 was introduced in 25.6 } diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java index a7c745354..471177428 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java @@ -1772,39 +1772,4 @@ void testDateWithAndWithoutCalendar() throws Exception { } } } - - /** - * Tests edge case: dates at the boundary of a day in extreme timezones. - * This is where day shift bugs are most likely to manifest. - */ - @Test(groups = {"integration"}) - void testDateBoundaryEdgeCases() throws Exception { - try (Connection conn = getJdbcConnection()) { - final java.sql.Date testDate = java.sql.Date.valueOf("2024-01-15"); - - // Test with a timezone that's far ahead of UTC (e.g., Pacific/Auckland UTC+12/+13) - // If there's a day shift bug, this would show as 2024-01-14 or 2024-01-16 - TimeZone auckland = TimeZone.getTimeZone("Pacific/Auckland"); - try (PreparedStatement stmt = conn.prepareStatement("SELECT toDate(?)")) { - stmt.setDate(1, testDate, new GregorianCalendar(auckland)); - try (ResultSet rs = stmt.executeQuery()) { - assertTrue(rs.next()); - assertEquals(rs.getDate(1).toString(), "2024-01-15", - "Date should not shift even with far-ahead timezone"); - } - } - - // Test with a timezone that's far behind UTC (e.g., Pacific/Honolulu UTC-10) - TimeZone honolulu = TimeZone.getTimeZone("Pacific/Honolulu"); - Calendar honoluluCal = new GregorianCalendar(honolulu); - try (PreparedStatement stmt = conn.prepareStatement("SELECT toDate(?)")) { - stmt.setDate(1, testDate, honoluluCal); - try (ResultSet rs = stmt.executeQuery()) { - assertTrue(rs.next()); - assertEquals(rs.getDate(1, honoluluCal).toString(), "2024-01-15", - "Date should not shift even with far-behind timezone"); - } - } - } - } } From d29caa26946029322e3f6f1215321d5083a6387a Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Wed, 18 Feb 2026 23:09:17 -0800 Subject: [PATCH 06/12] Fixed time, added tests fixed convertion to type --- .../jdbc/PreparedStatementImpl.java | 70 ++++++++++-------- .../jdbc/internal/ExceptionUtils.java | 1 + .../clickhouse/jdbc/JDBCDateTimeTests.java | 72 ++++++++++++++++--- .../jdbc/PreparedStatementTest.java | 3 +- 4 files changed, 107 insertions(+), 39 deletions(-) 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 024a9af54..9bdce577c 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java @@ -42,6 +42,8 @@ import java.sql.Struct; import java.sql.Time; import java.sql.Timestamp; +import java.sql.Types; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; @@ -253,27 +255,31 @@ int getParametersCount() { @Override public void setObject(int parameterIndex, Object x, int targetSqlType) throws SQLException { ensureOpen(); - // targetSQLType is only of JDBCType - values[parameterIndex-1] = encodeObject(x, jdbcType2ClickHouseDataType(JDBCType.valueOf(targetSqlType)), null); + + isValidForTargetType(x, targetSqlType); + values[parameterIndex-1] = encodeObject(x); } @Override public void setObject(int parameterIndex, Object x, int targetSqlType, int scaleOrLength) throws SQLException { ensureOpen(); - // targetSQLType is only of JDBCType - values[parameterIndex-1] = encodeObject(x, jdbcType2ClickHouseDataType(JDBCType.valueOf(targetSqlType)), scaleOrLength); + + isValidForTargetType(x, targetSqlType); + values[parameterIndex-1] = encodeObject(x, (long) scaleOrLength); } @Override public void setObject(int parameterIndex, Object x, SQLType targetSqlType) throws SQLException { ensureOpen(); - values[parameterIndex-1] = encodeObject(x, sqlType2ClickHouseDataType(targetSqlType), null); + + isValidForTargetType(x, targetSqlType.getVendorTypeNumber()); + values[parameterIndex-1] = encodeObject(x); } @Override public void setObject(int parameterIndex, Object x, SQLType targetSqlType, int scaleOrLength) throws SQLException { ensureOpen(); - values[parameterIndex-1] = encodeObject(x, sqlType2ClickHouseDataType(targetSqlType), scaleOrLength); + values[parameterIndex-1] = encodeObject(x, (long) scaleOrLength); } @Override @@ -763,6 +769,8 @@ private String encodeObject(Object x, Long length) throws SQLException { return encodeObject(((ZonedDateTime) x).toInstant()); } else if (x instanceof Instant) { return "fromUnixTimestamp64Nano(" + (((Instant) x).getEpochSecond() * 1_000_000_000L + ((Instant) x).getNano()) + ")"; + } else if (x instanceof Duration) { + return QUOTE + DataTypeUtils.durationToTimeString((Duration) x, 9) + QUOTE; } else if (x instanceof InetAddress) { return QUOTE + ((InetAddress) x).getHostAddress() + QUOTE; } else if (x instanceof byte[]) { @@ -973,34 +981,36 @@ private static String encodeCharacterStream(Reader reader, Long length) throws S } } - private ClickHouseDataType jdbcType2ClickHouseDataType(JDBCType type) throws SQLException{ - ClickHouseDataType clickHouseDataType = JdbcUtils.SQL_TO_CLICKHOUSE_TYPE_MAP.get(type); - if (clickHouseDataType == null) { - throw new SQLException("Cannot convert " + type + " to a ClickHouse one. Consider using java.sql.JDBCType or com.clickhouse.data.ClickHouseDataType"); + private void isValidForTargetType(Object value, int targetType) throws SQLException { + if (value == null) { + return; // NULL is handled in encoding and server checks if value can be NULL } - return clickHouseDataType; - } - - private ClickHouseDataType sqlType2ClickHouseDataType(SQLType type) throws SQLException { - ClickHouseDataType clickHouseDataType = null; - if (type instanceof JDBCType) { - clickHouseDataType = JdbcUtils.SQL_TO_CLICKHOUSE_TYPE_MAP.get(type); - } else if (type instanceof ClickHouseDataType) { - clickHouseDataType = (ClickHouseDataType) type; - if (JdbcUtils.INVALID_TARGET_TYPES.contains(clickHouseDataType)) { - throw new SQLException("Type " + clickHouseDataType + " cannot be used as target type here because requires additional parameters and API doesn't have a way to pass them. "); - } - } + Class vClass = value.getClass(); - if (clickHouseDataType == null) { - throw new SQLException("Cannot convert " + type + " to a ClickHouse one. Consider using java.sql.JDBCType or com.clickhouse.data.ClickHouseDataType"); + // Here we validate only specific types + switch (targetType) { + case Types.DATE: + if (vClass == LocalDate.class || vClass == java.sql.Date.class) { + return; + } + break; + case Types.TIME: + case Types.TIME_WITH_TIMEZONE: + if (vClass == LocalTime.class || vClass == java.sql.Time.class || vClass == LocalDateTime.class || vClass == ZonedDateTime.class) { + return; + } + break; + case Types.TIMESTAMP: + case Types.TIMESTAMP_WITH_TIMEZONE: + if (vClass == Timestamp.class || vClass == LocalDateTime.class || vClass == ZonedDateTime.class) { + return; + } + break; + default: + return; } - return clickHouseDataType; - } - - private String encodeObject(Object x, ClickHouseDataType clickHouseDataType, Integer scaleOrLength) throws SQLException { - return encodeObject(x); + throw new SQLException("Cannot convert value of type " + value.getClass() + " to SQL type " + JDBCType.valueOf(targetType)); } } diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/ExceptionUtils.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/ExceptionUtils.java index b86c01c4a..81067ab7d 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/ExceptionUtils.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/ExceptionUtils.java @@ -28,6 +28,7 @@ public final class ExceptionUtils { public static final String SQL_STATE_FEATURE_NOT_SUPPORTED = "0A000"; // Used only when method is called on wrong object type (for example, PreparedStatement.addBatch(String)) public static final String SQL_STATE_WRONG_OBJECT_TYPE = "42809"; + public static final String SQL_STATE_TYPE_MISMATCH = "2200G"; private ExceptionUtils() {}//Private constructor diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java index 8e33a0fdf..d18f4656e 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java @@ -12,8 +12,6 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; -import java.sql.Time; -import java.sql.Timestamp; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; @@ -23,8 +21,6 @@ import java.time.ZoneId; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; -import java.time.temporal.TemporalAmount; -import java.time.temporal.TemporalUnit; import java.util.Calendar; import java.util.Properties; import java.util.TimeZone; @@ -34,7 +30,6 @@ public class JDBCDateTimeTests extends JdbcIntegrationTest { - @Test(groups = {"integration"}) void testDaysBeforeBirthdayParty() throws SQLException { @@ -43,7 +38,7 @@ void testDaysBeforeBirthdayParty() throws SQLException { LocalDate birthdate = now.plusDays(daysBeforeParty); Instant birthdateInstant = birthdate.atStartOfDay(ZoneOffset.systemDefault()).toInstant(); - Object[] dataset = new Object[] { + Object[] dataset = new Object[]{ birthdate, java.sql.Date.valueOf(birthdate), birthdate.format(DataTypeUtils.DATE_FORMATTER) @@ -58,7 +53,6 @@ void testDaysBeforeBirthdayParty() throws SQLException { stmt.executeUpdate("CREATE TABLE test_days_before_birthday_party (id Int32, birthdate Date32) Engine MergeTree ORDER BY()"); - try (PreparedStatement ps = conn.prepareStatement("INSERT INTO test_days_before_birthday_party VALUES (?, ?)")) { for (int i = 0; i < dataset.length; i++) { ps.setInt(1, i + 1); @@ -139,7 +133,6 @@ void testWalkTime() throws Exception { stmt.executeUpdate("CREATE TABLE test_walk_time (id Int32, walk_time Time64(3)) Engine MergeTree ORDER BY()"); final String walkTimeStr = DataTypeUtils.durationToTimeString(walkTime, 3); - System.out.println(walkTimeStr); stmt.executeUpdate("INSERT INTO test_walk_time VALUES (1, '" + walkTimeStr + "')"); try (ResultSet rs = stmt.executeQuery("SELECT id, walk_time, walk_time::String, timezone() FROM test_walk_time")) { @@ -170,5 +163,68 @@ void testWalkTime() throws Exception { } } + @Test(groups = {"integration"}) + void testLapsTime() throws Exception { + if (isVersionMatch("(,25.5]")) { + return; // time64 was introduced in 25.6 + } + + Properties props = new Properties(); + props.put(ClientConfigProperties.serverSetting("allow_experimental_time_time64_type"), "1"); + try (Connection conn = getJdbcConnection(props); + Statement stmt = conn.createStatement()) { + stmt.executeUpdate("CREATE TABLE test_laps_time (racerId Int32, lapId Int32, lapTime Time64(3)) Engine MergeTree ORDER BY()"); + + Object[][] dataset = new Object[][]{ + { + Duration.of(10, ChronoUnit.SECONDS).plusMillis(123).plusMinutes(1), + Duration.of(8, ChronoUnit.SECONDS).plusMillis(456).plusMinutes(1), + }, + { + LocalTime.of(0, 3, 50), + LocalTime.of(0, 3, 59), + }, + { + Duration.of(-100, ChronoUnit.HOURS), + Duration.of(-100, ChronoUnit.HOURS), + } + }; + + try (PreparedStatement p = conn.prepareStatement("INSERT INTO test_laps_time VALUES (?, ?, ?)")) { + int racerId = 1; + + for (Object[] row : dataset) { + int lapId = 1; + for (Object time : row) { + p.setInt(1, racerId); + p.setInt(2, lapId++); + p.setObject(3, time); + + p.addBatch(); + } + racerId++; + } + + p.executeBatch(); + } + + try (ResultSet rs = stmt.executeQuery("SELECT * FROM test_laps_time ORDER BY racerId, lapId")) { + + int racerId = 1; + + for (Object[] row : dataset) { + int lapId = 1; + for (Object time : row) { + rs.next(); + Assert.assertEquals(rs.getInt(1), racerId); + Assert.assertEquals(rs.getInt(2), lapId++); + Object value = rs.getObject(3, time.getClass()); + Assert.assertEquals(rs.getObject(3, time.getClass()), time); + } + racerId++; + } + } + } + } } diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java index 471177428..b3c90a43f 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java @@ -214,8 +214,9 @@ public void testSetDate() throws Exception { } } - @Test(groups = { "integration" }) + @Test(groups = { "integration" }, enabled = false) public void testSetTime() throws Exception { + // https://github.com/ClickHouse/ClickHouse/issues/89896 try (Connection conn = getJdbcConnection()) { try (PreparedStatement stmt = conn.prepareStatement("SELECT toTime(?)")) { stmt.setTime(1, java.sql.Time.valueOf("12:34:56"), new GregorianCalendar(TimeZone.getTimeZone("UTC"))); From ae483d78f74fb499609e80baff4c0f312ac1e313 Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Thu, 19 Feb 2026 17:08:43 -0800 Subject: [PATCH 07/12] Fixed most of issues --- .../clickhouse/client/api/DataTypeUtils.java | 49 +- .../client/api/DataTypeUtilsTests.java | 1008 ++++++++--------- .../client/internal/SmallTests.java | 75 -- .../jdbc/PreparedStatementImpl.java | 6 +- .../jdbc/PreparedStatementTest.java | 12 +- 5 files changed, 562 insertions(+), 588 deletions(-) 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 06d0bd95d..1e80b82b4 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,10 +3,11 @@ import com.clickhouse.client.api.data_formats.internal.BinaryStreamReader; import com.clickhouse.data.ClickHouseDataType; -import java.time.Duration; +import java.math.BigInteger; import java.sql.Date; import java.sql.Time; import java.sql.Timestamp; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; @@ -17,11 +18,9 @@ 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.Calendar; import java.util.Objects; import java.util.TimeZone; +import java.util.concurrent.TimeUnit; import static com.clickhouse.client.api.data_formats.internal.BinaryStreamReader.BASES; @@ -292,6 +291,13 @@ public static LocalDateTime toLocalDateTime(Timestamp sqlTimestamp, TimeZone tim return LocalDateTime.ofInstant(sqlTimestamp.toInstant(), zoneId); } + public static ZonedDateTime toZonedDateTime(Timestamp x, TimeZone timeZone) { + + + + return x.toLocalDateTime().atZone(timeZone.toZoneId()); + } + // ==================== LocalDate/LocalTime/LocalDateTime to SQL types ==================== /** @@ -359,4 +365,39 @@ public static Timestamp toSqlTimestamp(LocalDateTime localDateTime, TimeZone tim timestamp.setNanos(localDateTime.getNano()); return timestamp; } + + private static final BigInteger NANOS_IN_SECOND = BigInteger.valueOf(1_000_000_000L); + + // Max value of epoch second that can be converted to nanosecond without overflow (and fine to add Integer.MAX nanoseconds) + // Used to avoid BigInteger on small numbers + private static final long MAX_EPOCH_SECONDS_WITHOUT_OVERFLOW = (Long.MAX_VALUE - Integer.MAX_VALUE) / 1_000_000_000L; + + public static String toUnixTimestampString(long seconds, int nanos) { + if (seconds <= MAX_EPOCH_SECONDS_WITHOUT_OVERFLOW) { + return String.valueOf(TimeUnit.SECONDS.toNanos(seconds) + nanos); + } else { + return BigInteger.valueOf(seconds).multiply(NANOS_IN_SECOND).add(BigInteger.valueOf(nanos)).toString(); + } + } + + /** + * Returns Unix Timestamp in nanoseconds as string + * + * @param localTs - LocalDateTime timestamp + * @param localTz - local timezone (useful to override default) + * @return String value. + */ + public static String toUnixTimestampString(LocalDateTime localTs, TimeZone localTz) { + return toUnixTimestampString(localTs.toEpochSecond(localTz.toZoneId().getRules().getOffset(localTs)), localTs.getNano()); + } + + /** + * Returns Unix Timestamp in nanoseconds as string + * + * @param instant - instant to convert + * @return String value. + */ + public static String toUnixTimestampString(Instant instant) { + return toUnixTimestampString(instant.getEpochSecond(), instant.getNano()); + } } 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 f7f6126df..2c42f200c 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,504 +1,504 @@ -package com.clickhouse.client.api; - -import org.testng.annotations.DataProvider; -import org.testng.annotations.Test; - -import com.clickhouse.data.ClickHouseDataType; - -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; - - -public class DataTypeUtilsTests { - - @Test - void testDateTimeFormatter() { - LocalDateTime dateTime = LocalDateTime.of(2021, 12, 31, 23, 59, 59); - String formattedDateTime = dateTime.format(DataTypeUtils.DATETIME_FORMATTER); - assertEquals(formattedDateTime, "2021-12-31 23:59:59"); - } - - @Test - void testDateFormatter() { - LocalDateTime date = LocalDateTime.of(2021, 12, 31, 10, 20); - String formattedDate = date.toLocalDate().format(DataTypeUtils.DATE_FORMATTER); - assertEquals(formattedDate, "2021-12-31"); - } - - @Test - void testDateTimeWithNanosFormatter() { - LocalDateTime dateTime = LocalDateTime.of(2021, 12, 31, 23, 59, 59, 123456789); - String formattedDateTimeWithNanos = dateTime.format(DataTypeUtils.DATETIME_WITH_NANOS_FORMATTER); - assertEquals(formattedDateTimeWithNanos, "2021-12-31 23:59:59.123456789"); - } - - @Test - void formatInstantForDateNullInstant() { - assertThrows( - NullPointerException.class, - () -> DataTypeUtils.formatInstant(null, ClickHouseDataType.Date, - ZoneId.systemDefault())); - } - - @Test - void formatInstantForDateNullTimeZone() { - assertThrows( - NullPointerException.class, - () -> DataTypeUtils.formatInstant(Instant.now(), ClickHouseDataType.Date, null)); - } - - @Test - void formatInstantForDate() { - ZoneId tzBER = ZoneId.of("Europe/Berlin"); - ZoneId tzLAX = ZoneId.of("America/Los_Angeles"); - Instant instant = ZonedDateTime.of( - 2025, 7, 20, 5, 5, 42, 0, tzBER).toInstant(); - assertEquals( - DataTypeUtils.formatInstant(instant, ClickHouseDataType.Date, tzBER), - "2025-07-20"); - assertEquals( - DataTypeUtils.formatInstant(instant, ClickHouseDataType.Date, tzLAX), - "2025-07-19"); - } - - @Test - void formatInstantNullValue() { - assertThrows( - NullPointerException.class, - () -> DataTypeUtils.formatInstant(null)); - } - - @Test - void formatInstantForDateTime() { - TimeZone tzBER = TimeZone.getTimeZone("Europe/Berlin"); - Instant instant = ZonedDateTime.of( - 2025, 7, 20, 5, 5, 42, 232323, tzBER.toZoneId()).toInstant(); - String formatted = DataTypeUtils.formatInstant(instant, ClickHouseDataType.DateTime); - assertEquals(formatted, "1752980742"); - assertEquals( - Instant.ofEpochSecond(Long.parseLong(formatted)), - instant.truncatedTo(ChronoUnit.SECONDS)); - } - - @Test - void formatInstantForDateTime64() { - TimeZone tzBER = TimeZone.getTimeZone("Europe/Berlin"); - Instant instant = ZonedDateTime.of( - 2025, 7, 20, 5, 5, 42, 232323232, tzBER.toZoneId()).toInstant(); - String formatted = DataTypeUtils.formatInstant(instant); - assertEquals(formatted, "1752980742.232323232"); - String[] formattedParts = formatted.split("\\."); - assertEquals( - Instant - .ofEpochSecond(Long.parseLong(formattedParts[0])) - .plusNanos(Long.parseLong(formattedParts[1])), - instant); - } - - @Test - void formatInstantForDateTime64SmallerNanos() { - TimeZone tzBER = TimeZone.getTimeZone("Europe/Berlin"); - Instant instant = ZonedDateTime.of( - 2025, 7, 20, 5, 5, 42, 23, tzBER.toZoneId()).toInstant(); - String formatted = DataTypeUtils.formatInstant(instant); - assertEquals(formatted, "1752980742.000000023"); - String[] formattedParts = formatted.split("\\."); - assertEquals( - Instant - .ofEpochSecond(Long.parseLong(formattedParts[0])) - .plusNanos(Long.parseLong(formattedParts[1])), - instant); - } - - @Test - void formatInstantForDateTime64Truncated() { - // precision is constant for Instant - TimeZone tzBER = TimeZone.getTimeZone("Europe/Berlin"); - Instant instant = ZonedDateTime.of( - 2025, 7, 20, 5, 5, 42, 232323232, tzBER.toZoneId()).toInstant(); - assertEquals( - DataTypeUtils.formatInstant( - instant.truncatedTo(ChronoUnit.SECONDS)), - "1752980742.000000000"); - assertEquals( - DataTypeUtils.formatInstant( - instant.truncatedTo(ChronoUnit.MILLIS)), - "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!! - } - @Test(groups = {"unit"}) - void testToLocalDateNullTimeZone() { - Date sqlDate = Date.valueOf("2024-01-15"); - assertThrows(NullPointerException.class, - () -> DataTypeUtils.toLocalDate(sqlDate, (TimeZone) null)); - } - - - @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.getTimeZone()); - 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.getTimeZone()); - 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.getTimeZone()); - 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, tz); - 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 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.getTimeZone()); - 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.getTimeZone()); - 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.getTimeZone()); - 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 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.getTimeZone()); - 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.getTimeZone()); - 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.getTimeZone()); - 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).getTimeZone()); - 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.getTimeZone()); - assertEquals(newYearInTokyo.toLocalDate(), LocalDate.of(2025, 1, 1), - "In Tokyo, it's New Year's Day"); - - LocalDateTime newYearInUtc = DataTypeUtils.toLocalDateTime(newYearTrade, - new GregorianCalendar(utcTz).getTimeZone()); - 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 testToSqlDateNullTimeZone() { - LocalDate localDate = LocalDate.of(2024, 1, 15); - assertThrows(NullPointerException.class, - () -> DataTypeUtils.toSqlDate(localDate, (TimeZone) null)); - } - - @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.getTimeZone()); - LocalDate roundTrip = DataTypeUtils.toLocalDate(sqlDate, cal.getTimeZone()); - - assertEquals(roundTrip, localDate, - "Round-trip should preserve date in timezone: " + tzId); - } - } - - // ==================== Tests for toSqlTimestamp ==================== - - @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.getTimeZone()); - - 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.getTimeZone()); - LocalDateTime roundTrip = DataTypeUtils.toLocalDateTime(sqlTimestamp, cal.getTimeZone()); - - 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.getTimeZone()); - Time sqlTimeTokyo = DataTypeUtils.toSqlTime(time, tokyoCal.getTimeZone()); - Timestamp sqlTimestampTokyo = DataTypeUtils.toSqlTimestamp(dateTime, tokyoCal.getTimeZone()); - - // Round-trip back using same timezone should preserve values - assertEquals(DataTypeUtils.toLocalDate(sqlDateTokyo, tokyoCal.getTimeZone()), date); - LocalTime timeRoundTrip = DataTypeUtils.toLocalTime(sqlTimeTokyo, tokyoCal.getTimeZone()); - assertEquals(timeRoundTrip.getHour(), time.getHour()); - assertEquals(timeRoundTrip.getMinute(), time.getMinute()); - assertEquals(timeRoundTrip.getSecond(), time.getSecond()); - assertEquals(DataTypeUtils.toLocalDateTime(sqlTimestampTokyo, tokyoCal.getTimeZone()), 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.getTimeZone()); - // 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"); - } -} +package com.clickhouse.client.api; + +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import com.clickhouse.data.ClickHouseDataType; + +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; + + +public class DataTypeUtilsTests { + + @Test + void testDateTimeFormatter() { + LocalDateTime dateTime = LocalDateTime.of(2021, 12, 31, 23, 59, 59); + String formattedDateTime = dateTime.format(DataTypeUtils.DATETIME_FORMATTER); + assertEquals(formattedDateTime, "2021-12-31 23:59:59"); + } + + @Test + void testDateFormatter() { + LocalDateTime date = LocalDateTime.of(2021, 12, 31, 10, 20); + String formattedDate = date.toLocalDate().format(DataTypeUtils.DATE_FORMATTER); + assertEquals(formattedDate, "2021-12-31"); + } + + @Test + void testDateTimeWithNanosFormatter() { + LocalDateTime dateTime = LocalDateTime.of(2021, 12, 31, 23, 59, 59, 123456789); + String formattedDateTimeWithNanos = dateTime.format(DataTypeUtils.DATETIME_WITH_NANOS_FORMATTER); + assertEquals(formattedDateTimeWithNanos, "2021-12-31 23:59:59.123456789"); + } + + @Test + void formatInstantForDateNullInstant() { + assertThrows( + NullPointerException.class, + () -> DataTypeUtils.formatInstant(null, ClickHouseDataType.Date, + ZoneId.systemDefault())); + } + + @Test + void formatInstantForDateNullTimeZone() { + assertThrows( + NullPointerException.class, + () -> DataTypeUtils.formatInstant(Instant.now(), ClickHouseDataType.Date, null)); + } + + @Test + void formatInstantForDate() { + ZoneId tzBER = ZoneId.of("Europe/Berlin"); + ZoneId tzLAX = ZoneId.of("America/Los_Angeles"); + Instant instant = ZonedDateTime.of( + 2025, 7, 20, 5, 5, 42, 0, tzBER).toInstant(); + assertEquals( + DataTypeUtils.formatInstant(instant, ClickHouseDataType.Date, tzBER), + "2025-07-20"); + assertEquals( + DataTypeUtils.formatInstant(instant, ClickHouseDataType.Date, tzLAX), + "2025-07-19"); + } + + @Test + void formatInstantNullValue() { + assertThrows( + NullPointerException.class, + () -> DataTypeUtils.formatInstant(null)); + } + + @Test + void formatInstantForDateTime() { + TimeZone tzBER = TimeZone.getTimeZone("Europe/Berlin"); + Instant instant = ZonedDateTime.of( + 2025, 7, 20, 5, 5, 42, 232323, tzBER.toZoneId()).toInstant(); + String formatted = DataTypeUtils.formatInstant(instant, ClickHouseDataType.DateTime); + assertEquals(formatted, "1752980742"); + assertEquals( + Instant.ofEpochSecond(Long.parseLong(formatted)), + instant.truncatedTo(ChronoUnit.SECONDS)); + } + + @Test + void formatInstantForDateTime64() { + TimeZone tzBER = TimeZone.getTimeZone("Europe/Berlin"); + Instant instant = ZonedDateTime.of( + 2025, 7, 20, 5, 5, 42, 232323232, tzBER.toZoneId()).toInstant(); + String formatted = DataTypeUtils.formatInstant(instant); + assertEquals(formatted, "1752980742.232323232"); + String[] formattedParts = formatted.split("\\."); + assertEquals( + Instant + .ofEpochSecond(Long.parseLong(formattedParts[0])) + .plusNanos(Long.parseLong(formattedParts[1])), + instant); + } + + @Test + void formatInstantForDateTime64SmallerNanos() { + TimeZone tzBER = TimeZone.getTimeZone("Europe/Berlin"); + Instant instant = ZonedDateTime.of( + 2025, 7, 20, 5, 5, 42, 23, tzBER.toZoneId()).toInstant(); + String formatted = DataTypeUtils.formatInstant(instant); + assertEquals(formatted, "1752980742.000000023"); + String[] formattedParts = formatted.split("\\."); + assertEquals( + Instant + .ofEpochSecond(Long.parseLong(formattedParts[0])) + .plusNanos(Long.parseLong(formattedParts[1])), + instant); + } + + @Test + void formatInstantForDateTime64Truncated() { + // precision is constant for Instant + TimeZone tzBER = TimeZone.getTimeZone("Europe/Berlin"); + Instant instant = ZonedDateTime.of( + 2025, 7, 20, 5, 5, 42, 232323232, tzBER.toZoneId()).toInstant(); + assertEquals( + DataTypeUtils.formatInstant( + instant.truncatedTo(ChronoUnit.SECONDS)), + "1752980742.000000000"); + assertEquals( + DataTypeUtils.formatInstant( + instant.truncatedTo(ChronoUnit.MILLIS)), + "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!! + } + @Test(groups = {"unit"}) + void testToLocalDateNullTimeZone() { + Date sqlDate = Date.valueOf("2024-01-15"); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toLocalDate(sqlDate, (TimeZone) null)); + } + + + @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.getTimeZone()); + 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.getTimeZone()); + 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.getTimeZone()); + 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, tz); + 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 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.getTimeZone()); + 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.getTimeZone()); + 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.getTimeZone()); + 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 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.getTimeZone()); + 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.getTimeZone()); + 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.getTimeZone()); + 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).getTimeZone()); + 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.getTimeZone()); + assertEquals(newYearInTokyo.toLocalDate(), LocalDate.of(2025, 1, 1), + "In Tokyo, it's New Year's Day"); + + LocalDateTime newYearInUtc = DataTypeUtils.toLocalDateTime(newYearTrade, + new GregorianCalendar(utcTz).getTimeZone()); + 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 testToSqlDateNullTimeZone() { + LocalDate localDate = LocalDate.of(2024, 1, 15); + assertThrows(NullPointerException.class, + () -> DataTypeUtils.toSqlDate(localDate, (TimeZone) null)); + } + + @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.getTimeZone()); + LocalDate roundTrip = DataTypeUtils.toLocalDate(sqlDate, cal.getTimeZone()); + + assertEquals(roundTrip, localDate, + "Round-trip should preserve date in timezone: " + tzId); + } + } + + // ==================== Tests for toSqlTimestamp ==================== + + @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.getTimeZone()); + + 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.getTimeZone()); + LocalDateTime roundTrip = DataTypeUtils.toLocalDateTime(sqlTimestamp, cal.getTimeZone()); + + 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.getTimeZone()); + Time sqlTimeTokyo = DataTypeUtils.toSqlTime(time, tokyoCal.getTimeZone()); + Timestamp sqlTimestampTokyo = DataTypeUtils.toSqlTimestamp(dateTime, tokyoCal.getTimeZone()); + + // Round-trip back using same timezone should preserve values + assertEquals(DataTypeUtils.toLocalDate(sqlDateTokyo, tokyoCal.getTimeZone()), date); + LocalTime timeRoundTrip = DataTypeUtils.toLocalTime(sqlTimeTokyo, tokyoCal.getTimeZone()); + assertEquals(timeRoundTrip.getHour(), time.getHour()); + assertEquals(timeRoundTrip.getMinute(), time.getMinute()); + assertEquals(timeRoundTrip.getSecond(), time.getSecond()); + assertEquals(DataTypeUtils.toLocalDateTime(sqlTimestampTokyo, tokyoCal.getTimeZone()), 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.getTimeZone()); + // 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/client-v2/src/test/java/com/clickhouse/client/internal/SmallTests.java b/client-v2/src/test/java/com/clickhouse/client/internal/SmallTests.java index e0d84a17e..42be10846 100644 --- a/client-v2/src/test/java/com/clickhouse/client/internal/SmallTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/internal/SmallTests.java @@ -1,83 +1,8 @@ package com.clickhouse.client.internal; -import org.testng.annotations.Test; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.ZoneId; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import java.util.TimeZone; -import java.util.concurrent.TimeUnit; - /** * Tests playground */ public class SmallTests { - - @Test - public void testInstantVsLocalTime() { - - // Date - LocalDate longBeforeEpoch = LocalDate.ofEpochDay(-47482); - LocalDate beforeEpoch = LocalDate.ofEpochDay(-1); - LocalDate epoch = LocalDate.ofEpochDay(0); - LocalDate dateMaxValue = LocalDate.ofEpochDay(65535); - LocalDate date32MaxValue = LocalDate.ofEpochDay(47482); - - System.out.println(longBeforeEpoch); - System.out.println(beforeEpoch); - System.out.println(epoch); - System.out.println(date32MaxValue); - System.out.println(dateMaxValue); - - System.out.println(); - - // Time - - LocalDateTime beforeEpochTime = LocalDateTime.ofEpochSecond(-999, 0, ZoneOffset.UTC); - LocalDateTime epochTime = LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC); - LocalDateTime maxTime = LocalDateTime.ofEpochSecond(TimeUnit.HOURS.toSeconds(999) + TimeUnit.MINUTES.toSeconds(59) + 59, - 123999999, ZoneOffset.UTC); - - System.out.println(beforeEpochTime); - System.out.println("before time: " + (beforeEpochTime.getSecond())); - System.out.println(epochTime); - System.out.println(maxTime); - System.out.println(maxTime.getDayOfYear()); - } - - @Test - public void testInstantFromUTC() { - - LocalDate ld = LocalDate.of(1970, 1, 1); - - ZonedDateTime atTokyo = ld.atStartOfDay(TimeZone.getTimeZone("Asia/Tokyo").toZoneId()); - Instant tokyoInstant = atTokyo.toInstant(); - - ZonedDateTime atUtc = ld.atStartOfDay(TimeZone.getTimeZone("UTC").toZoneId()); - Instant utcInstant = atUtc.toInstant(); - - System.out.println(ld); - System.out.println(atTokyo); - System.out.println(tokyoInstant); - System.out.println(atUtc); - System.out.println(utcInstant); - - } - - @Test - public void testTimezoneOffset() { - ZoneId tokyoTz = ZoneId.of("Asia/Tokyo"); - ZoneId losAngelesTz = ZoneId.of("America/Los_Angeles"); - - System.out.println(tokyoTz.getRules().getTransitionRules()); - System.out.println(losAngelesTz.getRules().getTransitionRules()); - - ZonedDateTime ld = LocalDate.of(1970, 3, 7).atStartOfDay(losAngelesTz); - System.out.println(ld.toOffsetDateTime()); - } } 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 9bdce577c..70a131394 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java @@ -478,7 +478,7 @@ public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLExceptio @Override public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException { ensureOpen(); - values[parameterIndex - 1] = encodeObject(DataTypeUtils.toLocalDateTime(x, cal.getTimeZone())); + values[parameterIndex - 1] = encodeObject(DataTypeUtils.toZonedDateTime(x, cal.getTimeZone())); } @Override @@ -762,13 +762,13 @@ private String encodeObject(Object x, Long length) throws SQLException { } else if (x instanceof Timestamp) { return QUOTE + DataTypeUtils.DATE_TIME_WITH_OPTIONAL_NANOS.format(((Timestamp) x).toLocalDateTime()) + QUOTE; } else if (x instanceof LocalDateTime) { - return QUOTE + DataTypeUtils.DATE_TIME_WITH_OPTIONAL_NANOS.format((LocalDateTime) x) + QUOTE; + return "fromUnixTimestamp64Nano(" + DataTypeUtils.toUnixTimestampString((LocalDateTime) x, defaultCalendar.getTimeZone()) + ")"; } else if (x instanceof OffsetDateTime) { return encodeObject(((OffsetDateTime) x).toInstant()); } else if (x instanceof ZonedDateTime) { return encodeObject(((ZonedDateTime) x).toInstant()); } else if (x instanceof Instant) { - return "fromUnixTimestamp64Nano(" + (((Instant) x).getEpochSecond() * 1_000_000_000L + ((Instant) x).getNano()) + ")"; + return "fromUnixTimestamp64Nano(" + DataTypeUtils.toUnixTimestampString((Instant) x) + ")"; } else if (x instanceof Duration) { return QUOTE + DataTypeUtils.durationToTimeString((Duration) x, 9) + QUOTE; } else if (x instanceof InetAddress) { diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java index b3c90a43f..85b43c426 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java @@ -24,9 +24,11 @@ import java.sql.Statement; import java.sql.Timestamp; import java.sql.Types; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.concurrent.TimeUnit; @@ -234,10 +236,16 @@ public void testSetTimestamp() throws Exception { final Calendar calendar = new GregorianCalendar(TimeZone.getTimeZone("UTC")); try (Connection conn = getJdbcConnection()) { try (PreparedStatement stmt = conn.prepareStatement("SELECT toDateTime64(?, 3)")) { - stmt.setTimestamp(1, java.sql.Timestamp.valueOf("2021-01-01 01:34:56.456"), calendar); + Timestamp tsToWrite = java.sql.Timestamp.valueOf("2021-01-01 01:34:56.456"); + ZonedDateTime tsSeenInCalendarTz = ZonedDateTime.ofInstant(tsToWrite.toInstant(), calendar.getTimeZone().toZoneId()); + stmt.setTimestamp(1, tsToWrite, calendar); try (ResultSet rs = stmt.executeQuery()) { assertTrue(rs.next()); - assertEquals(rs.getTimestamp(1, calendar).toString(), "2021-01-01 01:34:56.456"); + + assertEquals(rs.getObject(1, ZonedDateTime.class), tsSeenInCalendarTz); + Timestamp dbTimestamp = rs.getTimestamp(1, calendar); + assertEquals(dbTimestamp.toInstant(), tsSeenInCalendarTz.toInstant()); +// assertEquals(rs.getTimestamp(1, calendar).toString(), "2021-01-01 01:34:56.456"); assertFalse(rs.next()); } } From 052558c5ed23f6f295589c96ca33420769cf1ca0 Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Thu, 19 Feb 2026 17:39:05 -0800 Subject: [PATCH 08/12] Fixed timestamp test --- .../clickhouse/client/api/DataTypeUtils.java | 24 +++++++++++++++---- .../jdbc/PreparedStatementTest.java | 16 +++++++++---- 2 files changed, 31 insertions(+), 9 deletions(-) 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 1e80b82b4..2d3ce3561 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 @@ -291,11 +291,27 @@ public static LocalDateTime toLocalDateTime(Timestamp sqlTimestamp, TimeZone tim return LocalDateTime.ofInstant(sqlTimestamp.toInstant(), zoneId); } - public static ZonedDateTime toZonedDateTime(Timestamp x, TimeZone timeZone) { - - + /** + * Converts a {@link java.sql.Timestamp} to {@link ZonedDateTime} by expressing + * the timestamp's instant in the specified timezone. + * + *

The underlying instant is preserved — only the timezone context changes. + * This matches the JDBC {@code setTimestamp(int, Timestamp, Calendar)} contract + * where the Calendar's timezone is used to interpret the Timestamp's absolute + * point in time.

+ * + *

Note: This method preserves nanosecond precision from the Timestamp.

+ * + * @param sqlTimestamp the java.sql.Timestamp to convert + * @param timeZone the timezone to express the instant in + * @return the ZonedDateTime representing the same instant in the specified timezone + * @throws NullPointerException if sqlTimestamp or timeZone is null + */ + public static ZonedDateTime toZonedDateTime(Timestamp sqlTimestamp, TimeZone timeZone) { + Objects.requireNonNull(sqlTimestamp, "sqlTimestamp must not be null"); + Objects.requireNonNull(timeZone, "timeZone must not be null"); - return x.toLocalDateTime().atZone(timeZone.toZoneId()); + return sqlTimestamp.toInstant().atZone(timeZone.toZoneId()); } // ==================== LocalDate/LocalTime/LocalDateTime to SQL types ==================== diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java index 85b43c426..93d0af6c2 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java @@ -28,6 +28,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.*; @@ -245,17 +246,22 @@ public void testSetTimestamp() throws Exception { assertEquals(rs.getObject(1, ZonedDateTime.class), tsSeenInCalendarTz); Timestamp dbTimestamp = rs.getTimestamp(1, calendar); assertEquals(dbTimestamp.toInstant(), tsSeenInCalendarTz.toInstant()); -// assertEquals(rs.getTimestamp(1, calendar).toString(), "2021-01-01 01:34:56.456"); assertFalse(rs.next()); } } - try (PreparedStatement stmt = conn.prepareStatement("SELECT toDateTime64(?, 3)")) { - stmt.setObject(1, LocalDateTime.parse("2021-01-01T01:34:56").withNano((int) TimeUnit.MILLISECONDS.toNanos(456))); + String localTimezone = TimeZone.getDefault().getID(); + try (PreparedStatement stmt = conn.prepareStatement("SELECT toDateTime64(?, 3, ?)")) { + LocalDateTime localTs = LocalDateTime.parse("2021-01-01T01:34:56").withNano((int) TimeUnit.MILLISECONDS.toNanos(456)); + stmt.setObject(1, localTs); + stmt.setString(2, localTimezone); try (ResultSet rs = stmt.executeQuery()) { assertTrue(rs.next()); - assertEquals(rs.getTimestamp(1).getNanos(), TimeUnit.MILLISECONDS.toNanos(456)); - assertEquals(rs.getTimestamp(1).toString(), "2021-01-01 01:34:56.456"); + + assertEquals(rs.getObject(1, LocalDateTime.class), localTs); + Timestamp dbTimestamp = rs.getTimestamp(1); + + assertEquals(dbTimestamp.getTime(), localTs.toInstant(ZoneId.systemDefault().getRules().getOffset(localTs)).toEpochMilli()); assertFalse(rs.next()); } } From 26f5d23b08588b7c1048e0f42c7263376d076c3a Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Thu, 19 Feb 2026 17:45:36 -0800 Subject: [PATCH 09/12] Fixed test that used wrong datetime to compare --- .../java/com/clickhouse/client/api/DataTypeUtilsTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 2c42f200c..54f9a7000 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 @@ -472,7 +472,7 @@ void testToSqlTimestampRoundTripWithVariousTimezones() { void testRoundTripConversionsWithDifferentTimezones() { // Original values LocalDate date = LocalDate.of(2024, 7, 4); - LocalTime time = LocalTime.of(14, 30, 45, 123000000); + LocalTime time = LocalTime.of(3, 30, 45, 123000000); LocalDateTime dateTime = LocalDateTime.of(date, time); TimeZone tokyo = TimeZone.getTimeZone("Asia/Tokyo"); @@ -497,7 +497,7 @@ void testRoundTripConversionsWithDifferentTimezones() { Calendar nyCal = new GregorianCalendar(newYork); LocalDateTime dateTimeInNy = DataTypeUtils.toLocalDateTime(sqlTimestampTokyo, nyCal.getTimeZone()); // 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) + // (03:30 Tokyo = 14:30 NY previous day during EDT) assertEquals(dateTimeInNy.toLocalDate(), LocalDate.of(2024, 7, 4).minusDays(1), "Same instant should be previous day in New York"); } From 6250f95d4447bf20015aad61aeed22dac04527f7 Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Thu, 19 Feb 2026 19:37:36 -0800 Subject: [PATCH 10/12] Fixing issue with dates --- .../jdbc/PreparedStatementTest.java | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java index 93d0af6c2..b644ad52a 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java @@ -1,5 +1,6 @@ package com.clickhouse.jdbc; +import com.clickhouse.client.api.DataTypeUtils; import com.clickhouse.data.ClickHouseColumn; import com.clickhouse.data.ClickHouseDataType; import com.clickhouse.data.ClickHouseVersion; @@ -1656,17 +1657,6 @@ public void testEncodingArray() throws Exception { } } - - /** - * Tests the "day shift" bug that can occur when timezone differences cause dates to shift by a day. - * - * The issue: java.sql.Date internally stores milliseconds since epoch at midnight in some timezone. - * If the driver interprets that instant using a different timezone, the date can shift. - * - * Example: "2024-01-15 00:00:00" in America/New_York (UTC-5) is "2024-01-15 05:00:00" in UTC. - * But "2024-01-15 00:00:00" in Pacific/Auckland (UTC+13) is "2024-01-14 11:00:00" in UTC. - * If not handled correctly, dates near midnight can shift to the previous or next day. - */ @Test(groups = {"integration"}) void testDateDayShiftWithDifferentTimezones() throws Exception { String table = "test_date_day_shift"; @@ -1697,15 +1687,18 @@ void testDateDayShiftWithDifferentTimezones() throws Exception { int id = 0; for (String dateStr : testDates) { - java.sql.Date expectedDate = java.sql.Date.valueOf(dateStr); - + LocalDate localDate = LocalDate.parse(dateStr); + java.sql.Date inputDate = java.sql.Date.valueOf(localDate); for (TimeZone tz : timezones) { id++; + Calendar calendar = new GregorianCalendar(tz); + LocalDate expectedLocalDate = DataTypeUtils.toLocalDate(inputDate, calendar.getTimeZone()); + java.sql.Date expectedDate = DataTypeUtils.toSqlDate(expectedLocalDate, calendar.getTimeZone()); try (PreparedStatement stmt = conn.prepareStatement( "INSERT INTO " + table + " (id, d) VALUES (?, ?)")) { stmt.setInt(1, id); // Use Calendar to specify the timezone context for the date - stmt.setDate(2, expectedDate, new GregorianCalendar(tz)); + stmt.setDate(2, inputDate, calendar); stmt.executeUpdate(); } @@ -1715,10 +1708,15 @@ void testDateDayShiftWithDifferentTimezones() throws Exception { stmt.setInt(1, id); try (ResultSet rs = stmt.executeQuery()) { assertTrue(rs.next(), "Expected row for id=" + id); - java.sql.Date actualDate = rs.getDate(1); - assertEquals(actualDate.toString(), expectedDate.toString(), + java.sql.Date actualDate = rs.getDate(1, calendar); + LocalDate actualLocalDate = rs.getObject(1, LocalDate.class); + assertEquals(actualDate.getTime(), expectedDate.getTime()); + assertEquals(DataTypeUtils.toLocalDate(actualDate, calendar.getTimeZone()), expectedLocalDate, String.format("Date mismatch for %s with timezone %s: expected %s, got %s", - dateStr, tz.getID(), expectedDate, actualDate)); + dateStr, tz.getID(), expectedLocalDate, DataTypeUtils.toLocalDate(actualDate, calendar.getTimeZone()))); + assertEquals(actualLocalDate, expectedLocalDate, + String.format("LocalDate mismatch for %s with timezone %s: expected %s, got %s", + dateStr, tz.getID(), expectedLocalDate, actualLocalDate)); } } } From b8dbfacca28b68c216e031f0411f6107a7d2699d Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Fri, 20 Feb 2026 10:18:27 -0800 Subject: [PATCH 11/12] Fixed NPE and added missing checks --- .../clickhouse/jdbc/PreparedStatementImpl.java | 15 ++++++++++----- .../com/clickhouse/jdbc/WriterStatementImpl.java | 2 -- 2 files changed, 10 insertions(+), 7 deletions(-) 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 70a131394..af22f8dbe 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java @@ -58,6 +58,7 @@ import java.util.List; import java.util.Map; import java.util.Stack; +import java.util.TimeZone; import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -67,7 +68,7 @@ public class PreparedStatementImpl extends StatementImpl implements PreparedStatement, JdbcV2Wrapper { private static final Logger LOG = LoggerFactory.getLogger(PreparedStatementImpl.class); - private final Calendar defaultCalendar; + protected final Calendar defaultCalendar; private final String originalSql; private final String[] values; // temp value holder (set can be called > once) @@ -279,6 +280,7 @@ public void setObject(int parameterIndex, Object x, SQLType targetSqlType) throw @Override public void setObject(int parameterIndex, Object x, SQLType targetSqlType, int scaleOrLength) throws SQLException { ensureOpen(); + isValidForTargetType(x, targetSqlType.getVendorTypeNumber()); values[parameterIndex-1] = encodeObject(x, (long) scaleOrLength); } @@ -466,19 +468,22 @@ 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(DataTypeUtils.toLocalDate(x, cal.getTimeZone())); + TimeZone tz = (cal == null ? defaultCalendar : cal).getTimeZone(); + values[parameterIndex - 1] = encodeObject(DataTypeUtils.toLocalDate(x, tz)); } @Override public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLException { ensureOpen(); - values[parameterIndex - 1] = encodeObject(DataTypeUtils.toLocalTime(x, cal.getTimeZone())); + TimeZone tz = (cal == null ? defaultCalendar : cal).getTimeZone(); + values[parameterIndex - 1] = encodeObject(DataTypeUtils.toLocalTime(x, tz)); } @Override public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException { ensureOpen(); - values[parameterIndex - 1] = encodeObject(DataTypeUtils.toZonedDateTime(x, cal.getTimeZone())); + TimeZone tz = (cal == null ? defaultCalendar : cal).getTimeZone(); + values[parameterIndex - 1] = encodeObject(DataTypeUtils.toZonedDateTime(x, tz)); } @Override @@ -1003,7 +1008,7 @@ private void isValidForTargetType(Object value, int targetType) throws SQLExcept break; case Types.TIMESTAMP: case Types.TIMESTAMP_WITH_TIMEZONE: - if (vClass == Timestamp.class || vClass == LocalDateTime.class || vClass == ZonedDateTime.class) { + if (vClass == Timestamp.class || vClass == LocalDateTime.class || vClass == ZonedDateTime.class || vClass == OffsetDateTime.class) { return; } break; 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 18bc4564d..cbe1d1982 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/WriterStatementImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/WriterStatementImpl.java @@ -48,14 +48,12 @@ 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 insertColumns = new ArrayList<>(); for (String column : parsedStatement.getInsertColumns()) { From 5c582f798a56e07c384db0f02ee204feedb83def Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Fri, 20 Feb 2026 12:40:42 -0800 Subject: [PATCH 12/12] Fixed issues after review. --- .../main/java/com/clickhouse/client/api/DataTypeUtils.java | 4 +++- .../main/java/com/clickhouse/jdbc/PreparedStatementImpl.java | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) 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 2d3ce3561..cc451aef6 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 @@ -48,7 +48,9 @@ public class DataTypeUtils { .appendFraction(ChronoField.NANO_OF_SECOND, 9, 9, true) .toFormatter(); - public static final DateTimeFormatter TIME_WITH_NANOS_FORMATTER = INSTANT_FORMATTER; + public static final DateTimeFormatter TIME_WITH_NANOS_FORMATTER = new DateTimeFormatterBuilder().appendPattern("HH:mm:ss") + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) + .toFormatter();; public static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); 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 af22f8dbe..a29a220f1 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/PreparedStatementImpl.java @@ -763,7 +763,7 @@ private String encodeObject(Object x, Long length) throws SQLException { } else if (x instanceof Time) { return QUOTE + DataTypeUtils.TIME_FORMATTER.format(((Time) x).toLocalTime()) + QUOTE; } else if (x instanceof LocalTime) { - return QUOTE + DataTypeUtils.TIME_FORMATTER.format((LocalTime) x) + QUOTE; + return QUOTE + DataTypeUtils.TIME_WITH_NANOS_FORMATTER.format((LocalTime) x) + QUOTE; } else if (x instanceof Timestamp) { return QUOTE + DataTypeUtils.DATE_TIME_WITH_OPTIONAL_NANOS.format(((Timestamp) x).toLocalDateTime()) + QUOTE; } else if (x instanceof LocalDateTime) {