Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* 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;

/**
* 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 BigQueryTimezoneUtility {

private BigQueryTimezoneUtility() {}

/**
* 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 by anchoring it to Jan 1, 1970 of
* the provided timezone (or JVM default if null).
*/
public static Time boxTime(String val, ZoneId zoneId) {
ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault();
return new Time(
LocalTime.parse(val)
.atDate(LocalDate.of(1970, 1, 1))
.atZone(targetZone)
.toInstant()
.toEpochMilli());
}
Comment on lines +59 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When converting or shifting timezone fields for JDBC types like java.sql.Time, use legacy Calendar manipulation instead of java.time APIs to ensure Daylight Saving Time (DST) is handled properly.

Suggested change
public static Time boxTime(String val, ZoneId zoneId) {
ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault();
return new Time(
LocalTime.parse(val)
.atDate(LocalDate.of(1970, 1, 1))
.atZone(targetZone)
.toInstant()
.toEpochMilli());
}
public static Time boxTime(String val, ZoneId zoneId) {
ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault();
LocalTime localTime = LocalTime.parse(val);
java.util.Calendar cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone(targetZone.getId()));
cal.clear();
cal.set(1970, 0, 1, localTime.getHour(), localTime.getMinute(), localTime.getSecond());
cal.set(java.util.Calendar.MILLISECOND, localTime.getNano() / 1_000_000);
return new Time(cal.getTimeInMillis());
}
References
  1. When converting or shifting timezone fields for JDBC types like java.sql.Time, use legacy Calendar manipulation instead of java.time APIs to ensure Daylight Saving Time (DST) is handled properly.


/**
* 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) {
if (val.contains("T") || val.contains("Z")) {
return Timestamp.from(Instant.parse(val));
}
return Timestamp.valueOf(val);
}
Comment on lines +73 to +78

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

BigQuery's canonical string representation for absolute TIMESTAMP values ends with a UTC suffix (e.g., 2014-08-19 12:41:35.220000 UTC). Calling Timestamp.valueOf on such strings will throw an IllegalArgumentException because it does not support timezone suffixes. We should explicitly handle the UTC suffix by converting it to an ISO-8601 UTC format before parsing.

  public static Timestamp boxTimestamp(String val) {
    if (val.endsWith(" UTC")) {
      String isoString = val.substring(0, val.length() - 4).replace(' ', 'T') + 'Z';
      return Timestamp.from(Instant.parse(isoString));
    }
    if (val.contains("T") || val.contains("Z")) {
      return Timestamp.from(Instant.parse(val));
    }
    return Timestamp.valueOf(val);
  }

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
/*
* 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 com.google.cloud.bigquery.StandardSQLTypeName;
import com.google.cloud.bigquery.exception.BigQueryJdbcException;
import java.math.BigDecimal;
import java.sql.Array;
import java.sql.Date;
import java.sql.Struct;
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.Arrays;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* A central, bidirectional engine for resolving and coercing types between JDBC, Java, and
* BigQuery.
*/
final class BigQueryTypeRegistry {

private static final TypeDescriptor<?>[] DESCRIPTORS_BY_ORDINAL;
private static final Map<Class<?>, TypeDescriptor<?>> DESCRIPTORS_BY_CLASS;
private static final Map<Integer, TypeDescriptor<?>> DESCRIPTORS_BY_JDBC_TYPE;

static {
int maxOrdinal = 0;
for (StandardSQLTypeName type : StandardSQLTypeName.values()) {
if (type.ordinal() > maxOrdinal) {
maxOrdinal = type.ordinal();
}
}
DESCRIPTORS_BY_ORDINAL = new TypeDescriptor<?>[maxOrdinal + 1];
Comment on lines +48 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since enum ordinals are 0-indexed and sequential, the maximum ordinal is guaranteed to be StandardSQLTypeName.values().length - 1. We can simplify the array size calculation and avoid the loop entirely.

Suggested change
static {
int maxOrdinal = 0;
for (StandardSQLTypeName type : StandardSQLTypeName.values()) {
if (type.ordinal() > maxOrdinal) {
maxOrdinal = type.ordinal();
}
}
DESCRIPTORS_BY_ORDINAL = new TypeDescriptor<?>[maxOrdinal + 1];
static {
DESCRIPTORS_BY_ORDINAL = new TypeDescriptor<?>[StandardSQLTypeName.values().length];

DESCRIPTORS_BY_CLASS = new ConcurrentHashMap<>();
DESCRIPTORS_BY_JDBC_TYPE = new ConcurrentHashMap<>();

// BOOL
register(
new TypeDescriptor<>(
Types.BOOLEAN,
Boolean.class,
StandardSQLTypeName.BOOL,
Arrays.asList(Boolean.class),
(val, targetClass, zone) -> {
if (val instanceof Boolean) return val;
if (val instanceof String) return Boolean.parseBoolean((String) val);
throw new BigQueryJdbcException("Cannot convert to BOOL: " + val);
}));

// STRING
register(
new TypeDescriptor<>(
Types.NVARCHAR,
String.class,
StandardSQLTypeName.STRING,
Arrays.asList(String.class),
(val, targetClass, zone) -> String.valueOf(val)));

// INT64
register(
new TypeDescriptor<>(
Types.BIGINT,
Long.class,
StandardSQLTypeName.INT64,
Arrays.asList(Long.class, Integer.class, Short.class, Byte.class),
(val, targetClass, zone) -> {
long longVal;
if (val instanceof Number) longVal = ((Number) val).longValue();
else if (val instanceof String) longVal = Long.parseLong((String) val);
else throw new BigQueryJdbcException("Cannot convert to INT64: " + val);

if (targetClass == Integer.class) return (int) longVal;
if (targetClass == Short.class) return (short) longVal;
if (targetClass == Byte.class) return (byte) longVal;
return longVal;
}));
Comment on lines +88 to +98

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When converting an INT64 (represented as a long) to smaller integer types (Integer, Short, Byte), casting directly can cause silent truncation or overflow. We should perform bounds checks and throw a BigQueryJdbcException if the value is out of range for the target type.

            (val, targetClass, zone) -> {
              long longVal;
              if (val instanceof Number) longVal = ((Number) val).longValue();
              else if (val instanceof String) longVal = Long.parseLong((String) val);
              else throw new BigQueryJdbcException("Cannot convert to INT64: " + val);

              if (targetClass == Integer.class) {
                if (longVal < Integer.MIN_VALUE || longVal > Integer.MAX_VALUE) {
                  throw new BigQueryJdbcException("Loss of precision converting " + longVal + " to Integer");
                }
                return (int) longVal;
              }
              if (targetClass == Short.class) {
                if (longVal < Short.MIN_VALUE || longVal > Short.MAX_VALUE) {
                  throw new BigQueryJdbcException("Loss of precision converting " + longVal + " to Short");
                }
                return (short) longVal;
              }
              if (targetClass == Byte.class) {
                if (longVal < Byte.MIN_VALUE || longVal > Byte.MAX_VALUE) {
                  throw new BigQueryJdbcException("Loss of precision converting " + longVal + " to Byte");
                }
                return (byte) longVal;
              }
              return longVal;
            }));


// FLOAT64
register(
new TypeDescriptor<>(
Types.DOUBLE,
Double.class,
StandardSQLTypeName.FLOAT64,
Arrays.asList(Double.class, Float.class),
(val, targetClass, zone) -> {
double doubleVal;
if (val instanceof Number) doubleVal = ((Number) val).doubleValue();
else if (val instanceof String) doubleVal = Double.parseDouble((String) val);
else throw new BigQueryJdbcException("Cannot convert to FLOAT64: " + val);

if (targetClass == Float.class) return (float) doubleVal;
return doubleVal;
}));

// NUMERIC
register(
new TypeDescriptor<>(
Types.NUMERIC,
BigDecimal.class,
StandardSQLTypeName.NUMERIC,
Arrays.asList(BigDecimal.class),
(val, targetClass, zone) -> {
if (val instanceof BigDecimal) return val;
if (val instanceof Number) return new BigDecimal(val.toString());
if (val instanceof String) return new BigDecimal((String) val);
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.

// BYTES
register(
new TypeDescriptor<>(
Types.VARBINARY,
byte[].class,
StandardSQLTypeName.BYTES,
Arrays.asList(byte[].class),
(val, targetClass, zone) -> {
if (val instanceof byte[]) return val;
throw new BigQueryJdbcException("Cannot convert to BYTES: " + val);
}));

// ARRAY
register(
new TypeDescriptor<>(
Types.ARRAY,
Array.class,
StandardSQLTypeName.ARRAY,
Arrays.asList(Array.class),
(val, targetClass, zone) -> {
if (val instanceof Array) return val;
throw new BigQueryJdbcException("Cannot convert to ARRAY: " + val);
}));

// STRUCT
register(
new TypeDescriptor<>(
Types.STRUCT,
Struct.class,
StandardSQLTypeName.STRUCT,
Arrays.asList(Struct.class),
(val, targetClass, zone) -> {
if (val instanceof Struct) return val;
throw new BigQueryJdbcException("Cannot convert to STRUCT: " + val);
}));

// JSON
register(
new TypeDescriptor<>(
Types.OTHER,
String.class,
StandardSQLTypeName.JSON,
Arrays.asList(com.google.gson.JsonObject.class),
(val, targetClass, zone) -> String.valueOf(val)));

// BIGNUMERIC
register(
new TypeDescriptor<>(
Types.NUMERIC,
BigDecimal.class,
StandardSQLTypeName.BIGNUMERIC,
Arrays.asList(BigDecimal.class),
(val, targetClass, zone) -> {
if (val instanceof BigDecimal) return val;
if (val instanceof Number) return new BigDecimal(val.toString());
if (val instanceof String) return new BigDecimal((String) val);
throw new BigQueryJdbcException("Cannot convert to BIGNUMERIC: " + val);
}));

// GEOGRAPHY
register(
new TypeDescriptor<>(
Types.OTHER,
String.class,
StandardSQLTypeName.GEOGRAPHY,
Arrays.asList(String.class),
(val, targetClass, zone) -> String.valueOf(val)));

// INTERVAL
register(
new TypeDescriptor<>(
Types.OTHER,
String.class,
StandardSQLTypeName.INTERVAL,
Arrays.asList(String.class),
(val, targetClass, zone) -> String.valueOf(val)));

// RANGE
register(
new TypeDescriptor<>(
Types.OTHER,
String.class,
StandardSQLTypeName.RANGE,
Arrays.asList(String.class),
(val, targetClass, zone) -> String.valueOf(val)));
}

private static void register(TypeDescriptor<?> descriptor) {
if (DESCRIPTORS_BY_ORDINAL[descriptor.getBqType().ordinal()] == null) {
DESCRIPTORS_BY_ORDINAL[descriptor.getBqType().ordinal()] = descriptor;
}
DESCRIPTORS_BY_JDBC_TYPE.putIfAbsent(descriptor.getJdbcType(), descriptor);
for (Class<?> clazz : descriptor.getSupportedJavaTypes()) {
DESCRIPTORS_BY_CLASS.putIfAbsent(clazz, descriptor);
}
}

private BigQueryTypeRegistry() {}

/**
* Returns the exact BigQuery StandardSQLTypeName for a given Java class. If no mapping is found,
* returns StandardSQLTypeName.STRING as a fallback.
*/
public static StandardSQLTypeName toBigQueryType(Class<?> clazz) {
TypeDescriptor<?> descriptor = getDescriptorForClass(clazz);
if (descriptor != null) {
return descriptor.getBqType();
}
return StandardSQLTypeName.STRING;
}

/** Returns the default Java target class for a given JDBC type constant. */
public static Class<?> toJavaClass(int jdbcType) {
TypeDescriptor<?> descriptor = DESCRIPTORS_BY_JDBC_TYPE.get(jdbcType);
if (descriptor != null) {
return descriptor.getDefaultJavaClass();
}
return String.class; // fallback
}

/**
* Converts the input value to the target class type by looking up the target class descriptor.
*/
@SuppressWarnings("unchecked")
public static <T> T convert(Object input, Class<T> targetClass) throws BigQueryJdbcException {
if (input == null) {
return null;
}
TypeDescriptor<?> descriptor = getDescriptorForClass(targetClass);
if (descriptor == null) {
throw new BigQueryJdbcException("Unsupported target class: " + targetClass.getName());
}
return (T) descriptor.convert(input, targetClass, null);
}
Comment on lines +259 to +268

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We can optimize the convert method by adding a fast-path check: if the input is already an instance of the target class, we can return it directly. This avoids map lookups, descriptor retrieval, and coercion overhead.

  public static <T> T convert(Object input, Class<T> targetClass) throws BigQueryJdbcException {
    if (input == null) {
      return null;
    }
    if (targetClass.isInstance(input)) {
      return targetClass.cast(input);
    }
    TypeDescriptor<?> descriptor = getDescriptorForClass(targetClass);
    if (descriptor == null) {
      throw new BigQueryJdbcException("Unsupported target class: " + targetClass.getName());
    }
    return (T) descriptor.convert(input, targetClass, null);
  }


/**
* High-performance hotpath convert for ResultSets. Converts the input value using the default
* mapping for the given BigQuery type via O(1) array indexing.
*/
public static Object convert(Object input, StandardSQLTypeName bqType, ZoneId zoneId)
throws BigQueryJdbcException {
if (input == null) return null;
int ordinal = bqType.ordinal();
if (ordinal >= DESCRIPTORS_BY_ORDINAL.length || DESCRIPTORS_BY_ORDINAL[ordinal] == null) {
throw new BigQueryJdbcException("No type descriptor registered for BigQuery type: " + bqType);
}
TypeDescriptor<?> descriptor = DESCRIPTORS_BY_ORDINAL[ordinal];
return descriptor.convert(input, descriptor.getDefaultJavaClass(), zoneId);
}

/**
* High-performance hotpath convert for ResultSets. Converts the input value to the target class
* using the descriptor for the given BigQuery type via O(1) array indexing.
*/
@SuppressWarnings("unchecked")
public static <T> T convert(
Object input, StandardSQLTypeName bqType, Class<T> targetClass, ZoneId zoneId)
throws BigQueryJdbcException {
if (input == null) return null;
int ordinal = bqType.ordinal();
if (ordinal >= DESCRIPTORS_BY_ORDINAL.length || DESCRIPTORS_BY_ORDINAL[ordinal] == null) {
throw new BigQueryJdbcException("No type descriptor registered for BigQuery type: " + bqType);
}
return (T) DESCRIPTORS_BY_ORDINAL[ordinal].convert(input, targetClass, zoneId);
}

private static TypeDescriptor<?> getDescriptorForClass(Class<?> clazz) {
TypeDescriptor<?> descriptor = DESCRIPTORS_BY_CLASS.get(clazz);
Comment on lines +301 to +302

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since DESCRIPTORS_BY_CLASS is a ConcurrentHashMap, calling get(null) will throw a NullPointerException. We should add a null check for clazz at the beginning of getDescriptorForClass to safely return null instead.

Suggested change
private static TypeDescriptor<?> getDescriptorForClass(Class<?> clazz) {
TypeDescriptor<?> descriptor = DESCRIPTORS_BY_CLASS.get(clazz);
private static TypeDescriptor<?> getDescriptorForClass(Class<?> clazz) {
if (clazz == null) {
return null;
}
TypeDescriptor<?> descriptor = DESCRIPTORS_BY_CLASS.get(clazz);

if (descriptor != null) {
return descriptor;
}
// Fallback logic for subclasses/interfaces (O(N) initial lookup)
for (Map.Entry<Class<?>, TypeDescriptor<?>> entry : DESCRIPTORS_BY_CLASS.entrySet()) {
if (entry.getKey().isAssignableFrom(clazz)) {
TypeDescriptor<?> matchedDescriptor = entry.getValue();
// Cache the result in the ConcurrentHashMap to turn subsequent subclass lookups into O(1)
DESCRIPTORS_BY_CLASS.putIfAbsent(clazz, matchedDescriptor);
return matchedDescriptor;
}
}
return null;
}
}
Loading
Loading