diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseResultSet.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseResultSet.java index a0d51a64503a..094ab838cfe1 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseResultSet.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseResultSet.java @@ -52,8 +52,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.LocalTime; +import java.time.OffsetDateTime; import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.Calendar; import java.util.List; import javax.sql.rowset.serial.SerialBlob; @@ -317,9 +322,12 @@ protected Object getObject(int columnIndex, ZoneId zone) throws SQLException { public T getObject(int columnIndex, Class type) throws SQLException { LOG.finestTrace("getObject"); try { - if (type == LocalTime.class || type == String.class) { + if (isRawViewTarget(type)) { StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex); - if (bqType == StandardSQLTypeName.TIME) { + // java.sql types can't hold every value of these (sub-ms TIME, pre-1582 DATE/DATETIME). + if (bqType == StandardSQLTypeName.TIME + || bqType == StandardSQLTypeName.DATE + || bqType == StandardSQLTypeName.DATETIME) { return BigQueryTypeRegistry.convert(getObjectInternal(columnIndex), bqType, type, null); } } @@ -339,6 +347,16 @@ public T getObject(int columnIndex, Class type) throws SQLException { } } + private static boolean isRawViewTarget(Class type) { + return type == String.class + || type == LocalDate.class + || type == LocalDateTime.class + || type == LocalTime.class + || type == Instant.class + || type == OffsetDateTime.class + || type == ZonedDateTime.class; + } + @Override public T getObject(String columnLabel, Class type) throws SQLException { return getObject(getColumnIndex(columnLabel), type); diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatement.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatement.java index e1ad8925eb0a..87fbe9eae997 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatement.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatement.java @@ -16,6 +16,7 @@ package com.google.cloud.bigquery.jdbc; +import com.google.cloud.Tuple; import com.google.cloud.bigquery.StandardSQLTypeName; import com.google.cloud.bigquery.exception.BigQueryJdbcException; import com.google.cloud.bigquery.exception.BigQueryJdbcSqlFeatureNotSupportedException; @@ -794,6 +795,12 @@ public void setObject(String parameterName, Object value) throws SQLException { setNull(parameterName, Types.OTHER); return; } + Tuple> binding = BigQueryParameterHandler.bindingForJavaTime(value); + if (binding != null) { + this.parameterHandler.setParameter( + parameterName, binding.x(), binding.y(), BigQueryStatementParameterType.IN, 0); + return; + } this.parameterHandler.setParameter( parameterName, value, value.getClass(), BigQueryStatementParameterType.IN, 0); } diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandler.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandler.java index b003aaae7eab..6e65e978880c 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandler.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandler.java @@ -16,6 +16,7 @@ package com.google.cloud.bigquery.jdbc; +import com.google.cloud.Tuple; import com.google.cloud.bigquery.QueryJobConfiguration; import com.google.cloud.bigquery.QueryParameterValue; import com.google.cloud.bigquery.StandardSQLTypeName; @@ -29,7 +30,9 @@ 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.OffsetDateTime; import java.time.ZoneOffset; import java.time.ZonedDateTime; @@ -48,18 +51,50 @@ class BigQueryParameterHandler { // Locale.ROOT marks these as wire formats; the patterns are numeric-only, so it is a guard, not // a fix. private static final DateTimeFormatter TIMESTAMP_MICROS_UTC = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSSXXX", Locale.ROOT); + DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss.SSSSSSXXX", Locale.ROOT); private static final DateTimeFormatter TIMESTAMP_NANOS_UTC = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSSSSSXXX", Locale.ROOT); + DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss.SSSSSSSSSXXX", Locale.ROOT); // DATETIME is a zone-less wall clock; the validator requires six fraction digits and no offset. private static final DateTimeFormatter DATETIME_MICROS = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS", Locale.ROOT); + DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss.SSSSSS", Locale.ROOT); + private static final DateTimeFormatter DATE_DIGITS = + DateTimeFormatter.ofPattern("uuuu-MM-dd", Locale.ROOT); + private static final DateTimeFormatter TIME_MICROS = + DateTimeFormatter.ofPattern("HH:mm:ss.SSSSSS", Locale.ROOT); /** Wall-clock digits for DATETIME (read as UTC if sent to TIMESTAMP). Nanos are truncated. */ static String formatDateTimeDigits(LocalDateTime value) { return DATETIME_MICROS.format(value); } + /** + * The value and declared class a java.time object is bound with by {@code setObject}, or null if + * {@code value} is not a supported java.time type. LocalDate and LocalTime stay unboxed so their + * digits are sent as-is; LocalDateTime is bound as STRING so both DATETIME and TIMESTAMP columns + * accept it. + */ + static Tuple> bindingForJavaTime(Object value) { + if (value instanceof LocalDate) { + return Tuple.of(value, Date.class); + } + if (value instanceof LocalTime) { + return Tuple.of(value, Time.class); + } + if (value instanceof LocalDateTime) { + return Tuple.of(formatDateTimeDigits((LocalDateTime) value), String.class); + } + if (value instanceof OffsetDateTime) { + return Tuple.of(Timestamp.from(((OffsetDateTime) value).toInstant()), Timestamp.class); + } + if (value instanceof ZonedDateTime) { + return Tuple.of(Timestamp.from(((ZonedDateTime) value).toInstant()), Timestamp.class); + } + if (value instanceof Instant) { + return Tuple.of(Timestamp.from((Instant) value), Timestamp.class); + } + return null; + } + BigQueryParameterHandler(int parameterCount) { this(parameterCount, false); } @@ -155,6 +190,18 @@ static Object formatValueForQueryParameter( } } + // Civil java.time values are sent as their digits. Boxing into java.sql would shift + // 1582-10-05..14 (no such days in its calendar) and drop sub-second TIME digits. + if (parameterValue instanceof LocalDate) { + return DATE_DIGITS.format((LocalDate) parameterValue); + } + if (parameterValue instanceof LocalTime) { + return TIME_MICROS.format((LocalTime) parameterValue); + } + if (parameterValue instanceof LocalDateTime) { + return DATETIME_MICROS.format((LocalDateTime) parameterValue); + } + if (parameterValue instanceof Timestamp) { // DATETIME slot: JVM-local wall-clock digits, six-digit fraction, no offset (prior // behaviour). diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatement.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatement.java index 189c589b404d..1d4f9b2f01f9 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatement.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatement.java @@ -18,6 +18,7 @@ import com.google.api.gax.retrying.RetrySettings; import com.google.api.services.bigquery.model.QueryParameter; +import com.google.cloud.Tuple; import com.google.cloud.bigquery.FieldList; import com.google.cloud.bigquery.JobStatistics.QueryStatistics; import com.google.cloud.bigquery.JobStatistics.QueryStatistics.StatementType; @@ -61,12 +62,6 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.OffsetDateTime; -import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; @@ -365,36 +360,13 @@ public void setObject(int parameterIndex, Object value) throws SQLException { this.parameterHandler.setParameter(parameterIndex, value, value.getClass()); } - private boolean setTemporalObject(int parameterIndex, Object value) throws SQLException { - if (value instanceof LocalDate) { - setDate(parameterIndex, Date.valueOf((LocalDate) value)); - return true; + private boolean setTemporalObject(int parameterIndex, Object value) { + Tuple> binding = BigQueryParameterHandler.bindingForJavaTime(value); + if (binding == null) { + return false; } - if (value instanceof LocalTime) { - setTime(parameterIndex, Time.valueOf((LocalTime) value)); - return true; - } - if (value instanceof LocalDateTime) { - // Bound as STRING, not DATETIME, to avoid a breaking change: a STRING is accepted by both - // DATETIME and TIMESTAMP columns, whereas a typed DATETIME would reject the TIMESTAMP - // inserts that work today. The digits are sent unchanged; no JVM zone is involved. - setString( - parameterIndex, BigQueryParameterHandler.formatDateTimeDigits((LocalDateTime) value)); - return true; - } - if (value instanceof OffsetDateTime) { - setTimestamp(parameterIndex, Timestamp.from(((OffsetDateTime) value).toInstant())); - return true; - } - if (value instanceof Instant) { - setTimestamp(parameterIndex, Timestamp.from((Instant) value)); - return true; - } - if (value instanceof ZonedDateTime) { - setTimestamp(parameterIndex, Timestamp.from(((ZonedDateTime) value).toInstant())); - return true; - } - return false; + this.parameterHandler.setParameter(parameterIndex, binding.x(), binding.y()); + return true; } @Override diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtility.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtility.java index 516fd839ea7c..4f4a173851b9 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtility.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtility.java @@ -31,12 +31,12 @@ 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.format.DateTimeParseException; import java.time.temporal.ChronoField; import java.util.Calendar; +import java.util.GregorianCalendar; import java.util.TimeZone; /** @@ -77,11 +77,43 @@ public static Timestamp boxDateTime(String val, ZoneId zoneId) { /** * Converts a civil {@link LocalDateTime} into a {@link Timestamp} by anchoring it to the provided - * timezone (or JVM default if null). + * timezone (or JVM default if null). Uses java.sql's calendar so the Timestamp keeps the digits + * before 1582 and before standard time, where java.time disagrees. */ static Timestamp boxDateTime(LocalDateTime localDateTime, ZoneId zoneId) { - ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault(); - return Timestamp.from(localDateTime.atZone(targetZone).toInstant()); + if (zoneId == null) { + return Timestamp.valueOf(localDateTime); + } + GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone(zoneId)); + cal.clear(); + cal.set( + localDateTime.getYear(), + localDateTime.getMonthValue() - 1, + localDateTime.getDayOfMonth(), + localDateTime.getHour(), + localDateTime.getMinute(), + localDateTime.getSecond()); + Timestamp timestamp = new Timestamp(cal.getTimeInMillis()); + timestamp.setNanos(localDateTime.getNano()); + return timestamp; + } + + /** Inverse of {@link #boxDateTime(LocalDateTime, ZoneId)}. */ + static LocalDateTime unboxDateTime(Timestamp timestamp, ZoneId zoneId) { + if (zoneId == null) { + return timestamp.toLocalDateTime(); + } + GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone(zoneId)); + cal.setTimeInMillis(timestamp.getTime()); + int year = cal.get(Calendar.YEAR); + return LocalDateTime.of( + cal.get(Calendar.ERA) == GregorianCalendar.BC ? 1 - year : year, + cal.get(Calendar.MONTH) + 1, + cal.get(Calendar.DAY_OF_MONTH), + cal.get(Calendar.HOUR_OF_DAY), + cal.get(Calendar.MINUTE), + cal.get(Calendar.SECOND), + timestamp.getNanos()); } /** @@ -97,8 +129,10 @@ public static Date boxDate(String val, ZoneId zoneId) { * provided timezone (or JVM default if null). */ static Date boxDate(LocalDate localDate, ZoneId zoneId) { - ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault(); - return new Date(localDate.atStartOfDay(targetZone).toInstant().toEpochMilli()); + if (zoneId == null) { + return Date.valueOf(localDate); + } + return new Date(boxDateTime(localDate.atStartOfDay(), zoneId).getTime()); } /** @@ -468,14 +502,12 @@ static Date convertDateWithCalendar(Date date, Calendar cal) { if (date == null || cal == null) { return date; } - ZoneId systemZone = ZoneId.systemDefault(); - ZoneId targetZone = cal.getTimeZone().toZoneId(); - if (systemZone.equals(targetZone)) { + TimeZone systemZone = TimeZone.getDefault(); + TimeZone targetZone = cal.getTimeZone(); + if (systemZone.getID().equals(targetZone.getID())) { return date; } - LocalDate localDate = date.toLocalDate(); - ZonedDateTime zdt = localDate.atStartOfDay(targetZone); - return new Date(zdt.toInstant().toEpochMilli()); + return moveDateFields(date, systemZone, targetZone); } /** @@ -492,14 +524,28 @@ static Date convertDateToCalendar(Date date, Calendar cal) { if (date == null || cal == null) { return date; } - ZoneId systemZone = ZoneId.systemDefault(); - ZoneId targetZone = cal.getTimeZone().toZoneId(); - if (systemZone.equals(targetZone)) { + TimeZone systemZone = TimeZone.getDefault(); + TimeZone targetZone = cal.getTimeZone(); + if (systemZone.getID().equals(targetZone.getID())) { return date; } - LocalDate localDate = Instant.ofEpochMilli(date.getTime()).atZone(targetZone).toLocalDate(); - ZonedDateTime zdt = localDate.atStartOfDay(systemZone); - return new Date(zdt.toInstant().toEpochMilli()); + return moveDateFields(date, targetZone, systemZone); + } + + /** + * Returns midnight in {@code to} of the calendar date {@code date} has in {@code from}. Uses + * legacy calendars on both sides because {@link Date} is read with legacy rules; java.time + * differs before 1582 (Julian vs proleptic Gregorian) and before standard time (LMT offsets). + */ + private static Date moveDateFields(Date date, TimeZone from, TimeZone to) { + Calendar source = new GregorianCalendar(from); + source.setTime(date); + Calendar target = new GregorianCalendar(to); + target.clear(); + target.set(Calendar.ERA, source.get(Calendar.ERA)); + target.set( + source.get(Calendar.YEAR), source.get(Calendar.MONTH), source.get(Calendar.DAY_OF_MONTH)); + return new Date(target.getTimeInMillis()); } /** diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistry.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistry.java index fa50995af79a..7d8f09fa0e1b 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistry.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistry.java @@ -227,26 +227,40 @@ static TypeDescriptor createDateDescriptor() { Arrays.asList(Date.class, LocalDate.class), (val, targetClass, zone) -> { // Modern fast-path: Bypass intermediate object creation for JSR-310 targets - if (targetClass == LocalDate.class) { + if (targetClass == LocalDate.class + || targetClass == String.class + || targetClass == LocalDateTime.class + || isInstantView(targetClass)) { + LocalDate localDate; if (val instanceof LocalDate) { - return val; + localDate = (LocalDate) val; } else if (val instanceof Date) { - return ((Date) val).toLocalDate(); + localDate = ((Date) val).toLocalDate(); } else if (val instanceof Timestamp) { - return ((Timestamp) val).toLocalDateTime().toLocalDate(); + localDate = ((Timestamp) val).toLocalDateTime().toLocalDate(); } else if (val instanceof java.sql.Time) { throw new BigQueryJdbcException("Cannot convert to DATE: " + val); } else if (val instanceof java.util.Date) { - return new Date(((java.util.Date) val).getTime()).toLocalDate(); + localDate = new Date(((java.util.Date) val).getTime()).toLocalDate(); } else if (val instanceof LocalDateTime) { - return ((LocalDateTime) val).toLocalDate(); + localDate = ((LocalDateTime) val).toLocalDate(); } else if (val instanceof Integer) { - return LocalDate.ofEpochDay(((Integer) val).longValue()); + localDate = LocalDate.ofEpochDay(((Integer) val).longValue()); } else if (val instanceof String) { - return LocalDate.parse((String) val); + localDate = LocalDate.parse((String) val); } else { throw new BigQueryJdbcException("Cannot convert to DATE: " + val); } + if (targetClass == LocalDate.class) { + return localDate; + } + if (targetClass == String.class) { + return localDate.toString(); + } + if (targetClass == LocalDateTime.class) { + return localDate.atStartOfDay(); + } + return anchorCivil(localDate.atStartOfDay(), targetClass, zone); } // Legacy path: Box values into java.sql.Date @@ -254,8 +268,8 @@ static TypeDescriptor createDateDescriptor() { if (val instanceof Date) { sqlDate = (Date) val; } else if (val instanceof Timestamp) { - ZoneId targetZone = zone != null ? zone : ZoneId.systemDefault(); - LocalDate localDate = ((Timestamp) val).toInstant().atZone(targetZone).toLocalDate(); + LocalDate localDate = + BigQueryTemporalUtility.unboxDateTime((Timestamp) val, zone).toLocalDate(); sqlDate = BigQueryTemporalUtility.boxDate(localDate, zone); } else if (val instanceof java.sql.Time) { throw new BigQueryJdbcException("Cannot convert to DATE: " + val); @@ -286,21 +300,39 @@ static TypeDescriptor createDatetimeDescriptor() { Arrays.asList(Timestamp.class, LocalDateTime.class), (val, targetClass, zone) -> { // Modern fast-path: Bypass intermediate object creation for JSR-310 targets - if (targetClass == LocalDateTime.class) { + if (targetClass == LocalDateTime.class + || targetClass == String.class + || targetClass == LocalDate.class + || targetClass == LocalTime.class + || isInstantView(targetClass)) { + LocalDateTime localDateTime; if (val instanceof LocalDateTime) { - return val; + localDateTime = (LocalDateTime) val; } else if (val instanceof Timestamp) { - return ((Timestamp) val).toLocalDateTime(); + localDateTime = ((Timestamp) val).toLocalDateTime(); } else if (val instanceof java.util.Date) { - return new Timestamp(((java.util.Date) val).getTime()).toLocalDateTime(); + localDateTime = new Timestamp(((java.util.Date) val).getTime()).toLocalDateTime(); } else if (val instanceof String) { String isoString = BigQueryTemporalUtility.truncateFractionalSeconds( ((String) val).replace(' ', 'T'), 9); - return LocalDateTime.parse(isoString); + localDateTime = LocalDateTime.parse(isoString); } else { throw new BigQueryJdbcException("Cannot convert to DATETIME: " + val); } + if (targetClass == LocalDateTime.class) { + return localDateTime; + } + if (targetClass == String.class) { + return TIMESTAMP_FORMATTER.format(localDateTime); + } + if (targetClass == LocalDate.class) { + return localDateTime.toLocalDate(); + } + if (targetClass == LocalTime.class) { + return localDateTime.toLocalTime(); + } + return anchorCivil(localDateTime, targetClass, zone); } // Legacy path: Box values into java.sql.Timestamp @@ -329,10 +361,13 @@ static TypeDescriptor createTimestampDescriptor() { Arrays.asList(Timestamp.class, OffsetDateTime.class, Instant.class, ZonedDateTime.class), (val, targetClass, zone) -> { // Modern fast-path: Bypass intermediate object creation for JSR-310 targets - if (targetClass == Instant.class - || targetClass == OffsetDateTime.class - || targetClass == ZonedDateTime.class - || targetClass == String.class) { + if (isInstantView(targetClass) + || targetClass == String.class + || targetClass == LocalDateTime.class + || targetClass == LocalDate.class + || targetClass == LocalTime.class + || targetClass == Date.class + || targetClass == Time.class) { Instant instant; if (val instanceof Instant) { instant = (Instant) val; @@ -354,18 +389,27 @@ static TypeDescriptor createTimestampDescriptor() { throw new BigQueryJdbcException("Cannot convert to TIMESTAMP: " + val); } - if (targetClass == Instant.class) { - return instant; - } - if (targetClass == OffsetDateTime.class) { - return instant.atOffset(ZoneOffset.UTC); - } - if (targetClass == ZonedDateTime.class) { - return instant.atZone(ZoneOffset.UTC); + if (isInstantView(targetClass)) { + return renderInstant(instant, targetClass); } if (targetClass == String.class) { return TIMESTAMP_FORMATTER.format(instant.atOffset(ZoneOffset.UTC)); } + // java.time, not Timestamp.toLocalDateTime(), which shifts dates before 1582. + ZonedDateTime local = instant.atZone(zone != null ? zone : ZoneId.systemDefault()); + if (targetClass == LocalDateTime.class) { + return local.toLocalDateTime(); + } + if (targetClass == LocalDate.class) { + return local.toLocalDate(); + } + if (targetClass == LocalTime.class) { + return local.toLocalTime(); + } + if (targetClass == Date.class) { + return BigQueryTemporalUtility.boxDate(local.toLocalDate(), zone); + } + return BigQueryTemporalUtility.boxTime(local.toLocalTime(), zone); } // Legacy path: Box values into java.sql.Timestamp @@ -434,8 +478,8 @@ static TypeDescriptor createTimeDescriptor() { Time sqlTime; if (val instanceof Time) sqlTime = (Time) val; else if (val instanceof Timestamp) { - ZoneId targetZone = zone != null ? zone : ZoneId.systemDefault(); - LocalTime localTime = ((Timestamp) val).toInstant().atZone(targetZone).toLocalTime(); + LocalTime localTime = + BigQueryTemporalUtility.unboxDateTime((Timestamp) val, zone).toLocalTime(); sqlTime = BigQueryTemporalUtility.boxTime(localTime, zone); } else if (val instanceof Date) { throw new BigQueryJdbcException("Cannot convert to TIME: " + val); @@ -639,6 +683,27 @@ private static void register(TypeDescriptor descriptor) { } } + private static boolean isInstantView(Class targetClass) { + return targetClass == Instant.class + || targetClass == OffsetDateTime.class + || targetClass == ZonedDateTime.class; + } + + private static Object renderInstant(Instant instant, Class targetClass) { + if (targetClass == OffsetDateTime.class) { + return instant.atOffset(ZoneOffset.UTC); + } + if (targetClass == ZonedDateTime.class) { + return instant.atZone(ZoneOffset.UTC); + } + return instant; + } + + private static Object anchorCivil(LocalDateTime civil, Class targetClass, ZoneId zone) { + ZoneId targetZone = zone != null ? zone : ZoneId.systemDefault(); + return renderInstant(civil.atZone(targetZone).toInstant(), targetClass); + } + private BigQueryTypeRegistry() {} /** diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatementTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatementTest.java index 50e425de7030..c655b9ef46e4 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatementTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatementTest.java @@ -23,6 +23,7 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import com.google.cloud.bigquery.QueryParameterValue; import com.google.cloud.bigquery.StandardSQLTypeName; import com.google.cloud.bigquery.jdbc.BigQueryParameterHandler.BigQueryStatementParameterType; import java.io.IOException; @@ -30,6 +31,9 @@ import java.io.StringReader; import java.math.BigDecimal; import java.sql.*; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.Calendar; import java.util.HashMap; import java.util.Map; @@ -1007,6 +1011,30 @@ public void testSetObjectSqlTypeScaleParamByName() throws SQLException { assertEquals(5, scale); } + /** Named setObject with java.time must produce the same parameter as positional setObject. */ + @Test + public void testSetObjectJavaTimeParamByName() throws SQLException { + BigQueryCallableStatement statement = + new BigQueryCallableStatement(bigQueryConnection, "call testProc('?')"); + BigQueryParameterHandler handler = statement.getParameterHandler(); + + statement.setObject(PARAM_KEY, LocalDate.of(1582, 10, 10)); + assertEquals("DATE 1582-10-10", sentValue(handler)); + statement.setObject(PARAM_KEY, LocalTime.of(12, 34, 56, 789_123_000)); + assertEquals("TIME 12:34:56.789123", sentValue(handler)); + statement.setObject(PARAM_KEY, LocalDateTime.of(2025, 1, 1, 12, 34, 56)); + assertEquals("STRING 2025-01-01 12:34:56.000000", sentValue(handler)); + } + + /** The type and value configureParameters would send for {@link #PARAM_KEY}. */ + private static String sentValue(BigQueryParameterHandler handler) { + StandardSQLTypeName type = handler.getSqlType(PARAM_KEY); + Object value = + BigQueryParameterHandler.formatValueForQueryParameter( + handler.getParameter(PARAM_KEY), type, false); + return type + " " + QueryParameterValue.of(value, type).getValue(); + } + @Test public void testSetStringParamByName() throws SQLException { BigQueryCallableStatement statement = diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandlerTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandlerTest.java index da7c27288e1d..496982f42683 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandlerTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandlerTest.java @@ -187,6 +187,16 @@ public void testFormatValueForQueryParameter_timestampPrecision() { assertEquals("2024-01-01 12:34:56.123456Z", microsFormatted); } + @Test + public void testFormatValueForQueryParameter_timestampBeforeYearOneKeepsEra() { + // 1 BC is proleptic year 0; "yyyy" printed it as "0001" and silently moved it into AD 1. + Timestamp ts = Timestamp.from(Instant.parse("0000-12-30T12:00:00Z")); + assertEquals( + "0000-12-30 12:00:00.000000Z", + BigQueryParameterHandler.formatValueForQueryParameter( + ts, StandardSQLTypeName.TIMESTAMP, false)); + } + @ParameterizedTest @ValueSource(strings = {"UTC", "America/New_York", "Asia/Kolkata"}) public void testFormatValueForQueryParameter_timestampPreservesInstantAcrossJvmZones( diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatementSettersTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatementSettersTest.java index 5f2e1db845a2..59fc26767278 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatementSettersTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatementSettersTest.java @@ -30,6 +30,7 @@ import com.google.cloud.bigquery.Field; import com.google.cloud.bigquery.FieldList; import com.google.cloud.bigquery.QueryJobConfiguration; +import com.google.cloud.bigquery.QueryParameterValue; import com.google.cloud.bigquery.Schema; import com.google.cloud.bigquery.StandardSQLTypeName; import com.google.cloud.bigquery.exception.BigQueryJdbcException; @@ -52,6 +53,7 @@ import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Calendar; +import java.util.List; import java.util.TimeZone; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -288,15 +290,9 @@ public void testGetMetaDataReturnsNullForInsert() throws Exception { @Test public void testSetObjectWithJavaTime() throws Exception { preparedStatement = new BigQueryPreparedStatement(connection, "SELECT ?, ?, ?, ?, ?, ?"); - LocalDate localDate = LocalDate.of(2025, 12, 3); - preparedStatement.setObject(1, localDate); - assertEquals(Date.class, preparedStatement.parameterHandler.getType(1)); - assertEquals(Date.valueOf(localDate), preparedStatement.parameterHandler.getParameter(1)); - - LocalTime localTime = LocalTime.of(12, 30, 0); - preparedStatement.setObject(2, localTime); - assertEquals(Time.class, preparedStatement.parameterHandler.getType(2)); - assertEquals(Time.valueOf(localTime), preparedStatement.parameterHandler.getParameter(2)); + // 1582-10-10 does not exist in java.sql's calendar; the time has sub-second digits. + preparedStatement.setObject(1, LocalDate.of(1582, 10, 10)); + preparedStatement.setObject(2, LocalTime.of(12, 34, 56, 789_123_000)); // LocalDateTime is bound as STRING so it is accepted by both DATETIME and TIMESTAMP columns. LocalDateTime localDateTime = LocalDateTime.of(2025, 12, 3, 12, 30, 0); @@ -324,6 +320,18 @@ public void testSetObjectWithJavaTime() throws Exception { assertEquals( Timestamp.from(zonedDateTime.toInstant()), preparedStatement.parameterHandler.getParameter(6)); + + QueryJobConfiguration.Builder builder = QueryJobConfiguration.newBuilder("SELECT 1"); + List sent = + preparedStatement + .parameterHandler + .configureParameters(builder) + .build() + .getPositionalParameters(); + assertEquals(StandardSQLTypeName.DATE, sent.get(0).getType()); + assertEquals("1582-10-10", sent.get(0).getValue()); + assertEquals(StandardSQLTypeName.TIME, sent.get(1).getType()); + assertEquals("12:34:56.789123", sent.get(1).getValue()); } // Verifies standard DML QueryJobConfiguration creates positional parameters with null values when diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java index 814cad844c30..8ec6d375c122 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java @@ -31,6 +31,8 @@ import java.util.Calendar; import java.util.TimeZone; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; public class BigQueryTemporalUtilityTest { @@ -101,24 +103,46 @@ public void testConvertDateToCalendar() { assertThat(result.getTime()).isEqualTo(expectedSystemMillis); } - @Test - public void testCrossTimezoneDateRoundtrip() { - // Writer in Los Angeles (UTC-7), Reader in Tokyo (UTC+9) across the date line - ZoneId writeZone = ZoneId.of("America/Los_Angeles"); - ZoneId readZone = ZoneId.of("Asia/Tokyo"); - Calendar writeCal = Calendar.getInstance(TimeZone.getTimeZone(writeZone)); - Calendar readCal = Calendar.getInstance(TimeZone.getTimeZone(readZone)); - - Date inputDate = - new Date(LocalDate.of(2026, 8, 24).atStartOfDay(writeZone).toInstant().toEpochMilli()); - Date normalizedDate = BigQueryTemporalUtility.convertDateToCalendar(inputDate, writeCal); - Date readDate = BigQueryTemporalUtility.convertDateWithCalendar(normalizedDate, readCal); - - Calendar verifyCal = Calendar.getInstance(TimeZone.getTimeZone(readZone)); - verifyCal.setTime(readDate); - assertThat(verifyCal.get(Calendar.YEAR)).isEqualTo(2026); - assertThat(verifyCal.get(Calendar.MONTH)).isEqualTo(Calendar.AUGUST); - assertThat(verifyCal.get(Calendar.DAY_OF_MONTH)).isEqualTo(24); + /** setDate(cal) / getDate(cal) keep the calendar date, including pre-1582 and pre-1900 dates. */ + @ParameterizedTest + @ValueSource(strings = {"UTC", "America/Los_Angeles", "Asia/Kolkata"}) + public void testCrossTimezoneDateRoundtrip(String jvmZone) { + TimeZone originalDefault = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(jvmZone)); + // Writer in Los Angeles (UTC-8), reader in Tokyo (UTC+9) across the date line. + TimeZone writeZone = TimeZone.getTimeZone("America/Los_Angeles"); + TimeZone readZone = TimeZone.getTimeZone("Asia/Tokyo"); + for (String digits : new String[] {"0001-01-01", "1582-10-04", "1850-06-01", "2026-08-24"}) { + // The caller's Date is midnight of `digits` in the writer's Calendar zone. + LocalDate expected = LocalDate.parse(digits); + Calendar writeCal = Calendar.getInstance(writeZone); + writeCal.clear(); + writeCal.set(expected.getYear(), expected.getMonthValue() - 1, expected.getDayOfMonth()); + + Date written = + BigQueryTemporalUtility.convertDateToCalendar( + new Date(writeCal.getTimeInMillis()), writeCal); + assertThat(written.toString()).isEqualTo(digits); + + Calendar readCal = Calendar.getInstance(readZone); + readCal.setTime(BigQueryTemporalUtility.convertDateWithCalendar(written, readCal)); + assertThat( + LocalDate.of( + readCal.get(Calendar.YEAR), + readCal.get(Calendar.MONTH) + 1, + readCal.get(Calendar.DAY_OF_MONTH))) + .isEqualTo(expected); + assertThat(readCal.get(Calendar.HOUR_OF_DAY)).isEqualTo(0); + + // Reverse order: the Date getDate(col, cal) returns, bound back via setDate(i, d, cal). + Date read = BigQueryTemporalUtility.boxDate(digits, readZone.toZoneId()); + assertThat(BigQueryTemporalUtility.convertDateToCalendar(read, readCal).toString()) + .isEqualTo(digits); + } + } finally { + TimeZone.setDefault(originalDefault); + } } @Test @@ -225,6 +249,37 @@ public void testBoxDateTimeAndDate() { assertThat(picosDt.getNanos()).isEqualTo(123456789); } + /** Pre-1582 and pre-standard-time digits, with and without a Calendar zone. */ + @ParameterizedTest + @ValueSource(strings = {"UTC", "America/Los_Angeles", "Asia/Kolkata"}) + public void testBoxCivilValuesKeepDigits(String jvmZone) { + TimeZone originalDefault = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(jvmZone)); + ZoneId calZone = ZoneId.of("Asia/Tokyo"); + for (String digits : new String[] {"0001-01-01", "1582-10-04", "1850-06-01"}) { + LocalDateTime noon = LocalDateTime.parse(digits + "T12:00:00"); + assertThat(BigQueryTemporalUtility.boxDate(digits, null).toLocalDate()) + .isEqualTo(LocalDate.parse(digits)); + assertThat( + BigQueryTemporalUtility.boxDateTime(digits + " 12:00:00", null).toLocalDateTime()) + .isEqualTo(noon); + assertThat( + BigQueryTemporalUtility.unboxDateTime( + BigQueryTemporalUtility.boxDateTime(noon, calZone), calZone)) + .isEqualTo(noon); + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(calZone)); + cal.setTime(BigQueryTemporalUtility.boxDate(digits, calZone)); + assertThat( + LocalDate.of( + cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DATE))) + .isEqualTo(LocalDate.parse(digits)); + } + } finally { + TimeZone.setDefault(originalDefault); + } + } + @Test public void testBoxTime() { // Null zone (modern JSR-310) diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistryTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistryTest.java index 1223b0d11a12..ba91b21e051f 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistryTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistryTest.java @@ -222,6 +222,19 @@ public void testTimestampReadCoercionsAreJvmZoneInvariant(String zone) throws Ex BigQueryTypeRegistry.convert( epochMicros, StandardSQLTypeName.TIMESTAMP, Instant.class, null)) .isEqualTo(microsInstant); + + // Local views before 1582 follow java.time. + Instant yearOne = Instant.parse("0001-01-01T00:00:00Z"); + long yearOneMicros = yearOne.getEpochSecond() * 1_000_000L; + ZonedDateTime yearOneLocal = yearOne.atZone(ZoneId.of(zone)); + assertThat( + BigQueryTypeRegistry.convert( + yearOneMicros, StandardSQLTypeName.TIMESTAMP, LocalDateTime.class, null)) + .isEqualTo(yearOneLocal.toLocalDateTime()); + assertThat( + BigQueryTypeRegistry.convert( + yearOneMicros, StandardSQLTypeName.TIMESTAMP, LocalDate.class, null)) + .isEqualTo(yearOneLocal.toLocalDate()); } @Test diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITTimestampTimeZoneTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITTimestampTimeZoneTest.java index 505157119ca7..30a4a0a2ad71 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITTimestampTimeZoneTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITTimestampTimeZoneTest.java @@ -63,12 +63,17 @@ public class ITTimestampTimeZoneTest extends ITBase { private static final LocalDateTime DT_DIGITS = LocalDateTime.parse("2025-01-01T12:34:56.123"); private static final LocalDate D_DIGITS = LocalDate.parse("2025-01-01"); private static final LocalTime T_DIGITS = LocalTime.parse("12:34:56"); + private static final Instant TS_MIN = Instant.parse("0001-01-01T00:00:00Z"); // Never one of the JVM zones under test, so the Calendar always differs from the JVM zone. private static final ZoneId CAL_ZONE = ZoneId.of("Asia/Tokyo"); private static final String READ_SQL = - "SELECT TIMESTAMP '2025-01-01 12:34:56.123 UTC' AS ts," + // Pre-1582 and pre-standard-time values. + "SELECT TIMESTAMP '0001-01-01 00:00:00 UTC' AS ts_min, DATETIME '0001-01-01 00:00:00' AS" + + " dt_min, DATE '0001-01-01' AS d_min, DATETIME '1850-06-01 12:00:00' AS dt_1850," + + " DATE '1850-06-01' AS d_1850, DATE '1582-10-10' AS d_gap," + + " TIMESTAMP '2025-01-01 12:34:56.123 UTC' AS ts," + " DATETIME '2025-01-01 12:34:56.123' AS dt," + " DATE '2025-01-01' AS d," + " TIME '12:34:56' AS t," @@ -236,7 +241,10 @@ public void testSetTimeWithCalendar_sendsTimeInCalendarZone(String zone) throws } } - /** Midnight of 2025-01-01 in Kolkata or Auckland is still 2024-12-31 in UTC. */ + /** + * Midnight of 2025-01-01 in Kolkata or Auckland is still 2024-12-31 in UTC. A historical date + * read with getDate(col, cal) and bound back with setDate(i, d, cal) must arrive unchanged. + */ @ParameterizedTest(name = "JVM zone {0}") @MethodSource("jvmZones") public void testSetDateWithCalendar_sendsDateInCalendarZone(String zone) throws SQLException { @@ -244,13 +252,22 @@ public void testSetDateWithCalendar_sendsDateInCalendarZone(String zone) throws Date date = Date.valueOf(D_DIGITS); String expected = Instant.ofEpochMilli(date.getTime()).atZone(ZoneOffset.UTC).toLocalDate().toString(); + Calendar cal = calendar(CAL_ZONE); try (Connection conn = connect(); - PreparedStatement ps = conn.prepareStatement(PROBE_DATE)) { + PreparedStatement ps = conn.prepareStatement(PROBE_DATE); + Statement read = conn.createStatement(); + ResultSet rs = + read.executeQuery("SELECT DATE '0001-01-01' AS d_min, DATE '1850-06-01' AS d_1850")) { ps.setDate(1, date, calendar(ZoneOffset.UTC)); assertEquals( expected, storedString(ps), label(zone, "setDate(d, cal=UTC) must send the date of d in the Calendar's zone")); + assertTrue(rs.next()); + ps.setDate(1, rs.getDate("d_min", cal), cal); + assertEquals("0001-01-01", storedString(ps), label(zone, "d_min round trip, cal")); + ps.setDate(1, rs.getDate("d_1850", cal), cal); + assertEquals("1850-06-01", storedString(ps), label(zone, "d_1850 round trip, cal")); } } @@ -293,17 +310,30 @@ public void testSetDateAndSetTime_sendDigits(String zone) throws SQLException { } } + /** 1582-10-10 is what getObject(LocalDate) returns for DATE '1582-10-10'; it must round-trip. */ @ParameterizedTest(name = "JVM zone {0}") @MethodSource("jvmZones") - public void testSetObjectLocalDateTime_sendsDigits(String zone) throws SQLException { + public void testSetObjectJavaTime_sendsDigits(String zone) throws SQLException { useJvmZone(zone); try (Connection conn = connect(); - PreparedStatement ps = conn.prepareStatement(PROBE_DATETIME)) { - ps.setObject(1, DT_DIGITS); - assertEquals( - "2025-01-01 12:34:56.123", - storedString(ps), - label(zone, "setObject(LocalDateTime) must send its digits")); + PreparedStatement dateTime = conn.prepareStatement(PROBE_DATETIME); + PreparedStatement date = conn.prepareStatement(PROBE_DATE); + PreparedStatement time = conn.prepareStatement(PROBE_TIME)) { + dateTime.setObject(1, DT_DIGITS); + date.setObject(1, LocalDate.of(1582, 10, 10)); + time.setObject(1, LocalTime.of(12, 34, 56, 789_123_000)); + assertAll( + () -> + assertEquals( + "2025-01-01 12:34:56.123", + storedString(dateTime), + label(zone, "setObject(LocalDateTime) must send its digits")), + () -> + assertEquals( + "1582-10-10", storedString(date), label(zone, "setObject(LocalDate) digits")), + () -> + assertEquals( + "12:34:56.789123", storedString(time), label(zone, "setObject(LocalTime)"))); } } @@ -382,7 +412,17 @@ public void testLocalViewsOfTimestamp_useJvmZone(String zone) throws SQLExceptio assertEquals( local.toLocalTime().withNano(0), rs.getTime("ts").toLocalTime(), - label(zone, "getTime on TIMESTAMP"))); + label(zone, "getTime on TIMESTAMP")), + () -> + assertEquals( + TS_MIN.atZone(ZoneId.of(zone)).toLocalDateTime(), + rs.getObject("ts_min", LocalDateTime.class), + label(zone, "getObject(LocalDateTime) on pre-1582 TIMESTAMP")), + () -> + assertEquals( + TS_MIN.atZone(ZoneId.of(zone)).toLocalDate(), + rs.getObject("ts_min", LocalDate.class), + label(zone, "getObject(LocalDate) on pre-1582 TIMESTAMP"))); } } @@ -417,12 +457,41 @@ public void testWallClockTypes_returnDigits(String zone) throws SQLException { T_DIGITS, rs.getTime("dt").toLocalTime(), label(zone, "getTime on DATETIME")), () -> assertEquals(D_DIGITS, rs.getDate("d").toLocalDate(), label(zone, "getDate on DATE")), + () -> + assertEquals(T_DIGITS, rs.getTime("t").toLocalTime(), label(zone, "getTime on TIME")), + () -> assertHistoricDigits(rs, zone, "dt_min", "d_min", "0001-01-01T00:00"), + () -> assertHistoricDigits(rs, zone, "dt_1850", "d_1850", "1850-06-01T12:00"), () -> assertEquals( - T_DIGITS, rs.getTime("t").toLocalTime(), label(zone, "getTime on TIME"))); + LocalDateTime.parse("0001-01-01T00:00").atZone(ZoneId.of(zone)).toInstant(), + rs.getObject("dt_min", Instant.class), + label(zone, "getObject(Instant) on pre-1582 DATETIME")), + () -> assertEquals("1582-10-10", rs.getString("d_gap"), label(zone, "d_gap")), + () -> + assertEquals( + LocalDate.parse("1582-10-10"), + rs.getObject("d_gap", LocalDate.class), + label(zone, "d_gap"))); } } + private static void assertHistoricDigits( + ResultSet rs, String zone, String dtColumn, String dColumn, String dateTime) { + LocalDateTime dt = LocalDateTime.parse(dateTime); + LocalDate d = dt.toLocalDate(); + assertAll( + () -> assertEquals(dt, rs.getObject(dtColumn, LocalDateTime.class), label(zone, dtColumn)), + () -> assertEquals(dt, rs.getTimestamp(dtColumn).toLocalDateTime(), label(zone, dtColumn)), + () -> + assertEquals( + dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS")), + rs.getString(dtColumn), + label(zone, dtColumn)), + () -> assertEquals(d, rs.getObject(dColumn, LocalDate.class), label(zone, dColumn)), + () -> assertEquals(d, rs.getDate(dColumn).toLocalDate(), label(zone, dColumn)), + () -> assertEquals(d.toString(), rs.getString(dColumn), label(zone, dColumn))); + } + /** For DATETIME, DATE and TIME the Calendar is the zone the digits are interpreted in. */ @ParameterizedTest(name = "JVM zone {0}") @MethodSource("jvmZones") @@ -457,10 +526,32 @@ public void testCalendarGettersOnWallClockTypes_useCalendarZone(String zone) thr assertEquals( T_DIGITS, inCalendarZone(rs.getTime("t", cal).getTime()).toLocalTime().withNano(0), - label(zone, "getTime(TIME, cal)"))); + label(zone, "getTime(TIME, cal)")), + () -> + assertEquals( + "0001-01-01", + calendarDate(rs.getDate("d_min", cal).getTime()), + label(zone, "d_min, cal")), + () -> + assertEquals( + "1850-06-01", + calendarDate(rs.getDate("d_1850", cal).getTime()), + label(zone, "d_1850, cal")), + () -> + assertEquals( + "1850-06-01", + calendarDate(rs.getTimestamp("dt_1850", cal).getTime()), + label(zone, "dt_1850, cal"))); } } + private static String calendarDate(long epochMillis) { + Calendar c = calendar(CAL_ZONE); + c.setTimeInMillis(epochMillis); + return String.format( + "%04d-%02d-%02d", c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1, c.get(Calendar.DATE)); + } + /** A TIMESTAMP is already a moment, so the Calendar does not change the result. */ @ParameterizedTest(name = "JVM zone {0}") @MethodSource("jvmZones")