From 4079c26d56c5f5caf82c2d0f1bf6449f507fcc33 Mon Sep 17 00:00:00 2001 From: Neenu1995 Date: Wed, 29 Jul 2026 15:56:05 -0400 Subject: [PATCH] feat: add BigQueryTemporalUtility and wire temporal types (Phase 2) --- .../jdbc/BigQueryTemporalUtility.java | 115 ++++++++++++++++++ .../bigquery/jdbc/BigQueryTypeRegistry.java | 110 ++++++++++++++++- 2 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtility.java 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 new file mode 100644 index 000000000000..b26cf78bac0a --- /dev/null +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtility.java @@ -0,0 +1,115 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.bigquery.jdbc; + +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.util.Calendar; + +/** + * A highly optimized utility for bridging BigQuery's civil time and absolute time semantics to + * legacy JDBC Date/Time/Timestamp classes using JSR-310 timezone anchoring. + */ +final class BigQueryTemporalUtility { + + private BigQueryTemporalUtility() {} + + /** + * Converts a BigQuery civil DATETIME string into an absolute Timestamp by anchoring it to the + * provided timezone (or JVM default if null). + */ + public static Timestamp boxDateTime(String val, ZoneId zoneId) { + ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault(); + String isoString = val.replace(' ', 'T'); + return Timestamp.from(LocalDateTime.parse(isoString).atZone(targetZone).toInstant()); + } + + /** + * Converts a BigQuery civil DATE string into an absolute Date by anchoring it to midnight of the + * provided timezone (or JVM default if null). + */ + public static Date boxDate(String val, ZoneId zoneId) { + ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault(); + return new Date(LocalDate.parse(val).atStartOfDay(targetZone).toInstant().toEpochMilli()); + } + + /** + * Converts a BigQuery civil TIME string into an absolute Time. If a ZoneId is provided (e.g. from + * the legacy JDBC 3.0 getTime(col, Calendar) API), this uses legacy Calendar manipulation to + * strictly mimic older JVM historical DST quirks for 1970. If no ZoneId is provided (e.g. modern + * JDBC 4.2 getObject(col, LocalTime.class)), this uses pure JSR-310 math which guarantees + * perfectly accurate modern conversions. + */ + public static Time boxTime(String val, ZoneId zoneId) { + LocalTime localTime = LocalTime.parse(val); + + if (zoneId == null) { + // JDBC 4.2 Modern API (no Calendar provided): + // Use pure JSR-310 math for perfectly accurate modern conversions without Calendar quirks. + return new Time( + localTime + .atDate(LocalDate.of(1970, 1, 1)) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli()); + } + + // Legacy JDBC 3.0 API (Calendar provided): + // Use legacy Calendar manipulation to intentionally replicate old JVM historical DST quirks + // for January 1, 1970, ensuring strict backwards compatibility for legacy ORMs. + Calendar targetCal = Calendar.getInstance(java.util.TimeZone.getTimeZone(zoneId)); + targetCal.set(Calendar.YEAR, 1970); + targetCal.set(Calendar.MONTH, Calendar.JANUARY); + targetCal.set(Calendar.DAY_OF_MONTH, 1); + targetCal.set(Calendar.HOUR_OF_DAY, localTime.getHour()); + targetCal.set(Calendar.MINUTE, localTime.getMinute()); + targetCal.set(Calendar.SECOND, localTime.getSecond()); + targetCal.set(Calendar.MILLISECOND, localTime.getNano() / 1_000_000); + + return new Time(targetCal.getTimeInMillis()); + } + + /** + * Converts a BigQuery absolute TIMESTAMP string into a legacy Timestamp. Because it is absolute, + * the Calendar timezone is explicitly ignored per JDBC 4.2 spec. + */ + public static Timestamp boxTimestamp(String val) { + String iso = val; + // Handle the " UTC" suffix format + if (iso.endsWith(" UTC")) { + iso = iso.substring(0, iso.length() - 4) + "Z"; + } + // Replace the date-time space separator with 'T' (e.g. 2023-10-01 12:00:00 -> + // 2023-10-01T12:00:00) + if (iso.length() > 10 && iso.charAt(10) == ' ') { + iso = iso.substring(0, 10) + 'T' + iso.substring(11); + } + + try { + return Timestamp.from(Instant.parse(iso)); + } catch (java.time.format.DateTimeParseException e) { + // Fallback for non-standard formats + return Timestamp.valueOf(val); + } + } +} 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 01de760a9f35..890b8966967e 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 @@ -128,9 +128,113 @@ final class BigQueryTypeRegistry { throw new BigQueryJdbcException("Cannot convert to NUMERIC: " + val); })); - // --- TEMPORAL TYPES MOVED TO PHASE 2 PR --- - // DATE, DATETIME, TIMESTAMP, and TIME descriptors require BigQueryTemporalUtility - // which will be introduced in the stacked Phase 2 PR. + // DATE + register( + new TypeDescriptor<>( + Types.DATE, + Date.class, + StandardSQLTypeName.DATE, + Arrays.asList(Date.class, LocalDate.class), + (val, targetClass, zone) -> { + // TODO(Phase 3): Add native JSR-310 fast-path to bypass boxing for LocalDate + Date sqlDate; + if (val instanceof Date) sqlDate = (Date) val; + else if (val instanceof java.util.Date) + sqlDate = new Date(((java.util.Date) val).getTime()); + else if (val instanceof LocalDate) sqlDate = Date.valueOf((LocalDate) val); + else if (val instanceof String) + sqlDate = BigQueryTemporalUtility.boxDate((String) val, zone); + else throw new BigQueryJdbcException("Cannot convert to DATE: " + val); + + if (targetClass == LocalDate.class) return sqlDate.toLocalDate(); + return sqlDate; + })); + + // DATETIME + register( + new TypeDescriptor<>( + Types.TIMESTAMP, + Timestamp.class, + StandardSQLTypeName.DATETIME, + Arrays.asList(Timestamp.class, LocalDateTime.class), + (val, targetClass, zone) -> { + // TODO(Phase 3): Add native JSR-310 fast-path to bypass boxing for LocalDateTime + Timestamp ts; + if (val instanceof Timestamp) ts = (Timestamp) val; + else if (val instanceof java.util.Date) + ts = new Timestamp(((java.util.Date) val).getTime()); + else if (val instanceof LocalDateTime) ts = Timestamp.valueOf((LocalDateTime) val); + else if (val instanceof String) + ts = BigQueryTemporalUtility.boxDateTime((String) val, zone); + else throw new BigQueryJdbcException("Cannot convert to DATETIME: " + val); + + if (targetClass == LocalDateTime.class) return ts.toLocalDateTime(); + return ts; + })); + + // TIMESTAMP + register( + new TypeDescriptor<>( + Types.TIMESTAMP, + Timestamp.class, + StandardSQLTypeName.TIMESTAMP, + Arrays.asList( + Timestamp.class, OffsetDateTime.class, Instant.class, ZonedDateTime.class), + (val, targetClass, zone) -> { + // TODO(Phase 3): Add native JSR-310 fast-path to bypass boxing for Instant, etc. + Timestamp ts; + if (val instanceof Timestamp) ts = (Timestamp) val; + else if (val instanceof java.util.Date) + ts = new Timestamp(((java.util.Date) val).getTime()); + else if (val instanceof Instant) ts = Timestamp.from((Instant) val); + else if (val instanceof OffsetDateTime) + ts = Timestamp.from(((OffsetDateTime) val).toInstant()); + else if (val instanceof ZonedDateTime) + ts = Timestamp.from(((ZonedDateTime) val).toInstant()); + else if (val instanceof String) + ts = BigQueryTemporalUtility.boxTimestamp((String) val); + else throw new BigQueryJdbcException("Cannot convert to TIMESTAMP: " + val); + + if (targetClass == Instant.class) return ts.toInstant(); + if (targetClass == OffsetDateTime.class) + return ts.toInstant().atOffset(java.time.ZoneOffset.UTC); + if (targetClass == ZonedDateTime.class) + return ts.toInstant().atZone(java.time.ZoneOffset.UTC); + return ts; + })); + + // TIME + register( + new TypeDescriptor<>( + Types.TIME, + Time.class, + StandardSQLTypeName.TIME, + Arrays.asList(Time.class, LocalTime.class), + (val, targetClass, zone) -> { + if (targetClass == LocalTime.class && val instanceof String) { + // Phase 3 Fast Path: Parse directly to LocalTime to preserve microsecond precision + return LocalTime.parse((String) val); + } + + Time sqlTime; + if (val instanceof Time) sqlTime = (Time) val; + else if (val instanceof java.util.Date) + sqlTime = new Time(((java.util.Date) val).getTime()); + else if (val instanceof LocalTime) sqlTime = Time.valueOf((LocalTime) val); + else if (val instanceof String) + sqlTime = BigQueryTemporalUtility.boxTime((String) val, zone); + else throw new BigQueryJdbcException("Cannot convert to TIME: " + val); + + if (targetClass == LocalTime.class) { + // java.sql.Time.toLocalTime() drops milliseconds (JDK bug). + // We manually convert it using the JVM offset to preserve millisecond precision. + long epochMillis = sqlTime.getTime(); + return Instant.ofEpochMilli(epochMillis) + .atZone(ZoneId.systemDefault()) + .toLocalTime(); + } + return sqlTime; + })); // BYTES register(