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
new file mode 100644
index 0000000..d3d7223
--- /dev/null
+++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java
@@ -0,0 +1,1181 @@
+package com.github.magnusp.libsql.itest;
+
+import org.junit.jupiter.api.function.Executable;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.ByteArrayInputStream;
+import java.io.StringReader;
+import java.lang.reflect.Proxy;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.net.URI;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.sql.Array;
+import java.sql.Blob;
+import java.sql.Clob;
+import java.sql.Connection;
+import java.sql.Date;
+import java.sql.DriverManager;
+import java.sql.NClob;
+import java.sql.PreparedStatement;
+import java.sql.Ref;
+import java.sql.ResultSet;
+import java.sql.RowId;
+import java.sql.SQLException;
+import java.sql.SQLXML;
+import java.sql.Statement;
+import java.sql.Struct;
+import java.sql.Time;
+import java.sql.Timestamp;
+import java.sql.Types;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * JDBC 4.3 Appendix B, server round trips: Tables B-1, B-2, B-3, B-4, B-5 and B-6.
+ *
+ *
The companion unit suite ({@code Jdbc4AppendixBReadTest} and {@code
+ * Jdbc4AppendixBSetObjectTest}) supplies values through Hrana metadata. This suite creates real
+ * sqld tables so the declared type comes from the server and the binding actually round trips.
+ *
+ *
The oracle is the specification, not sqlite-jdbc. A failure names the table, row and column.
+ * Where the appendix intentionally leaves the value representation open (for example Calendar to
+ * CHAR, or Time to TIMESTAMP), the assertion is limited to the required Java type.
+ */
+public class Jdbc4AppendixBTypeMappingIT extends SqldContainerBase {
+
+ private static String url() {
+ return "jdbc:libsql:" + getHttpUrl();
+ }
+
+ private static Connection connect() throws SQLException {
+ return DriverManager.getConnection(url());
+ }
+
+ private static String table(String prefix) {
+ return prefix + "_" + UUID.randomUUID().toString().replace("-", "");
+ }
+
+ // =====================================================================
+ // Table B-1, B-3: read path over real DDL.
+ // =====================================================================
+
+ record JdbcType(
+ String name,
+ int sqlType,
+ Class> b1JavaType,
+ Class> b3ObjectType,
+ Object sample) {}
+
+ private static final String TEXT_ABC = "abc";
+ private static final String TEXT_DATE = "2024-01-02";
+ private static final String TEXT_TIME = "03:04:05";
+ private static final String TEXT_TIMESTAMP = "2024-01-02 03:04:05";
+ private static final String TEXT_URL = "https://example.com";
+ private static final String TEXT_ROWID = "rowid-1";
+ private static final String TEXT_XML = "";
+ private static final byte[] BYTES_ABC = "abc".getBytes(StandardCharsets.UTF_8);
+
+ private static final List READ_TYPES = List.of(
+ jdbc("CHAR", Types.CHAR, String.class, String.class, TEXT_ABC),
+ jdbc("VARCHAR", Types.VARCHAR, String.class, String.class, TEXT_ABC),
+ jdbc("LONGVARCHAR", Types.LONGVARCHAR, String.class, String.class, TEXT_ABC),
+ jdbc("NUMERIC", Types.NUMERIC, BigDecimal.class, BigDecimal.class, 42L),
+ jdbc("DECIMAL", Types.DECIMAL, BigDecimal.class, BigDecimal.class, 42L),
+ jdbc("BIT", Types.BIT, boolean.class, Boolean.class, 42L),
+ jdbc("BOOLEAN", Types.BOOLEAN, boolean.class, Boolean.class, 42L),
+ jdbc("TINYINT", Types.TINYINT, byte.class, Integer.class, 42L),
+ jdbc("SMALLINT", Types.SMALLINT, short.class, Integer.class, 42L),
+ jdbc("INTEGER", Types.INTEGER, int.class, Integer.class, 42L),
+ jdbc("BIGINT", Types.BIGINT, long.class, Long.class, 42L),
+ jdbc("REAL", Types.REAL, float.class, Float.class, 42.5d),
+ jdbc("FLOAT", Types.FLOAT, double.class, Double.class, 42.5d),
+ jdbc("DOUBLE", Types.DOUBLE, double.class, Double.class, 42.5d),
+ jdbc("BINARY", Types.BINARY, byte[].class, byte[].class, BYTES_ABC),
+ jdbc("VARBINARY", Types.VARBINARY, byte[].class, byte[].class, BYTES_ABC),
+ jdbc("LONGVARBINARY", Types.LONGVARBINARY, byte[].class, byte[].class, BYTES_ABC),
+ jdbc("DATE", Types.DATE, Date.class, Date.class, TEXT_DATE),
+ jdbc("TIME", Types.TIME, Time.class, Time.class, TEXT_TIME),
+ jdbc("TIMESTAMP", Types.TIMESTAMP, Timestamp.class, Timestamp.class, TEXT_TIMESTAMP),
+ jdbc("CLOB", Types.CLOB, Clob.class, Clob.class, TEXT_ABC),
+ jdbc("BLOB", Types.BLOB, Blob.class, Blob.class, BYTES_ABC),
+ jdbc("ARRAY", Types.ARRAY, Array.class, Array.class, TEXT_ABC),
+ jdbc("DISTINCT", Types.DISTINCT, String.class, String.class, TEXT_ABC),
+ jdbc("STRUCT", Types.STRUCT, Struct.class, Struct.class, TEXT_ABC),
+ jdbc("REF", Types.REF, Ref.class, Ref.class, TEXT_ABC),
+ jdbc("DATALINK", Types.DATALINK, URL.class, URL.class, TEXT_URL),
+ jdbc("JAVA_OBJECT", Types.JAVA_OBJECT, String.class, String.class, TEXT_ABC),
+ jdbc("ROWID", Types.ROWID, RowId.class, RowId.class, TEXT_ROWID),
+ jdbc("NCHAR", Types.NCHAR, String.class, String.class, TEXT_ABC),
+ jdbc("NVARCHAR", Types.NVARCHAR, String.class, String.class, TEXT_ABC),
+ jdbc("LONGNVARCHAR", Types.LONGNVARCHAR, String.class, String.class, TEXT_ABC),
+ jdbc("NCLOB", Types.NCLOB, NClob.class, NClob.class, TEXT_ABC),
+ jdbc("SQLXML", Types.SQLXML, SQLXML.class, SQLXML.class, TEXT_XML));
+
+ private static JdbcType jdbc(
+ String name, int sqlType, Class> b1, Class> b3, Object sample) {
+ return new JdbcType(name, sqlType, b1, b3, sample);
+ }
+
+ interface SqlRead {
+ Object read(ResultSet resultSet, int column) throws SQLException;
+ }
+
+ private static final Map NATURAL_GETTER = naturalGetters();
+
+ private static Map naturalGetters() {
+ Map getters = new LinkedHashMap<>();
+ for (String name : List.of("CHAR", "VARCHAR", "LONGVARCHAR", "NCHAR", "NVARCHAR", "LONGNVARCHAR", "DISTINCT")) {
+ getters.put(name, ResultSet::getString);
+ }
+ getters.put("NUMERIC", ResultSet::getBigDecimal);
+ getters.put("DECIMAL", ResultSet::getBigDecimal);
+ getters.put("BIT", ResultSet::getBoolean);
+ getters.put("BOOLEAN", ResultSet::getBoolean);
+ getters.put("TINYINT", ResultSet::getByte);
+ getters.put("SMALLINT", ResultSet::getShort);
+ getters.put("INTEGER", ResultSet::getInt);
+ getters.put("BIGINT", ResultSet::getLong);
+ getters.put("REAL", ResultSet::getFloat);
+ getters.put("FLOAT", ResultSet::getDouble);
+ getters.put("DOUBLE", ResultSet::getDouble);
+ getters.put("BINARY", ResultSet::getBytes);
+ getters.put("VARBINARY", ResultSet::getBytes);
+ getters.put("LONGVARBINARY", ResultSet::getBytes);
+ getters.put("DATE", ResultSet::getDate);
+ getters.put("TIME", ResultSet::getTime);
+ getters.put("TIMESTAMP", ResultSet::getTimestamp);
+ getters.put("CLOB", ResultSet::getClob);
+ getters.put("BLOB", ResultSet::getBlob);
+ getters.put("ARRAY", ResultSet::getArray);
+ getters.put("STRUCT", (rs, i) -> rs.getObject(i));
+ getters.put("REF", ResultSet::getRef);
+ getters.put("DATALINK", ResultSet::getURL);
+ getters.put("JAVA_OBJECT", (rs, i) -> rs.getObject(i));
+ getters.put("ROWID", ResultSet::getRowId);
+ getters.put("NCLOB", ResultSet::getNClob);
+ getters.put("SQLXML", ResultSet::getSQLXML);
+ return Map.copyOf(getters);
+ }
+
+ @ParameterizedTest(name = "B-1/B-3 read {0} from a real column")
+ @MethodSource("readTypeRows")
+ void b1AndB3ReadPathAgainstRealDdl(JdbcType type) throws SQLException {
+ String table = table("read");
+ try (Connection connection = connect(); Statement statement = connection.createStatement()) {
+ statement.execute(
+ "CREATE TABLE " + table + " (v \"" + type.name() + "\")");
+ try (PreparedStatement insert =
+ connection.prepareStatement("INSERT INTO " + table + " (v) VALUES (?)")) {
+ insert.setObject(1, type.sample());
+ insert.executeUpdate();
+ }
+ try (Statement query = connection.createStatement();
+ ResultSet resultSet = query.executeQuery("SELECT v FROM " + table)) {
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getMetaData().getColumnTypeName(1))
+ .as("server reported declared type for %s", type.name())
+ .isEqualTo(type.name());
+
+ List checks = new ArrayList<>();
+ checks.add(() -> {
+ Object natural;
+ try {
+ natural = NATURAL_GETTER.get(type.name()).read(resultSet, 1);
+ } catch (SQLException e) {
+ throw new AssertionError(
+ "B-1: " + type.name() + " via its natural getter threw " + e, e);
+ }
+ assertThat(natural)
+ .as("B-1: %s must read as %s", type.name(), type.b1JavaType())
+ .isInstanceOf(boxed(type.b1JavaType()));
+ });
+ checks.add(() -> assertThat(resultSet.getObject(1))
+ .as("B-3: %s must read back as %s", type.name(), type.b3ObjectType())
+ .isInstanceOf(type.b3ObjectType()));
+ assertAll(type.name(), checks);
+ }
+ }
+ }
+
+ static Stream readTypeRows() {
+ return READ_TYPES.stream();
+ }
+
+ // =====================================================================
+ // Table B-6: each getter reads its recommended JDBC type from real DDL.
+ // =====================================================================
+
+ record GetterCell(
+ String getter, String declaredType, Object sample, SqlRead reader, Class> javaType) {}
+
+ private static final List B6_CELLS = List.of(
+ new GetterCell("getByte", "TINYINT", 42L, ResultSet::getByte, byte.class),
+ new GetterCell("getShort", "SMALLINT", 42L, ResultSet::getShort, short.class),
+ new GetterCell("getInt", "INTEGER", 42L, ResultSet::getInt, int.class),
+ new GetterCell("getLong", "BIGINT", 42L, ResultSet::getLong, long.class),
+ new GetterCell("getFloat", "REAL", 42.5d, ResultSet::getFloat, float.class),
+ new GetterCell("getDouble", "DOUBLE", 42.5d, ResultSet::getDouble, double.class),
+ new GetterCell("getBigDecimal", "DECIMAL", 42L, ResultSet::getBigDecimal, BigDecimal.class),
+ new GetterCell("getBoolean", "BOOLEAN", 42L, ResultSet::getBoolean, boolean.class),
+ new GetterCell("getString", "VARCHAR", TEXT_ABC, ResultSet::getString, String.class),
+ new GetterCell("getNString", "NCHAR", TEXT_ABC, ResultSet::getNString, String.class),
+ new GetterCell("getBytes", "VARBINARY", BYTES_ABC, ResultSet::getBytes, byte[].class),
+ new GetterCell("getDate", "DATE", TEXT_DATE, ResultSet::getDate, Date.class),
+ new GetterCell("getTime", "TIME", TEXT_TIME, ResultSet::getTime, Time.class),
+ new GetterCell("getTimestamp", "TIMESTAMP", TEXT_TIMESTAMP, ResultSet::getTimestamp,
+ Timestamp.class),
+ new GetterCell("getAsciiStream", "LONGVARCHAR", TEXT_ABC, ResultSet::getAsciiStream,
+ java.io.InputStream.class),
+ new GetterCell("getBinaryStream", "LONGVARBINARY", BYTES_ABC,
+ ResultSet::getBinaryStream, java.io.InputStream.class),
+ new GetterCell("getCharacterStream", "LONGVARCHAR", TEXT_ABC,
+ ResultSet::getCharacterStream, java.io.Reader.class),
+ new GetterCell("getNCharacterStream", "LONGNVARCHAR", TEXT_ABC,
+ ResultSet::getNCharacterStream, java.io.Reader.class),
+ new GetterCell("getClob", "CLOB", TEXT_ABC, ResultSet::getClob, Clob.class),
+ new GetterCell("getNClob", "NCLOB", TEXT_ABC, ResultSet::getNClob, NClob.class),
+ new GetterCell("getBlob", "BLOB", BYTES_ABC, ResultSet::getBlob, Blob.class),
+ new GetterCell("getArray", "ARRAY", TEXT_ABC, ResultSet::getArray, Array.class),
+ new GetterCell("getRef", "REF", TEXT_ABC, ResultSet::getRef, Ref.class),
+ 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("getRowId", "ROWID", TEXT_ROWID, ResultSet::getRowId, RowId.class),
+ new GetterCell("getSQLXML", "SQLXML", TEXT_XML, ResultSet::getSQLXML, SQLXML.class));
+
+ @ParameterizedTest(name = "B-6 {0} reads its recommended {1}")
+ @MethodSource("b6Cells")
+ void b6GetterReadsRecommendedTypeFromRealDdl(GetterCell cell) throws SQLException {
+ String table = table("b6");
+ try (Connection connection = connect(); Statement statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE " + table + " (v \"" + cell.declaredType() + "\")");
+ try (PreparedStatement insert =
+ connection.prepareStatement("INSERT INTO " + table + " (v) VALUES (?)")) {
+ insert.setObject(1, cell.sample());
+ insert.executeUpdate();
+ }
+ try (Statement query = connection.createStatement();
+ ResultSet resultSet = query.executeQuery("SELECT v FROM " + table)) {
+ assertThat(resultSet.next()).isTrue();
+ Object value;
+ try {
+ value = cell.reader().read(resultSet, 1);
+ } catch (SQLException e) {
+ throw new AssertionError(
+ "B-6: " + cell.getter() + " on " + cell.declaredType() + " threw " + e, e);
+ }
+ assertThat(value)
+ .as("B-6: %s on %s must return a %s", cell.getter(), cell.declaredType(),
+ cell.javaType())
+ .isNotNull();
+ if (cell.javaType() != Object.class && cell.javaType() != byte[].class) {
+ assertThat(value)
+ .as("B-6: %s on %s must return a %s", cell.getter(), cell.declaredType(),
+ cell.javaType())
+ .isInstanceOf(boxed(cell.javaType()));
+ }
+ }
+ }
+ }
+
+ static Stream b6Cells() {
+ return B6_CELLS.stream();
+ }
+
+ // =====================================================================
+ // Table B-2: standard setters map a Java type to a JDBC type.
+ // =====================================================================
+
+ record WriteRow(
+ String name,
+ Object value,
+ String targetName,
+ int targetType,
+ Set storage,
+ SqlRead reader,
+ Expected expected) {}
+
+ record Expected(Object value, Class> anyType) {
+ static Expected of(Object value) {
+ return new Expected(value, null);
+ }
+
+ static Expected any(Class> type) {
+ return new Expected(null, type);
+ }
+ }
+
+ private static final Set INTEGRAL = Set.of("integer");
+ private static final Set REAL = Set.of("real");
+ private static final Set NUMERIC = Set.of("integer", "real");
+ private static final Set TEXT = Set.of("text");
+ private static final Set BLOB = Set.of("blob");
+ private static final Set UNSPECIFIED = Set.of();
+
+ private static final List B2_ROWS = List.of(
+ new WriteRow("String", "abc", "VARCHAR", Types.VARCHAR, TEXT,
+ ResultSet::getString, Expected.of("abc")),
+ new WriteRow("BigDecimal", new BigDecimal("7"), "NUMERIC", Types.NUMERIC, NUMERIC,
+ ResultSet::getBigDecimal, Expected.of(new BigDecimal("7"))),
+ new WriteRow("boolean", true, "BOOLEAN", Types.BOOLEAN, INTEGRAL,
+ ResultSet::getBoolean, Expected.of(true)),
+ new WriteRow("byte", (byte) 7, "TINYINT", Types.TINYINT, INTEGRAL,
+ ResultSet::getByte, Expected.of((byte) 7)),
+ new WriteRow("short", (short) 7, "SMALLINT", Types.SMALLINT, INTEGRAL,
+ ResultSet::getShort, Expected.of((short) 7)),
+ new WriteRow("int", 7, "INTEGER", Types.INTEGER, INTEGRAL,
+ ResultSet::getInt, Expected.of(7)),
+ new WriteRow("long", 7L, "BIGINT", Types.BIGINT, INTEGRAL,
+ ResultSet::getLong, Expected.of(7L)),
+ new WriteRow("float", 7.0f, "REAL", Types.REAL, REAL,
+ ResultSet::getFloat, Expected.of(7.0f)),
+ new WriteRow("double", 7.0d, "DOUBLE", Types.DOUBLE, REAL,
+ ResultSet::getDouble, Expected.of(7.0d)),
+ new WriteRow("byte[]", new byte[] {1, 2, 3}, "VARBINARY", Types.VARBINARY, BLOB,
+ ResultSet::getBytes, Expected.of(new byte[] {1, 2, 3})),
+ new WriteRow("java.sql.Date", Date.valueOf("2024-01-02"), "DATE", Types.DATE, TEXT,
+ ResultSet::getDate, Expected.of(Date.valueOf("2024-01-02"))),
+ new WriteRow("java.sql.Time", Time.valueOf("03:04:05"), "TIME", Types.TIME, TEXT,
+ ResultSet::getTime, Expected.of(Time.valueOf("03:04:05"))),
+ new WriteRow("java.sql.Timestamp", Timestamp.valueOf("2024-01-02 03:04:05"),
+ "TIMESTAMP", Types.TIMESTAMP, TEXT, ResultSet::getTimestamp,
+ Expected.of(Timestamp.valueOf("2024-01-02 03:04:05"))),
+ new WriteRow("java.sql.Clob", clob("abc"), "CLOB", Types.CLOB, TEXT,
+ Jdbc4AppendixBTypeMappingIT::clobText, Expected.of("abc")),
+ new WriteRow("java.sql.Blob", blob(new byte[] {1, 2, 3}), "BLOB", Types.BLOB, BLOB,
+ Jdbc4AppendixBTypeMappingIT::blobBytes, Expected.of(new byte[] {1, 2, 3})),
+ new WriteRow("java.sql.Array", stub(Array.class), "ARRAY", Types.ARRAY, UNSPECIFIED,
+ ResultSet::getArray, Expected.any(Array.class)),
+ new WriteRow("java.sql.Struct", stub(Struct.class), "STRUCT", Types.STRUCT, UNSPECIFIED,
+ (rs, i) -> rs.getObject(i), Expected.any(Struct.class)),
+ new WriteRow("java.sql.Ref", stub(Ref.class), "REF", Types.REF, UNSPECIFIED,
+ ResultSet::getRef, Expected.any(Ref.class)),
+ new WriteRow("java.net.URL", url("https://example.com"), "DATALINK", Types.DATALINK,
+ TEXT, ResultSet::getURL, Expected.any(URL.class)),
+ new WriteRow("Java class", new Marker("abc"), "JAVA_OBJECT", Types.JAVA_OBJECT,
+ UNSPECIFIED, (rs, i) -> rs.getObject(i), Expected.of(new Marker("abc"))),
+ new WriteRow("java.sql.RowId", stub(RowId.class), "ROWID", Types.ROWID, UNSPECIFIED,
+ ResultSet::getRowId, Expected.any(RowId.class)),
+ new WriteRow("java.sql.NClob", nclob("abc"), "NCLOB", Types.NCLOB, TEXT,
+ ResultSet::getNClob, Expected.any(NClob.class)),
+ new WriteRow("java.sql.SQLXML", stub(SQLXML.class), "SQLXML", Types.SQLXML, UNSPECIFIED,
+ ResultSet::getSQLXML, Expected.any(SQLXML.class)));
+
+ @ParameterizedTest(name = "B-2 {0} -> {2}")
+ @MethodSource("b2Rows")
+ void b2StandardSettersMapJavaTypeToJdbcType(WriteRow row) throws SQLException {
+ String table = table("b2");
+ try (Connection connection = connect(); Statement statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE " + table + " (v \"" + row.targetName() + "\")");
+ try (PreparedStatement insert =
+ connection.prepareStatement("INSERT INTO " + table + " (v) VALUES (?)")) {
+ try {
+ bindB2(insert, row);
+ } catch (SQLException e) {
+ throw new AssertionError(
+ "B-2: " + row.name() + " -> " + row.targetName() + " bind failed: " + e,
+ e);
+ }
+ insert.executeUpdate();
+ }
+ verifyRoundTrip(connection, table, row, "B-2");
+ }
+ }
+
+ static Stream b2Rows() {
+ return B2_ROWS.stream();
+ }
+
+ private static void bindB2(PreparedStatement statement, WriteRow row) throws SQLException {
+ switch (row.name()) {
+ case "String" -> statement.setString(1, (String) row.value());
+ case "BigDecimal" -> statement.setBigDecimal(1, (BigDecimal) row.value());
+ case "boolean" -> statement.setBoolean(1, (boolean) row.value());
+ case "byte" -> statement.setByte(1, (byte) row.value());
+ case "short" -> statement.setShort(1, (short) row.value());
+ case "int" -> statement.setInt(1, (int) row.value());
+ case "long" -> statement.setLong(1, (long) row.value());
+ case "float" -> statement.setFloat(1, (float) row.value());
+ case "double" -> statement.setDouble(1, (double) row.value());
+ case "byte[]" -> statement.setBytes(1, (byte[]) row.value());
+ case "java.sql.Date" -> statement.setDate(1, (Date) row.value());
+ case "java.sql.Time" -> statement.setTime(1, (Time) row.value());
+ case "java.sql.Timestamp" -> statement.setTimestamp(1, (Timestamp) row.value());
+ case "java.sql.Clob" -> statement.setClob(1, (Clob) row.value());
+ case "java.sql.Blob" -> statement.setBlob(1, (Blob) row.value());
+ case "java.sql.Array" -> statement.setArray(1, (Array) row.value());
+ case "java.sql.Struct", "Java class" -> statement.setObject(1, row.value());
+ case "java.sql.Ref" -> statement.setRef(1, (Ref) row.value());
+ case "java.net.URL" -> statement.setURL(1, (URL) row.value());
+ case "java.sql.RowId" -> statement.setRowId(1, (RowId) row.value());
+ case "java.sql.NClob" -> statement.setNClob(1, (NClob) row.value());
+ case "java.sql.SQLXML" -> statement.setSQLXML(1, (SQLXML) row.value());
+ default -> throw new IllegalArgumentException(row.name());
+ }
+ }
+
+ // =====================================================================
+ // Table B-4: setObject without a target type maps a Java object type.
+ // =====================================================================
+
+ private static final Calendar CALENDAR = calendar2024();
+ private static final java.util.Date UTIL_DATE = new java.util.Date(CALENDAR.getTimeInMillis());
+
+ private static Calendar calendar2024() {
+ Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+ calendar.clear();
+ calendar.set(2024, Calendar.JANUARY, 2, 3, 4, 5);
+ return calendar;
+ }
+
+ private static final List B4_ROWS = List.of(
+ new WriteRow("String", "abc", "VARCHAR", Types.VARCHAR, TEXT,
+ ResultSet::getString, Expected.of("abc")),
+ new WriteRow("BigDecimal", new BigDecimal("7"), "NUMERIC", Types.NUMERIC, NUMERIC,
+ ResultSet::getBigDecimal, Expected.of(new BigDecimal("7"))),
+ new WriteRow("Boolean", true, "BOOLEAN", Types.BOOLEAN, INTEGRAL,
+ ResultSet::getBoolean, Expected.of(true)),
+ new WriteRow("Byte", (byte) 7, "TINYINT", Types.TINYINT, INTEGRAL,
+ ResultSet::getByte, Expected.of((byte) 7)),
+ new WriteRow("Short", (short) 7, "SMALLINT", Types.SMALLINT, INTEGRAL,
+ ResultSet::getShort, Expected.of((short) 7)),
+ new WriteRow("Integer", 7, "INTEGER", Types.INTEGER, INTEGRAL,
+ ResultSet::getInt, Expected.of(7)),
+ new WriteRow("Long", 7L, "BIGINT", Types.BIGINT, INTEGRAL,
+ ResultSet::getLong, Expected.of(7L)),
+ new WriteRow("Float", 7.0f, "REAL", Types.REAL, REAL,
+ ResultSet::getFloat, Expected.of(7.0f)),
+ new WriteRow("Double", 7.0d, "DOUBLE", Types.DOUBLE, REAL,
+ ResultSet::getDouble, Expected.of(7.0d)),
+ new WriteRow("byte[]", new byte[] {1, 2, 3}, "VARBINARY", Types.VARBINARY, BLOB,
+ ResultSet::getBytes, Expected.of(new byte[] {1, 2, 3})),
+ new WriteRow("java.math.BigInteger", BigInteger.valueOf(7), "BIGINT", Types.BIGINT,
+ INTEGRAL, ResultSet::getLong, Expected.of(7L)),
+ new WriteRow("java.sql.Date", Date.valueOf("2024-01-02"), "DATE", Types.DATE, TEXT,
+ ResultSet::getDate, Expected.of(Date.valueOf("2024-01-02"))),
+ new WriteRow("java.sql.Time", Time.valueOf("03:04:05"), "TIME", Types.TIME, TEXT,
+ ResultSet::getTime, Expected.of(Time.valueOf("03:04:05"))),
+ new WriteRow("java.sql.Timestamp", Timestamp.valueOf("2024-01-02 03:04:05"),
+ "TIMESTAMP", Types.TIMESTAMP, TEXT, ResultSet::getTimestamp,
+ Expected.of(Timestamp.valueOf("2024-01-02 03:04:05"))),
+ new WriteRow("java.sql.Clob", clob("abc"), "CLOB", Types.CLOB, TEXT,
+ Jdbc4AppendixBTypeMappingIT::clobText, Expected.of("abc")),
+ new WriteRow("java.sql.Blob", blob(new byte[] {1, 2, 3}), "BLOB", Types.BLOB, BLOB,
+ Jdbc4AppendixBTypeMappingIT::blobBytes, Expected.of(new byte[] {1, 2, 3})),
+ new WriteRow("java.sql.Array", stub(Array.class), "ARRAY", Types.ARRAY, UNSPECIFIED,
+ ResultSet::getArray, Expected.any(Array.class)),
+ new WriteRow("java.sql.Struct", stub(Struct.class), "STRUCT", Types.STRUCT, UNSPECIFIED,
+ (rs, i) -> rs.getObject(i), Expected.any(Struct.class)),
+ new WriteRow("java.sql.Ref", stub(Ref.class), "REF", Types.REF, UNSPECIFIED,
+ ResultSet::getRef, Expected.any(Ref.class)),
+ new WriteRow("java.net.URL", url("https://example.com"), "DATALINK", Types.DATALINK,
+ TEXT, ResultSet::getURL, Expected.any(URL.class)),
+ new WriteRow("Java class", new Marker("abc"), "JAVA_OBJECT", Types.JAVA_OBJECT,
+ UNSPECIFIED, (rs, i) -> rs.getObject(i), Expected.of(new Marker("abc"))),
+ new WriteRow("java.sql.RowId", stub(RowId.class), "ROWID", Types.ROWID, UNSPECIFIED,
+ ResultSet::getRowId, Expected.any(RowId.class)),
+ new WriteRow("java.sql.NClob", nclob("abc"), "NCLOB", Types.NCLOB, TEXT,
+ ResultSet::getNClob, Expected.any(NClob.class)),
+ new WriteRow("java.sql.SQLXML", stub(SQLXML.class), "SQLXML", Types.SQLXML, UNSPECIFIED,
+ ResultSet::getSQLXML, Expected.any(SQLXML.class)),
+ new WriteRow("java.util.Calendar", CALENDAR, "TIMESTAMP", Types.TIMESTAMP, TEXT,
+ ResultSet::getTimestamp,
+ Expected.of(Timestamp.valueOf(localDateTime(CALENDAR.toInstant())))),
+ new WriteRow("java.util.Date", UTIL_DATE, "TIMESTAMP", Types.TIMESTAMP, TEXT,
+ ResultSet::getTimestamp,
+ Expected.of(Timestamp.valueOf(localDateTime(UTIL_DATE.toInstant())))),
+ new WriteRow("java.time.LocalDate", LocalDate.of(2024, 1, 2), "DATE", Types.DATE,
+ TEXT, ResultSet::getDate, Expected.of(Date.valueOf("2024-01-02"))),
+ new WriteRow("java.time.LocalTime", LocalTime.of(3, 4, 5), "TIME", Types.TIME, TEXT,
+ ResultSet::getTime, Expected.of(Time.valueOf("03:04:05"))),
+ new WriteRow("java.time.LocalDateTime", LocalDateTime.of(2024, 1, 2, 3, 4, 5),
+ "TIMESTAMP", Types.TIMESTAMP, TEXT, ResultSet::getTimestamp,
+ Expected.of(Timestamp.valueOf("2024-01-02 03:04:05"))),
+ new WriteRow("java.time.OffsetTime", OffsetTime.of(3, 4, 5, 0, ZoneOffset.UTC),
+ "TIME_WITH_TIMEZONE", Types.TIME_WITH_TIMEZONE, TEXT,
+ (rs, i) -> rs.getObject(i, OffsetTime.class),
+ Expected.of(OffsetTime.of(3, 4, 5, 0, ZoneOffset.UTC))),
+ new WriteRow("java.time.OffsetDateTime",
+ OffsetDateTime.of(2024, 1, 2, 3, 4, 5, 0, ZoneOffset.UTC),
+ "TIMESTAMP_WITH_TIMEZONE", Types.TIMESTAMP_WITH_TIMEZONE, TEXT,
+ (rs, i) -> rs.getObject(i, OffsetDateTime.class),
+ Expected.of(OffsetDateTime.of(2024, 1, 2, 3, 4, 5, 0, ZoneOffset.UTC))));
+
+ @ParameterizedTest(name = "B-4 {0} -> {2}")
+ @MethodSource("b4Rows")
+ void b4SetObjectMapsJavaObjectTypeToJdbcType(WriteRow row) throws SQLException {
+ String table = table("b4");
+ try (Connection connection = connect(); Statement statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE " + table + " (v \"" + row.targetName() + "\")");
+ try (PreparedStatement insert =
+ connection.prepareStatement("INSERT INTO " + table + " (v) VALUES (?)")) {
+ insert.setObject(1, row.value());
+ insert.executeUpdate();
+ }
+ verifyRoundTrip(connection, table, row, "B-4");
+ }
+ }
+
+ static Stream b4Rows() {
+ return B4_ROWS.stream();
+ }
+
+ // =====================================================================
+ // Table B-5: setObject with a target type converts between Java and JDBC types.
+ // =====================================================================
+
+ record Target(String name, int sqlType) {}
+
+ private static final List TARGETS = List.of(
+ target("TINYINT", Types.TINYINT),
+ target("SMALLINT", Types.SMALLINT),
+ target("INTEGER", Types.INTEGER),
+ target("BIGINT", Types.BIGINT),
+ target("REAL", Types.REAL),
+ target("FLOAT", Types.FLOAT),
+ target("DOUBLE", Types.DOUBLE),
+ target("DECIMAL", Types.DECIMAL),
+ target("NUMERIC", Types.NUMERIC),
+ target("BIT", Types.BIT),
+ target("BOOLEAN", Types.BOOLEAN),
+ target("CHAR", Types.CHAR),
+ target("VARCHAR", Types.VARCHAR),
+ target("LONGVARCHAR", Types.LONGVARCHAR),
+ target("BINARY", Types.BINARY),
+ target("VARBINARY", Types.VARBINARY),
+ target("LONGVARBINARY", Types.LONGVARBINARY),
+ target("DATE", Types.DATE),
+ target("TIME", Types.TIME),
+ target("TIMESTAMP", Types.TIMESTAMP),
+ target("NCHAR", Types.NCHAR),
+ target("NVARCHAR", Types.NVARCHAR),
+ target("LONGNVARCHAR", Types.LONGNVARCHAR),
+ target("ARRAY", Types.ARRAY),
+ target("BLOB", Types.BLOB),
+ target("CLOB", Types.CLOB),
+ target("STRUCT", Types.STRUCT),
+ target("REF", Types.REF),
+ target("DATALINK", Types.DATALINK),
+ target("JAVA_OBJECT", Types.JAVA_OBJECT),
+ target("ROWID", Types.ROWID),
+ target("NCLOB", Types.NCLOB),
+ target("SQLXML", Types.SQLXML),
+ target("TIME_WITH_TIMEZONE", Types.TIME_WITH_TIMEZONE),
+ target("TIMESTAMP_WITH_TIMEZONE", Types.TIMESTAMP_WITH_TIMEZONE));
+
+ private static Target target(String name, int sqlType) {
+ return new Target(name, sqlType);
+ }
+
+ record JavaRow(String name, Object value, Set targets) {}
+
+ private static final Set NUMERIC_TEXT_TARGETS = Set.of(
+ Types.TINYINT, Types.SMALLINT, Types.INTEGER, Types.BIGINT, Types.REAL, Types.FLOAT,
+ Types.DOUBLE, Types.DECIMAL, Types.NUMERIC, Types.BIT, Types.BOOLEAN, Types.CHAR,
+ Types.VARCHAR, Types.LONGVARCHAR);
+
+ private static Set set(int... values) {
+ return Arrays.stream(values).boxed().collect(Collectors.toUnmodifiableSet());
+ }
+
+ private static Set union(Set base, int... extra) {
+ Set result = new java.util.HashSet<>(base);
+ for (int value : extra) {
+ result.add(value);
+ }
+ return Set.copyOf(result);
+ }
+
+ private static final List B5_ROWS = List.of(
+ new JavaRow("String", "42", union(NUMERIC_TEXT_TARGETS, Types.BINARY, Types.VARBINARY,
+ Types.LONGVARBINARY, Types.DATE, Types.TIME, Types.TIMESTAMP, Types.NCHAR,
+ Types.NVARCHAR, Types.LONGNVARCHAR)),
+ new JavaRow("BigDecimal", new BigDecimal("7"), NUMERIC_TEXT_TARGETS),
+ new JavaRow("Boolean", Boolean.TRUE, NUMERIC_TEXT_TARGETS),
+ new JavaRow("Byte", (byte) 7, NUMERIC_TEXT_TARGETS),
+ new JavaRow("Short", (short) 7, NUMERIC_TEXT_TARGETS),
+ new JavaRow("Integer", 7, NUMERIC_TEXT_TARGETS),
+ new JavaRow("Long", 7L, NUMERIC_TEXT_TARGETS),
+ new JavaRow("Float", 7.0f, NUMERIC_TEXT_TARGETS),
+ new JavaRow("Double", 7.0d, NUMERIC_TEXT_TARGETS),
+ new JavaRow("byte[]", new byte[] {1, 2, 3},
+ set(Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY)),
+ new JavaRow("BigInteger", BigInteger.valueOf(7),
+ set(Types.BIGINT, Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR)),
+ new JavaRow("java.sql.Date", Date.valueOf("2024-01-02"),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.DATE, Types.TIMESTAMP)),
+ new JavaRow("java.sql.Time", Time.valueOf("03:04:05"),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.TIME, Types.TIMESTAMP)),
+ new JavaRow("java.sql.Timestamp", Timestamp.valueOf("2024-01-02 03:04:05"),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.DATE, Types.TIME,
+ Types.TIMESTAMP)),
+ new JavaRow("java.sql.Array", stub(Array.class), set(Types.ARRAY)),
+ new JavaRow("java.sql.Blob", blob(new byte[] {1, 2, 3}), set(Types.BLOB)),
+ new JavaRow("java.sql.Clob", clob("abc"), set(Types.CLOB)),
+ new JavaRow("java.sql.Struct", stub(Struct.class), set(Types.STRUCT)),
+ new JavaRow("java.sql.Ref", stub(Ref.class), set(Types.REF)),
+ new JavaRow("java.net.URL", url("https://example.com"), set(Types.DATALINK)),
+ new JavaRow("Java class", new Marker("abc"), set(Types.JAVA_OBJECT)),
+ new JavaRow("java.sql.RowId", stub(RowId.class), set(Types.ROWID)),
+ new JavaRow("java.sql.NClob", nclob("abc"), set(Types.NCLOB)),
+ new JavaRow("java.sql.SQLXML", stub(SQLXML.class), set(Types.SQLXML)),
+ new JavaRow("java.util.Calendar", CALENDAR,
+ union(set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.DATE, Types.TIME,
+ Types.TIMESTAMP), Types.ARRAY)),
+ new JavaRow("java.util.Date", UTIL_DATE,
+ union(set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.DATE, Types.TIME,
+ Types.TIMESTAMP), Types.ARRAY)),
+ new JavaRow("java.time.LocalDate", LocalDate.of(2024, 1, 2),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.DATE)),
+ new JavaRow("java.time.LocalTime", LocalTime.of(3, 4, 5),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.TIME)),
+ new JavaRow("java.time.LocalDateTime", LocalDateTime.of(2024, 1, 2, 3, 4, 5),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.DATE, Types.TIME,
+ Types.TIMESTAMP)),
+ new JavaRow("java.time.OffsetTime", OffsetTime.of(3, 4, 5, 0, ZoneOffset.UTC),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.TIME_WITH_TIMEZONE)),
+ new JavaRow("java.time.OffsetDateTime",
+ OffsetDateTime.of(2024, 1, 2, 3, 4, 5, 0, ZoneOffset.UTC),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.TIME_WITH_TIMEZONE,
+ Types.TIMESTAMP_WITH_TIMEZONE)));
+
+ @ParameterizedTest(name = "B-5 {0} to every listed target")
+ @MethodSource("b5Rows")
+ void b5SetObjectConvertsToExplicitTargetType(JavaRow row) throws SQLException {
+ String table = table("b5");
+ String columns = TARGETS.stream()
+ .map(target -> "\"" + target.name() + "\" \"" + target.name() + "\"")
+ .collect(Collectors.joining(", "));
+ String placeholders = TARGETS.stream().map(ignored -> "?").collect(Collectors.joining(", "));
+ String select = TARGETS.stream()
+ .map(target -> "typeof(\"" + target.name() + "\"), \"" + target.name() + "\"")
+ .collect(Collectors.joining(", "));
+
+ try (Connection connection = connect(); Statement statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE " + table + " (id INTEGER, " + columns + ")");
+ try (PreparedStatement insert = connection.prepareStatement(
+ "INSERT INTO " + table + " (id, " + String.join(", ", columnNames()) + ") VALUES (1, "
+ + placeholders + ")")) {
+ int parameter = 1;
+ for (Target target : TARGETS) {
+ if (row.targets().contains(target.sqlType())) {
+ insert.setObject(parameter++, sourceFor(row, target), target.sqlType());
+ } else {
+ insert.setNull(parameter++, target.sqlType());
+ }
+ }
+ insert.executeUpdate();
+ }
+
+ try (Statement query = connection.createStatement();
+ ResultSet resultSet =
+ query.executeQuery("SELECT " + select + " FROM " + table + " WHERE id = 1")) {
+ assertThat(resultSet.next()).isTrue();
+ List checks = new ArrayList<>();
+ int targetIndex = 0;
+ for (Target target : TARGETS) {
+ int typeofColumn = targetIndex * 2 + 1;
+ int valueColumn = typeofColumn + 1;
+ targetIndex++;
+ if (!row.targets().contains(target.sqlType())) {
+ continue;
+ }
+ checks.add(() -> {
+ Set storage = storageOf(target.name());
+ if (!storage.isEmpty()) {
+ assertThat(resultSet.getString(typeofColumn))
+ .as("B-5: %s -> %s storage class", row.name(), target.name())
+ .isIn(storage);
+ }
+ Object actual;
+ try {
+ actual = readFor(target).read(resultSet, valueColumn);
+ } catch (SQLException e) {
+ throw new AssertionError(
+ "B-5: " + row.name() + " -> " + target.name()
+ + " read failed: " + e,
+ e);
+ }
+ assertExpected(actual, expectedFor(row, target), "B-5", row.name(), target.name());
+ });
+ }
+ assertAll("B-5 " + row.name(), checks);
+ }
+ }
+ }
+
+ static Stream b5Rows() {
+ return B5_ROWS.stream();
+ }
+
+ private static List columnNames() {
+ return TARGETS.stream().map(target -> "\"" + target.name() + "\"").toList();
+ }
+
+ // =====================================================================
+ // Round-trip helpers.
+ // =====================================================================
+
+ private static void verifyRoundTrip(
+ Connection connection, String table, WriteRow row, String tableName) throws SQLException {
+ try (Statement query = connection.createStatement();
+ ResultSet resultSet = query.executeQuery("SELECT typeof(v), v FROM " + table)) {
+ assertThat(resultSet.next()).isTrue();
+ if (!row.storage().isEmpty()) {
+ assertThat(resultSet.getString(1))
+ .as("%s: %s -> %s storage class", tableName, row.name(), row.targetName())
+ .isIn(row.storage());
+ }
+ Object actual;
+ try {
+ actual = row.reader().read(resultSet, 2);
+ } catch (SQLException e) {
+ throw new AssertionError(
+ tableName + ": " + row.name() + " -> " + row.targetName() + " read failed: " + e,
+ e);
+ }
+ assertExpected(actual, row.expected(), tableName, row.name(), row.targetName());
+ }
+ }
+
+ private static void assertExpected(
+ Object actual, Expected expected, String table, String row, String target) {
+ if (expected.anyType() != null) {
+ assertThat(actual)
+ .as("%s: %s -> %s must be a %s", table, row, target, expected.anyType())
+ .isInstanceOf(expected.anyType());
+ return;
+ }
+ Object value = expected.value();
+ if (value instanceof BigDecimal decimal) {
+ assertThat((BigDecimal) actual)
+ .as("%s: %s -> %s", table, row, target)
+ .isEqualByComparingTo(decimal);
+ return;
+ }
+ if (value != null && value.getClass().isArray()) {
+ assertThat(actual).as("%s: %s -> %s", table, row, target).isEqualTo(value);
+ return;
+ }
+ assertThat(actual).as("%s: %s -> %s", table, row, target).isEqualTo(value);
+ }
+
+ private static Set storageOf(String targetName) {
+ return switch (targetName) {
+ case "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "BIT", "BOOLEAN" -> INTEGRAL;
+ case "REAL", "FLOAT", "DOUBLE" -> REAL;
+ case "DECIMAL", "NUMERIC" -> NUMERIC;
+ case "CHAR", "VARCHAR", "LONGVARCHAR", "NCHAR", "NVARCHAR", "LONGNVARCHAR", "DATE",
+ "TIME", "TIMESTAMP", "CLOB", "NCLOB", "DATALINK", "TIME_WITH_TIMEZONE",
+ "TIMESTAMP_WITH_TIMEZONE" ->
+ TEXT;
+ case "BINARY", "VARBINARY", "LONGVARBINARY", "BLOB" -> BLOB;
+ default -> UNSPECIFIED;
+ };
+ }
+
+ private static SqlRead readFor(Target target) {
+ return switch (target.name()) {
+ case "TINYINT" -> ResultSet::getByte;
+ case "SMALLINT" -> ResultSet::getShort;
+ case "INTEGER" -> ResultSet::getInt;
+ case "BIGINT" -> ResultSet::getLong;
+ case "REAL" -> ResultSet::getFloat;
+ case "FLOAT", "DOUBLE" -> ResultSet::getDouble;
+ case "DECIMAL", "NUMERIC" -> ResultSet::getBigDecimal;
+ case "BIT", "BOOLEAN" -> ResultSet::getBoolean;
+ case "CHAR", "VARCHAR", "LONGVARCHAR", "NCHAR", "NVARCHAR", "LONGNVARCHAR" ->
+ ResultSet::getString;
+ case "BINARY", "VARBINARY", "LONGVARBINARY" -> ResultSet::getBytes;
+ case "DATE" -> ResultSet::getDate;
+ case "TIME" -> ResultSet::getTime;
+ case "TIMESTAMP" -> ResultSet::getTimestamp;
+ case "CLOB" -> Jdbc4AppendixBTypeMappingIT::clobText;
+ case "BLOB" -> Jdbc4AppendixBTypeMappingIT::blobBytes;
+ case "NCLOB" -> (rs, i) -> {
+ NClob nclob = rs.getNClob(i);
+ return nclob == null ? null : nclob.getSubString(1, (int) nclob.length());
+ };
+ case "ARRAY" -> ResultSet::getArray;
+ case "STRUCT" -> (rs, i) -> rs.getObject(i);
+ case "REF" -> ResultSet::getRef;
+ case "DATALINK" -> ResultSet::getURL;
+ case "JAVA_OBJECT" -> (rs, i) -> rs.getObject(i);
+ case "ROWID" -> ResultSet::getRowId;
+ case "SQLXML" -> ResultSet::getSQLXML;
+ case "TIME_WITH_TIMEZONE" -> (rs, i) -> rs.getObject(i, OffsetTime.class);
+ case "TIMESTAMP_WITH_TIMEZONE" -> (rs, i) -> rs.getObject(i, OffsetDateTime.class);
+ default -> throw new IllegalArgumentException(target.name());
+ };
+ }
+
+ private static Object sourceFor(JavaRow row, Target target) {
+ if (row.value() instanceof String) {
+ return switch (target.name()) {
+ case "DATE" -> "2024-01-02";
+ case "TIME" -> "03:04:05";
+ case "TIMESTAMP" -> "2024-01-02 03:04:05";
+ case "BINARY", "VARBINARY", "LONGVARBINARY" -> "abc";
+ case "BIT", "BOOLEAN" -> "true";
+ default -> "42";
+ };
+ }
+ return row.value();
+ }
+
+ private static Expected expectedFor(JavaRow row, Target target) {
+ Object source = sourceFor(row, target);
+ return switch (target.name()) {
+ case "TINYINT" -> Expected.of((byte) number(source));
+ case "SMALLINT" -> Expected.of((short) number(source));
+ case "INTEGER" -> Expected.of((int) number(source));
+ case "BIGINT" -> Expected.of((long) number(source));
+ case "REAL" -> Expected.of((float) number(source));
+ case "FLOAT", "DOUBLE" -> Expected.of(number(source));
+ case "DECIMAL", "NUMERIC" -> Expected.of(BigDecimal.valueOf(number(source)));
+ case "BIT", "BOOLEAN" -> Expected.of(number(source) != 0);
+ case "CHAR", "VARCHAR", "LONGVARCHAR", "NCHAR", "NVARCHAR", "LONGNVARCHAR" ->
+ textExpected(source);
+ case "DATE" -> dateExpected(source);
+ case "TIME" -> timeExpected(source);
+ case "TIMESTAMP" -> timestampExpected(source);
+ case "CLOB" -> Expected.of(clobTextValue(source));
+ case "NCLOB" -> Expected.any(NClob.class);
+ case "BLOB" -> Expected.of(bytesForm(source));
+ case "BINARY", "VARBINARY", "LONGVARBINARY" -> Expected.of(bytesForm(source));
+ case "DATALINK" -> Expected.any(URL.class);
+ case "ARRAY" -> Expected.any(Array.class);
+ case "STRUCT" -> Expected.any(Struct.class);
+ case "REF" -> Expected.any(Ref.class);
+ case "JAVA_OBJECT" -> Expected.of(source);
+ case "ROWID" -> Expected.any(RowId.class);
+ case "SQLXML" -> Expected.any(SQLXML.class);
+ case "TIME_WITH_TIMEZONE" -> offsetTimeExpected(source);
+ case "TIMESTAMP_WITH_TIMEZONE" -> offsetDateTimeExpected(source);
+ default -> throw new IllegalArgumentException(target.name());
+ };
+ }
+
+ private static Expected textExpected(Object source) {
+ // The appendix requires the Java type but leaves the text representation open for
+ // Calendar, java.util.Date and Boolean.
+ if (source instanceof Calendar
+ || source.getClass() == java.util.Date.class
+ || source instanceof Boolean) {
+ return Expected.any(String.class);
+ }
+ return Expected.of(textForm(source));
+ }
+
+ private static Expected dateExpected(Object source) {
+ if (source instanceof Date date) {
+ return Expected.of(date);
+ }
+ if (source instanceof Timestamp timestamp) {
+ return Expected.of(Date.valueOf(timestamp.toLocalDateTime().toLocalDate()));
+ }
+ if (source instanceof LocalDate localDate) {
+ return Expected.of(Date.valueOf(localDate));
+ }
+ if (source instanceof LocalDateTime localDateTime) {
+ return Expected.of(Date.valueOf(localDateTime.toLocalDate()));
+ }
+ if (source instanceof java.util.Date date) {
+ return Expected.of(Date.valueOf(LocalDate.ofInstant(date.toInstant(), ZoneId.systemDefault())));
+ }
+ if (source instanceof Calendar calendar) {
+ return Expected.of(Date.valueOf(LocalDate.ofInstant(calendar.toInstant(), ZoneId.systemDefault())));
+ }
+ if (source instanceof String text) {
+ return Expected.of(Date.valueOf(LocalDate.parse(text.substring(0, 10))));
+ }
+ throw new IllegalArgumentException(source.getClass().getName());
+ }
+
+ private static Expected timeExpected(Object source) {
+ if (source instanceof Time time) {
+ return Expected.of(time);
+ }
+ if (source instanceof Timestamp timestamp) {
+ return Expected.of(Time.valueOf(timestamp.toLocalDateTime().toLocalTime()));
+ }
+ if (source instanceof LocalTime localTime) {
+ return Expected.of(Time.valueOf(localTime));
+ }
+ if (source instanceof LocalDateTime localDateTime) {
+ return Expected.of(Time.valueOf(localDateTime.toLocalTime()));
+ }
+ if (source instanceof java.util.Date date) {
+ return Expected.of(Time.valueOf(LocalTime.ofInstant(date.toInstant(), ZoneId.systemDefault())));
+ }
+ if (source instanceof Calendar calendar) {
+ return Expected.of(Time.valueOf(LocalTime.ofInstant(calendar.toInstant(), ZoneId.systemDefault())));
+ }
+ if (source instanceof String text) {
+ return Expected.of(Time.valueOf(LocalTime.parse(text.substring(text.length() - 8))));
+ }
+ throw new IllegalArgumentException(source.getClass().getName());
+ }
+
+ private static Expected timestampExpected(Object source) {
+ if (source instanceof Timestamp timestamp) {
+ return Expected.of(timestamp);
+ }
+ if (source instanceof Date date) {
+ return Expected.of(Timestamp.valueOf(date.toLocalDate().atStartOfDay()));
+ }
+ if (source instanceof LocalDate localDate) {
+ return Expected.of(Timestamp.valueOf(localDate.atStartOfDay()));
+ }
+ if (source instanceof LocalDateTime localDateTime) {
+ return Expected.of(Timestamp.valueOf(localDateTime));
+ }
+ // The date part of a Time to TIMESTAMP conversion is implementation defined.
+ if (source instanceof Time) {
+ return Expected.any(Timestamp.class);
+ }
+ if (source instanceof java.util.Date date) {
+ return Expected.of(Timestamp.valueOf(localDateTime(date.toInstant())));
+ }
+ if (source instanceof Calendar calendar) {
+ return Expected.of(Timestamp.valueOf(localDateTime(calendar.toInstant())));
+ }
+ if (source instanceof String text) {
+ return Expected.of(Timestamp.valueOf(text.replace('T', ' ')));
+ }
+ throw new IllegalArgumentException(source.getClass().getName());
+ }
+
+ private static Expected offsetTimeExpected(Object source) {
+ if (source instanceof OffsetTime offsetTime) {
+ return Expected.of(offsetTime);
+ }
+ if (source instanceof OffsetDateTime offsetDateTime) {
+ return Expected.of(offsetDateTime.toOffsetTime());
+ }
+ if (source instanceof String text) {
+ return Expected.of(OffsetTime.parse(text));
+ }
+ throw new IllegalArgumentException(source.getClass().getName());
+ }
+
+ private static Expected offsetDateTimeExpected(Object source) {
+ if (source instanceof OffsetDateTime offsetDateTime) {
+ return Expected.of(offsetDateTime);
+ }
+ throw new IllegalArgumentException(source.getClass().getName());
+ }
+
+ private static String clobTextValue(Object source) {
+ if (source instanceof Clob clob) {
+ try {
+ return clob.getSubString(1, (int) clob.length());
+ } catch (SQLException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+ return textForm(source);
+ }
+
+ private static String textForm(Object source) {
+ return switch (source) {
+ case String text -> text;
+ case BigDecimal decimal -> decimal.toPlainString();
+ case BigInteger integer -> integer.toString();
+ case Boolean bool -> bool.toString();
+ case Number number -> String.valueOf(number);
+ case Date date -> date.toString();
+ case Time time -> time.toString();
+ case Timestamp timestamp -> timestamp.toString();
+ case LocalDate localDate -> localDate.toString();
+ case LocalTime localTime -> localTime.toString();
+ case LocalDateTime localDateTime -> localDateTime.toString();
+ case OffsetTime offsetTime -> offsetTime.toString();
+ case OffsetDateTime offsetDateTime -> offsetDateTime.toString();
+ default -> throw new IllegalArgumentException(source.getClass().getName());
+ };
+ }
+
+ private static byte[] bytesForm(Object source) {
+ if (source instanceof byte[] bytes) {
+ return bytes;
+ }
+ if (source instanceof String text) {
+ return text.getBytes(StandardCharsets.UTF_8);
+ }
+ if (source instanceof Blob blob) {
+ try {
+ return blob.getBytes(1, (int) blob.length());
+ } catch (SQLException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+ throw new IllegalArgumentException(source.getClass().getName());
+ }
+
+ private static double number(Object source) {
+ if (source instanceof Number number) {
+ return number.doubleValue();
+ }
+ if (source instanceof Boolean bool) {
+ return bool ? 1 : 0;
+ }
+ if (source instanceof String text) {
+ if ("true".equalsIgnoreCase(text)) {
+ return 1;
+ }
+ if ("false".equalsIgnoreCase(text)) {
+ return 0;
+ }
+ return Double.parseDouble(text);
+ }
+ throw new IllegalArgumentException(source.getClass().getName());
+ }
+
+ private static LocalDateTime localDateTime(Instant instant) {
+ return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
+ }
+
+ private static String clobText(ResultSet resultSet, int column) throws SQLException {
+ Clob clob = resultSet.getClob(column);
+ return clob == null ? null : clob.getSubString(1, (int) clob.length());
+ }
+
+ private static byte[] blobBytes(ResultSet resultSet, int column) throws SQLException {
+ Blob blob = resultSet.getBlob(column);
+ if (blob == null) {
+ return null;
+ }
+ try {
+ return blob.getBinaryStream().readAllBytes();
+ } catch (java.io.IOException e) {
+ throw new SQLException(e);
+ }
+ }
+
+ private static Class> boxed(Class> type) {
+ if (!type.isPrimitive()) {
+ return type;
+ }
+ return switch (type.getName()) {
+ case "byte" -> Byte.class;
+ case "short" -> Short.class;
+ case "int" -> Integer.class;
+ case "long" -> Long.class;
+ case "float" -> Float.class;
+ case "double" -> Double.class;
+ case "boolean" -> Boolean.class;
+ case "char" -> Character.class;
+ default -> throw new IllegalArgumentException(type.getName());
+ };
+ }
+
+ private record Marker(String value) {}
+
+ @SuppressWarnings("unchecked")
+ private static T stub(Class type) {
+ return (T) Proxy.newProxyInstance(
+ type.getClassLoader(),
+ new Class>[] {type},
+ (proxy, method, args) -> switch (method.getName()) {
+ case "toString" -> "stub";
+ case "hashCode" -> System.identityHashCode(proxy);
+ case "equals" -> proxy == args[0];
+ default -> defaultValue(method.getReturnType());
+ });
+ }
+
+ private static Blob blob(byte[] data) {
+ return (Blob) Proxy.newProxyInstance(
+ Blob.class.getClassLoader(),
+ new Class>[] {Blob.class},
+ (proxy, method, args) -> switch (method.getName()) {
+ case "toString" -> "blob";
+ case "hashCode" -> System.identityHashCode(proxy);
+ case "equals" -> proxy == args[0];
+ case "length" -> (long) data.length;
+ case "getBytes" -> data;
+ case "getBinaryStream" -> new ByteArrayInputStream(data);
+ default -> defaultValue(method.getReturnType());
+ });
+ }
+
+ private static Clob clob(String text) {
+ return (Clob) Proxy.newProxyInstance(
+ Clob.class.getClassLoader(),
+ new Class>[] {Clob.class},
+ (proxy, method, args) -> switch (method.getName()) {
+ case "toString" -> "clob";
+ case "hashCode" -> System.identityHashCode(proxy);
+ case "equals" -> proxy == args[0];
+ case "length" -> (long) text.length();
+ case "getSubString" -> text;
+ case "getCharacterStream" -> new StringReader(text);
+ default -> defaultValue(method.getReturnType());
+ });
+ }
+
+ private static NClob nclob(String text) {
+ return (NClob) Proxy.newProxyInstance(
+ NClob.class.getClassLoader(),
+ new Class>[] {NClob.class},
+ (proxy, method, args) -> switch (method.getName()) {
+ case "toString" -> text;
+ case "hashCode" -> System.identityHashCode(proxy);
+ case "equals" -> proxy == args[0];
+ case "length" -> (long) text.length();
+ case "getSubString" -> text;
+ case "getCharacterStream" -> new StringReader(text);
+ default -> defaultValue(method.getReturnType());
+ });
+ }
+
+ private static URL url(String value) {
+ try {
+ return URI.create(value).toURL();
+ } catch (java.net.MalformedURLException e) {
+ throw new IllegalArgumentException(value, e);
+ }
+ }
+
+ private static Object defaultValue(Class> type) {
+ if (!type.isPrimitive()) {
+ return null;
+ }
+ return switch (type.getName()) {
+ case "boolean" -> false;
+ case "char" -> '\0';
+ case "byte" -> (byte) 0;
+ case "short" -> (short) 0;
+ case "int" -> 0;
+ case "long" -> 0L;
+ case "float" -> 0.0f;
+ case "double" -> 0.0d;
+ default -> null;
+ };
+ }
+}
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
new file mode 100644
index 0000000..cce5622
--- /dev/null
+++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java
@@ -0,0 +1,614 @@
+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 org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.InputStream;
+import java.io.Reader;
+import java.math.BigDecimal;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+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.ResultSet;
+import java.sql.RowId;
+import java.sql.SQLException;
+import java.sql.SQLXML;
+import java.sql.Struct;
+import java.sql.Time;
+import java.sql.Timestamp;
+import java.sql.Types;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * JDBC 4.3 Appendix B, read side: Tables B-1, B-3 and B-6.
+ *
+ * The oracle is the specification, not sqlite-jdbc. A failure here means the driver disagrees
+ * with Appendix B; the test name names the table row, so the failure list is the gap list. Values
+ * are supplied through {@link StmtResult} metadata rather than a live server, which keeps the
+ * matrix exhaustive and fast.
+ *
+ *
B-1 is defined for CallableStatement getters, which this driver rejects. It is therefore
+ * exercised through the natural ResultSet getter for each JDBC type (the same Java type the
+ * CallableStatement getter would return). B-3 is exercised through {@code getObject(int)}. B-6 is
+ * exercised cell by cell: a listed conversion must succeed and return a non-null value of the
+ * getter's Java type, and an unlisted conversion must reject the value with a {@link SQLException}.
+ */
+class Jdbc4AppendixBReadTest {
+
+ // ---------------------------------------------------------------------
+ // Model for the JDBC types in Tables B-1, B-3 and B-6.
+ // ---------------------------------------------------------------------
+
+ record JdbcType(
+ String name,
+ int sqlType,
+ Class> b1JavaType,
+ Class> b3ObjectType,
+ Value sample,
+ boolean inB1B3) {}
+
+ private static final Value TEXT_ABC = Value.of("abc");
+ private static final Value TEXT_DATE = Value.of("2024-01-02");
+ private static final Value TEXT_TIME = Value.of("03:04:05");
+ private static final Value TEXT_TIMESTAMP = Value.of("2024-01-02 03:04:05");
+ private static final Value TEXT_URL = Value.of("https://example.com");
+ private static final Value TEXT_ROWID = Value.of("rowid-1");
+ private static final Value TEXT_XML = Value.of("");
+ private static final Value NUMBER_42 = Value.of(42L);
+ private static final Value REAL_42_5 = Value.of(42.5);
+ private static final Value BYTES_ABC = Value.of("abc".getBytes(StandardCharsets.UTF_8));
+
+ private static final List JDBC_TYPES = List.of(
+ jdbc("CHAR", Types.CHAR, String.class, String.class, TEXT_ABC),
+ jdbc("VARCHAR", Types.VARCHAR, String.class, String.class, TEXT_ABC),
+ jdbc("LONGVARCHAR", Types.LONGVARCHAR, String.class, String.class, TEXT_ABC),
+ jdbc("NUMERIC", Types.NUMERIC, BigDecimal.class, BigDecimal.class, NUMBER_42),
+ jdbc("DECIMAL", Types.DECIMAL, BigDecimal.class, BigDecimal.class, NUMBER_42),
+ jdbc("BIT", Types.BIT, boolean.class, Boolean.class, NUMBER_42),
+ jdbc("BOOLEAN", Types.BOOLEAN, boolean.class, Boolean.class, NUMBER_42),
+ jdbc("TINYINT", Types.TINYINT, byte.class, Integer.class, NUMBER_42),
+ jdbc("SMALLINT", Types.SMALLINT, short.class, Integer.class, NUMBER_42),
+ jdbc("INTEGER", Types.INTEGER, int.class, Integer.class, NUMBER_42),
+ // A small value: B-3 keys the object type on the declared type, not the magnitude.
+ jdbc("BIGINT", Types.BIGINT, long.class, Long.class, NUMBER_42),
+ jdbc("REAL", Types.REAL, float.class, Float.class, REAL_42_5),
+ jdbc("FLOAT", Types.FLOAT, double.class, Double.class, REAL_42_5),
+ jdbc("DOUBLE", Types.DOUBLE, double.class, Double.class, REAL_42_5),
+ jdbc("BINARY", Types.BINARY, byte[].class, byte[].class, BYTES_ABC),
+ jdbc("VARBINARY", Types.VARBINARY, byte[].class, byte[].class, BYTES_ABC),
+ jdbc("LONGVARBINARY", Types.LONGVARBINARY, byte[].class, byte[].class, BYTES_ABC),
+ jdbc("DATE", Types.DATE, Date.class, Date.class, TEXT_DATE),
+ jdbc("TIME", Types.TIME, Time.class, Time.class, TEXT_TIME),
+ jdbc("TIMESTAMP", Types.TIMESTAMP, Timestamp.class, Timestamp.class, TEXT_TIMESTAMP),
+ jdbc("CLOB", Types.CLOB, Clob.class, Clob.class, TEXT_ABC),
+ jdbc("BLOB", Types.BLOB, Blob.class, Blob.class, BYTES_ABC),
+ jdbc("ARRAY", Types.ARRAY, Array.class, Array.class, TEXT_ABC),
+ jdbc("DISTINCT", Types.DISTINCT, String.class, String.class, TEXT_ABC),
+ jdbc("STRUCT", Types.STRUCT, Struct.class, Struct.class, TEXT_ABC),
+ jdbc("REF", Types.REF, Ref.class, Ref.class, TEXT_ABC),
+ jdbc("DATALINK", Types.DATALINK, URL.class, URL.class, TEXT_URL),
+ jdbc("JAVA_OBJECT", Types.JAVA_OBJECT, String.class, String.class, TEXT_ABC),
+ jdbc("ROWID", Types.ROWID, RowId.class, RowId.class, TEXT_ROWID),
+ jdbc("NCHAR", Types.NCHAR, String.class, String.class, TEXT_ABC),
+ jdbc("NVARCHAR", Types.NVARCHAR, String.class, String.class, TEXT_ABC),
+ jdbc("LONGNVARCHAR", Types.LONGNVARCHAR, String.class, String.class, TEXT_ABC),
+ jdbc("NCLOB", Types.NCLOB, NClob.class, NClob.class, TEXT_ABC),
+ jdbc("SQLXML", Types.SQLXML, SQLXML.class, SQLXML.class, TEXT_XML),
+ new JdbcType(
+ "TIME_WITH_TIMEZONE",
+ Types.TIME_WITH_TIMEZONE,
+ null,
+ null,
+ Value.of("03:04:05+01:00"),
+ false),
+ new JdbcType(
+ "TIMESTAMP_WITH_TIMEZONE",
+ Types.TIMESTAMP_WITH_TIMEZONE,
+ null,
+ null,
+ Value.of("2024-01-02 03:04:05+01:00"),
+ false));
+
+ private static JdbcType jdbc(
+ String name, int sqlType, Class> b1JavaType, Class> b3ObjectType, Value sample) {
+ return new JdbcType(name, sqlType, b1JavaType, b3ObjectType, sample, true);
+ }
+
+ // ---------------------------------------------------------------------
+ // Table B-1: the natural getter for each JDBC type returns the B-1 Java type.
+ // ---------------------------------------------------------------------
+
+ @FunctionalInterface
+ interface SqlReader {
+ Object read(ResultSet resultSet, int column) throws SQLException;
+ }
+
+ record Accessor(String name, Class> javaType, SqlReader reader, Set supported) {}
+
+ private static final Accessor GET_BYTE =
+ new Accessor("getByte", byte.class, ResultSet::getByte, numericTextWithRowId());
+ private static final Accessor GET_SHORT =
+ new Accessor("getShort", short.class, ResultSet::getShort, numericText());
+ private static final Accessor GET_INT =
+ new Accessor("getInt", int.class, ResultSet::getInt, numericText());
+ private static final Accessor GET_LONG =
+ new Accessor("getLong", long.class, ResultSet::getLong, numericText());
+ private static final Accessor GET_FLOAT =
+ new Accessor("getFloat", float.class, ResultSet::getFloat, numericText());
+ private static final Accessor GET_DOUBLE =
+ new Accessor("getDouble", double.class, ResultSet::getDouble, numericText());
+ private static final Accessor GET_BIG_DECIMAL =
+ new Accessor("getBigDecimal", BigDecimal.class, ResultSet::getBigDecimal, numericText());
+ private static final Accessor GET_BOOLEAN =
+ new Accessor("getBoolean", boolean.class, ResultSet::getBoolean, numericText());
+ private static final Accessor GET_STRING =
+ new Accessor("getString", String.class, ResultSet::getString, stringTargets());
+ private static final Accessor GET_N_STRING =
+ new Accessor("getNString", String.class, ResultSet::getNString, stringTargets());
+ private static final Accessor GET_BYTES = new Accessor(
+ "getBytes", byte[].class, ResultSet::getBytes, set("BINARY", "VARBINARY", "LONGVARBINARY"));
+ private static final Accessor GET_DATE = new Accessor(
+ "getDate", Date.class, ResultSet::getDate, set("CHAR", "VARCHAR", "LONGVARCHAR", "DATE", "TIMESTAMP"));
+ private static final Accessor GET_TIME = new Accessor(
+ "getTime", Time.class, ResultSet::getTime, set("CHAR", "VARCHAR", "LONGVARCHAR", "TIME", "TIMESTAMP"));
+ private static final Accessor GET_TIMESTAMP = new Accessor(
+ "getTimestamp",
+ Timestamp.class,
+ ResultSet::getTimestamp,
+ set("CHAR", "VARCHAR", "LONGVARCHAR", "DATE", "TIME", "TIMESTAMP"));
+ private static final Accessor GET_ASCII_STREAM = new Accessor(
+ "getAsciiStream",
+ InputStream.class,
+ ResultSet::getAsciiStream,
+ set("CHAR", "VARCHAR", "LONGVARCHAR", "BINARY", "VARBINARY", "LONGVARBINARY", "CLOB", "NCLOB"));
+ private static final Accessor GET_BINARY_STREAM = new Accessor(
+ "getBinaryStream",
+ InputStream.class,
+ ResultSet::getBinaryStream,
+ set("BINARY", "VARBINARY", "LONGVARBINARY"));
+ private static final Accessor GET_CHARACTER_STREAM = new Accessor(
+ "getCharacterStream",
+ Reader.class,
+ ResultSet::getCharacterStream,
+ set(
+ "CHAR",
+ "VARCHAR",
+ "LONGVARCHAR",
+ "BINARY",
+ "VARBINARY",
+ "LONGVARBINARY",
+ "CLOB",
+ "NCHAR",
+ "NVARCHAR",
+ "LONGNVARCHAR",
+ "NCLOB",
+ "SQLXML"));
+ private static final Accessor GET_N_CHARACTER_STREAM = new Accessor(
+ "getNCharacterStream",
+ Reader.class,
+ ResultSet::getNCharacterStream,
+ set(
+ "CHAR",
+ "VARCHAR",
+ "LONGVARCHAR",
+ "BINARY",
+ "VARBINARY",
+ "LONGVARBINARY",
+ "CLOB",
+ "NCHAR",
+ "NVARCHAR",
+ "LONGNVARCHAR",
+ "NCLOB",
+ "SQLXML"));
+ private static final Accessor GET_CLOB =
+ new Accessor("getClob", Clob.class, ResultSet::getClob, set("CLOB", "NCLOB"));
+ private static final Accessor GET_N_CLOB =
+ new Accessor("getNClob", NClob.class, ResultSet::getNClob, set("CLOB", "NCLOB"));
+ private static final Accessor GET_BLOB =
+ new Accessor("getBlob", Blob.class, ResultSet::getBlob, set("BLOB"));
+ private static final Accessor GET_ARRAY =
+ new Accessor("getArray", Array.class, ResultSet::getArray, set("ARRAY"));
+ private static final Accessor GET_REF =
+ new Accessor("getRef", Ref.class, ResultSet::getRef, set("REF"));
+ private static final Accessor GET_URL =
+ new Accessor("getURL", URL.class, ResultSet::getURL, set("DATALINK"));
+ private static final Accessor GET_OBJECT =
+ new Accessor("getObject", Object.class, (rs, i) -> rs.getObject(i), allJdbcTypes());
+ private static final Accessor GET_ROW_ID =
+ new Accessor("getRowId", RowId.class, ResultSet::getRowId, set("ROWID"));
+ private static final Accessor GET_SQLXML =
+ new Accessor("getSQLXML", SQLXML.class, ResultSet::getSQLXML, set("SQLXML"));
+
+ private static final List ACCESSORS = List.of(
+ GET_BYTE,
+ GET_SHORT,
+ GET_INT,
+ GET_LONG,
+ GET_FLOAT,
+ GET_DOUBLE,
+ GET_BIG_DECIMAL,
+ GET_BOOLEAN,
+ GET_STRING,
+ GET_N_STRING,
+ GET_BYTES,
+ GET_DATE,
+ GET_TIME,
+ GET_TIMESTAMP,
+ GET_ASCII_STREAM,
+ GET_BINARY_STREAM,
+ GET_CHARACTER_STREAM,
+ GET_N_CHARACTER_STREAM,
+ GET_CLOB,
+ GET_N_CLOB,
+ GET_BLOB,
+ GET_ARRAY,
+ GET_REF,
+ GET_URL,
+ GET_OBJECT,
+ GET_ROW_ID,
+ GET_SQLXML);
+
+ private static final Map B1_ACCESSORS = b1Accessors();
+
+ private static Map b1Accessors() {
+ Map accessors = new LinkedHashMap<>();
+ for (String name : set("CHAR", "VARCHAR", "LONGVARCHAR", "NCHAR", "NVARCHAR", "LONGNVARCHAR", "DISTINCT")) {
+ accessors.put(name, GET_STRING);
+ }
+ accessors.put("NUMERIC", GET_BIG_DECIMAL);
+ accessors.put("DECIMAL", GET_BIG_DECIMAL);
+ accessors.put("BIT", GET_BOOLEAN);
+ accessors.put("BOOLEAN", GET_BOOLEAN);
+ accessors.put("TINYINT", GET_BYTE);
+ accessors.put("SMALLINT", GET_SHORT);
+ accessors.put("INTEGER", GET_INT);
+ accessors.put("BIGINT", GET_LONG);
+ accessors.put("REAL", GET_FLOAT);
+ accessors.put("FLOAT", GET_DOUBLE);
+ accessors.put("DOUBLE", GET_DOUBLE);
+ accessors.put("BINARY", GET_BYTES);
+ accessors.put("VARBINARY", GET_BYTES);
+ accessors.put("LONGVARBINARY", GET_BYTES);
+ accessors.put("DATE", GET_DATE);
+ accessors.put("TIME", GET_TIME);
+ accessors.put("TIMESTAMP", GET_TIMESTAMP);
+ accessors.put("CLOB", GET_CLOB);
+ accessors.put("BLOB", GET_BLOB);
+ accessors.put("ARRAY", GET_ARRAY);
+ accessors.put("STRUCT", GET_OBJECT);
+ accessors.put("REF", GET_REF);
+ accessors.put("DATALINK", GET_URL);
+ accessors.put("JAVA_OBJECT", GET_OBJECT);
+ accessors.put("ROWID", GET_ROW_ID);
+ accessors.put("NCLOB", GET_N_CLOB);
+ accessors.put("SQLXML", GET_SQLXML);
+ return Map.copyOf(accessors);
+ }
+
+ static Stream b1Cases() {
+ return JDBC_TYPES.stream()
+ .filter(JdbcType::inB1B3)
+ .map(type -> Arguments.of(type, B1_ACCESSORS.get(type.name())));
+ }
+
+ @ParameterizedTest(name = "B-1 {0} via {1}")
+ @MethodSource("b1Cases")
+ void b1NaturalGetterReturnsDeclaredJavaType(JdbcType type, Accessor accessor) throws SQLException {
+ LibsqlResultSet resultSet = oneRow(type, type.sample());
+ assertThat(resultSet.next()).isTrue();
+
+ Object value;
+ try {
+ value = accessor.reader().read(resultSet, 1);
+ } catch (SQLException e) {
+ throw new AssertionError(
+ "B-1: " + type.name() + " via " + accessor.name() + " threw " + e, e);
+ }
+
+ assertThat(value)
+ .as("B-1: %s read with %s must be a %s", type.name(), accessor.name(), type.b1JavaType())
+ .isInstanceOf(boxed(type.b1JavaType()));
+ }
+
+ // ---------------------------------------------------------------------
+ // Table B-3: getObject maps each JDBC type to its Java object type.
+ // ---------------------------------------------------------------------
+
+ static Stream b3Cases() {
+ return JDBC_TYPES.stream().filter(JdbcType::inB1B3);
+ }
+
+ @ParameterizedTest(name = "B-3 {0} -> {1}")
+ @MethodSource("b3Cases")
+ void b3GetObjectReturnsDeclaredObjectType(JdbcType type) throws SQLException {
+ LibsqlResultSet resultSet = oneRow(type, type.sample());
+ assertThat(resultSet.next()).isTrue();
+
+ assertThat(resultSet.getObject(1))
+ .as("B-3: %s must read back as %s", type.name(), type.b3ObjectType())
+ .isInstanceOf(type.b3ObjectType());
+ }
+
+ @Test
+ void b3GetObjectReturnsNullForNull() throws SQLException {
+ for (JdbcType type : JDBC_TYPES) {
+ if (!type.inB1B3()) {
+ continue;
+ }
+ LibsqlResultSet resultSet = oneRow(type, new Value.Null());
+ assertThat(resultSet.next()).isTrue();
+ 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();
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Table B-6: which JDBC types each getter may read.
+ // ---------------------------------------------------------------------
+
+ static Stream b6Cases() {
+ List arguments = new ArrayList<>();
+ for (Accessor accessor : ACCESSORS) {
+ for (JdbcType type : JDBC_TYPES) {
+ arguments.add(Arguments.of(accessor, type, accessor.supported().contains(type.name())));
+ }
+ }
+ return arguments.stream();
+ }
+
+ @ParameterizedTest(name = "B-6 {0} on {1} ({2})")
+ @MethodSource("b6Cases")
+ void b6GetterHonoursSupportedJdbcTypes(Accessor getter, JdbcType type, boolean supported)
+ throws SQLException {
+ LibsqlResultSet resultSet = oneRow(type, fixtureFor(getter, type));
+ assertThat(resultSet.next()).isTrue();
+
+ if (supported) {
+ Object value;
+ try {
+ value = getter.reader().read(resultSet, 1);
+ } catch (SQLException e) {
+ throw new AssertionError(
+ "B-6: " + getter.name() + " must support " + type.name() + " but threw " + e, e);
+ }
+ assertThat(value)
+ .as("B-6: %s must support %s", getter.name(), type.name())
+ .isNotNull();
+ if (getter.javaType() != Object.class && getter.javaType() != byte[].class) {
+ assertThat(value)
+ .as("B-6: %s on %s must return %s", getter.name(), type.name(), getter.javaType())
+ .isInstanceOf(boxed(getter.javaType()));
+ }
+ } else {
+ try {
+ Object value = getter.reader().read(resultSet, 1);
+ fail("B-6: " + getter.name() + " must reject " + type.name()
+ + " but returned " + value);
+ } catch (SQLException expected) {
+ // The specification lists no conversion, so rejection is the contract.
+ }
+ }
+ }
+
+ @Test
+ void b6NullIsVisibleThroughWasNull() throws SQLException {
+ for (Accessor getter : ACCESSORS) {
+ LibsqlResultSet resultSet = oneRow(jdbcType("VARCHAR"), new Value.Null());
+ assertThat(resultSet.next()).isTrue();
+ try {
+ getter.reader().read(resultSet, 1);
+ assertThat(resultSet.wasNull())
+ .as("B-6: %s on NULL must report wasNull", getter.name())
+ .isTrue();
+ } catch (SQLException unsupportedOnNull) {
+ // A getter the driver does not implement at all is still allowed to reject NULL.
+ }
+ }
+ }
+
+ @Test
+ void b6NumericGettersRejectNonNumericText() {
+ for (Accessor getter : List.of(
+ GET_BYTE, GET_SHORT, GET_INT, GET_LONG, GET_FLOAT, GET_DOUBLE, GET_BIG_DECIMAL)) {
+ LibsqlResultSet resultSet;
+ try {
+ resultSet = oneRow(jdbcType("VARCHAR"), Value.of("not-a-number"));
+ assertThat(resultSet.next()).isTrue();
+ } catch (SQLException e) {
+ throw new AssertionError(e);
+ }
+ try {
+ Object value = getter.reader().read(resultSet, 1);
+ fail("B-6: " + getter.name()
+ + " must reject non-numeric text instead of coercing to " + value);
+ } catch (SQLException expected) {
+ // Any SQLException is a pass: the spec does not prescribe the exception type.
+ }
+ }
+ }
+
+ @Test
+ void b6TemporalGettersRejectNonDateText() {
+ for (Accessor getter : List.of(GET_DATE, GET_TIME, GET_TIMESTAMP)) {
+ LibsqlResultSet resultSet;
+ try {
+ resultSet = oneRow(jdbcType("VARCHAR"), Value.of("not-a-date"));
+ assertThat(resultSet.next()).isTrue();
+ } catch (SQLException e) {
+ throw new AssertionError(e);
+ }
+ try {
+ Object value = getter.reader().read(resultSet, 1);
+ fail("B-6: " + getter.name() + " must reject non-date text but returned " + value);
+ } catch (SQLException expected) {
+ // Any SQLException is a pass: the spec does not prescribe the exception type.
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Fixtures and helpers.
+ // ---------------------------------------------------------------------
+
+ private static Value fixtureFor(Accessor getter, JdbcType type) {
+ boolean textual = isTextual(type.name());
+ return switch (getter.name()) {
+ case "getByte", "getShort", "getInt", "getLong", "getFloat", "getDouble", "getBigDecimal",
+ "getBoolean" ->
+ textual ? Value.of("42") : type.sample();
+ case "getDate", "getTime", "getTimestamp" -> textual ? TEXT_TIMESTAMP : type.sample();
+ case "getBytes", "getBinaryStream", "getAsciiStream", "getCharacterStream",
+ "getNCharacterStream", "getClob", "getNClob" ->
+ textual ? TEXT_ABC : type.sample();
+ case "getURL" -> TEXT_URL;
+ case "getRowId" -> TEXT_ROWID;
+ case "getSQLXML" -> TEXT_XML;
+ case "getArray", "getRef" -> TEXT_ABC;
+ default -> type.sample();
+ };
+ }
+
+ private static boolean isTextual(String name) {
+ return switch (name) {
+ case "CHAR", "VARCHAR", "LONGVARCHAR", "NCHAR", "NVARCHAR", "LONGNVARCHAR", "CLOB",
+ "NCLOB", "DATE", "TIME", "TIMESTAMP", "DATALINK", "SQLXML", "ROWID", "ARRAY",
+ "STRUCT", "REF", "JAVA_OBJECT", "DISTINCT", "TIME_WITH_TIMEZONE",
+ "TIMESTAMP_WITH_TIMEZONE" ->
+ true;
+ default -> false;
+ };
+ }
+
+ private static LibsqlResultSet oneRow(JdbcType type, Value value) {
+ return oneRow(type.name(), value);
+ }
+
+ 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));
+ }
+
+ private static JdbcType jdbcType(String name) {
+ return JDBC_TYPES.stream()
+ .filter(type -> type.name().equals(name))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException(name));
+ }
+
+ private static Class> boxed(Class> type) {
+ if (!type.isPrimitive()) {
+ return type;
+ }
+ if (type == byte.class) {
+ return Byte.class;
+ }
+ if (type == short.class) {
+ return Short.class;
+ }
+ if (type == int.class) {
+ return Integer.class;
+ }
+ if (type == long.class) {
+ return Long.class;
+ }
+ if (type == float.class) {
+ return Float.class;
+ }
+ if (type == double.class) {
+ return Double.class;
+ }
+ if (type == boolean.class) {
+ return Boolean.class;
+ }
+ if (type == char.class) {
+ return Character.class;
+ }
+ throw new IllegalArgumentException(type.getName());
+ }
+
+ private static Set set(String... names) {
+ return Set.of(names);
+ }
+
+ private static Set numericText() {
+ return set(
+ "TINYINT",
+ "SMALLINT",
+ "INTEGER",
+ "BIGINT",
+ "REAL",
+ "FLOAT",
+ "DOUBLE",
+ "DECIMAL",
+ "NUMERIC",
+ "BIT",
+ "BOOLEAN",
+ "CHAR",
+ "VARCHAR",
+ "LONGVARCHAR");
+ }
+
+ private static Set numericTextWithRowId() {
+ Set names = new java.util.HashSet<>(numericText());
+ names.add("ROWID");
+ return Set.copyOf(names);
+ }
+
+ private static Set stringTargets() {
+ return set(
+ "TINYINT",
+ "SMALLINT",
+ "INTEGER",
+ "BIGINT",
+ "REAL",
+ "FLOAT",
+ "DOUBLE",
+ "DECIMAL",
+ "NUMERIC",
+ "BIT",
+ "BOOLEAN",
+ "CHAR",
+ "VARCHAR",
+ "LONGVARCHAR",
+ "BINARY",
+ "VARBINARY",
+ "LONGVARBINARY",
+ "DATE",
+ "TIME",
+ "TIMESTAMP",
+ "DATALINK",
+ "NCHAR",
+ "NVARCHAR",
+ "LONGNVARCHAR");
+ }
+
+ private static Set allJdbcTypes() {
+ return JDBC_TYPES.stream()
+ .map(JdbcType::name)
+ .collect(java.util.stream.Collectors.toUnmodifiableSet());
+ }
+}
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
new file mode 100644
index 0000000..d64cbc0
--- /dev/null
+++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBSetObjectTest.java
@@ -0,0 +1,295 @@
+package com.github.magnusp.libsql.jdbc;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.lang.reflect.Proxy;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+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.SQLException;
+import java.sql.SQLXML;
+import java.sql.Struct;
+import java.sql.Time;
+import java.sql.Timestamp;
+import java.sql.Types;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * JDBC 4.3 Appendix B, Table B-5, negative half: a target JDBC type the table does not list for a
+ * Java object type must not be accepted.
+ *
+ * Table B-5 says which JDBC types {@code setObject} and {@code setNull} "may" be given for each
+ * Java type. This suite takes the strict reading: a listed pair must be accepted (covered by the
+ * integration suite, which performs the round trip), and an unlisted pair must be rejected with a
+ * {@link SQLException}. Any {@link SQLException} is a pass because the specification does not
+ * prescribe the exception type.
+ *
+ *
No connection is needed: binding happens locally before the statement is executed, so a
+ * statement over a {@code null} connection is enough to observe whether the target type is
+ * validated.
+ */
+class Jdbc4AppendixBSetObjectTest {
+
+ record Target(String name, int sqlType) {}
+
+ private static final List TARGETS = List.of(
+ new Target("TINYINT", Types.TINYINT),
+ new Target("SMALLINT", Types.SMALLINT),
+ new Target("INTEGER", Types.INTEGER),
+ new Target("BIGINT", Types.BIGINT),
+ new Target("REAL", Types.REAL),
+ new Target("FLOAT", Types.FLOAT),
+ new Target("DOUBLE", Types.DOUBLE),
+ new Target("DECIMAL", Types.DECIMAL),
+ new Target("NUMERIC", Types.NUMERIC),
+ new Target("BIT", Types.BIT),
+ new Target("BOOLEAN", Types.BOOLEAN),
+ new Target("CHAR", Types.CHAR),
+ new Target("VARCHAR", Types.VARCHAR),
+ new Target("LONGVARCHAR", Types.LONGVARCHAR),
+ new Target("BINARY", Types.BINARY),
+ new Target("VARBINARY", Types.VARBINARY),
+ new Target("LONGVARBINARY", Types.LONGVARBINARY),
+ new Target("DATE", Types.DATE),
+ new Target("TIME", Types.TIME),
+ new Target("TIMESTAMP", Types.TIMESTAMP),
+ new Target("NCHAR", Types.NCHAR),
+ new Target("NVARCHAR", Types.NVARCHAR),
+ new Target("LONGNVARCHAR", Types.LONGNVARCHAR),
+ new Target("ARRAY", Types.ARRAY),
+ new Target("BLOB", Types.BLOB),
+ new Target("CLOB", Types.CLOB),
+ new Target("STRUCT", Types.STRUCT),
+ new Target("REF", Types.REF),
+ new Target("DATALINK", Types.DATALINK),
+ new Target("JAVA_OBJECT", Types.JAVA_OBJECT),
+ new Target("ROWID", Types.ROWID),
+ 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));
+
+ record JavaRow(String name, Object value, Set targets) {}
+
+ private static final Set NUMERIC_TEXT = Set.of(
+ Types.TINYINT,
+ Types.SMALLINT,
+ Types.INTEGER,
+ Types.BIGINT,
+ Types.REAL,
+ Types.FLOAT,
+ Types.DOUBLE,
+ Types.DECIMAL,
+ Types.NUMERIC,
+ Types.BIT,
+ Types.BOOLEAN,
+ Types.CHAR,
+ Types.VARCHAR,
+ Types.LONGVARCHAR);
+
+ private static Set set(int... sqlTypes) {
+ return java.util.Arrays.stream(sqlTypes).boxed().collect(java.util.stream.Collectors.toUnmodifiableSet());
+ }
+
+ private static java.net.URL url(String value) {
+ try {
+ return URI.create(value).toURL();
+ } catch (java.net.MalformedURLException e) {
+ throw new IllegalArgumentException(value, e);
+ }
+ }
+
+ private static Set union(Set base, int... extra) {
+ Set result = new java.util.HashSet<>(base);
+ for (int sqlType : extra) {
+ result.add(sqlType);
+ }
+ return Set.copyOf(result);
+ }
+
+ private static final Calendar CALENDAR = calendar2024();
+
+ private static Calendar calendar2024() {
+ Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+ calendar.clear();
+ calendar.set(2024, Calendar.JANUARY, 2, 3, 4, 5);
+ return calendar;
+ }
+
+ private static final java.util.Date UTIL_DATE =
+ new java.util.Date(CALENDAR.getTimeInMillis());
+
+ private static final List ROWS = List.of(
+ new JavaRow(
+ "String",
+ "42",
+ union(NUMERIC_TEXT, Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY,
+ Types.DATE, Types.TIME, Types.TIMESTAMP, Types.NCHAR, Types.NVARCHAR,
+ Types.LONGNVARCHAR)),
+ new JavaRow("BigDecimal", new BigDecimal("7"), NUMERIC_TEXT),
+ new JavaRow("Boolean", Boolean.TRUE, NUMERIC_TEXT),
+ new JavaRow("Byte", (byte) 7, NUMERIC_TEXT),
+ new JavaRow("Short", (short) 7, NUMERIC_TEXT),
+ new JavaRow("Integer", 7, NUMERIC_TEXT),
+ new JavaRow("Long", 7L, NUMERIC_TEXT),
+ new JavaRow("Float", 7.0f, NUMERIC_TEXT),
+ new JavaRow("Double", 7.0d, NUMERIC_TEXT),
+ new JavaRow("byte[]", new byte[] {1, 2, 3},
+ set(Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY)),
+ new JavaRow("BigInteger", BigInteger.valueOf(7),
+ set(Types.BIGINT, Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR)),
+ new JavaRow("java.sql.Date", Date.valueOf("2024-01-02"),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.DATE, Types.TIMESTAMP)),
+ new JavaRow("java.sql.Time", Time.valueOf("03:04:05"),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.TIME, Types.TIMESTAMP)),
+ new JavaRow("java.sql.Timestamp", Timestamp.valueOf("2024-01-02 03:04:05"),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.DATE, Types.TIME,
+ Types.TIMESTAMP)),
+ new JavaRow("java.sql.Array", stub(Array.class), set(Types.ARRAY)),
+ new JavaRow("java.sql.Blob", new LibsqlBlob("abc".getBytes(StandardCharsets.UTF_8)),
+ set(Types.BLOB)),
+ new JavaRow("java.sql.Clob", new LibsqlClob("abc"), set(Types.CLOB)),
+ new JavaRow("java.sql.Struct", stub(Struct.class), set(Types.STRUCT)),
+ new JavaRow("java.sql.Ref", stub(Ref.class), set(Types.REF)),
+ new JavaRow("java.net.URL", url("https://example.com"),
+ set(Types.DATALINK)),
+ new JavaRow("Java class", new Object(), set(Types.JAVA_OBJECT)),
+ new JavaRow("java.sql.RowId", stub(RowId.class), set(Types.ROWID)),
+ new JavaRow("java.sql.NClob", stub(NClob.class), set(Types.NCLOB)),
+ new JavaRow("java.sql.SQLXML", stub(SQLXML.class), set(Types.SQLXML)),
+ new JavaRow("java.util.Calendar", CALENDAR,
+ union(set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.DATE, Types.TIME,
+ Types.TIMESTAMP), Types.ARRAY)),
+ new JavaRow("java.util.Date", UTIL_DATE,
+ union(set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.DATE, Types.TIME,
+ Types.TIMESTAMP), Types.ARRAY)),
+ new JavaRow("java.time.LocalDate", LocalDate.of(2024, 1, 2),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.DATE)),
+ new JavaRow("java.time.LocalTime", LocalTime.of(3, 4, 5),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.TIME)),
+ new JavaRow("java.time.LocalDateTime", LocalDateTime.of(2024, 1, 2, 3, 4, 5),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.DATE, Types.TIME,
+ Types.TIMESTAMP)),
+ new JavaRow("java.time.OffsetTime", OffsetTime.of(3, 4, 5, 0, ZoneOffset.UTC),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.TIME_WITH_TIMEZONE)),
+ new JavaRow("java.time.OffsetDateTime",
+ OffsetDateTime.of(2024, 1, 2, 3, 4, 5, 0, ZoneOffset.UTC),
+ set(Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.TIME_WITH_TIMEZONE,
+ Types.TIMESTAMP_WITH_TIMEZONE)));
+
+ static Stream javaRows() {
+ return ROWS.stream();
+ }
+
+ @ParameterizedTest(name = "B-5 rejects unlisted targets for {0}")
+ @MethodSource("javaRows")
+ void b5SetObjectRejectsUnlistedTargetTypes(JavaRow row) {
+ List checks = new ArrayList<>();
+ for (Target target : TARGETS) {
+ if (row.targets().contains(target.sqlType())) {
+ continue;
+ }
+ checks.add(() -> {
+ LibsqlPreparedStatement statement = new LibsqlPreparedStatement(null, "?");
+ try {
+ statement.setObject(1, row.value(), target.sqlType());
+ fail("B-5: setObject(" + row.name() + ", " + target.name()
+ + ") must be rejected, but it was accepted");
+ } catch (SQLException expected) {
+ // Any SQLException is a pass.
+ }
+ });
+ }
+ assertAll("B-5 " + row.name(), checks);
+ }
+
+ @Test
+ void b5SetObjectAcceptsListedTargetTypes() throws SQLException {
+ for (JavaRow row : ROWS) {
+ for (Target target : TARGETS) {
+ if (!row.targets().contains(target.sqlType())) {
+ continue;
+ }
+ LibsqlPreparedStatement statement = new LibsqlPreparedStatement(null, "?");
+ statement.setObject(1, row.value(), target.sqlType());
+ }
+ }
+ }
+
+ @Test
+ void b5SetNullAcceptsEveryTargetType() throws SQLException {
+ for (Target target : TARGETS) {
+ LibsqlPreparedStatement statement = new LibsqlPreparedStatement(null, "?");
+ statement.setNull(1, target.sqlType());
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static T stub(Class type) {
+ return (T) Proxy.newProxyInstance(
+ type.getClassLoader(),
+ new Class>[] {type},
+ (proxy, method, args) -> switch (method.getName()) {
+ case "toString" -> "stub";
+ case "hashCode" -> System.identityHashCode(proxy);
+ case "equals" -> proxy == args[0];
+ default -> defaultValue(method.getReturnType());
+ });
+ }
+
+ private static Object defaultValue(Class> type) {
+ if (!type.isPrimitive()) {
+ return null;
+ }
+ if (type == boolean.class) {
+ return false;
+ }
+ if (type == char.class) {
+ return '\0';
+ }
+ if (type == byte.class) {
+ return (byte) 0;
+ }
+ if (type == short.class) {
+ return (short) 0;
+ }
+ if (type == int.class) {
+ return 0;
+ }
+ if (type == long.class) {
+ return 0L;
+ }
+ if (type == float.class) {
+ return 0.0f;
+ }
+ if (type == double.class) {
+ return 0.0d;
+ }
+ return null;
+ }
+}
diff --git a/vendor/JDBC4-TYPE-MAPPING.md b/vendor/JDBC4-TYPE-MAPPING.md
new file mode 100644
index 0000000..47fae2e
--- /dev/null
+++ b/vendor/JDBC4-TYPE-MAPPING.md
@@ -0,0 +1,274 @@
+# JDBC 4.3 Appendix B — Data Type Conversion Tables
+
+Source: JDBC 4.3 Specification (JSR 000221, Maintenance Release 3, July 2017),
+Appendix B, pages 189–201.
+
+Reproduced as published, with the typographical errors listed below corrected.
+Tables B-5 and B-6 are prose tables in 4.3; they were matrices in 4.0/4.1.
+
+## Errata corrected in this copy
+
+| Published | Corrected | Location |
+|--------------------------------------------|------------------------------|--------------------|
+| `java.sql.array` | `java.sql.Array` | Table B-1 |
+| `LONVARBINARY` | `LONGVARBINARY` | Tables B-5 and B-6 |
+| `java.time.OffsetDatetime` | `java.time.OffsetDateTime` | Tables B-4 and B-5 |
+| `Java Object Type` (column of getter names) | `ResultSet getter` | Table B-6 |
+
+## B.1 JDBC Types Mapped to Java Types
+
+TABLE B-1 shows the conceptual correspondence between JDBC types and Java types.
+A programmer should write code with this mapping in mind. For example, if a value
+in the database is a SMALLINT, a short should be the data type used in a JDBC
+application.
+
+All CallableStatement getter methods except for getObject use this mapping. The
+getObject methods for both the CallableStatement and ResultSet interfaces use the
+mapping in TABLE B-3.
+
+**TABLE B-1 — JDBC Types Mapped to Java Types**
+
+| JDBC Type | Java Type |
+|---------------|----------------------------|
+| CHAR | String |
+| VARCHAR | String |
+| LONGVARCHAR | String |
+| NUMERIC | java.math.BigDecimal |
+| DECIMAL | java.math.BigDecimal |
+| BIT | boolean |
+| BOOLEAN | boolean |
+| TINYINT | byte |
+| SMALLINT | short |
+| INTEGER | int |
+| BIGINT | long |
+| REAL | float |
+| FLOAT | double |
+| DOUBLE | double |
+| BINARY | byte[] |
+| VARBINARY | byte[] |
+| LONGVARBINARY | byte[] |
+| DATE | java.sql.Date |
+| TIME | java.sql.Time |
+| TIMESTAMP | java.sql.Timestamp |
+| CLOB | java.sql.Clob |
+| BLOB | java.sql.Blob |
+| ARRAY | java.sql.Array |
+| DISTINCT | Mapping of underlying type |
+| STRUCT | java.sql.Struct |
+| REF | java.sql.Ref |
+| DATALINK | java.net.URL |
+| JAVA_OBJECT | Underlying Java class |
+| ROWID | java.sql.RowId |
+| NCHAR | String |
+| NVARCHAR | String |
+| LONGNVARCHAR | String |
+| NCLOB | java.sql.NClob |
+| SQLXML | java.sql.SQLXML |
+
+## B.2 Java Types Mapped to JDBC Types
+
+TABLE B-2 shows the mapping a driver should use for the updater methods in the
+ResultSet interface and for IN parameters. PreparedStatement setter methods and
+RowSet setter methods use this table for mapping an IN parameter, which is a Java
+type, to the JDBC type that will be sent to the database. Note that the setObject
+methods for these two interfaces use the mapping shown in TABLE B-4.
+
+**TABLE B-2 — Standard Mapping from Java Types to JDBC Types**
+
+| Java Type | JDBC Type |
+|----------------------|-------------------------------------------------------------|
+| String | CHAR, VARCHAR, LONGVARCHAR, NCHAR, NVARCHAR or LONGNVARCHAR |
+| java.math.BigDecimal | NUMERIC |
+| boolean | BIT or BOOLEAN |
+| byte | TINYINT |
+| short | SMALLINT |
+| int | INTEGER |
+| long | BIGINT |
+| float | REAL |
+| double | DOUBLE |
+| byte[] | BINARY, VARBINARY, or LONGVARBINARY |
+| java.sql.Date | DATE |
+| java.sql.Time | TIME |
+| java.sql.Timestamp | TIMESTAMP |
+| java.sql.Clob | CLOB |
+| java.sql.Blob | BLOB |
+| java.sql.Array | ARRAY |
+| java.sql.Struct | STRUCT |
+| java.sql.Ref | REF |
+| java.net.URL | DATALINK |
+| Java class | JAVA_OBJECT |
+| java.sql.RowId | ROWID |
+| java.sql.NClob | NCLOB |
+| java.sql.SQLXML | SQLXML |
+
+## B.3 JDBC Types Mapped to Java Object Types
+
+ResultSet.getObject and CallableStatement.getObject use the mapping shown in
+TABLE B-3 for standard mappings.
+
+> **Note** — The JDBC 1.0 specification defined the Java object mapping for the
+> SMALLINT and TINYINT JDBC types to be Integer. The Java language did not
+> include the Byte and Short data types when the JDBC 1.0 specification was
+> finalized. The mapping of SMALLINT and TINYINT to Integer is maintained to
+> preserve backwards compatibility.
+
+**TABLE B-3 — Mapping from JDBC Types to Java Object Types**
+
+| JDBC Type | Java Object Type |
+|---------------|-------------------------------------|
+| CHAR | String |
+| VARCHAR | String |
+| LONGVARCHAR | String |
+| NUMERIC | java.math.BigDecimal |
+| DECIMAL | java.math.BigDecimal |
+| BIT | Boolean |
+| BOOLEAN | Boolean |
+| TINYINT | Integer |
+| SMALLINT | Integer |
+| INTEGER | Integer |
+| BIGINT | Long |
+| REAL | Float |
+| FLOAT | Double |
+| DOUBLE | Double |
+| BINARY | byte[] |
+| VARBINARY | byte[] |
+| LONGVARBINARY | byte[] |
+| DATE | java.sql.Date |
+| TIME | java.sql.Time |
+| TIMESTAMP | java.sql.Timestamp |
+| DISTINCT | Object type of underlying type |
+| CLOB | java.sql.Clob |
+| BLOB | java.sql.Blob |
+| ARRAY | java.sql.Array |
+| STRUCT | java.sql.Struct or java.sql.SQLData |
+| REF | java.sql.Ref |
+| DATALINK | java.net.URL |
+| JAVA_OBJECT | Underlying Java class |
+| ROWID | java.sql.RowId |
+| NCHAR | String |
+| NVARCHAR | String |
+| LONGNVARCHAR | String |
+| NCLOB | java.sql.NClob |
+| SQLXML | java.sql.SQLXML |
+
+## B.4 Java Object Types Mapped to JDBC Types
+
+PreparedStatement.setObject, PreparedStatement.setNull, RowSet.setNull and
+RowSet.setObject use the mapping shown in TABLE B-4 when no parameter specifying
+a target JDBC type is provided.
+
+**TABLE B-4 — Mapping from Java Object Types to JDBC Types**
+
+| Java Object Type | JDBC Type |
+|----------------------|-------------------------------------------------------------|
+| String | CHAR, VARCHAR, LONGVARCHAR, NCHAR, NVARCHAR or LONGNVARCHAR |
+| java.math.BigDecimal | NUMERIC |
+| Boolean | BIT or BOOLEAN |
+| Byte | TINYINT |
+| Short | SMALLINT |
+| Integer | INTEGER |
+| Long | BIGINT |
+| Float | REAL |
+| Double | DOUBLE |
+| byte[] | BINARY, VARBINARY, or LONGVARBINARY |
+| java.math.BigInteger | BIGINT |
+| java.sql.Date | DATE |
+| java.sql.Time | TIME |
+| java.sql.Timestamp | TIMESTAMP |
+| java.sql.Clob | CLOB |
+| java.sql.Blob | BLOB |
+| java.sql.Array | ARRAY |
+| java.sql.Struct | STRUCT |
+| java.sql.Ref | REF |
+| java.net.URL | DATALINK |
+| Java class | JAVA_OBJECT |
+| java.sql.RowId | ROWID |
+| java.sql.NClob | NCLOB |
+| java.sql.SQLXML | SQLXML |
+| java.util.Calendar | TIMESTAMP |
+| java.util.Date | TIMESTAMP |
+| java.time.LocalDate | DATE |
+| java.time.LocalTime | TIME |
+| java.time.LocalDateTime | TIMESTAMP |
+| java.time.OffsetTime | TIME_WITH_TIMEZONE |
+| java.time.OffsetDateTime | TIMESTAMP_WITH_TIMEZONE |
+
+## B.5 Conversions by setObject and setNull from Java Object Types to JDBC Types
+
+TABLE B-5 shows which JDBC types may be specified as the target JDBC type to the
+methods PreparedStatement.setObject, PreparedStatement.setNull,
+RowSet.setNull, and RowSet.setObject.
+
+**TABLE B-5 — Conversions Performed by setObject and setNull Between Java Object Types and Target JDBC Types**
+
+| Java Object Type | Supported JDBC Type |
+|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| String | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, DATE, TIME, TIMESTAMP, NCHAR, NVARCHAR, LONGNVARCHAR |
+| java.math.BigDecimal | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Boolean | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Byte | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Short | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Integer | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Long | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Float | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Double | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| byte[] | BINARY, VARBINARY, or LONGVARBINARY |
+| java.math.BigInteger | BIGINT, CHAR, VARCHAR, LONGVARCHAR |
+| java.sql.Date | CHAR, VARCHAR, LONGVARCHAR, DATE, TIMESTAMP |
+| java.sql.Time | CHAR, VARCHAR, LONGVARCHAR, TIME, TIMESTAMP |
+| java.sql.Timestamp | CHAR, VARCHAR, LONGVARCHAR, DATE, TIME, TIMESTAMP |
+| java.sql.Array | ARRAY |
+| java.sql.Blob | BLOB |
+| java.sql.Clob | CLOB |
+| java.sql.Struct | STRUCT |
+| java.sql.Ref | REF |
+| java.net.URL | DATALINK |
+| Java class | JAVA_OBJECT |
+| java.sql.RowId | ROWID |
+| java.sql.NClob | NCLOB |
+| java.sql.SQLXML | SQLXML |
+| java.util.Calendar | CHAR, VARCHAR, LONGVARCHAR, DATE, TIME, TIMESTAMP, ARRAY |
+| java.util.Date | CHAR, VARCHAR, LONGVARCHAR, DATE, TIME, TIMESTAMP, ARRAY |
+| java.time.LocalDate | CHAR, VARCHAR, LONGVARCHAR, DATE |
+| java.time.LocalTime | CHAR, VARCHAR, LONGVARCHAR, TIME |
+| java.time.LocalDateTime | CHAR, VARCHAR, LONGVARCHAR, DATE, TIME, TIMESTAMP |
+| java.time.OffsetTime | CHAR, VARCHAR, LONGVARCHAR, TIME_WITH_TIMEZONE |
+| java.time.OffsetDateTime | CHAR, VARCHAR, LONGVARCHAR, TIME_WITH_TIMEZONE, TIMESTAMP_WITH_TIMEZONE |
+
+## B.6 Type Conversions Supported by ResultSet getter Methods
+
+TABLE B-6 shows which JDBC types may be returned by ResultSet getter methods.
+This table also shows the conversions used by the SQLInput reader methods, except
+that they use only the recommended conversions.
+
+**TABLE B-6 — Use of ResultSet getter Methods to Retrieve JDBC Data Types**
+
+| ResultSet getter | Recommended JDBC Type | Supported JDBC Type |
+|---------------------|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| getByte | TINYINT | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR, ROWID |
+| getShort | SMALLINT | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getInt | INTEGER | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getLong | BIGINT | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getFloat | REAL | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getDouble | FLOAT, DOUBLE | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getBigDecimal | DECIMAL, NUMERIC | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getBoolean | BIT, BOOLEAN | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getString | CHAR, VARCHAR | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, DATE, TIME, TIMESTAMP, DATALINK, NCHAR, NVARCHAR, LONGNVARCHAR |
+| getNString | NCHAR, NVARCHAR | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, DATE, TIME, TIMESTAMP, DATALINK, NCHAR, NVARCHAR, LONGNVARCHAR |
+| getBytes | BINARY, VARBINARY | BINARY, VARBINARY, LONGVARBINARY |
+| getDate | DATE | CHAR, VARCHAR, LONGVARCHAR, DATE, TIMESTAMP |
+| getTime | TIME | CHAR, VARCHAR, LONGVARCHAR, TIME, TIMESTAMP |
+| getTimestamp | TIMESTAMP | CHAR, VARCHAR, LONGVARCHAR, DATE, TIME, TIMESTAMP |
+| getAsciiStream | LONGVARCHAR | CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, CLOB, NCLOB |
+| getBinaryStream | LONGVARBINARY | BINARY, VARBINARY, LONGVARBINARY |
+| getCharacterStream | LONGVARCHAR | CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, CLOB, NCHAR, NVARCHAR, LONGNVARCHAR, NCLOB, SQLXML |
+| getNCharacterStream | LONGNVARCHAR | CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, CLOB, NCHAR, NVARCHAR, LONGNVARCHAR, NCLOB, SQLXML |
+| getClob | CLOB | CLOB, NCLOB |
+| getNClob | NCLOB | CLOB, NCLOB |
+| getBlob | BLOB | BLOB |
+| getArray | ARRAY | ARRAY |
+| getRef | REF | REF |
+| getURL | DATALINK | DATALINK |
+| getObject | STRUCT, JAVA_OBJECT | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, DATE, TIME, TIMESTAMP, CLOB, BLOB, ARRAY, REF, DATALINK, STRUCT, JAVA_OBJECT, ROWID, NCHAR, NVARCHAR, LONGNVARCHAR, NCLOB, SQLXML, TIME_WITH_TIMEZONE, TIMESTAMP_WITH_TIMEZONE |
+| getRowId | ROWID | ROWID |
+| getSQLXML | SQLXML | SQLXML |