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
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Comment thread
Neenu1995 marked this conversation as resolved.
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

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.

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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,11 @@ private boolean setTemporalObject(int parameterIndex, Object value) throws SQLEx
return true;
}
if (value instanceof LocalDateTime) {
setTimestamp(parameterIndex, Timestamp.valueOf((LocalDateTime) value));
// 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

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.

Could we use @ParameterizedTest (same as done in my previous PR) for these tests

@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");
Comment thread
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
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Up @@ -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));

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.

Let's also assert the stored parameter value (getParameter(4)) for Instant instead of only checking getType(4), and add cases for OffsetDateTime and ZonedDateTime here as well.

Instant instant = Instant.now();
preparedStatement.setObject(4, instant);
Expand Down Expand Up @@ -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 ?");
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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));
}
}
Loading
Loading