From d12cdf14f5ed84176614908ebf7765928625f1a0 Mon Sep 17 00:00:00 2001 From: Will Noble <78444363+wnob@users.noreply.github.com> Date: Mon, 11 Dec 2023 15:28:14 -0800 Subject: [PATCH] Use recommended semantics for getObject of date/time types (#181) --- .../clouddb/jdbc/BQForwardOnlyResultSet.java | 160 ++++++------------ .../clouddb/jdbc/BQScrollableResultSet.java | 31 ++-- .../clouddb/jdbc/DateTimeUtils.java | 156 +++++++++++++++++ .../BQForwardOnlyResultSetFunctionTest.java | 113 +++++++++++-- 4 files changed, 322 insertions(+), 138 deletions(-) create mode 100644 src/main/java/net/starschema/clouddb/jdbc/DateTimeUtils.java diff --git a/src/main/java/net/starschema/clouddb/jdbc/BQForwardOnlyResultSet.java b/src/main/java/net/starschema/clouddb/jdbc/BQForwardOnlyResultSet.java index de1664d7..9000b056 100644 --- a/src/main/java/net/starschema/clouddb/jdbc/BQForwardOnlyResultSet.java +++ b/src/main/java/net/starschema/clouddb/jdbc/BQForwardOnlyResultSet.java @@ -61,10 +61,6 @@ import java.sql.Statement; import java.sql.Time; import java.sql.Timestamp; -import java.text.SimpleDateFormat; -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; import java.util.*; import javax.annotation.Nullable; import org.slf4j.Logger; @@ -127,9 +123,6 @@ public class BQForwardOnlyResultSet implements java.sql.ResultSet { */ private int Cursor = -1; - private final DateTimeFormatter TIMESTAMP_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").withZone(ZoneId.of("UTC")); - public BQForwardOnlyResultSet( Bigquery bigquery, String projectId, @@ -248,7 +241,7 @@ public BQForwardOnlyResultSet( */ public Object getObject(int columnIndex) throws SQLException { - String result = getString(columnIndex, false); + String result = rawString(columnIndex); if (this.wasNull()) { return null; @@ -274,16 +267,26 @@ public Object getObject(int columnIndex) throws SQLException { if (Columntype.equals("INTEGER")) { return toLong(result); } + if (Columntype.equals("DATETIME")) { + // A BigQuery DATETIME is essentially defined by its string representation; + // the "clock-calendar parameters" comprising year, month, day, hour, minute, etc. + // On the other hand, a [java.sql.Timestamp] object is defined as a global instant, similar + // to BQ TIMESTAMP. It has a [toString] method that interprets that instant in the system + // default time zone. Thus, in order to produce a [Timestamp] object whose [toString] method + // has the correct result, we must adjust the value of the instant according to the system + // default time zone (passing a null Calendar uses the system default). + return DateTimeUtils.parseDateTime(result, null); + } if (Columntype.equals("TIMESTAMP")) { - return toTimestamp(result, null); + // A BigQuery TIMESTAMP is defined as a global instant in time, so when we create the + // [java.sql.Timestamp] object to represent it, we must not make any time zone adjustment. + return DateTimeUtils.parseTimestamp(result); } if (Columntype.equals("DATE")) { - return toDate(result, null); + return DateTimeUtils.parseDate(result, null); } - if (Columntype.equals("DATETIME")) { - // Date time represents a "clock face" time and so should NOT be processed into an actual - // time - return result; + if (Columntype.equals("TIME")) { + return DateTimeUtils.parseTime(result, null); } if (Columntype.equals("NUMERIC")) { return toBigDecimal(result); @@ -321,49 +324,6 @@ private Long toLong(String value) throws SQLException { } } - /** Parse date/time types */ - private Timestamp toTimestamp(String value, Calendar cal) throws SQLException { - try { - long dbValue = - new BigDecimal(value) - .movePointRight(3) - .longValue(); // movePointRight(3) = *1000 (done before rounding) - from seconds - // (BigQuery specifications) to milliseconds (required by java). - // Precision under millisecond is discarded (BigQuery supports - // micro-seconds) - if (cal == null) { - cal = - Calendar.getInstance( - TimeZone.getTimeZone( - "UTC")); // The time zone of the server that host the JVM should NOT impact the - // results. Use UTC calendar instead (which wont apply any correction, - // whatever the time zone of the data) - } - - Calendar dbCal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - dbCal.setTimeInMillis(dbValue); - cal.set(Calendar.YEAR, dbCal.get(Calendar.YEAR)); - cal.set(Calendar.MONTH, dbCal.get(Calendar.MONTH)); - cal.set(Calendar.DAY_OF_MONTH, dbCal.get(Calendar.DAY_OF_MONTH)); - cal.set(Calendar.HOUR_OF_DAY, dbCal.get(Calendar.HOUR_OF_DAY)); - cal.set(Calendar.MINUTE, dbCal.get(Calendar.MINUTE)); - cal.set(Calendar.SECOND, dbCal.get(Calendar.SECOND)); - cal.set(Calendar.MILLISECOND, dbCal.get(Calendar.MILLISECOND)); - return new Timestamp(cal.getTime().getTime()); - } catch (NumberFormatException e) { - // before giving up, check to see if we've been given a 'time' value without a - // date, e.g. from current_time(), and if we have, try to parse it - try { - SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); - String dateStringFromValue = ("1970-01-01 " + value).substring(0, 19); - java.util.Date parsedDate = dateFormat.parse(dateStringFromValue); - return new java.sql.Timestamp(parsedDate.getTime()); - } catch (Exception e2) { - throw new BQSQLException(e); - } - } - } - // Secondary converters /** Parse integral or floating types with (virtually) infinite precision */ @@ -371,21 +331,6 @@ private BigDecimal toBigDecimal(String value) { return new BigDecimal(value); } - static Date toDate(String value, Calendar cal) throws SQLException { - // Dates in BigQuery come back in the YYYY-MM-DD format - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); - try { - java.util.Date date = sdf.parse(value); - return new java.sql.Date(date.getTime()); - } catch (java.text.ParseException e) { - throw new BQSQLException(e); - } - } - - private Time toTime(String value, Calendar cal) throws SQLException { - return new java.sql.Time(toTimestamp(value, cal).getTime()); - } - @Override /** * Returns the current rows Data at the given index as String @@ -396,10 +341,18 @@ private Time toTime(String value, Calendar cal) throws SQLException { * is unsupported */ public String getString(int columnIndex) throws SQLException { - return getString(columnIndex, true); + String rawString = rawString(columnIndex); + + BQResultsetMetaData metadata = getBQResultsetMetaData(); + if (metadata.getColumnTypeName(columnIndex).equals("TIMESTAMP") + && !metadata.getColumnMode(columnIndex).equals("REPEATED")) { + return DateTimeUtils.formatTimestamp(rawString); + } + + return rawString; } - private String getString(int columnIndex, boolean formatTimestamps) throws SQLException { + private String rawString(int columnIndex) throws SQLException { // to make the logfiles smaller! // logger.debug("Function call getString columnIndex is: " + String.valueOf(columnIndex)); this.closestrm(); @@ -410,21 +363,21 @@ private String getString(int columnIndex, boolean formatTimestamps) throws SQLEx throw new BQSQLException("ColumnIndex is not valid"); } - if (this.rowsofResult == null) throw new BQSQLException("Invalid position!"); + if (this.rowsofResult == null) { + throw new BQSQLException("Invalid position!"); + } + + // Since the results came from BigQuery's JSON API, + // the only types we'll ever see for resultObject are JSON-supported types, + // i.e. strings, numbers, arrays, booleans, or null. + // Many standard Java types, like timestamps, will be represented as strings. Object resultObject = this.rowsofResult.get(this.Cursor).getF().get(columnIndex - 1).getV(); + if (Data.isNull(resultObject)) { this.wasnull = true; return null; } this.wasnull = false; - if (!this.getBQResultsetMetaData().getColumnMode(columnIndex).equals("REPEATED") - && formatTimestamps - && getMetaData().getColumnTypeName(columnIndex).equals("TIMESTAMP")) { - Instant instant = - Instant.ofEpochMilli( - (new BigDecimal((String) resultObject).movePointRight(3)).longValue()); - return TIMESTAMP_FORMATTER.format(instant); - } if (resultObject instanceof List || resultObject instanceof Map) { Object resultTransformedWithSchema = smartTransformResult(resultObject, schema.getFields().get(columnIndex - 1)); @@ -913,11 +866,7 @@ public String getCursorName() throws SQLException { /** {@inheritDoc} */ @Override public Date getDate(int columnIndex) throws SQLException { - String value = this.getString(columnIndex); - if (this.wasNull()) { - return null; - } - return toDate(value, null); + return getDate(columnIndex, null); } /** {@inheritDoc} */ @@ -927,14 +876,13 @@ public Date getDate(int columnIndex, Calendar cal) throws SQLException { if (this.wasNull()) { return null; } - return toDate(value, cal); + return DateTimeUtils.parseDate(value, cal); } /** {@inheritDoc} */ @Override public Date getDate(String columnLabel) throws SQLException { - int columnIndex = this.findColumn(columnLabel); - return this.getDate(columnIndex); + return getDate(columnLabel, null); } /** {@inheritDoc} */ @@ -1336,11 +1284,7 @@ public String getString(String columnLabel) throws SQLException { /** {@inheritDoc} */ @Override public Time getTime(int columnIndex) throws SQLException { - String value = this.getString(columnIndex); - if (this.wasNull()) { - return null; - } - return toTime(value, null); + return getTime(columnIndex, null); } /** {@inheritDoc} */ @@ -1350,14 +1294,13 @@ public Time getTime(int columnIndex, Calendar cal) throws SQLException { if (this.wasNull()) { return null; } - return toTime(value, cal); + return DateTimeUtils.parseTime(value, cal); } /** {@inheritDoc} */ @Override public Time getTime(String columnLabel) throws SQLException { - int columnIndex = this.findColumn(columnLabel); - return this.getTime(columnIndex); + return getTime(columnLabel, null); } /** {@inheritDoc} */ @@ -1370,28 +1313,29 @@ public Time getTime(String columnLabel, Calendar cal) throws SQLException { /** {@inheritDoc} */ @Override public Timestamp getTimestamp(int columnIndex) throws SQLException { - String value = this.getString(columnIndex); - if (this.wasNull()) { - return null; - } - return toTimestamp(value, null); + return getTimestamp(columnIndex, null); } /** {@inheritDoc} */ @Override public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException { - String value = this.getString(columnIndex); + String value = rawString(columnIndex); if (this.wasNull()) { return null; } - return toTimestamp(value, cal); + // Both the TIMESTAMP and DATETIME objects support JDBC's getTimestamp method. + // DATETIME in BigQuery is analogous to TIMESTAMP in ISO SQL. + // TIMESTAMP in BigQuery is analogous to TIMESTAMP WITH LOCAL TIME ZONE in ISO SQL. + String columnType = this.schema.getFields().get(columnIndex - 1).getType(); + return "TIMESTAMP".equals(columnType) + ? DateTimeUtils.parseTimestamp(value) + : DateTimeUtils.parseDateTime(value, cal); } /** {@inheritDoc} */ @Override public Timestamp getTimestamp(String columnLabel) throws SQLException { - int columnIndex = this.findColumn(columnLabel); - return this.getTimestamp(columnIndex); + return getTimestamp(columnLabel, null); } /** {@inheritDoc} */ diff --git a/src/main/java/net/starschema/clouddb/jdbc/BQScrollableResultSet.java b/src/main/java/net/starschema/clouddb/jdbc/BQScrollableResultSet.java index 816d0b44..b938852c 100644 --- a/src/main/java/net/starschema/clouddb/jdbc/BQScrollableResultSet.java +++ b/src/main/java/net/starschema/clouddb/jdbc/BQScrollableResultSet.java @@ -22,8 +22,6 @@ */ package net.starschema.clouddb.jdbc; -import static net.starschema.clouddb.jdbc.BQForwardOnlyResultSet.toDate; - import com.google.api.client.util.Data; import com.google.api.services.bigquery.model.BiEngineReason; import com.google.api.services.bigquery.model.GetQueryResultsResponse; @@ -36,7 +34,6 @@ import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Statement; -import java.sql.Timestamp; import java.util.List; import javax.annotation.Nullable; @@ -204,21 +201,31 @@ public Object getObject(int columnIndex) throws SQLException { if (Columntype.equals("INTEGER")) { return Long.parseLong(result); } + if (Columntype.equals("DATETIME")) { + // A BigQuery DATETIME is essentially defined by its string representation; + // the "clock-calendar parameters" comprising year, month, day, hour, minute, etc. + // On the other hand, a [java.sql.Timestamp] object is defined as a global instant, + // similar to BQ TIMESTAMP. It has a [toString] method that interprets that instant in the + // system default time zone. Thus, in order to produce a [Timestamp] object whose + // [toString] method has the correct result, we must adjust the value of the instant + // according to the system default time zone (passing a null Calendar uses the system + // default). + return DateTimeUtils.parseDateTime(result, null); + } if (Columntype.equals("TIMESTAMP")) { - long val = new BigDecimal(result).longValue() * 1000; - return new Timestamp(val); + // A BigQuery TIMESTAMP is defined as a global instant in time, so when we create the + // [java.sql.Timestamp] object to represent it, we must not make any time zone adjustment. + return DateTimeUtils.parseTimestamp(result); } - if (Columntype.equals("DATETIME")) { - // Date time represents a "clock face" time and so should NOT be processed into an actual - // time - return result; + if (Columntype.equals("DATE")) { + return DateTimeUtils.parseDate(result, null); + } + if (Columntype.equals("TIME")) { + return DateTimeUtils.parseTime(result, null); } if (Columntype.equals("NUMERIC")) { return new BigDecimal(result); } - if (Columntype.equals("DATE")) { - return toDate(result, null); - } throw new BQSQLException("Unsupported Type"); } catch (NumberFormatException e) { throw new BQSQLException(e); diff --git a/src/main/java/net/starschema/clouddb/jdbc/DateTimeUtils.java b/src/main/java/net/starschema/clouddb/jdbc/DateTimeUtils.java new file mode 100644 index 00000000..e54a65d7 --- /dev/null +++ b/src/main/java/net/starschema/clouddb/jdbc/DateTimeUtils.java @@ -0,0 +1,156 @@ +package net.starschema.clouddb.jdbc; + +import java.math.BigDecimal; +import java.sql.Date; +import java.sql.SQLException; +import java.sql.Time; +import java.sql.Timestamp; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoField; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.Locale; +import java.util.TimeZone; + +class DateTimeUtils { + + private static final ZoneId UTC_ZONE = ZoneId.of("UTC"); + private static final Calendar DEFAULT_CALENDAR = new GregorianCalendar(TimeZone.getDefault()); + + /** Formatter used to parse DATETIME literals. */ + private static final DateTimeFormatter DATETIME_FORMATTER = + new DateTimeFormatterBuilder() + // The date part is always YYYY-MM-DD. + .appendValue(ChronoField.YEAR, 4) + .appendLiteral('-') + .appendValue(ChronoField.MONTH_OF_YEAR, 2) + .appendLiteral('-') + .appendValue(ChronoField.DAY_OF_MONTH, 2) + // Accept either a literal 'T' or a space to separate the date from the time. + // Make the space optional but pad with 'T' if it's omitted, so that parsing accepts both, + // but formatting defaults to using the space. + .padNext(1, 'T') + .optionalStart() + .appendLiteral(' ') + .optionalEnd() + // The whole-second time part is always HH:MM:SS. + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(':') + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .appendLiteral(':') + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + // BigQuery has optional microsecond precision. + .optionalStart() + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 6, true) + .optionalEnd() + .toFormatter(Locale.ROOT); + + /** Formatter used to parse TIME literals. */ + private static final DateTimeFormatter TIME_FORMATTER = + new DateTimeFormatterBuilder() + // The whole-second time part is always HH:MM:SS. + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(':') + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .appendLiteral(':') + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + // BigQuery only has microsecond precision, but this parser supports up to nanosecond + // precision after the decimal point. + .optionalStart() + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) + .optionalEnd() + .toFormatter(Locale.ROOT); + + private static final LocalDate TIME_EPOCH = LocalDate.of(1970, 1, 1); + + /** Parse a BigQuery DATETIME represented as a String. */ + static Timestamp parseDateTime(String value, Calendar cal) throws SQLException { + if (cal == null) { + cal = DEFAULT_CALENDAR; + } + try { + // BigQuery DATETIME has a string representation that looks like e.g. "2010-10-26 02:49:35". + LocalDateTime localDateTime = LocalDateTime.parse(value, DATETIME_FORMATTER); + // In order to represent it as a [java.sql.Timestamp], which is defined by an instant, + // we must subtract the offset from the calendar, so that the [toString] method of the + // resulting [Timestamp] has the correct result. Since time zones can have different offsets + // at different times of year (e.g. due to Daylight Saving), first calculate the instant + // assuming no offset, and use that to calculate the offset. + long utcInstant = localDateTime.toInstant(ZoneOffset.UTC).toEpochMilli(); + ZoneOffset offset = ZoneOffset.ofTotalSeconds(cal.getTimeZone().getOffset(utcInstant) / 1000); + Instant instant = localDateTime.atOffset(offset).toInstant(); + Timestamp timestamp = new Timestamp(instant.toEpochMilli()); + timestamp.setNanos(instant.getNano()); + return timestamp; + } catch (DateTimeParseException e) { + throw new BQSQLException(e); + } + } + + /** Parse a BigQuery TIMESTAMP literal, represented as the number of seconds since epoch. */ + static Timestamp parseTimestamp(String value) throws SQLException { + try { + // BigQuery TIMESTAMP has a string representation that looks like e.g. "1.288061375E9" + // for 2010-10-26 02:49:35 UTC. + // It is the (possibly fractional) number of seconds since the epoch. + BigDecimal secondsSinceEpoch = new BigDecimal(value); + // https://stackoverflow.com/questions/5839411/java-sql-timestamp-way-of-storing-nanoseconds + // In order to support sub-millisecond precision, we need to first initialize the timestamp + // with the correct number of whole seconds (expressed in milliseconds), + // then set the nanosecond value, which overrides the initial milliseconds. + long wholeSeconds = secondsSinceEpoch.longValue(); + Timestamp timestamp = new Timestamp(wholeSeconds * 1000L); + int nanoSeconds = secondsSinceEpoch.remainder(BigDecimal.ONE).movePointRight(9).intValue(); + timestamp.setNanos(nanoSeconds); + return timestamp; + } catch (NumberFormatException e) { + throw new BQSQLException(e); + } + } + + static String formatTimestamp(String rawString) throws SQLException { + Timestamp timestamp = parseTimestamp(rawString); + return DATETIME_FORMATTER.format(OffsetDateTime.ofInstant(timestamp.toInstant(), UTC_ZONE)) + + " UTC"; + } + + static Date parseDate(String value, Calendar cal) throws SQLException { + // Dates in BigQuery come back in the YYYY-MM-DD format + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + try { + java.util.Date date = sdf.parse(value); + return new java.sql.Date(date.getTime()); + } catch (java.text.ParseException e) { + throw new BQSQLException(e); + } + } + + static Time parseTime(String value, Calendar cal) throws SQLException { + if (cal == null) { + cal = DEFAULT_CALENDAR; + } + try { + // BigQuery DATETIME has a string representation that looks like e.g. "2010-10-26 02:49:35". + LocalTime localTime = LocalTime.parse(value, TIME_FORMATTER); + // In order to represent it as a [java.sql.Time], which is defined by an instant, + // we must subtract the offset from the calendar, so that the [toString] method of the + // resulting [Time] has the correct result. Since time values do not have date components, + // assume the standard offset should be used. + ZoneOffset offset = ZoneOffset.ofTotalSeconds(cal.getTimeZone().getRawOffset() / 1000); + Instant instant = localTime.atDate(TIME_EPOCH).atOffset(offset).toInstant(); + return new Time(instant.toEpochMilli()); + } catch (DateTimeParseException e) { + throw new BQSQLException(e); + } + } +} diff --git a/src/test/java/net/starschema/clouddb/jdbc/BQForwardOnlyResultSetFunctionTest.java b/src/test/java/net/starschema/clouddb/jdbc/BQForwardOnlyResultSetFunctionTest.java index f51be8eb..41195c81 100644 --- a/src/test/java/net/starschema/clouddb/jdbc/BQForwardOnlyResultSetFunctionTest.java +++ b/src/test/java/net/starschema/clouddb/jdbc/BQForwardOnlyResultSetFunctionTest.java @@ -29,12 +29,16 @@ import java.io.IOException; import java.math.BigDecimal; import java.sql.*; +import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.util.Calendar; import java.util.Date; +import java.util.GregorianCalendar; import java.util.HashMap; import java.util.Map; import java.util.Properties; +import java.util.TimeZone; import junit.framework.Assert; import org.junit.Before; import org.junit.Test; @@ -432,7 +436,7 @@ public void testResultSetTypesInGetString() throws SQLException { + "['a', 'b', 'c'], " + "[STRUCT(1 as a, 'hello' as b), STRUCT(2 as a, 'goodbye' as b)], " + "STRUCT(1 as a, ['an', 'array'] as b)," - + "TIMESTAMP('2012-01-01 00:00:03.032') as t"; + + "TIMESTAMP('2012-01-01 00:00:03.0000') as t"; this.NewConnection(false); java.sql.ResultSet result = null; @@ -486,15 +490,79 @@ public void testResultSetTypesInGetString() throws SQLException { new Gson().toJson(new String[] {"an", "array"}), new Gson().toJson(mixedBagActual.get("b"))); - Assert.assertEquals("2012-01-01 00:00:03.032", result.getString(5)); + Assert.assertEquals("2012-01-01 00:00:03 UTC", result.getString(5)); + } + + @Test + public void testResultSetDateTimeType() throws SQLException, ParseException { + final String sql = "SELECT DATETIME('2012-01-01 01:02:03.04567')"; + final Calendar istCalendar = new GregorianCalendar(TimeZone.getTimeZone("Asia/Kolkata")); + final DateFormat utcDateFormatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + utcDateFormatter.setTimeZone(TimeZone.getTimeZone("UTC")); + + this.NewConnection(false); + Statement stmt = + BQForwardOnlyResultSetFunctionTest.con.createStatement( + ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY); + stmt.setQueryTimeout(500); + ResultSet result = stmt.executeQuery(sql); + Assert.assertTrue(result.next()); + + Timestamp resultTimestamp = result.getTimestamp(1); + Object resultObject = result.getObject(1); + String resultString = result.getString(1); + + // getObject() and getTimestamp() should behave the same for DATETIME. + Assert.assertEquals(resultTimestamp, resultObject); + + // getTimestamp().toString() should be equivalent to getString(), with full microsecond support. + Assert.assertEquals("2012-01-01 01:02:03.04567", resultTimestamp.toString()); + Assert.assertEquals("2012-01-01T01:02:03.045670", resultString); + + // If a different calendar is used, the string representation should be adjusted. + Timestamp adjustedTimestamp = result.getTimestamp(1, istCalendar); + // Render it from the perspective of UTC. + // Since it was created for IST, it should be adjusted by -5:30. + String adjustedString = utcDateFormatter.format(adjustedTimestamp); + Assert.assertEquals("2011-12-31 19:32:03.045", adjustedString); + // SimpleDateFormat does not support microseconds, + // but they should be correct on the adjusted timestamp. + Assert.assertEquals(45670000, adjustedTimestamp.getNanos()); + } + + @Test + public void testResultSetTimestampType() throws SQLException, ParseException { + final String sql = "SELECT TIMESTAMP('2012-01-01 01:02:03.04567')"; + + this.NewConnection(false); + Statement stmt = + BQForwardOnlyResultSetFunctionTest.con.createStatement( + ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY); + stmt.setQueryTimeout(500); + ResultSet result = stmt.executeQuery(sql); + Assert.assertTrue(result.next()); + + Timestamp resultTimestamp = result.getTimestamp(1); + Object resultObject = result.getObject(1); + String resultString = result.getString(1); + + // getObject() and getTimestamp() should behave the same for TIMESTAMP. + Assert.assertEquals(resultTimestamp, resultObject); + + // getString() should be the string representation in UTC+0. + Assert.assertEquals("2012-01-01 01:02:03.04567 UTC", resultString); + + // getTimestamp() should have the right number of milliseconds elapsed since epoch. + // 1325379723045 milliseconds after epoch, the time is 2012-01-01 01:02:03.045 in UTC+0. + Assert.assertEquals(1325379723045L, resultTimestamp.getTime()); + // The microseconds should also be correct, but nanoseconds are not supported by BigQuery. + Assert.assertEquals(45670000, resultTimestamp.getNanos()); } @Test public void testResultSetTypesInGetObject() throws SQLException, ParseException { final String sql = "SELECT " - + "DATETIME('2012-01-01 00:00:02'), " - + "TIMESTAMP('2012-01-01 00:00:03'), " + "CAST('2312412432423423334.234234234' AS NUMERIC), " + "CAST('2011-04-03' AS DATE), " + "CAST('nan' AS FLOAT)"; @@ -513,19 +581,14 @@ public void testResultSetTypesInGetObject() throws SQLException, ParseException } Assert.assertNotNull(result); Assert.assertTrue(result.next()); - Assert.assertEquals("2012-01-01T00:00:02", result.getObject(1)); - SimpleDateFormat timestampDateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss z"); - Date parsedDate = timestampDateFormat.parse("2012-01-01 00:00:03 UTC"); - Timestamp timestamp = new java.sql.Timestamp(parsedDate.getTime()); - Assert.assertEquals(timestamp, result.getObject(2)); + Assert.assertEquals(new BigDecimal("2312412432423423334.234234234"), result.getObject(1)); - Assert.assertEquals(new BigDecimal("2312412432423423334.234234234"), result.getObject(3)); SimpleDateFormat dateDateFormat = new SimpleDateFormat("yyyy-MM-dd"); Date parsedDateDate = new java.sql.Date(dateDateFormat.parse("2011-04-03").getTime()); - Assert.assertEquals(parsedDateDate, result.getObject(4)); + Assert.assertEquals(parsedDateDate, result.getObject(2)); - Assert.assertEquals(Double.NaN, result.getObject(5)); + Assert.assertEquals(Double.NaN, result.getObject(3)); } @Test @@ -565,7 +628,7 @@ public void testResultSetArraysInGetObject() throws SQLException, ParseException @Test public void testResultSetTimeType() throws SQLException, ParseException { - final String sql = "select current_time(), CAST('00:00:02.123455' AS TIME)"; + final String sql = "select current_time(), CAST('00:00:02.12345' AS TIME)"; this.NewConnection(false); java.sql.ResultSet result = null; try { @@ -585,14 +648,28 @@ public void testResultSetTimeType() throws SQLException, ParseException { Object resultObject = result.getObject(1); String resultString = result.getString(1); - // getObject() and getString() should behave the same for TIME - Assert.assertEquals(resultString, (String) resultObject); + // getObject() and getTime() should behave the same for TIME. + Assert.assertEquals(resultTime, resultObject); - // getTime() will return a 'time' without milliseconds + // getTime().toString() should be equivalent to getString() without milliseconds. Assert.assertTrue(resultString.startsWith(resultTime.toString())); - // also check that explicit casts to TIME work as expected - Assert.assertEquals(result.getTime(2).toString(), "00:00:02"); + // getTime() should have milliseconds, though. They're just not included in toString(). + // Get whole milliseconds (modulo whole seconds) from resultTime. + long timeMillis = resultTime.getTime() % 1000; + // Get whole milliseconds from resultString. + int decimalPlace = resultString.lastIndexOf('.'); + long stringMillis = Long.parseLong(resultString.substring(decimalPlace + 1, decimalPlace + 4)); + Assert.assertEquals(timeMillis, stringMillis); + + // Check that explicit casts to TIME work as expected. + Time fixedTime = result.getTime(2); + Assert.assertEquals("00:00:02", fixedTime.toString()); + // The object should have milliseconds even though they're hidden by toString(). + // AFAICT [java.sql.Time] does not support microseconds. + Assert.assertEquals(123, fixedTime.getTime() % 1000); + // getString() should show microseconds. + Assert.assertEquals("00:00:02.123450", result.getString(2)); } @Test