From 60360061270afc91dcd8e1807f0608d4dbd90e11 Mon Sep 17 00:00:00 2001 From: Keshav Dandeva Date: Mon, 7 Sep 2026 17:34:33 +0000 Subject: [PATCH 1/3] feat(bigquery-jdbc): implement picosecond temporal math and formatting engine --- .../jdbc/BigQueryTemporalUtility.java | 233 +++++++++++++++++- .../jdbc/BigQueryTemporalUtilityTest.java | 148 +++++++++++ 2 files changed, 370 insertions(+), 11 deletions(-) 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 cd7f6a962ed9..7cdbdf299c74 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 @@ -16,24 +16,37 @@ package com.google.cloud.bigquery.jdbc; +import com.google.cloud.bigquery.exception.BigQueryJdbcException; import java.math.BigDecimal; +import java.math.RoundingMode; import java.sql.Date; import java.sql.Time; import java.sql.Timestamp; +import java.time.DateTimeException; 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.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.Calendar; +import java.util.TimeZone; /** * 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. + * legacy JDBC Date/Time/Timestamp classes using JSR-310 timezone anchoring and high-precision + * temporal formatting. */ final class BigQueryTemporalUtility { + private static final BigDecimal PICOS_PER_SECOND = new BigDecimal("1000000000000"); + private static final BigDecimal MICROS_PER_SECOND = new BigDecimal("1000000"); + private static final DateTimeFormatter BASE_FORMATTER = + DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss."); + private BigQueryTemporalUtility() {} /** @@ -42,7 +55,7 @@ private BigQueryTemporalUtility() {} */ public static Timestamp boxDateTime(String val, ZoneId zoneId) { ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault(); - String isoString = val.replace(' ', 'T'); + String isoString = truncateIsoFractionToNanos(val.replace(' ', 'T')); return Timestamp.from(LocalDateTime.parse(isoString).atZone(targetZone).toInstant()); } @@ -63,7 +76,7 @@ public static Date boxDate(String val, ZoneId zoneId) { * perfectly accurate modern conversions. */ public static Time boxTime(String val, ZoneId zoneId) { - LocalTime localTime = LocalTime.parse(val); + LocalTime localTime = LocalTime.parse(truncateIsoFractionToNanos(val)); if (zoneId == null) { // JDBC 4.2 Modern API (no Calendar provided): @@ -79,7 +92,7 @@ public static Time boxTime(String val, ZoneId zoneId) { // 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)); + Calendar targetCal = Calendar.getInstance(TimeZone.getTimeZone(zoneId)); targetCal.set(Calendar.YEAR, 1970); targetCal.set(Calendar.MONTH, Calendar.JANUARY); targetCal.set(Calendar.DAY_OF_MONTH, 1); @@ -101,12 +114,7 @@ public static Timestamp boxTimestamp(String val) { if (val.indexOf('-') < 0 || (val.startsWith("-") && val.indexOf('-', 1) < 0)) { // Quick check to ensure it's not a date string - BigDecimal bd = new BigDecimal(val); - long secondsLong = bd.longValue(); - int nanos = bd.remainder(BigDecimal.ONE).multiply(new BigDecimal(1_000_000_000)).intValue(); - Timestamp ts = new Timestamp(secondsLong * 1000L); - ts.setNanos(nanos); - return ts; + return Timestamp.from(parseEpochDecimalToInstant(val)); } } catch (NumberFormatException ignored) { } @@ -126,9 +134,12 @@ public static Timestamp boxTimestamp(String val) { iso = iso + "Z"; } + // Truncate sub-nanosecond fraction (> 9 digits) to prevent Instant.parse failure + iso = truncateIsoFractionToNanos(iso); + try { return Timestamp.from(Instant.parse(iso)); - } catch (java.time.format.DateTimeParseException e) { + } catch (DateTimeParseException e) { // Fallback for non-standard formats String fallback = val; if (fallback.indexOf('T') > 0) { @@ -138,6 +149,206 @@ public static Timestamp boxTimestamp(String val) { } } + /** + * Converts an epoch decimal string (seconds since 1970-01-01 00:00:00 UTC) into an {@link + * Instant}, truncating sub-nanosecond precision towards zero to prevent rollover or overflow. + */ + static Instant parseEpochDecimalToInstant(String epochDecimal) { + if (epochDecimal == null) { + return null; + } + + BigDecimal bd = new BigDecimal(epochDecimal); + BigDecimal secondsBd = bd.setScale(0, RoundingMode.FLOOR); + BigDecimal fractionBd = bd.subtract(secondsBd); + BigDecimal nanosBd = fractionBd.multiply(BigDecimal.valueOf(1_000_000_000L)); + int nanos = nanosBd.setScale(0, RoundingMode.DOWN).intValue(); + return Instant.ofEpochSecond(secondsBd.longValue(), nanos); + } + + /** + * Formats an epoch decimal string (seconds since epoch) into a standard UTC JDBC timestamp string + * formatted as {@code yyyy-MM-dd HH:mm:ss.ffffff} (if {@code enableTimestampPicos} is false) or + * {@code yyyy-MM-dd HH:mm:ss.ffffffffffff} (if {@code enableTimestampPicos} is true). Truncates + * deterministically towards zero to prevent sub-second rollover. + */ + static String formatTimestampString(String epochDecimal, boolean enableTimestampPicos) + throws BigQueryJdbcException { + if (epochDecimal == null) { + return null; + } + + if (epochDecimal.indexOf(':') >= 0) { + return formatTimestampStringFromIso(epochDecimal, enableTimestampPicos); + } + + int scale = enableTimestampPicos ? 12 : 6; + BigDecimal bd; + try { + bd = new BigDecimal(epochDecimal).setScale(scale, RoundingMode.DOWN); + } catch (NumberFormatException e) { + try { + return formatTimestampStringFromIso(epochDecimal, enableTimestampPicos); + } catch (BigQueryJdbcException ignored) { + throw new BigQueryJdbcException("Invalid timestamp value: " + epochDecimal, e); + } + } + + BigDecimal secondsBd = bd.setScale(0, RoundingMode.FLOOR); + long epochSeconds = secondsBd.longValue(); + BigDecimal fractionBd = bd.subtract(secondsBd); + BigDecimal multiplier = scale == 12 ? PICOS_PER_SECOND : MICROS_PER_SECOND; + long fractionVal = fractionBd.multiply(multiplier).setScale(0, RoundingMode.DOWN).longValue(); + + LocalDateTime dt = LocalDateTime.ofEpochSecond(epochSeconds, 0, ZoneOffset.UTC); + StringBuilder sb = formatDateTimeBase(dt, scale); + appendPadded(sb, fractionVal, scale); + return sb.toString(); + } + + /** + * Normalizes an ISO-8601 or civil timestamp string into a standard UTC JDBC timestamp string + * formatted with 6 or 12 fractional digits according to {@code enableTimestampPicos}. + */ + static String formatTimestampStringFromIso(String isoString, boolean enableTimestampPicos) + throws BigQueryJdbcException { + if (isoString == null) { + return null; + } + + // 1. Separate fractional seconds (which may exceed 9 digits) from the base timestamp + int dotIdx = isoString.indexOf('.'); + String fraction = ""; + String remaining = isoString; + if (dotIdx >= 0) { + int endFraction = dotIdx + 1; + while (endFraction < isoString.length() && Character.isDigit(isoString.charAt(endFraction))) { + endFraction++; + } + fraction = isoString.substring(dotIdx + 1, endFraction); + remaining = isoString.substring(0, dotIdx) + isoString.substring(endFraction); + } + + // 2. Normalize UTC suffixes + String offsetPart = null; + if (remaining.endsWith(" UTC")) { + remaining = remaining.substring(0, remaining.length() - 4); + } else if (remaining.endsWith("Z")) { + remaining = remaining.substring(0, remaining.length() - 1); + } + + // 3. Extract timezone offset (+/-) if present in the time portion + int sepIdx = remaining.indexOf('T'); + if (sepIdx < 0) { + sepIdx = remaining.indexOf(' '); + } + if (sepIdx >= 0) { + int plusIdx = remaining.indexOf('+', sepIdx); + int minusIdx = remaining.indexOf('-', sepIdx); + int offsetIdx = plusIdx >= 0 ? plusIdx : minusIdx; + if (offsetIdx >= 0) { + offsetPart = remaining.substring(offsetIdx); + remaining = remaining.substring(0, offsetIdx); + } + } + + // 4. Ensure standard ISO LocalDateTime format (YYYY-MM-DDTHH:mm:ss) + remaining = remaining.replace(' ', 'T'); + if (remaining.indexOf('T') < 0) { + remaining = remaining + "T00:00:00"; + } + + // 5. Parse base date-time and shift to UTC if timezone offset was present + LocalDateTime utcDt; + try { + LocalDateTime ldt = LocalDateTime.parse(remaining); + if (offsetPart != null) { + ZoneOffset offset = ZoneOffset.of(offsetPart); + utcDt = ldt.atOffset(offset).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(); + } else { + utcDt = ldt; + } + } catch (DateTimeException e) { + throw new BigQueryJdbcException("Invalid timestamp format: " + isoString, e); + } + + // 6. Build the normalized JDBC timestamp string with the requested scale + int scale = enableTimestampPicos ? 12 : 6; + StringBuilder sb = formatDateTimeBase(utcDt, scale); + if (fraction.length() >= scale) { + sb.append(fraction, 0, scale); + } else { + sb.append(fraction); + for (int i = fraction.length(); i < scale; i++) { + sb.append('0'); + } + } + return sb.toString(); + } + + /** + * Formats a microsecond timestamp (microseconds since 1970-01-01 00:00:00 UTC) into standard UTC + * timestamp string with 6 or 12 fractional digits according to {@code enableTimestampPicos}. + */ + static String formatTimestampStringFromMicroseconds( + long microseconds, boolean enableTimestampPicos) { + long epochSeconds = Math.floorDiv(microseconds, 1_000_000L); + long microsOfSecond = Math.floorMod(microseconds, 1_000_000L); + + LocalDateTime dt = LocalDateTime.ofEpochSecond(epochSeconds, 0, ZoneOffset.UTC); + int scale = enableTimestampPicos ? 12 : 6; + StringBuilder sb = formatDateTimeBase(dt, scale); + long fractionVal = enableTimestampPicos ? microsOfSecond * 1_000_000L : microsOfSecond; + appendPadded(sb, fractionVal, scale); + return sb.toString(); + } + + private static StringBuilder formatDateTimeBase(LocalDateTime dt, int scale) { + StringBuilder sb = new StringBuilder(scale == 12 ? 32 : 26); + BASE_FORMATTER.formatTo(dt, sb); + return sb; + } + + /** + * Truncates sub-second fractional digits to at most 9 digits (nanoseconds) so that standard JDK + * temporal parsers (which cap at nanosecond precision) can parse the string without throwing + * {@link java.time.format.DateTimeParseException}. Any trailing timezone offset or suffix is + * preserved intact. + */ + private static String truncateIsoFractionToNanos(String iso) { + int dotIdx = iso.indexOf('.'); + if (dotIdx < 0) { + return iso; + } + + int fractionStart = dotIdx + 1; + int fractionEnd = fractionStart; + while (fractionEnd < iso.length() && Character.isDigit(iso.charAt(fractionEnd))) { + fractionEnd++; + } + + int fractionDigits = fractionEnd - fractionStart; + if (fractionDigits <= 9) { + return iso; + } + + // Retain the first 9 fractional digits and append any trailing suffix (e.g., 'Z' or offset) + return iso.substring(0, fractionStart + 9) + iso.substring(fractionEnd); + } + + /** + * Appends a non-negative integer zero-padded to {@code width} digits directly into the {@link + * StringBuilder} without intermediate string allocations. + */ + private static void appendPadded(StringBuilder sb, long val, int width) { + int end = sb.length() + width; + sb.setLength(end); + for (int i = end - 1; i >= end - width; i--) { + sb.setCharAt(i, (char) ('0' + (val % 10))); + val /= 10; + } + } + /** * Converts milliseconds of the day to a local epoch millis anchored to 1970-01-01 in the given * timezone. 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 ae66ec6fb8dd..757bdfb3de43 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 @@ -17,7 +17,9 @@ package com.google.cloud.bigquery.jdbc; import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import com.google.cloud.bigquery.exception.BigQueryJdbcException; import java.sql.Date; import java.sql.Time; import java.sql.Timestamp; @@ -182,6 +184,11 @@ public void testBoxDateTimeAndDate() { long expectedDateMillis = LocalDate.of(2026, 8, 24).atStartOfDay(ZoneId.of("UTC")).toInstant().toEpochMilli(); assertThat(date.getTime()).isEqualTo(expectedDateMillis); + + // 12-digit picosecond DATETIME string safely truncated to nanoseconds + Timestamp picosDt = + BigQueryTemporalUtility.boxDateTime("2026-08-24 15:30:45.123456789012", ZoneId.of("UTC")); + assertThat(picosDt.getNanos()).isEqualTo(123456789); } @Test @@ -203,6 +210,10 @@ public void testBoxTime() { assertThat(cal.get(Calendar.YEAR)).isEqualTo(1970); assertThat(cal.get(Calendar.HOUR_OF_DAY)).isEqualTo(15); assertThat(cal.get(Calendar.MILLISECOND)).isEqualTo(123); + + // 12-digit picosecond TIME string safely truncated to nanoseconds + Time picosTime = BigQueryTemporalUtility.boxTime("15:30:45.123456789012", null); + assertThat(picosTime).isNotNull(); } @Test @@ -219,5 +230,142 @@ public void testBoxTimestamp() { // ISO string with 'Z' Timestamp zTs = BigQueryTemporalUtility.boxTimestamp("2026-08-24T15:30:45.123456Z"); assertThat(zTs).isEqualTo(Timestamp.from(Instant.parse("2026-08-24T15:30:45.123456Z"))); + + // 12-digit picosecond strings safely truncated to nanoseconds + Timestamp picosTs = BigQueryTemporalUtility.boxTimestamp("1680174859.820226912345"); + assertThat(picosTs.getNanos()).isEqualTo(820226912); + + Timestamp picosIsoTs = + BigQueryTemporalUtility.boxTimestamp("2026-08-24T15:30:45.123456789012Z"); + assertThat(picosIsoTs.getNanos()).isEqualTo(123456789); + } + + @Test + public void testParseEpochDecimalToInstant() { + // Standard positive epoch decimal + Instant inst6 = BigQueryTemporalUtility.parseEpochDecimalToInstant("1680174859.820226"); + assertThat(inst6.getEpochSecond()).isEqualTo(1680174859L); + assertThat(inst6.getNano()).isEqualTo(820226000); + + // 12-digit picosecond decimal: sub-nanoseconds truncated + Instant inst12 = BigQueryTemporalUtility.parseEpochDecimalToInstant("1680174859.820226123456"); + assertThat(inst12.getEpochSecond()).isEqualTo(1680174859L); + assertThat(inst12.getNano()).isEqualTo(820226123); + + // Pre-1970 negative epoch decimal (-0.123456) + Instant negInst = BigQueryTemporalUtility.parseEpochDecimalToInstant("-0.123456"); + assertThat(negInst.getEpochSecond()).isEqualTo(-1L); + assertThat(negInst.getNano()).isEqualTo(876544000); + + // Scientific notation + Instant sciInst = BigQueryTemporalUtility.parseEpochDecimalToInstant("1.6905474E9"); + assertThat(sciInst.getEpochSecond()).isEqualTo(1690547400L); + assertThat(sciInst.getNano()).isEqualTo(0); + + // Sub-second rollover prevention + Instant rolloverInst = + BigQueryTemporalUtility.parseEpochDecimalToInstant("1680174859.9999999999"); + assertThat(rolloverInst.getEpochSecond()).isEqualTo(1680174859L); + assertThat(rolloverInst.getNano()).isEqualTo(999999999); + } + + @Test + public void testParseEpochDecimalToInstantInvalid() { + assertThrows( + NumberFormatException.class, + () -> BigQueryTemporalUtility.parseEpochDecimalToInstant("invalid_epoch")); + } + + @Test + public void testFormatTimestampStringWithPicosEnabled() throws BigQueryJdbcException { + // 12-digit picosecond decimal + String formatted12 = + BigQueryTemporalUtility.formatTimestampString("1680174859.820226123456", true); + assertThat(formatted12).isEqualTo("2023-03-30 11:14:19.820226123456"); + + // 6-digit microsecond decimal padded with 6 zeros to 12 digits + String formatted6 = BigQueryTemporalUtility.formatTimestampString("1680174859.820226", true); + assertThat(formatted6).isEqualTo("2023-03-30 11:14:19.820226000000"); + + // Pre-1970 negative epoch decimal with 12 digits + String negFormatted = BigQueryTemporalUtility.formatTimestampString("-0.123456789012", true); + assertThat(negFormatted).isEqualTo("1969-12-31 23:59:59.876543210988"); + } + + @Test + public void testFormatTimestampStringWithPicosDisabled() throws BigQueryJdbcException { + // 6-digit microsecond decimal + String formatted6 = BigQueryTemporalUtility.formatTimestampString("1680174859.820226", false); + assertThat(formatted6).isEqualTo("2023-03-30 11:14:19.820226"); + + // 12-digit picosecond decimal truncated to 6 digits + String formatted12 = + BigQueryTemporalUtility.formatTimestampString("1680174859.820226123456", false); + assertThat(formatted12).isEqualTo("2023-03-30 11:14:19.820226"); + + // Rollover prevention: .9999999 must truncate to .999999 and not roll over to next second + String rollover = BigQueryTemporalUtility.formatTimestampString("1680174859.9999999", false); + assertThat(rollover).isEqualTo("2023-03-30 11:14:19.999999"); + } + + @Test + public void testFormatTimestampStringFromIso() throws BigQueryJdbcException { + // ISO string with 'Z' and 12-digit picoseconds, picos enabled + String iso12 = + BigQueryTemporalUtility.formatTimestampStringFromIso( + "2050-12-25T15:30:55.123456789012Z", true); + assertThat(iso12).isEqualTo("2050-12-25 15:30:55.123456789012"); + + // ISO string with 'Z' and 12-digit picoseconds, picos disabled (truncated to 6 digits) + String iso6 = + BigQueryTemporalUtility.formatTimestampStringFromIso( + "2050-12-25T15:30:55.123456789012Z", false); + assertThat(iso6).isEqualTo("2050-12-25 15:30:55.123456"); + + // String with " UTC" suffix + String utcSuffix = + BigQueryTemporalUtility.formatTimestampStringFromIso( + "2026-08-24 15:30:45.123456 UTC", false); + assertThat(utcSuffix).isEqualTo("2026-08-24 15:30:45.123456"); + + String utcSuffixPicos = + BigQueryTemporalUtility.formatTimestampStringFromIso( + "2026-08-24 15:30:45.123456 UTC", true); + assertThat(utcSuffixPicos).isEqualTo("2026-08-24 15:30:45.123456000000"); + + // String without fraction + String noFraction = + BigQueryTemporalUtility.formatTimestampStringFromIso("2026-08-24 15:30:45", false); + assertThat(noFraction).isEqualTo("2026-08-24 15:30:45.000000"); + + // String with offset shifted across day boundary + String dayBoundary = + BigQueryTemporalUtility.formatTimestampStringFromIso( + "2026-08-24T01:30:00.123456789012+05:00", true); + assertThat(dayBoundary).isEqualTo("2026-08-23 20:30:00.123456789012"); + } + + @Test + public void testFormatTimestampStringFromIsoInvalid() { + assertThrows( + BigQueryJdbcException.class, + () -> BigQueryTemporalUtility.formatTimestampStringFromIso("invalid-date-time", true)); + } + + @Test + public void testFormatTimestampStringFromMicroseconds() { + // Standard positive microseconds (picos=false) + String formatted = + BigQueryTemporalUtility.formatTimestampStringFromMicroseconds(1680174859820226L, false); + assertThat(formatted).isEqualTo("2023-03-30 11:14:19.820226"); + + // Standard positive microseconds with picos=true (padded to 12 digits) + String formattedPicos = + BigQueryTemporalUtility.formatTimestampStringFromMicroseconds(1680174859820226L, true); + assertThat(formattedPicos).isEqualTo("2023-03-30 11:14:19.820226000000"); + + // Pre-1970 negative microsecond (-1 microsecond) + String negOne = BigQueryTemporalUtility.formatTimestampStringFromMicroseconds(-1L, false); + assertThat(negOne).isEqualTo("1969-12-31 23:59:59.999999"); } } From 4bd296236b9d623d709cf95180b51a412a78c2fb Mon Sep 17 00:00:00 2001 From: Keshav Dandeva Date: Mon, 7 Sep 2026 17:43:50 +0000 Subject: [PATCH 2/3] address pr feedback --- .../google/cloud/bigquery/jdbc/BigQueryTemporalUtility.java | 6 +++--- .../cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) 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 7cdbdf299c74..06f7a16816ff 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 @@ -43,6 +43,7 @@ final class BigQueryTemporalUtility { private static final BigDecimal PICOS_PER_SECOND = new BigDecimal("1000000000000"); + private static final BigDecimal NANOS_PER_SECOND = new BigDecimal("1000000000"); private static final BigDecimal MICROS_PER_SECOND = new BigDecimal("1000000"); private static final DateTimeFormatter BASE_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss."); @@ -158,11 +159,10 @@ static Instant parseEpochDecimalToInstant(String epochDecimal) { return null; } - BigDecimal bd = new BigDecimal(epochDecimal); + BigDecimal bd = new BigDecimal(epochDecimal).setScale(9, RoundingMode.DOWN); BigDecimal secondsBd = bd.setScale(0, RoundingMode.FLOOR); BigDecimal fractionBd = bd.subtract(secondsBd); - BigDecimal nanosBd = fractionBd.multiply(BigDecimal.valueOf(1_000_000_000L)); - int nanos = nanosBd.setScale(0, RoundingMode.DOWN).intValue(); + int nanos = fractionBd.multiply(NANOS_PER_SECOND).intValue(); return Instant.ofEpochSecond(secondsBd.longValue(), nanos); } 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 757bdfb3de43..4a7d4a2ad96d 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 @@ -257,6 +257,11 @@ public void testParseEpochDecimalToInstant() { assertThat(negInst.getEpochSecond()).isEqualTo(-1L); assertThat(negInst.getNano()).isEqualTo(876544000); + // Pre-1970 negative epoch decimal with 12 digits: sub-nanoseconds truncated towards zero + Instant negInst12 = BigQueryTemporalUtility.parseEpochDecimalToInstant("-0.123456789012"); + assertThat(negInst12.getEpochSecond()).isEqualTo(-1L); + assertThat(negInst12.getNano()).isEqualTo(876543211); + // Scientific notation Instant sciInst = BigQueryTemporalUtility.parseEpochDecimalToInstant("1.6905474E9"); assertThat(sciInst.getEpochSecond()).isEqualTo(1690547400L); From bfc2356b91eda21d62c6ee6aa1bca76e9d4c9efd Mon Sep 17 00:00:00 2001 From: Keshav Dandeva Date: Mon, 7 Sep 2026 18:16:23 +0000 Subject: [PATCH 3/3] fix(bigquery-jdbc): handle whitespace before timezone offset and prioritize first sign --- .../cloud/bigquery/jdbc/BigQueryTemporalUtility.java | 9 ++++++--- .../bigquery/jdbc/BigQueryTemporalUtilityTest.java | 11 +++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) 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 06f7a16816ff..d69b166842c1 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 @@ -245,15 +245,18 @@ static String formatTimestampStringFromIso(String isoString, boolean enableTimes if (sepIdx >= 0) { int plusIdx = remaining.indexOf('+', sepIdx); int minusIdx = remaining.indexOf('-', sepIdx); - int offsetIdx = plusIdx >= 0 ? plusIdx : minusIdx; + int offsetIdx = + plusIdx >= 0 && minusIdx >= 0 + ? Math.min(plusIdx, minusIdx) + : (plusIdx >= 0 ? plusIdx : minusIdx); if (offsetIdx >= 0) { - offsetPart = remaining.substring(offsetIdx); + offsetPart = remaining.substring(offsetIdx).trim(); remaining = remaining.substring(0, offsetIdx); } } // 4. Ensure standard ISO LocalDateTime format (YYYY-MM-DDTHH:mm:ss) - remaining = remaining.replace(' ', 'T'); + remaining = remaining.trim().replace(' ', 'T'); if (remaining.indexOf('T') < 0) { remaining = remaining + "T00:00:00"; } 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 4a7d4a2ad96d..5cb717868414 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 @@ -348,6 +348,17 @@ public void testFormatTimestampStringFromIso() throws BigQueryJdbcException { BigQueryTemporalUtility.formatTimestampStringFromIso( "2026-08-24T01:30:00.123456789012+05:00", true); assertThat(dayBoundary).isEqualTo("2026-08-23 20:30:00.123456789012"); + + // String with space before timezone offset (both positive and negative offsets) + String spaceBeforeOffset = + BigQueryTemporalUtility.formatTimestampStringFromIso( + "2026-08-24 15:30:45.123456789012 +02:00", true); + assertThat(spaceBeforeOffset).isEqualTo("2026-08-24 13:30:45.123456789012"); + + String negativeOffset = + BigQueryTemporalUtility.formatTimestampStringFromIso( + "2026-08-24 15:30:45.123456789012 -05:00", true); + assertThat(negativeOffset).isEqualTo("2026-08-24 20:30:45.123456789012"); } @Test