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 millis | Julian day | date/time text | bad 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));