From bb72ef6c06d65aa836837da32b82009678ed1edd Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Sun, 25 Oct 2026 03:58:00 +0100
Subject: [PATCH 1/4] feat(jdbc): record n/a types and table b-6 getter support
---
.../magnusp/libsql/jdbc/JdbcTypeMapping.java | 205 +++++++++++++++++-
1 file changed, 201 insertions(+), 4 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 214369c..798e127 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
@@ -5,20 +5,27 @@
import java.math.BigDecimal;
import java.net.URL;
+import java.sql.Array;
import java.sql.Blob;
import java.sql.Clob;
import java.sql.Date;
import java.sql.NClob;
+import java.sql.Ref;
import java.sql.RowId;
import java.sql.SQLXML;
+import java.sql.Struct;
import java.sql.Time;
import java.sql.Timestamp;
import java.sql.Types;
import java.time.OffsetDateTime;
import java.time.OffsetTime;
+import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
/**
* Resolves the JDBC type of a result-set column from its declared type.
@@ -33,6 +40,12 @@
* {@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.
+ *
+ *
{@code ARRAY}, {@code STRUCT}, {@code REF}, {@code JAVA_OBJECT}, and {@code DISTINCT} are
+ * recorded as {@link Support#UNSUPPORTED}. SQLite has no interoperable representation for them, so
+ * the driver rejects the declared type instead of returning a value of the wrong Java type. The
+ * Table B-6 getter matrix is stored on each supported entry as a bitmask, which keeps a declared
+ * column's getter check to one field read and one mask test.
*/
final class JdbcTypeMapping {
@@ -50,7 +63,58 @@ enum StorageClass {
NULL
}
- record JdbcType(int sqlType, Class> javaClass, Class> objectClass, StorageClass storage, Support support) {}
+ /** One ResultSet getter, named for Table B-6 and for an error message. */
+ enum Getter {
+ GET_BYTE("getByte"),
+ GET_SHORT("getShort"),
+ GET_INT("getInt"),
+ GET_LONG("getLong"),
+ GET_FLOAT("getFloat"),
+ GET_DOUBLE("getDouble"),
+ GET_BIG_DECIMAL("getBigDecimal"),
+ GET_BOOLEAN("getBoolean"),
+ GET_STRING("getString"),
+ GET_N_STRING("getNString"),
+ GET_BYTES("getBytes"),
+ GET_DATE("getDate"),
+ GET_TIME("getTime"),
+ GET_TIMESTAMP("getTimestamp"),
+ GET_ASCII_STREAM("getAsciiStream"),
+ GET_BINARY_STREAM("getBinaryStream"),
+ GET_CHARACTER_STREAM("getCharacterStream"),
+ GET_N_CHARACTER_STREAM("getNCharacterStream"),
+ GET_CLOB("getClob"),
+ GET_N_CLOB("getNClob"),
+ GET_BLOB("getBlob"),
+ GET_ARRAY("getArray"),
+ GET_REF("getRef"),
+ GET_URL("getURL"),
+ GET_OBJECT("getObject"),
+ GET_ROW_ID("getRowId"),
+ GET_SQLXML("getSQLXML");
+
+ private final String methodName;
+
+ Getter(String methodName) {
+ this.methodName = methodName;
+ }
+
+ String methodName() {
+ return methodName;
+ }
+
+ int mask() {
+ return 1 << ordinal();
+ }
+ }
+
+ record JdbcType(
+ int sqlType,
+ Class> javaClass,
+ Class> objectClass,
+ StorageClass storage,
+ Support support,
+ int getterMask) {}
private static final JdbcType INTEGER_FALLBACK =
fallback(Types.INTEGER, int.class, Integer.class, StorageClass.INTEGER);
@@ -65,6 +129,7 @@ record JdbcType(int sqlType, Class> javaClass, Class> objectClass, StorageCl
private static final JdbcType NULL_FALLBACK =
fallback(Types.NUMERIC, Object.class, Object.class, StorageClass.NULL);
+ private static final Map> GETTER_SUPPORT = getterSupport();
private static final Map DECLARED = declaredTypeRegistry();
private static final Map ALIASES = aliasTypes();
@@ -98,6 +163,34 @@ private static JdbcType declared(Col col) {
return type != null ? type : ALIASES.get(name);
}
+ static boolean isUnsupported(JdbcType type) {
+ return type.support() == Support.UNSUPPORTED;
+ }
+
+ static boolean supports(JdbcType type, Getter getter) {
+ return (type.getterMask() & getter.mask()) != 0;
+ }
+
+ /** The JDBC type names this driver reads, for the compliance profile. */
+ static SortedSet supportedTypeNames() {
+ return typeNames(Support.SUPPORTED);
+ }
+
+ /** The JDBC type names this driver records as N/A, for the compliance profile. */
+ static SortedSet unsupportedTypeNames() {
+ return typeNames(Support.UNSUPPORTED);
+ }
+
+ private static SortedSet typeNames(Support support) {
+ SortedSet names = new TreeSet<>();
+ DECLARED.forEach((name, type) -> {
+ if (type.support() == support) {
+ names.add(name);
+ }
+ });
+ return java.util.Collections.unmodifiableSortedSet(names);
+ }
+
static JdbcType fallback(Value value) {
return switch (storageClass(value)) {
case INTEGER -> integerFallback(value);
@@ -134,7 +227,91 @@ static long integer(Value value) {
}
private static JdbcType fallback(int sqlType, Class> javaClass, Class> objectClass, StorageClass storage) {
- return new JdbcType(sqlType, javaClass, objectClass, storage, Support.FALLBACK);
+ return new JdbcType(sqlType, javaClass, objectClass, storage, Support.FALLBACK, 0);
+ }
+
+ /**
+ * The Table B-6 supported JDBC types for each getter. A declared column resolves through this
+ * table; an expression column has no declared type and keeps the permissive storage fallback.
+ */
+ private static Map> getterSupport() {
+ Set numeric = Set.of(
+ "TINYINT",
+ "SMALLINT",
+ "INTEGER",
+ "BIGINT",
+ "REAL",
+ "FLOAT",
+ "DOUBLE",
+ "DECIMAL",
+ "NUMERIC",
+ "BIT",
+ "BOOLEAN",
+ "CHAR",
+ "VARCHAR",
+ "LONGVARCHAR");
+ Set numericWithRowId = union(numeric, "ROWID");
+ Set strings = union(numeric, "BINARY", "VARBINARY", "LONGVARBINARY", "DATE", "TIME",
+ "TIMESTAMP", "DATALINK", "NCHAR", "NVARCHAR", "LONGNVARCHAR");
+ Set binary = Set.of("BINARY", "VARBINARY", "LONGVARBINARY");
+ Set date = Set.of("CHAR", "VARCHAR", "LONGVARCHAR", "DATE", "TIMESTAMP");
+ Set time = Set.of("CHAR", "VARCHAR", "LONGVARCHAR", "TIME", "TIMESTAMP");
+ Set timestamp = Set.of("CHAR", "VARCHAR", "LONGVARCHAR", "DATE", "TIME", "TIMESTAMP");
+ Set asciiStream = union(binary, "CHAR", "VARCHAR", "LONGVARCHAR", "CLOB", "NCLOB");
+ Set characterStream = union(
+ binary, "CHAR", "VARCHAR", "LONGVARCHAR", "CLOB", "NCHAR", "NVARCHAR", "LONGNVARCHAR",
+ "NCLOB", "SQLXML");
+ Set clob = Set.of("CLOB", "NCLOB");
+ Set all = union(
+ strings, "CLOB", "BLOB", "ARRAY", "STRUCT", "REF", "DATALINK", "JAVA_OBJECT",
+ "ROWID", "NCLOB", "SQLXML", "TIME_WITH_TIMEZONE", "TIMESTAMP_WITH_TIMEZONE");
+
+ Map> support = new EnumMap<>(Getter.class);
+ support.put(Getter.GET_BYTE, numericWithRowId);
+ support.put(Getter.GET_SHORT, numeric);
+ support.put(Getter.GET_INT, numeric);
+ support.put(Getter.GET_LONG, numeric);
+ support.put(Getter.GET_FLOAT, numeric);
+ support.put(Getter.GET_DOUBLE, numeric);
+ support.put(Getter.GET_BIG_DECIMAL, numeric);
+ support.put(Getter.GET_BOOLEAN, numeric);
+ support.put(Getter.GET_STRING, strings);
+ support.put(Getter.GET_N_STRING, strings);
+ support.put(Getter.GET_BYTES, binary);
+ support.put(Getter.GET_DATE, date);
+ support.put(Getter.GET_TIME, time);
+ support.put(Getter.GET_TIMESTAMP, timestamp);
+ support.put(Getter.GET_ASCII_STREAM, asciiStream);
+ support.put(Getter.GET_BINARY_STREAM, binary);
+ support.put(Getter.GET_CHARACTER_STREAM, characterStream);
+ support.put(Getter.GET_N_CHARACTER_STREAM, characterStream);
+ support.put(Getter.GET_CLOB, clob);
+ support.put(Getter.GET_N_CLOB, clob);
+ support.put(Getter.GET_BLOB, Set.of("BLOB"));
+ support.put(Getter.GET_ARRAY, Set.of("ARRAY"));
+ support.put(Getter.GET_REF, Set.of("REF"));
+ support.put(Getter.GET_URL, Set.of("DATALINK"));
+ // B-6 omits DISTINCT from getObject; the other four N/A types are rejected before the mask.
+ support.put(Getter.GET_OBJECT, all);
+ support.put(Getter.GET_ROW_ID, Set.of("ROWID"));
+ support.put(Getter.GET_SQLXML, Set.of("SQLXML"));
+ return Map.copyOf(support);
+ }
+
+ private static Set union(Set base, String... extra) {
+ Set result = new java.util.HashSet<>(base);
+ result.addAll(List.of(extra));
+ return Set.copyOf(result);
+ }
+
+ private static int getterMask(String typeName) {
+ int mask = 0;
+ for (Getter getter : Getter.values()) {
+ if (GETTER_SUPPORT.get(getter).contains(typeName)) {
+ mask |= getter.mask();
+ }
+ }
+ return mask;
}
private static Map declaredTypeRegistry() {
@@ -179,12 +356,32 @@ private static Map declaredTypeRegistry() {
entry("SQLXML", Types.SQLXML, SQLXML.class, SQLXML.class, StorageClass.TEXT),
entry("ROWID", Types.ROWID, RowId.class, RowId.class, StorageClass.TEXT),
entry("BLOB", Types.BLOB, Blob.class, Blob.class, StorageClass.BLOB),
- entry("DATALINK", Types.DATALINK, URL.class, URL.class, StorageClass.TEXT));
+ entry("DATALINK", Types.DATALINK, URL.class, URL.class, StorageClass.TEXT),
+ unsupported("ARRAY", Types.ARRAY, Array.class, Array.class, StorageClass.TEXT),
+ unsupported("STRUCT", Types.STRUCT, Struct.class, Struct.class, StorageClass.TEXT),
+ unsupported("REF", Types.REF, Ref.class, Ref.class, StorageClass.TEXT),
+ unsupported("JAVA_OBJECT", Types.JAVA_OBJECT, Object.class, Object.class, StorageClass.TEXT),
+ unsupported("DISTINCT", Types.DISTINCT, Object.class, Object.class, StorageClass.TEXT));
}
private static Map.Entry entry(
String name, int sqlType, Class> javaClass, Class> objectClass, StorageClass storage) {
- return Map.entry(name, new JdbcType(sqlType, javaClass, objectClass, storage, Support.SUPPORTED));
+ return Map.entry(
+ name,
+ new JdbcType(
+ sqlType,
+ javaClass,
+ objectClass,
+ storage,
+ Support.SUPPORTED,
+ getterMask(name)));
+ }
+
+ private static Map.Entry unsupported(
+ String name, int sqlType, Class> javaClass, Class> objectClass, StorageClass storage) {
+ return Map.entry(
+ name,
+ new JdbcType(sqlType, javaClass, objectClass, storage, Support.UNSUPPORTED, 0));
}
/**
From ec28549231fee7b33c670aa34c66054cde390384 Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Sun, 25 Oct 2026 03:58:00 +0100
Subject: [PATCH 2/4] feat(jdbc): reject unsupported reads and unlisted getter
conversions
---
.../magnusp/libsql/jdbc/LibsqlResultSet.java | 144 +++++++++++++-----
.../libsql/jdbc/Jdbc4AppendixBReadTest.java | 39 +++++
2 files changed, 146 insertions(+), 37 deletions(-)
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 ee0c239..0197a48 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
@@ -121,19 +121,27 @@ private Value getCurrentValue(int columnIndex) throws SQLException {
}
/**
- * 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.
+ * Enforces the Table B-6 matrix for a declared column. A column with no {@code decltype} keeps
+ * the permissive storage fallback. An N/A declared type is rejected through every getter, and a
+ * getter that Table B-6 does not list for the declared type is rejected as well.
*/
- private void rejectUnlistedOffsetGetter(int columnIndex) throws SQLException {
+ private void checkGetter(int columnIndex, JdbcTypeMapping.Getter getter) 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)) {
+ if (type == null) {
+ return;
+ }
+ if (JdbcTypeMapping.isUnsupported(type)) {
+ throw new SQLFeatureNotSupportedException(
+ "Declared " + JdbcTypeMapping.sqlTypeName(type.sqlType()) + " is not supported");
+ }
+ if (!JdbcTypeMapping.supports(type, getter)) {
throw new SQLFeatureNotSupportedException(
- "Declared " + JdbcTypeMapping.sqlTypeName(type.sqlType()) + " columns support only getObject");
+ getter.methodName() + " does not support declared "
+ + JdbcTypeMapping.sqlTypeName(type.sqlType()));
}
}
@@ -210,7 +218,7 @@ public boolean wasNull() throws SQLException {
*/
@Override
public String getString(int columnIndex) throws SQLException {
- rejectUnlistedOffsetGetter(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_STRING);
return stringValue(getCurrentValue(columnIndex));
}
@@ -233,7 +241,7 @@ private static String stringValue(Value val) {
*/
@Override
public boolean getBoolean(int columnIndex) throws SQLException {
- rejectUnlistedOffsetGetter(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_BOOLEAN);
return booleanValue(getCurrentValue(columnIndex));
}
@@ -252,36 +260,83 @@ private static boolean booleanValue(Value value) {
@Override
public byte getByte(int columnIndex) throws SQLException {
- return (byte) getLong(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_BYTE);
+ return (byte) longValue(columnIndex);
}
@Override
public short getShort(int columnIndex) throws SQLException {
- return (short) getLong(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_SHORT);
+ return (short) longValue(columnIndex);
}
@Override
public int getInt(int columnIndex) throws SQLException {
- rejectUnlistedOffsetGetter(columnIndex);
- return (int) toLong(getCurrentValue(columnIndex));
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_INT);
+ return (int) longValue(columnIndex);
}
@Override
public long getLong(int columnIndex) throws SQLException {
- rejectUnlistedOffsetGetter(columnIndex);
- return toLong(getCurrentValue(columnIndex));
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_LONG);
+ return longValue(columnIndex);
}
@Override
public float getFloat(int columnIndex) throws SQLException {
- rejectUnlistedOffsetGetter(columnIndex);
- return (float) toDouble(getCurrentValue(columnIndex));
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_FLOAT);
+ return (float) doubleValue(columnIndex);
}
@Override
public double getDouble(int columnIndex) throws SQLException {
- rejectUnlistedOffsetGetter(columnIndex);
- return toDouble(getCurrentValue(columnIndex));
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_DOUBLE);
+ return doubleValue(columnIndex);
+ }
+
+ /**
+ * Reads a declared column strictly: a text or blob value that is not a number is an error
+ * rather than SQLite's zero coercion. An expression column has no declared type and keeps the
+ * permissive storage fallback that xerial users rely on.
+ */
+ private long longValue(int columnIndex) throws SQLException {
+ Value value = getCurrentValue(columnIndex);
+ if (declaredTypes[columnIndex - 1] == null) {
+ return toLong(value);
+ }
+ return switch (value) {
+ case Value.Text text -> strictLong(text.value());
+ case Value.Blob blob -> strictLong(SqliteNumericText.blobAsText(blob.bytes()));
+ default -> toLong(value);
+ };
+ }
+
+ private double doubleValue(int columnIndex) throws SQLException {
+ Value value = getCurrentValue(columnIndex);
+ if (declaredTypes[columnIndex - 1] == null) {
+ return toDouble(value);
+ }
+ return switch (value) {
+ case Value.Text text -> strictDouble(text.value());
+ case Value.Blob blob -> strictDouble(SqliteNumericText.blobAsText(blob.bytes()));
+ default -> toDouble(value);
+ };
+ }
+
+ private static long strictLong(String text) throws SQLException {
+ try {
+ return new BigDecimal(text.trim()).longValue();
+ } catch (NumberFormatException e) {
+ throw new SQLException("Bad value for type numeric: " + text, e);
+ }
+ }
+
+ private static double strictDouble(String text) throws SQLException {
+ try {
+ return Double.parseDouble(text.trim());
+ } catch (NumberFormatException e) {
+ throw new SQLException("Bad value for type numeric: " + text, e);
+ }
}
private static long toLong(Value val) {
@@ -315,7 +370,7 @@ public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException
@Override
public byte[] getBytes(int columnIndex) throws SQLException {
- rejectUnlistedOffsetGetter(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_BYTES);
return bytesValue(getCurrentValue(columnIndex));
}
@@ -331,7 +386,7 @@ private static byte[] bytesValue(Value val) {
@Override
public Date getDate(int columnIndex) throws SQLException {
- rejectUnlistedOffsetGetter(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_DATE);
return dateValue(getCurrentValue(columnIndex));
}
@@ -342,7 +397,7 @@ private static Date dateValue(Value value) throws SQLException {
@Override
public Time getTime(int columnIndex) throws SQLException {
- rejectUnlistedOffsetGetter(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_TIME);
return timeValue(getCurrentValue(columnIndex));
}
@@ -353,7 +408,7 @@ private static Time timeValue(Value value) throws SQLException {
@Override
public Timestamp getTimestamp(int columnIndex) throws SQLException {
- rejectUnlistedOffsetGetter(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_TIMESTAMP);
return timestampValue(getCurrentValue(columnIndex));
}
@@ -399,7 +454,7 @@ private static String sqlTimestampSeparator(String text) {
@Override
public BigDecimal getBigDecimal(int columnIndex) throws SQLException {
- rejectUnlistedOffsetGetter(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_BIG_DECIMAL);
return bigDecimalValue(getCurrentValue(columnIndex));
}
@@ -417,6 +472,7 @@ private static BigDecimal bigDecimalValue(Value value) throws SQLException {
@Override
public Object getObject(int columnIndex) throws SQLException {
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_OBJECT);
Value value = getCurrentValue(columnIndex);
if (value instanceof Value.Null) {
return null;
@@ -710,14 +766,16 @@ public boolean isLast() throws SQLException {
@Override
public InputStream getAsciiStream(int columnIndex) throws SQLException {
- String text = getString(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_ASCII_STREAM);
+ String text = stringValue(getCurrentValue(columnIndex));
return text == null
? null
: new ByteArrayInputStream(text.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
}
@Override @Deprecated @SuppressWarnings("deprecation") public InputStream getUnicodeStream(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); }
@Override public InputStream getBinaryStream(int columnIndex) throws SQLException {
- byte[] bytes = getBytes(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_BINARY_STREAM);
+ byte[] bytes = bytesValue(getCurrentValue(columnIndex));
return bytes == null ? null : new ByteArrayInputStream(bytes);
}
@Override public InputStream getAsciiStream(String columnLabel) throws SQLException { return getAsciiStream(findColumn(columnLabel)); }
@@ -727,7 +785,8 @@ public InputStream getAsciiStream(int columnIndex) throws SQLException {
@Override public void clearWarnings() throws SQLException {}
@Override public String getCursorName() throws SQLException { return null; }
@Override public Reader getCharacterStream(int columnIndex) throws SQLException {
- String string = getString(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_CHARACTER_STREAM);
+ String string = stringValue(getCurrentValue(columnIndex));
return string == null ? null : new StringReader(string);
}
@Override public Reader getCharacterStream(String columnLabel) throws SQLException { return getCharacterStream(findColumn(columnLabel)); }
@@ -808,9 +867,12 @@ public void setFetchSize(int rows) throws SQLException {
rejectTypeMap(map);
return getObject(columnIndex);
}
- @Override public Ref getRef(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); }
+ @Override public Ref getRef(int columnIndex) throws SQLException {
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_REF);
+ throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver");
+ }
@Override public Blob getBlob(int columnIndex) throws SQLException {
- rejectUnlistedOffsetGetter(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_BLOB);
return blobValue(getCurrentValue(columnIndex));
}
private static Blob blobValue(Value value) {
@@ -818,14 +880,17 @@ private static Blob blobValue(Value value) {
return bytes == null ? null : new LibsqlBlob(bytes);
}
@Override public Clob getClob(int columnIndex) throws SQLException {
- rejectUnlistedOffsetGetter(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_CLOB);
return clobValue(getCurrentValue(columnIndex));
}
private static Clob clobValue(Value value) {
String string = stringValue(value);
return string == null ? null : new LibsqlClob(string);
}
- @Override public Array getArray(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); }
+ @Override public Array getArray(int columnIndex) throws SQLException {
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_ARRAY);
+ throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver");
+ }
@Override public Object getObject(String columnLabel, Map> map) throws SQLException {
rejectTypeMap(map);
return getObject(columnLabel);
@@ -842,7 +907,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);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_URL);
return urlValue(getCurrentValue(columnIndex));
}
@@ -869,7 +934,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);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_ROW_ID);
return rowIdValue(getCurrentValue(columnIndex));
}
private static RowId rowIdValue(Value value) {
@@ -885,7 +950,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);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_N_CLOB);
return nclobValue(getCurrentValue(columnIndex));
}
private static NClob nclobValue(Value value) {
@@ -894,7 +959,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);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_SQLXML);
return sqlxmlValue(getCurrentValue(columnIndex));
}
private static SQLXML sqlxmlValue(Value value) {
@@ -904,10 +969,14 @@ private static SQLXML sqlxmlValue(Value value) {
@Override public SQLXML getSQLXML(String columnLabel) throws SQLException { return getSQLXML(findColumn(columnLabel)); }
@Override public void updateSQLXML(int columnIndex, SQLXML xmlObject) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); }
@Override public void updateSQLXML(String columnLabel, SQLXML xmlObject) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); }
- @Override public String getNString(int columnIndex) throws SQLException { return getString(columnIndex); }
- @Override public String getNString(String columnLabel) throws SQLException { return getString(columnLabel); }
+ @Override public String getNString(int columnIndex) throws SQLException {
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_N_STRING);
+ return stringValue(getCurrentValue(columnIndex));
+ }
+ @Override public String getNString(String columnLabel) throws SQLException { return getNString(findColumn(columnLabel)); }
@Override public Reader getNCharacterStream(int columnIndex) throws SQLException {
- String text = getNString(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_N_CHARACTER_STREAM);
+ String text = stringValue(getCurrentValue(columnIndex));
return text == null ? null : new StringReader(text);
}
@Override public Reader getNCharacterStream(String columnLabel) throws SQLException { return getNCharacterStream(findColumn(columnLabel)); }
@@ -1012,6 +1081,7 @@ public T getObject(int columnIndex, Class type) throws SQLException {
if (type == null) {
throw new SQLException("requested type cannot be null");
}
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_OBJECT);
if (type == String.class) {
return type.cast(getString(columnIndex));
}
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 8c3208e..e91bef3 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
@@ -24,6 +24,7 @@
import java.sql.ResultSet;
import java.sql.RowId;
import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
import java.sql.SQLXML;
import java.sql.Struct;
import java.sql.Time;
@@ -138,6 +139,14 @@ private static JdbcType jdbc(
return new JdbcType(name, sqlType, b1JavaType, b3ObjectType, sample, true);
}
+ /** The N/A policy: these declared types throw SQLFeatureNotSupportedException. */
+ private static final Set NA_TYPES =
+ set("ARRAY", "STRUCT", "REF", "JAVA_OBJECT", "DISTINCT");
+
+ private static boolean isNa(JdbcType type) {
+ return NA_TYPES.contains(type.name());
+ }
+
// ---------------------------------------------------------------------
// Table B-1: the natural getter for each JDBC type returns the B-1 Java type.
// ---------------------------------------------------------------------
@@ -321,6 +330,13 @@ void b1NaturalGetterReturnsDeclaredJavaType(JdbcType type, Accessor accessor) th
LibsqlResultSet resultSet = oneRow(type, type.sample());
assertThat(resultSet.next()).isTrue();
+ if (isNa(type)) {
+ assertThatThrownBy(() -> accessor.reader().read(resultSet, 1))
+ .as("B-1: %s is N/A and must throw SQLFeatureNotSupportedException", type.name())
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ return;
+ }
+
Object value;
try {
value = accessor.reader().read(resultSet, 1);
@@ -348,6 +364,13 @@ void b3GetObjectReturnsDeclaredObjectType(JdbcType type) throws SQLException {
LibsqlResultSet resultSet = oneRow(type, type.sample());
assertThat(resultSet.next()).isTrue();
+ if (isNa(type)) {
+ assertThatThrownBy(() -> resultSet.getObject(1))
+ .as("B-3: %s is N/A and must throw SQLFeatureNotSupportedException", type.name())
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ return;
+ }
+
assertThat(resultSet.getObject(1))
.as("B-3: %s must read back as %s", type.name(), type.b3ObjectType())
.isInstanceOf(type.b3ObjectType());
@@ -361,6 +384,13 @@ void b3GetObjectReturnsNullForNull() throws SQLException {
}
LibsqlResultSet resultSet = oneRow(type, new Value.Null());
assertThat(resultSet.next()).isTrue();
+ if (isNa(type)) {
+ assertThatThrownBy(() -> resultSet.getObject(1))
+ .as("B-3: NULL %s is N/A and must throw SQLFeatureNotSupportedException",
+ type.name())
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ continue;
+ }
assertThat(resultSet.getObject(1)).as("B-3: NULL %s", type.name()).isNull();
assertThat(resultSet.wasNull()).as("B-3: wasNull for NULL %s", type.name()).isTrue();
}
@@ -387,6 +417,15 @@ void b6GetterHonoursSupportedJdbcTypes(Accessor getter, JdbcType type, boolean s
LibsqlResultSet resultSet = oneRow(type, fixtureFor(getter, type));
assertThat(resultSet.next()).isTrue();
+ if (isNa(type)) {
+ // The policy answer for an unrepresentable type is an N/A exception, not a wrong Java
+ // type. This holds whether Table B-6 lists the cell or not.
+ assertThatThrownBy(() -> getter.reader().read(resultSet, 1))
+ .as("B-6: %s is N/A and must throw SQLFeatureNotSupportedException", type.name())
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ return;
+ }
+
if (supported) {
Object value;
try {
From 2677afb18120c3b5ab19bd83c5fd34f843947609 Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Sun, 25 Oct 2026 03:58:00 +0100
Subject: [PATCH 3/4] feat(jdbc): reject unsupported setobject targets
---
.../libsql/jdbc/JdbcValueConverter.java | 83 ++++---------
.../jdbc/Jdbc4AppendixBProfileTest.java | 112 ++++++++++++++++++
.../jdbc/Jdbc4AppendixBSetObjectTest.java | 37 +++++-
3 files changed, 167 insertions(+), 65 deletions(-)
create mode 100644 libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBProfileTest.java
diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcValueConverter.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcValueConverter.java
index d639305..5a7c2de 100644
--- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcValueConverter.java
+++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcValueConverter.java
@@ -122,9 +122,25 @@ static Value toValue(Object value) throws SQLException {
if (value instanceof Blob blob) {
return Value.of(binaryBytes(blob));
}
- // Table B-4 maps every other Java class to JAVA_OBJECT. PR-5 decides whether those types
- // are supported; until then they keep the sqlite-jdbc text binding.
- return Value.of(value.toString());
+ if (value instanceof URL || value instanceof RowId || value instanceof SQLXML) {
+ // Table B-4 maps these to DATALINK, ROWID and SQLXML, which the driver supports.
+ return Value.of(value.toString());
+ }
+ // Table B-4 maps Array, Struct, Ref and every other class to an N/A JDBC type.
+ throw unsupported(value, unsupportedSourceType(value));
+ }
+
+ private static int unsupportedSourceType(Object value) {
+ if (value instanceof Array) {
+ return Types.ARRAY;
+ }
+ if (value instanceof Struct) {
+ return Types.STRUCT;
+ }
+ if (value instanceof Ref) {
+ return Types.REF;
+ }
+ return Types.JAVA_OBJECT;
}
/** Converts a value to the target JDBC type, then to the Hrana value of that type. */
@@ -153,15 +169,12 @@ static Value toValue(Object value, int targetSqlType) throws SQLException {
case Types.TIME -> Value.of(timeText(value, targetSqlType));
case Types.TIMESTAMP -> Value.of(timestampText(value, targetSqlType));
case Types.DATALINK -> Value.of(datalinkText(value));
- case Types.ARRAY -> Value.of(arrayText(value));
- case Types.STRUCT -> Value.of(structText(value));
- case Types.REF -> Value.of(refText(value));
- case Types.JAVA_OBJECT -> Value.of(javaObjectText(value));
+ case Types.ARRAY, Types.STRUCT, Types.REF, Types.JAVA_OBJECT, Types.DISTINCT ->
+ throw unsupported(value, targetSqlType);
case Types.ROWID -> Value.of(rowIdText(value));
case Types.SQLXML -> Value.of(sqlXmlText(value));
case Types.TIME_WITH_TIMEZONE -> Value.of(offsetTimeText(value));
case Types.TIMESTAMP_WITH_TIMEZONE -> Value.of(offsetDateTimeText(value));
- case Types.DISTINCT -> Value.of(value.toString());
default -> throw unsupported(value, targetSqlType);
};
}
@@ -368,31 +381,6 @@ private static String datalinkText(Object value) throws SQLException {
throw unsupported(value, Types.DATALINK);
}
- private static String arrayText(Object value) throws SQLException {
- if (value instanceof Array array) {
- return array.toString();
- }
- // Table B-5 lists ARRAY for Calendar and java.util.Date, but not for their SQL subclasses.
- if (value instanceof Calendar || value.getClass() == java.util.Date.class) {
- return value.toString();
- }
- throw unsupported(value, Types.ARRAY);
- }
-
- private static String structText(Object value) throws SQLException {
- if (value instanceof Struct struct) {
- return struct.toString();
- }
- throw unsupported(value, Types.STRUCT);
- }
-
- private static String refText(Object value) throws SQLException {
- if (value instanceof Ref ref) {
- return ref.toString();
- }
- throw unsupported(value, Types.REF);
- }
-
private static String rowIdText(Object value) throws SQLException {
if (value instanceof RowId rowId) {
return rowId.toString();
@@ -407,13 +395,6 @@ private static String sqlXmlText(Object value) throws SQLException {
throw unsupported(value, Types.SQLXML);
}
- private static String javaObjectText(Object value) throws SQLException {
- if (knownSourceFamily(value)) {
- throw unsupported(value, Types.JAVA_OBJECT);
- }
- return value.toString();
- }
-
private static String offsetTimeText(Object value) throws SQLException {
if (value instanceof OffsetTime time) {
return time.toString();
@@ -431,28 +412,6 @@ private static String offsetDateTimeText(Object value) throws SQLException {
throw unsupported(value, Types.TIMESTAMP_WITH_TIMEZONE);
}
- private static boolean knownSourceFamily(Object value) {
- return value instanceof String
- || value instanceof Number
- || value instanceof Boolean
- || value instanceof byte[]
- || value instanceof java.util.Date
- || value instanceof Calendar
- || value instanceof LocalDate
- || value instanceof LocalTime
- || value instanceof LocalDateTime
- || value instanceof OffsetTime
- || value instanceof OffsetDateTime
- || value instanceof Clob
- || value instanceof Blob
- || value instanceof Array
- || value instanceof Struct
- || value instanceof Ref
- || value instanceof URL
- || value instanceof RowId
- || value instanceof SQLXML;
- }
-
private static LocalDateTime localDateTime(java.time.Instant instant) {
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
}
diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBProfileTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBProfileTest.java
new file mode 100644
index 0000000..8daa230
--- /dev/null
+++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBProfileTest.java
@@ -0,0 +1,112 @@
+package com.github.magnusp.libsql.jdbc;
+
+import com.github.magnusp.libsql.hrana.codec.Col;
+import com.github.magnusp.libsql.hrana.codec.Row;
+import com.github.magnusp.libsql.hrana.codec.StmtResult;
+import com.github.magnusp.libsql.hrana.codec.Value;
+import org.junit.jupiter.api.Test;
+
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * The JDBC 4.3 Appendix B compliance profile for the unrepresentable types.
+ *
+ * This is the policy claim, stated once: {@code ARRAY}, {@code STRUCT}, {@code REF}, {@code
+ * JAVA_OBJECT}, and {@code DISTINCT} are N/A and throw {@link SQLFeatureNotSupportedException}.
+ * Every other declared type the driver resolves is supported. An expression column has no declared
+ * type and keeps the permissive storage fallback.
+ */
+class Jdbc4AppendixBProfileTest {
+
+ private static final Set NA_TYPES =
+ Set.of("ARRAY", "STRUCT", "REF", "JAVA_OBJECT", "DISTINCT");
+
+ private static final Set SUPPORTED_TYPES = Set.of(
+ "TINYINT",
+ "SMALLINT",
+ "INTEGER",
+ "BIGINT",
+ "REAL",
+ "FLOAT",
+ "DOUBLE",
+ "NUMERIC",
+ "DECIMAL",
+ "BIT",
+ "BOOLEAN",
+ "CHAR",
+ "VARCHAR",
+ "LONGVARCHAR",
+ "NCHAR",
+ "NVARCHAR",
+ "LONGNVARCHAR",
+ "BINARY",
+ "VARBINARY",
+ "LONGVARBINARY",
+ "DATE",
+ "TIME",
+ "TIMESTAMP",
+ "TIME_WITH_TIMEZONE",
+ "TIMESTAMP_WITH_TIMEZONE",
+ "CLOB",
+ "NCLOB",
+ "SQLXML",
+ "ROWID",
+ "BLOB",
+ "DATALINK");
+
+ @Test
+ void profileListsExactlyTheUnsupportedTypes() {
+ assertThat(JdbcTypeMapping.unsupportedTypeNames())
+ .as("N/A JDBC types")
+ .containsExactlyInAnyOrderElementsOf(NA_TYPES);
+ }
+
+ @Test
+ void profileListsExactlyTheSupportedTypes() {
+ assertThat(JdbcTypeMapping.supportedTypeNames())
+ .as("supported JDBC types")
+ .containsExactlyInAnyOrderElementsOf(SUPPORTED_TYPES);
+ }
+
+ @Test
+ void everyUnsupportedDeclaredTypeThrowsFeatureNotSupported() throws SQLException {
+ for (String type : NA_TYPES) {
+ LibsqlResultSet resultSet = oneRow(type, Value.of("42"));
+ assertThat(resultSet.next()).isTrue();
+ assertThatThrownBy(() -> resultSet.getObject(1))
+ .as("getObject on declared %s", type)
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ assertThatThrownBy(() -> resultSet.getArray(1))
+ .as("getArray on declared %s", type)
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ assertThatThrownBy(() -> resultSet.getRef(1))
+ .as("getRef on declared %s", type)
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ }
+ }
+
+ @Test
+ void expressionFallbackStaysPermissive() throws Exception {
+ LibsqlResultSet resultSet = oneRow(null, Value.of("not-a-number"));
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getInt(1))
+ .as("an expression column keeps SQLite's zero coercion")
+ .isZero();
+ }
+
+ private static LibsqlResultSet oneRow(String declaredType, Value value) {
+ return new LibsqlResultSet(
+ new LibsqlStatement(null),
+ new StmtResult(
+ List.of(new Col("c", declaredType)),
+ List.of(new Row(List.of(value))),
+ 0,
+ null));
+ }
+}
diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBSetObjectTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBSetObjectTest.java
index d64cbc0..0ac7c6a 100644
--- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBSetObjectTest.java
+++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBSetObjectTest.java
@@ -18,6 +18,7 @@
import java.sql.Ref;
import java.sql.RowId;
import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
import java.sql.SQLXML;
import java.sql.Struct;
import java.sql.Time;
@@ -36,6 +37,7 @@
import java.util.TimeZone;
import java.util.stream.Stream;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.fail;
@@ -92,7 +94,12 @@ record Target(String name, int sqlType) {}
new Target("NCLOB", Types.NCLOB),
new Target("SQLXML", Types.SQLXML),
new Target("TIME_WITH_TIMEZONE", Types.TIME_WITH_TIMEZONE),
- new Target("TIMESTAMP_WITH_TIMEZONE", Types.TIMESTAMP_WITH_TIMEZONE));
+ new Target("TIMESTAMP_WITH_TIMEZONE", Types.TIMESTAMP_WITH_TIMEZONE),
+ new Target("DISTINCT", Types.DISTINCT));
+
+ /** The N/A policy: these target JDBC types are rejected by setObject. */
+ private static final Set NA_TARGETS = Set.of(
+ Types.ARRAY, Types.STRUCT, Types.REF, Types.JAVA_OBJECT, Types.DISTINCT);
record JavaRow(String name, Object value, Set targets) {}
@@ -211,7 +218,7 @@ static Stream javaRows() {
void b5SetObjectRejectsUnlistedTargetTypes(JavaRow row) {
List checks = new ArrayList<>();
for (Target target : TARGETS) {
- if (row.targets().contains(target.sqlType())) {
+ if (row.targets().contains(target.sqlType()) || NA_TARGETS.contains(target.sqlType())) {
continue;
}
checks.add(() -> {
@@ -232,7 +239,8 @@ void b5SetObjectRejectsUnlistedTargetTypes(JavaRow row) {
void b5SetObjectAcceptsListedTargetTypes() throws SQLException {
for (JavaRow row : ROWS) {
for (Target target : TARGETS) {
- if (!row.targets().contains(target.sqlType())) {
+ if (!row.targets().contains(target.sqlType())
+ || NA_TARGETS.contains(target.sqlType())) {
continue;
}
LibsqlPreparedStatement statement = new LibsqlPreparedStatement(null, "?");
@@ -241,6 +249,29 @@ void b5SetObjectAcceptsListedTargetTypes() throws SQLException {
}
}
+ @Test
+ void b5SetObjectRejectsUnsupportedTypesAndValues() {
+ for (Target target : TARGETS) {
+ if (!NA_TARGETS.contains(target.sqlType())) {
+ continue;
+ }
+ assertThatThrownBy(() -> new LibsqlPreparedStatement(null, "?")
+ .setObject(1, "42", target.sqlType()))
+ .as("B-5: setObject(%s) is N/A", target.name())
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ }
+
+ // Table B-4 maps these Java object types to the N/A JDBC types.
+ Object[] unsupportedValues = {
+ stub(Array.class), stub(Struct.class), stub(Ref.class), new Object()
+ };
+ for (Object value : unsupportedValues) {
+ assertThatThrownBy(() -> new LibsqlPreparedStatement(null, "?").setObject(1, value))
+ .as("B-4: setObject(%s) is N/A", value.getClass().getName())
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ }
+ }
+
@Test
void b5SetNullAcceptsEveryTargetType() throws SQLException {
for (Target target : TARGETS) {
From 068e91d9cfc87d18b4fe73d5c3506eabee484d52 Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Sun, 25 Oct 2026 03:58:00 +0100
Subject: [PATCH 4/4] test(jdbc): assert n/a rows against sqld
---
.../itest/Jdbc4AppendixBTypeMappingIT.java | 65 ++++++++++++++++++-
1 file changed, 63 insertions(+), 2 deletions(-)
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 ba0e29c..49ab0c5 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
@@ -24,6 +24,7 @@
import java.sql.ResultSet;
import java.sql.RowId;
import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
import java.sql.SQLXML;
import java.sql.Statement;
import java.sql.Struct;
@@ -51,6 +52,7 @@
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.fail;
@@ -140,6 +142,17 @@ private static JdbcType jdbc(
return new JdbcType(name, sqlType, b1, b3, sample);
}
+ /** The N/A policy: these declared and target types throw SQLFeatureNotSupportedException. */
+ private static final Set NA_TYPES =
+ Set.of("ARRAY", "STRUCT", "REF", "JAVA_OBJECT", "DISTINCT");
+
+ private static final Set NA_TARGETS =
+ Set.of(Types.ARRAY, Types.STRUCT, Types.REF, Types.JAVA_OBJECT, Types.DISTINCT);
+
+ /** The Table B-2/B-4 Java object types that map to an N/A JDBC type. */
+ private static final Set NA_VALUES =
+ Set.of("java.sql.Array", "java.sql.Struct", "java.sql.Ref", "Java class");
+
interface SqlRead {
Object read(ResultSet resultSet, int column) throws SQLException;
}
@@ -190,7 +203,11 @@ void b1AndB3ReadPathAgainstRealDdl(JdbcType type) throws SQLException {
"CREATE TABLE " + table + " (v \"" + type.name() + "\")");
try (PreparedStatement insert =
connection.prepareStatement("INSERT INTO " + table + " (v) VALUES (?)")) {
- insert.setObject(1, type.sample());
+ if (NA_TYPES.contains(type.name())) {
+ insert.setString(1, String.valueOf(type.sample()));
+ } else {
+ insert.setObject(1, type.sample());
+ }
insert.executeUpdate();
}
try (Statement query = connection.createStatement();
@@ -200,6 +217,16 @@ void b1AndB3ReadPathAgainstRealDdl(JdbcType type) throws SQLException {
.as("server reported declared type for %s", type.name())
.isEqualTo(type.name());
+ if (NA_TYPES.contains(type.name())) {
+ assertThatThrownBy(() -> NATURAL_GETTER.get(type.name()).read(resultSet, 1))
+ .as("B-1: %s is N/A", type.name())
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ assertThatThrownBy(() -> resultSet.getObject(1))
+ .as("B-3: %s is N/A", type.name())
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ return;
+ }
+
List checks = new ArrayList<>();
checks.add(() -> {
Object natural;
@@ -285,6 +312,12 @@ void b6GetterReadsRecommendedTypeFromRealDdl(GetterCell cell) throws SQLExceptio
try (Statement query = connection.createStatement();
ResultSet resultSet = query.executeQuery("SELECT v FROM " + table)) {
assertThat(resultSet.next()).isTrue();
+ if (NA_TYPES.contains(cell.declaredType())) {
+ assertThatThrownBy(() -> cell.reader().read(resultSet, 1))
+ .as("B-6: %s on %s is N/A", cell.getter(), cell.declaredType())
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ return;
+ }
Object value;
try {
value = cell.reader().read(resultSet, 1);
@@ -397,6 +430,12 @@ void b2StandardSettersMapJavaTypeToJdbcType(WriteRow row) throws SQLException {
statement.execute("CREATE TABLE " + table + " (v \"" + row.targetName() + "\")");
try (PreparedStatement insert =
connection.prepareStatement("INSERT INTO " + table + " (v) VALUES (?)")) {
+ if (NA_VALUES.contains(row.name())) {
+ assertThatThrownBy(() -> bindB2(insert, row))
+ .as("B-2: %s -> %s is N/A", row.name(), row.targetName())
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ return;
+ }
try {
bindB2(insert, row);
} catch (SQLException e) {
@@ -537,6 +576,12 @@ void b4SetObjectMapsJavaObjectTypeToJdbcType(WriteRow row) throws SQLException {
statement.execute("CREATE TABLE " + table + " (v \"" + row.targetName() + "\")");
try (PreparedStatement insert =
connection.prepareStatement("INSERT INTO " + table + " (v) VALUES (?)")) {
+ if (NA_VALUES.contains(row.name())) {
+ assertThatThrownBy(() -> insert.setObject(1, row.value()))
+ .as("B-4: %s -> %s is N/A", row.name(), row.targetName())
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ return;
+ }
insert.setObject(1, row.value());
insert.executeUpdate();
}
@@ -686,7 +731,16 @@ void b5SetObjectConvertsToExplicitTargetType(JavaRow row) throws SQLException {
+ placeholders + ")")) {
int parameter = 1;
for (Target target : TARGETS) {
- if (row.targets().contains(target.sqlType())) {
+ if (row.targets().contains(target.sqlType())
+ && NA_TARGETS.contains(target.sqlType())) {
+ int index = parameter;
+ assertThatThrownBy(
+ () -> insert.setObject(
+ index, sourceFor(row, target), target.sqlType()))
+ .as("B-5: %s -> %s is N/A", row.name(), target.name())
+ .isInstanceOf(SQLFeatureNotSupportedException.class);
+ insert.setNull(parameter++, target.sqlType());
+ } else if (row.targets().contains(target.sqlType())) {
insert.setObject(parameter++, sourceFor(row, target), target.sqlType());
} else {
insert.setNull(parameter++, target.sqlType());
@@ -708,6 +762,13 @@ void b5SetObjectConvertsToExplicitTargetType(JavaRow row) throws SQLException {
if (!row.targets().contains(target.sqlType())) {
continue;
}
+ if (NA_TARGETS.contains(target.sqlType())) {
+ checks.add(() -> assertThatThrownBy(
+ () -> readFor(target).read(resultSet, valueColumn))
+ .as("B-5: reading %s for %s is N/A", target.name(), row.name())
+ .isInstanceOf(SQLFeatureNotSupportedException.class));
+ continue;
+ }
checks.add(() -> {
Set storage = storageOf(target.name());
if (!storage.isEmpty()) {