Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.

Commit 0ba9b8d

Browse files
committed
chore: Use custom timestamp validator for ISO8601 timestamps with more than nanosecond precision
1 parent 6dcc900 commit 0ba9b8d

2 files changed

Lines changed: 134 additions & 45 deletions

File tree

google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/QueryParameterValue.java

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import com.google.api.services.bigquery.model.RangeValue;
2727
import com.google.auto.value.AutoValue;
2828
import com.google.cloud.Timestamp;
29+
import com.google.common.annotations.VisibleForTesting;
2930
import com.google.common.base.Function;
3031
import com.google.common.collect.ImmutableList;
3132
import com.google.common.collect.ImmutableMap;
@@ -44,6 +45,8 @@
4445
import java.util.HashMap;
4546
import java.util.List;
4647
import java.util.Map;
48+
import java.util.regex.Matcher;
49+
import java.util.regex.Pattern;
4750
import javax.annotation.Nullable;
4851
import org.threeten.extra.PeriodDuration;
4952

@@ -76,7 +79,7 @@
7679
@AutoValue
7780
public abstract class QueryParameterValue implements Serializable {
7881

79-
private static final DateTimeFormatter timestampFormatter =
82+
static final DateTimeFormatter TIMESTAMP_FORMATTER =
8083
new DateTimeFormatterBuilder()
8184
.parseLenient()
8285
.append(DateTimeFormatter.ISO_LOCAL_DATE)
@@ -94,15 +97,20 @@ public abstract class QueryParameterValue implements Serializable {
9497
.optionalEnd()
9598
.toFormatter()
9699
.withZone(ZoneOffset.UTC);
97-
private static final DateTimeFormatter timestampValidator =
100+
private static final DateTimeFormatter TIMESTAMP_VALIDATOR =
98101
new DateTimeFormatterBuilder()
99102
.parseLenient()
100-
.append(timestampFormatter)
103+
.append(TIMESTAMP_FORMATTER)
101104
.optionalStart()
102105
.appendOffsetId()
103106
.optionalEnd()
104107
.toFormatter()
105108
.withZone(ZoneOffset.UTC);
109+
// Regex to identify >9 digits in the fraction part (e.g. .123456789123)
110+
// Matches the dot, followed by 10-12 digits, followed by non-digits (like +00) or end of string
111+
private static final Pattern ISO8601_TIMESTAMP_HIGH_PRECISION_PATTERN =
112+
Pattern.compile("(\\.\\d{10,12})(?:\\D|$)");
113+
106114
private static final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
107115
private static final DateTimeFormatter timeFormatter =
108116
DateTimeFormatter.ofPattern("HH:mm:ss.SSSSSS");
@@ -303,6 +311,9 @@ public static QueryParameterValue bytes(byte[] value) {
303311
/**
304312
* Creates a {@code QueryParameterValue} object with a type of TIMESTAMP.
305313
*
314+
* <p>This method only supports microsecond precision for timestamp. To use higher precision,
315+
* prefer {@link #timestamp(String)} with an ISO8601 String
316+
*
306317
* @param value Microseconds since epoch, e.g. 1733945416000000 corresponds to 2024-12-11
307318
* 19:30:16.929Z
308319
*/
@@ -311,8 +322,14 @@ public static QueryParameterValue timestamp(Long value) {
311322
}
312323

313324
/**
314-
* Creates a {@code QueryParameterValue} object with a type of TIMESTAMP. Must be in the format
315-
* "yyyy-MM-dd HH:mm:ss.SSSSSSZZ", e.g. "2014-08-19 12:41:35.220000+00:00".
325+
* Creates a {@code QueryParameterValue} object with a type of TIMESTAMP.
326+
*
327+
* <p>This method supports up to picosecond precision (12 digits) for timestamp. Input should
328+
* conform to ISO8601 format.
329+
*
330+
* <p>Must be in the format "yyyy-MM-dd HH:mm:ss.SSSSSS{SSSSSSS}ZZ", e.g. "2014-08-19
331+
* 12:41:35.123456+00:00" for microsecond precision and "2014-08-19 12:41:35.123456789123+00:00"
332+
* for picosecond precision
316333
*/
317334
public static QueryParameterValue timestamp(String value) {
318335
return of(value, StandardSQLTypeName.TIMESTAMP);
@@ -481,12 +498,15 @@ private static <T> String valueToStringOrNull(T value, StandardSQLTypeName type)
481498
throw new IllegalArgumentException("Cannot convert RANGE to String value");
482499
case TIMESTAMP:
483500
if (value instanceof Long) {
501+
// Timestamp passed as a Long only support Microsecond precision
484502
Timestamp timestamp = Timestamp.ofTimeMicroseconds((Long) value);
485-
return timestampFormatter.format(
503+
return TIMESTAMP_FORMATTER.format(
486504
Instant.ofEpochSecond(timestamp.getSeconds(), timestamp.getNanos()));
487505
} else if (value instanceof String) {
488-
// verify that the String is in the right format
489-
checkFormat(value, timestampValidator);
506+
// Timestamp passed as a String can support up picosecond precision, however,
507+
// DateTimeFormatter only supports nanosecond precision. Higher than nanosecond
508+
// requires a custom validator.
509+
checkValidTimestamp((String) value);
490510
return (String) value;
491511
}
492512
break;
@@ -521,9 +541,46 @@ private static <T> String valueToStringOrNull(T value, StandardSQLTypeName type)
521541
"Type " + type + " incompatible with " + value.getClass().getCanonicalName());
522542
}
523543

544+
/**
545+
* Internal helper method to check that the timestamp follows the expected String input of ISO8601
546+
* string. Allows the fractional portion of the timestamp to support up to 12 digits of precision
547+
* (up to picosecond).
548+
*
549+
* @throws IllegalArgumentException if timestamp is invalid or exceeds picosecond precision
550+
*/
551+
@VisibleForTesting
552+
static void checkValidTimestamp(String timestamp) {
553+
// Match if there is greater than nanosecond precision (>9 fractional digits)
554+
Matcher matcher = ISO8601_TIMESTAMP_HIGH_PRECISION_PATTERN.matcher(timestamp);
555+
if (matcher.find()) {
556+
// Group 1 is the fractional part including the dot (e.g., ".123456789123")
557+
String fraction = matcher.group(1);
558+
559+
// Truncate to . + 9 digits
560+
String truncatedFraction = fraction.substring(0, 10);
561+
// Replace the entire fractional portion with the nanosecond portion. Digits exceeding the
562+
// nanosecond will be checked separately.
563+
String truncatedTimestamp =
564+
timestamp.replaceFirst(Pattern.quote(fraction), truncatedFraction);
565+
566+
// It is valid as long as DateTimeFormatter doesn't throw an exception AND
567+
// the fractional portion past nanosecond precision is valid (up to picosecond)
568+
checkFormat(truncatedTimestamp, TIMESTAMP_VALIDATOR);
569+
long value = Long.parseLong(fraction.substring(10));
570+
// Picosecond supports the next three digits (0 - 999)
571+
if (value >= 1000) {
572+
throw new IllegalArgumentException(
573+
"Fractional portion of ISO8601 supports up to picosecond (12 digits)");
574+
}
575+
return;
576+
}
577+
578+
// It is valid as long as DateTimeFormatter doesn't throw an exception
579+
checkFormat(timestamp, TIMESTAMP_VALIDATOR);
580+
}
581+
524582
private static void checkFormat(Object value, DateTimeFormatter formatter) {
525583
try {
526-
527584
formatter.parse((String) value);
528585
} catch (DateTimeParseException e) {
529586
throw new IllegalArgumentException(e.getMessage(), e);

google-cloud-bigquery/src/test/java/com/google/cloud/bigquery/QueryParameterValueTest.java

Lines changed: 68 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,9 @@
1616

1717
package com.google.cloud.bigquery;
1818

19+
import static com.google.cloud.bigquery.QueryParameterValue.TIMESTAMP_FORMATTER;
1920
import static com.google.common.truth.Truth.assertThat;
20-
import static java.time.temporal.ChronoField.HOUR_OF_DAY;
21-
import static java.time.temporal.ChronoField.MINUTE_OF_HOUR;
22-
import static java.time.temporal.ChronoField.NANO_OF_SECOND;
23-
import static java.time.temporal.ChronoField.SECOND_OF_MINUTE;
21+
import static org.junit.Assert.assertThrows;
2422

2523
import com.google.api.services.bigquery.model.QueryParameterType;
2624
import com.google.common.collect.ImmutableMap;
@@ -29,9 +27,6 @@
2927
import java.text.ParseException;
3028
import java.time.Instant;
3129
import java.time.Period;
32-
import java.time.ZoneOffset;
33-
import java.time.format.DateTimeFormatter;
34-
import java.time.format.DateTimeFormatterBuilder;
3530
import java.util.ArrayList;
3631
import java.util.Date;
3732
import java.util.HashMap;
@@ -43,25 +38,6 @@
4338

4439
public class QueryParameterValueTest {
4540

46-
private static final DateTimeFormatter TIMESTAMPFORMATTER =
47-
new DateTimeFormatterBuilder()
48-
.parseLenient()
49-
.append(DateTimeFormatter.ISO_LOCAL_DATE)
50-
.appendLiteral(' ')
51-
.appendValue(HOUR_OF_DAY, 2)
52-
.appendLiteral(':')
53-
.appendValue(MINUTE_OF_HOUR, 2)
54-
.optionalStart()
55-
.appendLiteral(':')
56-
.appendValue(SECOND_OF_MINUTE, 2)
57-
.optionalStart()
58-
.appendFraction(NANO_OF_SECOND, 6, 9, true)
59-
.optionalStart()
60-
.appendOffset("+HHMM", "+00:00")
61-
.optionalEnd()
62-
.toFormatter()
63-
.withZone(ZoneOffset.UTC);
64-
6541
private static final QueryParameterValue QUERY_PARAMETER_VALUE =
6642
QueryParameterValue.newBuilder()
6743
.setType(StandardSQLTypeName.STRING)
@@ -326,6 +302,7 @@ public void testStringArray() {
326302

327303
@Test
328304
public void testTimestampFromLong() {
305+
// Expects output to be ISO8601 string with microsecond precision
329306
QueryParameterValue value = QueryParameterValue.timestamp(1408452095220000L);
330307
assertThat(value.getValue()).isEqualTo("2014-08-19 12:41:35.220000+00:00");
331308
assertThat(value.getType()).isEqualTo(StandardSQLTypeName.TIMESTAMP);
@@ -340,18 +317,52 @@ public void testTimestampWithFormatter() {
340317
long secs = Math.floorDiv(timestampInMicroseconds, microseconds);
341318
int nano = (int) Math.floorMod(timestampInMicroseconds, microseconds) * 1000;
342319
Instant instant = Instant.ofEpochSecond(secs, nano);
343-
String expected = TIMESTAMPFORMATTER.format(instant);
320+
String expected = TIMESTAMP_FORMATTER.format(instant);
344321
assertThat(expected)
345322
.isEqualTo(QueryParameterValue.timestamp(timestampInMicroseconds).getValue());
346323
}
347324

348325
@Test
349-
public void testTimestamp() {
350-
QueryParameterValue value = QueryParameterValue.timestamp("2014-08-19 12:41:35.220000+00:00");
351-
assertThat(value.getValue()).isEqualTo("2014-08-19 12:41:35.220000+00:00");
352-
assertThat(value.getType()).isEqualTo(StandardSQLTypeName.TIMESTAMP);
353-
assertThat(value.getArrayType()).isNull();
354-
assertThat(value.getArrayValues()).isNull();
326+
public void testTimestampFromString() {
327+
// QueryParameterValue value = QueryParameterValue.timestamp("2014-08-19
328+
// 12:41:35.220000+00:00");
329+
// assertThat(value.getValue()).isEqualTo("2014-08-19 12:41:35.220000+00:00");
330+
// assertThat(value.getType()).isEqualTo(StandardSQLTypeName.TIMESTAMP);
331+
// assertThat(value.getArrayType()).isNull();
332+
// assertThat(value.getArrayValues()).isNull();
333+
//
334+
// QueryParameterValue value1 =
335+
// QueryParameterValue.timestamp("2025-08-19 12:34:56.123456789+00:00");
336+
// assertThat(value1.getValue()).isEqualTo("2025-08-19 12:34:56.123456789+00:00");
337+
// assertThat(value1.getType()).isEqualTo(StandardSQLTypeName.TIMESTAMP);
338+
// assertThat(value1.getArrayType()).isNull();
339+
// assertThat(value1.getArrayValues()).isNull();
340+
341+
// The following test cases test more than nanosecond precision
342+
// 10 digits of precision (1 digit more than nanosecond)
343+
QueryParameterValue value2 =
344+
QueryParameterValue.timestamp("2025-12-08 12:34:56.1234567890+00:00");
345+
assertThat(value2.getValue()).isEqualTo("2025-12-08 12:34:56.1234567890+00:00");
346+
assertThat(value2.getType()).isEqualTo(StandardSQLTypeName.TIMESTAMP);
347+
assertThat(value2.getArrayType()).isNull();
348+
assertThat(value2.getArrayValues()).isNull();
349+
350+
// 12 digits (picosecond precision)
351+
QueryParameterValue value3 =
352+
QueryParameterValue.timestamp("2025-12-08 12:34:56.123456789123+00:00");
353+
assertThat(value3.getValue()).isEqualTo("2025-12-08 12:34:56.123456789123+00:00");
354+
assertThat(value3.getType()).isEqualTo(StandardSQLTypeName.TIMESTAMP);
355+
assertThat(value3.getArrayType()).isNull();
356+
assertThat(value3.getArrayValues()).isNull();
357+
358+
// More than picosecond precision
359+
assertThrows(
360+
IllegalArgumentException.class,
361+
() -> QueryParameterValue.timestamp("2025-12-08 12:34:56.1234567891234+00:00"));
362+
assertThrows(
363+
IllegalArgumentException.class,
364+
() ->
365+
QueryParameterValue.timestamp("2025-12-08 12:34:56.123456789123456789123456789+00:00"));
355366
}
356367

357368
@Test
@@ -373,10 +384,31 @@ public void testTimestampWithDateTimeFormatterBuilder() {
373384
assertThat(value2.getArrayValues()).isNull();
374385
}
375386

376-
@Test(expected = IllegalArgumentException.class)
377-
public void testInvalidTimestamp() {
387+
@Test
388+
public void testInvalidTimestampStringValues() {
389+
assertThrows(IllegalArgumentException.class, () -> QueryParameterValue.timestamp("abc"));
390+
378391
// missing the time
379-
QueryParameterValue.timestamp("2014-08-19");
392+
assertThrows(IllegalArgumentException.class, () -> QueryParameterValue.timestamp("2014-08-19"));
393+
394+
// missing the hour
395+
assertThrows(
396+
IllegalArgumentException.class, () -> QueryParameterValue.timestamp("2014-08-19 12"));
397+
398+
// can't have the 'T' separator
399+
assertThrows(
400+
IllegalArgumentException.class, () -> QueryParameterValue.timestamp("2014-08-19T12"));
401+
assertThrows(
402+
IllegalArgumentException.class,
403+
() -> QueryParameterValue.timestamp("2014-08-19T12:34:00.123456"));
404+
405+
// Fractional part has picosecond length, but fractional part is not a valid number
406+
assertThrows(
407+
IllegalArgumentException.class,
408+
() -> QueryParameterValue.timestamp("2014-08-19T12:34:00.123456789abc+00:00"));
409+
assertThrows(
410+
IllegalArgumentException.class,
411+
() -> QueryParameterValue.timestamp("2014-08-19T12:34:00.123456abc789+00:00"));
380412
}
381413

382414
@Test

0 commit comments

Comments
 (0)