Repository navigation
fix(bigquery-jdbc): preserve the instant on the TIMESTAMP write path and bind LocalDateTime as STRING #14647
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
38240a8
d2e4092
c9868f2
226dfa8
9939e6e
87562e8
cc30f1c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,16 +22,40 @@ | |
| import com.google.cloud.bigquery.exception.BigQueryJdbcException; | ||
| import com.google.cloud.bigquery.exception.BigQueryJdbcSqlFeatureNotSupportedException; | ||
| import java.math.BigInteger; | ||
| import java.sql.Date; | ||
| import java.sql.SQLException; | ||
| import java.sql.Time; | ||
| import java.sql.Timestamp; | ||
| import java.time.Instant; | ||
| import java.time.LocalDateTime; | ||
| import java.time.OffsetDateTime; | ||
| import java.time.ZoneOffset; | ||
| import java.time.ZonedDateTime; | ||
| import java.time.format.DateTimeFormatter; | ||
| import java.util.ArrayList; | ||
| import java.util.Locale; | ||
|
|
||
| class BigQueryParameterHandler { | ||
| private final BigQueryJdbcCustomLogger LOG = new BigQueryJdbcCustomLogger(this.toString()); | ||
| private final int parametersArraySize; | ||
| private final boolean enableTimestampPicos; | ||
| final ArrayList<BigQueryJdbcParameter> parametersList; | ||
| // TIMESTAMP is rendered in UTC with a trailing 'Z': the instant is preserved, the output does not | ||
| // depend on the JVM zone, and QueryParameterValue's validator accepts 'Z' but rejects "-05:00". | ||
| // 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); | ||
| private static final DateTimeFormatter TIMESTAMP_NANOS_UTC = | ||
| DateTimeFormatter.ofPattern("yyyy-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); | ||
|
|
||
| /** 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); | ||
| } | ||
|
|
||
| BigQueryParameterHandler(int parameterCount) { | ||
| this(parameterCount, false); | ||
|
|
@@ -103,36 +127,55 @@ static Object formatValueForQueryParameter( | |
| if (sqlType == StandardSQLTypeName.FLOAT64 && parameterValue instanceof Float) { | ||
| return ((Number) parameterValue).doubleValue(); | ||
| } | ||
| if (parameterValue instanceof Timestamp) { | ||
| return formatTimestampParameter((Timestamp) parameterValue, enableTimestampPicos); | ||
| } | ||
| if (sqlType == StandardSQLTypeName.TIMESTAMP && parameterValue instanceof String) { | ||
| String str = ((String) parameterValue).trim(); | ||
| if (str.length() > 10 && str.charAt(10) == 'T') { | ||
| str = str.substring(0, 10) + ' ' + str.substring(11); | ||
| if (sqlType == StandardSQLTypeName.TIMESTAMP) { | ||
| // Reduce to an Instant and render in UTC so digits and 'Z' can never disagree. | ||
| if (parameterValue instanceof Timestamp) { | ||
| return formatInstantUtc(((Timestamp) parameterValue).toInstant(), enableTimestampPicos); | ||
| } | ||
| if (parameterValue instanceof OffsetDateTime) { | ||
| return formatInstantUtc( | ||
| ((OffsetDateTime) parameterValue).toInstant(), enableTimestampPicos); | ||
| } | ||
| if (parameterValue instanceof ZonedDateTime) { | ||
| return formatInstantUtc(((ZonedDateTime) parameterValue).toInstant(), enableTimestampPicos); | ||
| } | ||
| if (parameterValue instanceof Instant) { | ||
| return formatInstantUtc((Instant) parameterValue, enableTimestampPicos); | ||
| } | ||
| return BigQueryTemporalUtility.truncateFractionalSeconds(str, enableTimestampPicos ? 12 : 6); | ||
| if (parameterValue instanceof String) { | ||
| String str = ((String) parameterValue).trim(); | ||
| if (str.length() > 10 && str.charAt(10) == 'T') { | ||
| str = str.substring(0, 10) + ' ' + str.substring(11); | ||
| } | ||
| return BigQueryTemporalUtility.truncateFractionalSeconds( | ||
| str, enableTimestampPicos ? 12 : 6); | ||
| } | ||
| } | ||
|
|
||
| if (parameterValue instanceof Timestamp) { | ||
| // DATETIME slot: JVM-local wall-clock digits, six-digit fraction, no offset (prior | ||
| // behaviour). | ||
| return DATETIME_MICROS.format(((Timestamp) parameterValue).toLocalDateTime()); | ||
| } | ||
|
Comment on lines
+155
to
159
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this condition ever reachable? |
||
|
|
||
| if (parameterValue instanceof Time) { | ||
| String timeStr = parameterValue.toString(); | ||
| if (timeStr.length() == 8) { | ||
| return timeStr + ".000000"; | ||
| } | ||
| return timeStr; | ||
| } | ||
| if (parameterValue instanceof java.sql.Date || parameterValue instanceof java.util.Date) { | ||
| if (parameterValue instanceof Date || parameterValue instanceof java.util.Date) { | ||
| return parameterValue.toString(); | ||
| } | ||
| return parameterValue; | ||
| } | ||
|
|
||
| private static String formatTimestampParameter(Timestamp ts, boolean enableTimestampPicos) { | ||
| if (enableTimestampPicos) { | ||
| return ts.toString(); | ||
| } | ||
| Timestamp copy = new Timestamp(ts.getTime()); | ||
| copy.setNanos((ts.getNanos() / 1000) * 1000); | ||
| return copy.toString(); | ||
| /** Renders an absolute instant as UTC wall-clock digits with a trailing 'Z'. */ | ||
| private static String formatInstantUtc(Instant instant, boolean enableTimestampPicos) { | ||
| // Fixed-width 'S' truncates (does not round), matching the legacy nanos/1000*1000 behaviour. | ||
| return (enableTimestampPicos ? TIMESTAMP_NANOS_UTC : TIMESTAMP_MICROS_UTC) | ||
| .format(instant.atOffset(ZoneOffset.UTC)); | ||
| } | ||
|
|
||
| private BigQueryJdbcParameter getOrCreateParameter(int parameterIndex) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,9 +20,18 @@ | |
| import static org.junit.jupiter.api.Assertions.assertNotNull; | ||
|
|
||
| import com.google.cloud.bigquery.QueryJobConfiguration; | ||
| import com.google.cloud.bigquery.QueryParameterValue; | ||
| import com.google.cloud.bigquery.StandardSQLTypeName; | ||
| import com.google.cloud.bigquery.jdbc.BigQueryParameterHandler.BigQueryStatementParameterType; | ||
| import java.sql.Timestamp; | ||
| import java.time.Instant; | ||
| import java.time.LocalDateTime; | ||
| import java.time.OffsetDateTime; | ||
| import java.time.ZoneId; | ||
| import java.time.ZoneOffset; | ||
| import java.time.ZonedDateTime; | ||
| import java.time.format.DateTimeFormatter; | ||
| import java.util.TimeZone; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| public class BigQueryParameterHandlerTest { | ||
|
|
@@ -160,19 +169,106 @@ public void testConfigureParametersWidenNumericTypes() throws Exception { | |
|
|
||
| @Test | ||
| public void testFormatValueForQueryParameter_timestampPrecision() { | ||
| Timestamp ts = Timestamp.valueOf("2024-01-01 12:34:56.123456789"); | ||
| // Built from an Instant so the expected UTC rendering does not depend on the JVM zone. | ||
| Timestamp ts = Timestamp.from(Instant.parse("2024-01-01T12:34:56.123456789Z")); | ||
|
|
||
| // When enableTimestampPicos is true, full nanoseconds are preserved | ||
| Object picosFormatted = | ||
| BigQueryParameterHandler.formatValueForQueryParameter( | ||
| ts, StandardSQLTypeName.TIMESTAMP, true); | ||
| assertEquals("2024-01-01 12:34:56.123456789", picosFormatted); | ||
| assertEquals("2024-01-01 12:34:56.123456789Z", picosFormatted); | ||
|
|
||
| // When enableTimestampPicos is false, truncated to microseconds | ||
| Object microsFormatted = | ||
| BigQueryParameterHandler.formatValueForQueryParameter( | ||
| ts, StandardSQLTypeName.TIMESTAMP, false); | ||
| assertEquals("2024-01-01 12:34:56.123456", microsFormatted); | ||
| assertEquals("2024-01-01 12:34:56.123456Z", microsFormatted); | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we use |
||
| @Test | ||
| public void testFormatValueForQueryParameter_timestampPreservesInstantAcrossJvmZones() { | ||
| // The core Model A invariant: the string we send must decode back to the exact instant that | ||
| // was bound, regardless of the JVM default zone. Before the fix, a JVM in America/New_York | ||
| // sent bare local digits that BigQuery read as UTC, shifting the instant by five hours. | ||
| Instant original = Instant.parse("2024-01-01T17:34:56.123456Z"); | ||
| DateTimeFormatter parser = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSSXXX"); | ||
|
Neenu1995 marked this conversation as resolved.
|
||
| TimeZone saved = TimeZone.getDefault(); | ||
| try { | ||
| for (String zone : new String[] {"UTC", "America/New_York", "Asia/Kolkata"}) { | ||
| TimeZone.setDefault(TimeZone.getTimeZone(zone)); | ||
| String out = | ||
| (String) | ||
| BigQueryParameterHandler.formatValueForQueryParameter( | ||
| Timestamp.from(original), StandardSQLTypeName.TIMESTAMP, false); | ||
| assertEquals("2024-01-01 17:34:56.123456Z", out, "zone=" + zone); | ||
| assertEquals(original, OffsetDateTime.parse(out, parser).toInstant(), "zone=" + zone); | ||
| } | ||
| } finally { | ||
| TimeZone.setDefault(saved); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void testFormatValueForQueryParameter_zoneAwareTypesCollapseToSameInstant() { | ||
| // OffsetDateTime / ZonedDateTime / Instant / Timestamp for the same moment must all render | ||
| // identically: the caller's offset is folded into the UTC digits, not dropped. | ||
| Instant moment = Instant.parse("2024-01-01T17:34:56.123456Z"); | ||
| String expected = "2024-01-01 17:34:56.123456Z"; | ||
| assertEquals( | ||
| expected, | ||
| BigQueryParameterHandler.formatValueForQueryParameter( | ||
| moment.atOffset(ZoneOffset.ofHoursMinutes(5, 30)), | ||
| StandardSQLTypeName.TIMESTAMP, | ||
| false)); | ||
| assertEquals( | ||
| expected, | ||
| BigQueryParameterHandler.formatValueForQueryParameter( | ||
| ZonedDateTime.ofInstant(moment, ZoneId.of("America/New_York")), | ||
| StandardSQLTypeName.TIMESTAMP, | ||
| false)); | ||
| assertEquals( | ||
| expected, | ||
| BigQueryParameterHandler.formatValueForQueryParameter( | ||
| moment, StandardSQLTypeName.TIMESTAMP, false)); | ||
| assertEquals( | ||
| expected, | ||
| BigQueryParameterHandler.formatValueForQueryParameter( | ||
| Timestamp.from(moment), StandardSQLTypeName.TIMESTAMP, false)); | ||
| } | ||
|
|
||
| @Test | ||
| public void testFormatValueForQueryParameter_timestampAcceptedByClientLibraryValidator() { | ||
| // QueryParameterValue's TIMESTAMP validator rejects non-zero colon offsets such as "-05:00". | ||
| // Force a non-UTC JVM zone and prove the emitted string still passes validation; this is the | ||
| // scenario that previously threw IllegalArgumentException from configureParameters. | ||
| Timestamp ts = Timestamp.from(Instant.parse("2024-01-01T17:34:56.123456789Z")); | ||
| TimeZone saved = TimeZone.getDefault(); | ||
| try { | ||
| TimeZone.setDefault(TimeZone.getTimeZone("America/New_York")); | ||
| for (boolean picos : new boolean[] {false, true}) { | ||
| String out = | ||
| (String) | ||
| BigQueryParameterHandler.formatValueForQueryParameter( | ||
| ts, StandardSQLTypeName.TIMESTAMP, picos); | ||
| // Throws IllegalArgumentException if the format is not accepted. | ||
| assertEquals(out, QueryParameterValue.timestamp(out).getValue()); | ||
| } | ||
| } finally { | ||
| TimeZone.setDefault(saved); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void testFormatValueForQueryParameter_datetimeSlotEmitsWallClockWithoutOffset() { | ||
| // A Timestamp bound into a non-TIMESTAMP (DATETIME) slot is a zone-less wall clock: JVM-local | ||
| // digits, exactly six fraction digits, no offset. Sub-microsecond digits are truncated. | ||
| Timestamp ts = Timestamp.valueOf("2024-01-01 12:34:56.123456789"); | ||
| Object out = | ||
| BigQueryParameterHandler.formatValueForQueryParameter( | ||
| ts, StandardSQLTypeName.DATETIME, true); | ||
| assertEquals("2024-01-01 12:34:56.123456", out); | ||
| // And the DATETIME validator accepts it. | ||
| assertEquals(out, QueryParameterValue.dateTime((String) out).getValue()); | ||
| } | ||
|
|
||
| @Test | ||
|
|
@@ -222,7 +318,7 @@ public void testFormatValueForQueryParameter_stringTimestampPrecision() { | |
| @Test | ||
| public void testConfigureParameters_withEnableTimestampPicos() throws Exception { | ||
| BigQueryParameterHandler paramHandler = new BigQueryParameterHandler(2, true); | ||
| Timestamp ts = Timestamp.valueOf("2024-01-01 12:34:56.123456789"); | ||
| Timestamp ts = Timestamp.from(Instant.parse("2024-01-01T12:34:56.123456789Z")); | ||
| String picosStr = "2024-01-01 12:34:56.123456789012"; | ||
|
|
||
| paramHandler.setParameter(1, ts, Timestamp.class); | ||
|
|
@@ -234,10 +330,36 @@ public void testConfigureParameters_withEnableTimestampPicos() throws Exception | |
| QueryJobConfiguration config = builder.build(); | ||
| assertEquals(2, config.getPositionalParameters().size()); | ||
| assertEquals( | ||
| "2024-01-01 12:34:56.123456789", config.getPositionalParameters().get(0).getValue()); | ||
| "2024-01-01 12:34:56.123456789Z", config.getPositionalParameters().get(0).getValue()); | ||
| assertEquals(StandardSQLTypeName.TIMESTAMP, config.getPositionalParameters().get(0).getType()); | ||
| assertEquals( | ||
| "2024-01-01 12:34:56.123456789012", config.getPositionalParameters().get(1).getValue()); | ||
| assertEquals(StandardSQLTypeName.TIMESTAMP, config.getPositionalParameters().get(1).getType()); | ||
| } | ||
|
|
||
| @Test | ||
| public void testFormatDateTimeDigits_isZoneFreeAndFixedWidth() { | ||
| TimeZone saved = TimeZone.getDefault(); | ||
| try { | ||
| for (String zone : new String[] {"UTC", "America/New_York", "Asia/Kolkata"}) { | ||
| TimeZone.setDefault(TimeZone.getTimeZone(zone)); | ||
| assertEquals( | ||
| "2025-01-01 12:34:56.123000", | ||
| BigQueryParameterHandler.formatDateTimeDigits( | ||
| LocalDateTime.of(2025, 1, 1, 12, 34, 56, 123_000_000)), | ||
| zone); | ||
| } | ||
| } finally { | ||
| TimeZone.setDefault(saved); | ||
| } | ||
| // LocalDateTime.toString() would print "2025-01-01T12:00"; BigQuery needs full HH:mm:ss.SSSSSS. | ||
| assertEquals( | ||
| "2025-01-01 12:00:00.000000", | ||
| BigQueryParameterHandler.formatDateTimeDigits(LocalDateTime.of(2025, 1, 1, 12, 0))); | ||
| // Sub-microsecond digits are truncated, not rounded. | ||
| assertEquals( | ||
| "2025-01-01 12:00:00.123456", | ||
| BigQueryParameterHandler.formatDateTimeDigits( | ||
| LocalDateTime.of(2025, 1, 1, 12, 0, 0, 123_456_789))); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -288,9 +288,10 @@ public void testSetObjectWithJavaTime() throws Exception { | |
| preparedStatement.setObject(2, localTime); | ||
| assertEquals(Time.class, preparedStatement.parameterHandler.getType(2)); | ||
|
|
||
| // 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); | ||
| preparedStatement.setObject(3, localDateTime); | ||
| assertEquals(Timestamp.class, preparedStatement.parameterHandler.getType(3)); | ||
| assertEquals(String.class, preparedStatement.parameterHandler.getType(3)); | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's also assert the stored parameter value ( |
||
| Instant instant = Instant.now(); | ||
| preparedStatement.setObject(4, instant); | ||
|
|
@@ -413,7 +414,8 @@ public void testSetTimestamp_picosEnabledPreservesNanoseconds() throws Exception | |
| BigQueryPreparedStatement ps = | ||
| new BigQueryPreparedStatement(picosConnection, "INSERT INTO t (col) VALUES (?)"); | ||
|
|
||
| Timestamp ts = Timestamp.valueOf("2024-01-01 12:34:56.123456789"); | ||
| // Built from an Instant so the expected UTC rendering does not depend on the JVM zone. | ||
| Timestamp ts = Timestamp.from(Instant.parse("2024-01-01T12:34:56.123456789Z")); | ||
| ps.setTimestamp(1, ts); | ||
|
|
||
| QueryJobConfiguration.Builder builder = QueryJobConfiguration.newBuilder("SELECT ?"); | ||
|
|
@@ -422,7 +424,7 @@ public void testSetTimestamp_picosEnabledPreservesNanoseconds() throws Exception | |
|
|
||
| assertEquals(1, config.getPositionalParameters().size()); | ||
| assertEquals( | ||
| "2024-01-01 12:34:56.123456789", config.getPositionalParameters().get(0).getValue()); | ||
| "2024-01-01 12:34:56.123456789Z", config.getPositionalParameters().get(0).getValue()); | ||
| } | ||
|
|
||
| @Test | ||
|
|
@@ -438,15 +440,15 @@ public void testSetTimestamp_picosDisabledTruncatesToMicroseconds() throws Excep | |
| BigQueryPreparedStatement ps = | ||
| new BigQueryPreparedStatement(nonPicosConnection, "INSERT INTO t (col) VALUES (?)"); | ||
|
|
||
| Timestamp ts = Timestamp.valueOf("2024-01-01 12:34:56.123456789"); | ||
| Timestamp ts = Timestamp.from(Instant.parse("2024-01-01T12:34:56.123456789Z")); | ||
| ps.setTimestamp(1, ts); | ||
|
|
||
| QueryJobConfiguration.Builder builder = QueryJobConfiguration.newBuilder("SELECT ?"); | ||
| ps.parameterHandler.configureParameters(builder); | ||
| QueryJobConfiguration config = builder.build(); | ||
|
|
||
| assertEquals(1, config.getPositionalParameters().size()); | ||
| assertEquals("2024-01-01 12:34:56.123456", config.getPositionalParameters().get(0).getValue()); | ||
| assertEquals("2024-01-01 12:34:56.123456Z", config.getPositionalParameters().get(0).getValue()); | ||
| } | ||
|
|
||
| @Test | ||
|
|
@@ -462,13 +464,36 @@ public void testBatchConfiguration_withEnableTimestampPicos() throws Exception { | |
| BigQueryPreparedStatement ps = | ||
| new BigQueryPreparedStatement(picosConnection, "INSERT INTO t (col) VALUES (?)"); | ||
|
|
||
| ps.setTimestamp(1, Timestamp.valueOf("2024-01-01 12:34:56.123456789")); | ||
| ps.setTimestamp(1, Timestamp.from(Instant.parse("2024-01-01T12:34:56.123456789Z"))); | ||
| ps.addBatch(); | ||
|
|
||
| QueryJobConfiguration batchConfig = | ||
| ps.getStandardBatchJobConfiguration("INSERT INTO t (col) VALUES (?)"); | ||
| assertEquals(1, batchConfig.getPositionalParameters().size()); | ||
| assertEquals( | ||
| "2024-01-01 12:34:56.123456789", batchConfig.getPositionalParameters().get(0).getValue()); | ||
| "2024-01-01 12:34:56.123456789Z", batchConfig.getPositionalParameters().get(0).getValue()); | ||
| } | ||
|
|
||
| @ParameterizedTest | ||
| @ValueSource(strings = {"UTC", "America/New_York", "Asia/Kolkata"}) | ||
| public void testSetObjectLocalDateTime_bindsDigitsAsString_inAnyJvmZone(String zone) | ||
| throws SQLException { | ||
| TimeZone previous = TimeZone.getDefault(); | ||
| try { | ||
| TimeZone.setDefault(TimeZone.getTimeZone(zone)); | ||
| preparedStatement.setObject(1, LocalDateTime.of(2025, 1, 1, 12, 34, 56, 123_000_000)); | ||
| assertEquals( | ||
| "2025-01-01 12:34:56.123000", preparedStatement.parameterHandler.getParameter(1)); | ||
| assertEquals(StandardSQLTypeName.STRING, preparedStatement.parameterHandler.getSqlType(1)); | ||
| } finally { | ||
| TimeZone.setDefault(previous); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void testSetObjectLocalDateTime_zeroSecondsStillHasFullDigits() throws SQLException { | ||
| // LocalDateTime.toString() would print "2025-01-01T12:00"; BigQuery needs full HH:mm:ss.SSSSSS. | ||
| preparedStatement.setObject(1, LocalDateTime.of(2025, 1, 1, 12, 0)); | ||
| assertEquals("2025-01-01 12:00:00.000000", preparedStatement.parameterHandler.getParameter(1)); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.