From 9e8c42d29df904d5ccb399b58f60f4adc330a908 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:43:57 +0100 Subject: [PATCH 1/3] feat(jdbc): read timezone-aware temporal columns Map TIME_WITH_TIMEZONE to OffsetTime and TIMESTAMP_WITH_TIMEZONE to OffsetDateTime, parse ISO-8601 offset text, and reject unlisted getters on the new declared types. --- .../magnusp/libsql/jdbc/JdbcTypeMapping.java | 23 ++++- .../magnusp/libsql/jdbc/LibsqlResultSet.java | 92 ++++++++++++++++++- 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java index 1244c07..214369c 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java @@ -14,6 +14,8 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; +import java.time.OffsetDateTime; +import java.time.OffsetTime; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -26,10 +28,11 @@ * falls back to the storage class of the value and marks the result {@link Support#FALLBACK} so a * caller can record the exception. * - *

The registry holds the 27 canonical JDBC 4.3 Appendix B type names. A separate alias table - * maps the SQLite spellings that Appendix B does not name, such as {@code INT8} and {@code - * DATETIME}, to the registry entry they stand for, preserving the sqlite-jdbc metadata that - * existing users see. Both tables resolve through the declared type, never through the value. + *

The registry holds the canonical JDBC 4.3 Appendix B type names that have a declared-type + * entry. A separate alias table maps the SQLite spellings that Appendix B does not name, such as + * {@code INT8} and {@code DATETIME}, to the registry entry they stand for, preserving the + * sqlite-jdbc metadata that existing users see. Both tables resolve through the declared type, + * never through the value. */ final class JdbcTypeMapping { @@ -159,6 +162,18 @@ private static Map declaredTypeRegistry() { entry("DATE", Types.DATE, Date.class, Date.class, StorageClass.TEXT), entry("TIME", Types.TIME, Time.class, Time.class, StorageClass.TEXT), entry("TIMESTAMP", Types.TIMESTAMP, Timestamp.class, Timestamp.class, StorageClass.TEXT), + entry( + "TIME_WITH_TIMEZONE", + Types.TIME_WITH_TIMEZONE, + OffsetTime.class, + OffsetTime.class, + StorageClass.TEXT), + entry( + "TIMESTAMP_WITH_TIMEZONE", + Types.TIMESTAMP_WITH_TIMEZONE, + OffsetDateTime.class, + OffsetDateTime.class, + StorageClass.TEXT), entry("CLOB", Types.CLOB, Clob.class, Clob.class, StorageClass.TEXT), entry("NCLOB", Types.NCLOB, NClob.class, NClob.class, StorageClass.TEXT), entry("SQLXML", Types.SQLXML, SQLXML.class, SQLXML.class, StorageClass.TEXT), diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java index cb9b721..ee0c239 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java @@ -32,6 +32,9 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.format.DateTimeParseException; import java.util.Calendar; import java.util.HashMap; import java.util.List; @@ -117,6 +120,23 @@ private Value getCurrentValue(int columnIndex) throws SQLException { return valueAtCursor(columnIndex); } + /** + * Rejects a declared offset column through a getter that Table B-6 does not list for it. The + * offset types are new in this release and expose only {@code getObject}; the rest of the + * strict Table B-6 matrix lands with the unsupported-type policy. + */ + private void rejectUnlistedOffsetGetter(int columnIndex) throws SQLException { + checkClosed(); + if (columnIndex < 1 || columnIndex > declaredTypes.length) { + return; + } + JdbcTypeMapping.JdbcType type = declaredTypes[columnIndex - 1]; + if (type != null && (type.objectClass() == OffsetTime.class || type.objectClass() == OffsetDateTime.class)) { + throw new SQLFeatureNotSupportedException( + "Declared " + JdbcTypeMapping.sqlTypeName(type.sqlType()) + " columns support only getObject"); + } + } + Value valueForMetadata(int columnIndex) { int rowIndex = currentRowIndex < 0 ? 0 : currentRowIndex; if (rowIndex >= rows.size()) { @@ -190,6 +210,7 @@ public boolean wasNull() throws SQLException { */ @Override public String getString(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return stringValue(getCurrentValue(columnIndex)); } @@ -212,6 +233,7 @@ private static String stringValue(Value val) { */ @Override public boolean getBoolean(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return booleanValue(getCurrentValue(columnIndex)); } @@ -240,21 +262,25 @@ public short getShort(int columnIndex) throws SQLException { @Override public int getInt(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return (int) toLong(getCurrentValue(columnIndex)); } @Override public long getLong(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return toLong(getCurrentValue(columnIndex)); } @Override public float getFloat(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return (float) toDouble(getCurrentValue(columnIndex)); } @Override public double getDouble(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return toDouble(getCurrentValue(columnIndex)); } @@ -289,6 +315,7 @@ public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException @Override public byte[] getBytes(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return bytesValue(getCurrentValue(columnIndex)); } @@ -304,6 +331,7 @@ private static byte[] bytesValue(Value val) { @Override public Date getDate(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return dateValue(getCurrentValue(columnIndex)); } @@ -314,6 +342,7 @@ private static Date dateValue(Value value) throws SQLException { @Override public Time getTime(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return timeValue(getCurrentValue(columnIndex)); } @@ -324,6 +353,7 @@ private static Time timeValue(Value value) throws SQLException { @Override public Timestamp getTimestamp(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return timestampValue(getCurrentValue(columnIndex)); } @@ -332,8 +362,44 @@ private static Timestamp timestampValue(Value value) throws SQLException { return dateTime == null ? null : Timestamp.valueOf(dateTime); } + private static OffsetTime offsetTimeValue(Value value) throws SQLException { + String text = stringValue(value); + if (text == null) { + return null; + } + try { + return OffsetTime.parse(text); + } catch (DateTimeParseException e) { + throw new SQLException("Bad value for type OffsetTime: " + text, e); + } + } + + private static OffsetDateTime offsetDateTimeValue(Value value) throws SQLException { + String text = stringValue(value); + if (text == null) { + return null; + } + try { + return OffsetDateTime.parse(sqlTimestampSeparator(text)); + } catch (DateTimeParseException e) { + throw new SQLException("Bad value for type OffsetDateTime: " + text, e); + } + } + + /** + * Accepts the SQLite text form with a space between the date and the time as well as the + * ISO-8601 form with {@code T}. {@link OffsetDateTime#parse(CharSequence)} accepts only the + * latter, and SQLite columns are commonly written with the former. + */ + private static String sqlTimestampSeparator(String text) { + return text.length() > 10 && text.charAt(10) == ' ' + ? text.substring(0, 10) + 'T' + text.substring(11) + : text; + } + @Override public BigDecimal getBigDecimal(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return bigDecimalValue(getCurrentValue(columnIndex)); } @@ -423,6 +489,12 @@ private static Object convertedObject(Value value, JdbcTypeMapping.JdbcType type if (objectClass == Timestamp.class) { return timestampValue(value); } + if (objectClass == OffsetTime.class) { + return offsetTimeValue(value); + } + if (objectClass == OffsetDateTime.class) { + return offsetDateTimeValue(value); + } if (objectClass == Clob.class) { return clobValue(value); } @@ -738,6 +810,7 @@ public void setFetchSize(int rows) throws SQLException { } @Override public Ref getRef(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public Blob getBlob(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return blobValue(getCurrentValue(columnIndex)); } private static Blob blobValue(Value value) { @@ -745,6 +818,7 @@ private static Blob blobValue(Value value) { return bytes == null ? null : new LibsqlBlob(bytes); } @Override public Clob getClob(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return clobValue(getCurrentValue(columnIndex)); } private static Clob clobValue(Value value) { @@ -768,6 +842,7 @@ private static Clob clobValue(Value value) { @Override public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLException { return getTimestamp(columnLabel); } @Override public URL getURL(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return urlValue(getCurrentValue(columnIndex)); } @@ -794,6 +869,7 @@ private static URL urlValue(Value value) throws SQLException { @Override public void updateArray(int columnIndex, Array x) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public void updateArray(String columnLabel, Array x) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public RowId getRowId(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return rowIdValue(getCurrentValue(columnIndex)); } private static RowId rowIdValue(Value value) { @@ -809,6 +885,7 @@ private static RowId rowIdValue(Value value) { @Override public void updateNClob(int columnIndex, NClob nClob) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public void updateNClob(String columnLabel, NClob nClob) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public NClob getNClob(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return nclobValue(getCurrentValue(columnIndex)); } private static NClob nclobValue(Value value) { @@ -817,6 +894,7 @@ private static NClob nclobValue(Value value) { } @Override public NClob getNClob(String columnLabel) throws SQLException { return getNClob(findColumn(columnLabel)); } @Override public SQLXML getSQLXML(int columnIndex) throws SQLException { + rejectUnlistedOffsetGetter(columnIndex); return sqlxmlValue(getCurrentValue(columnIndex)); } private static SQLXML sqlxmlValue(Value value) { @@ -902,7 +980,7 @@ private static SQLXML sqlxmlValue(Value value) { * * * {@code Date}, {@code Time}, {@code Timestamp}, {@code LocalDate}, {@code LocalTime}, - * {@code LocalDateTime} + * {@code LocalDateTime}, {@code OffsetTime}, {@code OffsetDateTime} * epoch millisJulian daydate/time textbad value * {@code null} * @@ -916,8 +994,10 @@ private static SQLXML sqlxmlValue(Value value) { * Julian days, and TEXT values use the SQLite date and time formats, including a bare date and * a bare time. A bare time uses SQLite's 2000-01-01 default date. The SQLite TEXT format is * {@code yyyy-MM-dd HH:mm:ss.SSS}, with optional fractional digits and an optional time zone - * suffix. All six date and time targets are built from the same storage-class value, so - * {@code getObject(column, LocalDate.class)} and {@code getDate(column)} agree. + * suffix. {@code OffsetTime} and {@code OffsetDateTime} read ISO-8601 offset text, accepting + * either a {@code T} or a space between the date and the time. All eight date and time targets + * are built from the same storage-class value, so {@code getObject(column, LocalDate.class)} + * and {@code getDate(column)} agree. * *

{@code Short} and {@code Byte} are rejected rather than narrowed from the numeric * targets, preserving the sqlite-jdbc 3.53.4.0 contract. @@ -962,6 +1042,12 @@ public T getObject(int columnIndex, Class type) throws SQLException { if (type == LocalDateTime.class) { return type.cast(getLocalDateTime(columnIndex)); } + if (type == OffsetTime.class) { + return type.cast(offsetTimeValue(getCurrentValue(columnIndex))); + } + if (type == OffsetDateTime.class) { + return type.cast(offsetDateTimeValue(getCurrentValue(columnIndex))); + } Value stored = getCurrentValue(columnIndex); boolean numeric = stored instanceof Value.Integer || stored instanceof Value.Float; From cf6a031be3c0319a823317d2a8908aec9e728418 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:44:00 +0100 Subject: [PATCH 2/3] test(jdbc): cover offset parsing and declared offset reads Assert Z and +01:00 parse to OffsetTime and OffsetDateTime, malformed offset text raises SQLException, and declared offset columns return the offset classes. --- .../libsql/jdbc/Jdbc4AppendixBReadTest.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java index a6e8feb..8c3208e 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java @@ -29,6 +29,9 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -568,6 +571,51 @@ void b3RowIdComparesAndCopiesItsBytes() { .isNotEqualTo(new LibsqlRowId(new byte[] {1, 3})); } + // --------------------------------------------------------------------- + // PR-4 offset types: TIME_WITH_TIMEZONE and TIMESTAMP_WITH_TIMEZONE. + // --------------------------------------------------------------------- + + @Test + void b6DeclaredOffsetTypesReturnOffsetJavaTypes() throws SQLException { + LibsqlResultSet timeRow = oneRow(jdbcType("TIME_WITH_TIMEZONE"), Value.of("03:04:05+01:00")); + assertThat(timeRow.next()).isTrue(); + assertThat(timeRow.getObject(1)) + .isEqualTo(OffsetTime.of(3, 4, 5, 0, ZoneOffset.ofHours(1))); + assertThat(timeRow.getMetaData().getColumnType(1)).isEqualTo(Types.TIME_WITH_TIMEZONE); + assertThat(timeRow.getMetaData().getColumnClassName(1)) + .isEqualTo(OffsetTime.class.getName()); + + LibsqlResultSet timestampRow = oneRow( + jdbcType("TIMESTAMP_WITH_TIMEZONE"), Value.of("2024-01-02 03:04:05+01:00")); + assertThat(timestampRow.next()).isTrue(); + assertThat(timestampRow.getObject(1)) + .isEqualTo(OffsetDateTime.of(2024, 1, 2, 3, 4, 5, 0, ZoneOffset.ofHours(1))); + assertThat(timestampRow.getMetaData().getColumnType(1)) + .isEqualTo(Types.TIMESTAMP_WITH_TIMEZONE); + assertThat(timestampRow.getMetaData().getColumnClassName(1)) + .isEqualTo(OffsetDateTime.class.getName()); + } + + @Test + void offsetTextParsesZuluAndPlusOneAndRejectsMalformedText() throws SQLException { + LibsqlResultSet timeRow = oneRow(jdbcType("TIME_WITH_TIMEZONE"), Value.of("03:04:05Z")); + assertThat(timeRow.next()).isTrue(); + assertThat(timeRow.getObject(1, OffsetTime.class)) + .isEqualTo(OffsetTime.of(3, 4, 5, 0, ZoneOffset.UTC)); + + LibsqlResultSet timestampRow = oneRow( + jdbcType("TIMESTAMP_WITH_TIMEZONE"), Value.of("2024-01-02T03:04:05+01:00")); + assertThat(timestampRow.next()).isTrue(); + assertThat(timestampRow.getObject(1, OffsetDateTime.class)) + .isEqualTo(OffsetDateTime.of(2024, 1, 2, 3, 4, 5, 0, ZoneOffset.ofHours(1))); + + LibsqlResultSet malformed = oneRow(jdbcType("TIME_WITH_TIMEZONE"), Value.of("not-a-time")); + assertThat(malformed.next()).isTrue(); + assertThatThrownBy(() -> malformed.getObject(1, OffsetTime.class)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("OffsetTime"); + } + // --------------------------------------------------------------------- // Fixtures and helpers. // --------------------------------------------------------------------- From 3d11c0cb71f890733b0709f75a2fa6ef71aa9272 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:44:05 +0100 Subject: [PATCH 3/3] test(jdbc): verify declared offset reads against sqld Add the missing B-6 getObject cells for TIME_WITH_TIMEZONE and TIMESTAMP_WITH_TIMEZONE to the server suite. --- .../magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java index d3d7223..ba0e29c 100644 --- a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java +++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java @@ -264,6 +264,10 @@ record GetterCell( new GetterCell("getURL", "DATALINK", TEXT_URL, ResultSet::getURL, URL.class), new GetterCell("getObject", "JAVA_OBJECT", TEXT_ABC, (rs, i) -> rs.getObject(i), Object.class), + new GetterCell("getObject", "TIME_WITH_TIMEZONE", "03:04:05Z", + (rs, i) -> rs.getObject(i), OffsetTime.class), + new GetterCell("getObject", "TIMESTAMP_WITH_TIMEZONE", "2024-01-02T03:04:05Z", + (rs, i) -> rs.getObject(i), OffsetDateTime.class), new GetterCell("getRowId", "ROWID", TEXT_ROWID, ResultSet::getRowId, RowId.class), new GetterCell("getSQLXML", "SQLXML", TEXT_XML, ResultSet::getSQLXML, SQLXML.class));