From aafd14ce78dbfdfe909ee0e9c1250feaa7775774 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:36:25 +0200 Subject: [PATCH 01/29] Add type mapping doc --- vendor/JDBC4-TYPE-MAPPING.md | 244 +++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 vendor/JDBC4-TYPE-MAPPING.md diff --git a/vendor/JDBC4-TYPE-MAPPING.md b/vendor/JDBC4-TYPE-MAPPING.md new file mode 100644 index 0000000..94b48d1 --- /dev/null +++ b/vendor/JDBC4-TYPE-MAPPING.md @@ -0,0 +1,244 @@ +# TABLE B-1 JDBC Types Mapped to Java Types + +This table 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. + +| 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 | + +# TABLE B-2 Java Types Mapper to JDBC Types + +This table 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. + +| 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 | + +# TABLE B-3 JDBC Types Mapped to Java Object Types + +ResultSet.getObject and CallableStatement.getObject use the mapping shown in this +table for standard mappings. + +| 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 | + +# TABLE B-4 Java Object Types Mapped to JDBC Types + +PreparedStatement.setObject, PreparedStatement.setNull, RowSet.setNull and +RowSet.setObject use the mapping shown in this table when no parameter +specifying a target JDBC type is provided. + +| 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.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 | + +# TABLE B-5 Conversions by setObject and setNull from Java Object Types to JDBC Types + +This table shows which JDBC types may be specified as the target JDBC type to the +methods PreparedStatement.setObject, PreparedStatement.setNull, RowSet.setNull +and RowSet.setObject. + +| Java object type | TINYINT | SMALLINT | INTEGER | BIGINT | REAL | FLOAT | DOUBLE | DECIMAL | NUMERIC | BIT | BOOLEAN | CHAR | VARCHAR | LONGVARCHAR | BINARY | VARBINARY | LONGVARBINARY | DATE | TIME | TIMESTAMP | ARRAY | BLOB | CLOB | STRUCT | REF | DATALINK | JAVA_OBJECT | ROWID | NCHAR | NVARCHAR | NCLOB | SQLXML | +|----------------------|---------|----------|---------|--------|------|-------|--------|---------|---------|-----|---------|------|---------|-------------|--------|-----------|---------------|------|------|-----------|-------|------|------|--------|-----|----------|-------------|-------|-------|----------|-------|--------| +| String | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | x | x | | | +| java.math.BigDecimal | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | +| Boolean | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | +| Byte | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | +| Short | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | +| Integer | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | +| Long | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | +| Float | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | +| Double | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | +| byte[] | | | | | | | | | | | | | | | x | x | x | | | | | | | | | | | | | | | | +| java.sql.Date | | | | | | | | | | | | x | x | x | | | | x | | x | | | | | | | | | | | | | +| java.sql.Time | | | | | | | | | | | | x | x | x | | | | | x | x | | | | | | | | | | | | | +| java.sql.Timestamp | | | | | | | | | | | | x | x | x | | | | x | x | x | | | | | | | | | | | | | +| java.sql.Array | | | | | | | | | | | | | | | | | | | | | x | | | | | | | | | | | | +| java.sql.Blob | | | | | | | | | | | | | | | | | | | | | | x | | | | | | | | | | | +| java.sql.Clob | | | | | | | | | | | | | | | | | | | | | | | x | | | | | | | | | | +| java.sql.Struct | | | | | | | | | | | | | | | | | | | | | | | | x | | | | | | | | | +| java.sql.Ref | | | | | | | | | | | | | | | | | | | | | | | | | x | | | | | | | | +| java.net.URL | | | | | | | | | | | | | | | | | | | | | | | | | | x | | | | | | | +| Java class | | | | | | | | | | | | | | | | | | | | | | | | | | | x | | | | | | +| java.sql.RowId | | | | | | | | | | | | | | | | | | | | | | | | | | | | x | | | | | +| java.sql.NClob | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | x | | +| java.sql.SQLXML | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | x | + +Notes: + +- Blank cells mean "not a valid target type for `setObject`/`setNull`"; `x` means the driver must support the + conversion. +- JDBC 4.1+ adds the `LONGNVARCHAR` column (`String` also targets it), plus rows for `BigInteger`, `java.util.Calendar`, + and `java.util.Date`; JDBC 4.2+ adds `java.time` rows. +- `java.sql.Date`/`Time` do not cross-map to each other — only `Timestamp` targets all three of `DATE`, `TIME`, + `TIMESTAMP`. + +# TABLE B-6 Type Conversions Supported by ResultSet getter Methods + +This table shows which JDBC types may be returned by ResultSet getter methods. A +bold X indicates the method recommended for retrieving a JDBC type. A plain x +indicates for which JDBC types it is possible to use a getter method. + +That image is the JDBC spec's **Table B-6 — Use of `ResultSet` getter Methods to Retrieve JDBC Data Types**. Converted +(`**X**` = recommended getter, `x` = supported conversion, blank = not supported): + +| ResultSet getter | 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 | +|---------------------|---------|----------|---------|--------|-------|-------|--------|---------|---------|-------|---------|-------|---------|-------------|--------|-----------|---------------|-------|-------|-----------|-------|-------|-------|-------|----------|--------|-------------|-------|-------|----------|--------------|-------|--------| +| getByte | **X** | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | x | | | | | | +| getShort | x | **X** | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | | +| getInt | x | x | **X** | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | | +| getLong | x | x | x | **X** | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | | +| getFloat | x | x | x | x | **X** | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | | +| getDouble | x | x | x | x | x | **X** | **X** | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | | +| getBigDecimal | x | x | x | x | x | x | x | **X** | **X** | x | x | x | x | x | | | | | | | | | | | | | | | | | | | | +| getBoolean | x | x | x | x | x | x | x | x | x | **X** | **X** | x | x | x | | | | | | | | | | | | | | | | | | | | +| getString | x | x | x | x | x | x | x | x | x | x | x | **X** | **X** | x | x | x | x | x | x | x | | | | | x | | | | x | x | x | | | +| getNString | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | x | | | | **X** | **X** | x | | | +| getBytes | | | | | | | | | | | | | | | **X** | **X** | x | | | | | | | | | | | | | | | | | +| getDate | | | | | | | | | | | | x | x | x | | | | **X** | | x | | | | | | | | | | | | | | +| getTime | | | | | | | | | | | | x | x | x | | | | | **X** | x | | | | | | | | | | | | | | +| getTimestamp | | | | | | | | | | | | x | x | x | | | | x | x | **X** | | | | | | | | | | | | | | +| getAsciiStream | | | | | | | | | | | | x | x | **X** | x | x | x | | | | x | | | | | | | | | | | x | | +| getBinaryStream | | | | | | | | | | | | | | | x | x | **X** | | | | | x | | | | | | | | | | | x | +| getCharacterStream | | | | | | | | | | | | x | x | **X** | x | x | x | | | | x | | | | | | | | x | x | x | x | x | +| getNCharacterStream | | | | | | | | | | | | x | x | x | x | x | x | | | | x | | | | | | | | x | x | **X** | x | x | +| getClob | | | | | | | | | | | | | | | | | | | | | **X** | | | | | | | | | | | x | | +| getNClob | | | | | | | | | | | | | | | | | | | | | x | | | | | | | | | | | **X** | | +| getBlob | | | | | | | | | | | | | | | | | | | | | | **X** | | | | | | | | | | | | +| getArray | | | | | | | | | | | | | | | | | | | | | | | **X** | | | | | | | | | | | +| getRef | | | | | | | | | | | | | | | | | | | | | | | | **X** | | | | | | | | | | +| getURL | | | | | | | | | | | | | | | | | | | | | | | | | **X** | | | | | | | | | +| getObject | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | **X** | **X** | x | x | x | x | x | x | +| getRowId | | | | | | | | | | | | | | | | | | | | | | | | | | | | **X** | | | | | | +| getSQLXML | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **X** | + +Notes: + +- Read a column as: "this JDBC type can be fetched with this getter"; the bold `X` is the JDBC-recommended getter for + that SQL type. +- Worth flagging because it surprises people: `getObject` accepts every listed SQL type; `getString` covers all numeric + types plus binary and date/time; and `getBinaryStream` in this revision lists `BLOB` and `SQLXML` (later revisions + narrowed it to the binary types). +- The column order here follows the printed table (`CLOB, BLOB, ARRAY, REF, …`); the current JDBC 4.3 spec presents the + same content as prose rather than a matrix. \ No newline at end of file From 3e8f582c0563323c91d825ab80b6708eb58f700d Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:24:25 +0200 Subject: [PATCH 02/29] Update to 4.3 --- vendor/JDBC4-TYPE-MAPPING.md | 240 ++++++++++++++++++++--------------- 1 file changed, 135 insertions(+), 105 deletions(-) diff --git a/vendor/JDBC4-TYPE-MAPPING.md b/vendor/JDBC4-TYPE-MAPPING.md index 94b48d1..47fae2e 100644 --- a/vendor/JDBC4-TYPE-MAPPING.md +++ b/vendor/JDBC4-TYPE-MAPPING.md @@ -1,13 +1,33 @@ -# TABLE B-1 JDBC Types Mapped to Java Types +# 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. -This table 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 | @@ -33,11 +53,11 @@ mapping in TABLE B-3. | CLOB | java.sql.Clob | | BLOB | java.sql.Blob | | ARRAY | java.sql.Array | -| DISTINCT | mapping of underlying type | +| DISTINCT | Mapping of underlying type | | STRUCT | java.sql.Struct | | REF | java.sql.Ref | | DATALINK | java.net.URL | -| JAVA_OBJECT | underlying Java class | +| JAVA_OBJECT | Underlying Java class | | ROWID | java.sql.RowId | | NCHAR | String | | NVARCHAR | String | @@ -45,14 +65,16 @@ mapping in TABLE B-3. | NCLOB | java.sql.NClob | | SQLXML | java.sql.SQLXML | -# TABLE B-2 Java Types Mapper to JDBC Types +## B.2 Java Types Mapped to JDBC Types -This table shows the mapping a driver should use for the updater methods in the +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 | @@ -79,10 +101,18 @@ methods for these two interfaces use the mapping shown in TABLE B-4. | java.sql.NClob | NCLOB | | java.sql.SQLXML | SQLXML | -# TABLE B-3 JDBC Types Mapped to Java Object Types +## 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. -ResultSet.getObject and CallableStatement.getObject use the mapping shown in this -table 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 | |---------------|-------------------------------------| @@ -113,7 +143,7 @@ table for standard mappings. | STRUCT | java.sql.Struct or java.sql.SQLData | | REF | java.sql.Ref | | DATALINK | java.net.URL | -| JAVA_OBJECT | underlying Java class | +| JAVA_OBJECT | Underlying Java class | | ROWID | java.sql.RowId | | NCHAR | String | | NVARCHAR | String | @@ -121,11 +151,13 @@ table for standard mappings. | NCLOB | java.sql.NClob | | SQLXML | java.sql.SQLXML | -# TABLE B-4 Java Object Types Mapped to JDBC Types +## B.4 Java Object Types Mapped to JDBC Types PreparedStatement.setObject, PreparedStatement.setNull, RowSet.setNull and -RowSet.setObject use the mapping shown in this table when no parameter -specifying a target JDBC type is provided. +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 | |----------------------|-------------------------------------------------------------| @@ -139,6 +171,7 @@ specifying a target JDBC type is provided. | 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 | @@ -152,93 +185,90 @@ specifying a target JDBC type is provided. | 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** -# TABLE B-5 Conversions by setObject and setNull from Java Object Types to JDBC Types - -This table shows which JDBC types may be specified as the target JDBC type to the -methods PreparedStatement.setObject, PreparedStatement.setNull, RowSet.setNull -and RowSet.setObject. - -| Java object type | TINYINT | SMALLINT | INTEGER | BIGINT | REAL | FLOAT | DOUBLE | DECIMAL | NUMERIC | BIT | BOOLEAN | CHAR | VARCHAR | LONGVARCHAR | BINARY | VARBINARY | LONGVARBINARY | DATE | TIME | TIMESTAMP | ARRAY | BLOB | CLOB | STRUCT | REF | DATALINK | JAVA_OBJECT | ROWID | NCHAR | NVARCHAR | NCLOB | SQLXML | -|----------------------|---------|----------|---------|--------|------|-------|--------|---------|---------|-----|---------|------|---------|-------------|--------|-----------|---------------|------|------|-----------|-------|------|------|--------|-----|----------|-------------|-------|-------|----------|-------|--------| -| String | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | x | x | | | -| java.math.BigDecimal | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | -| Boolean | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | -| Byte | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | -| Short | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | -| Integer | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | -| Long | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | -| Float | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | -| Double | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | -| byte[] | | | | | | | | | | | | | | | x | x | x | | | | | | | | | | | | | | | | -| java.sql.Date | | | | | | | | | | | | x | x | x | | | | x | | x | | | | | | | | | | | | | -| java.sql.Time | | | | | | | | | | | | x | x | x | | | | | x | x | | | | | | | | | | | | | -| java.sql.Timestamp | | | | | | | | | | | | x | x | x | | | | x | x | x | | | | | | | | | | | | | -| java.sql.Array | | | | | | | | | | | | | | | | | | | | | x | | | | | | | | | | | | -| java.sql.Blob | | | | | | | | | | | | | | | | | | | | | | x | | | | | | | | | | | -| java.sql.Clob | | | | | | | | | | | | | | | | | | | | | | | x | | | | | | | | | | -| java.sql.Struct | | | | | | | | | | | | | | | | | | | | | | | | x | | | | | | | | | -| java.sql.Ref | | | | | | | | | | | | | | | | | | | | | | | | | x | | | | | | | | -| java.net.URL | | | | | | | | | | | | | | | | | | | | | | | | | | x | | | | | | | -| Java class | | | | | | | | | | | | | | | | | | | | | | | | | | | x | | | | | | -| java.sql.RowId | | | | | | | | | | | | | | | | | | | | | | | | | | | | x | | | | | -| java.sql.NClob | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | x | | -| java.sql.SQLXML | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | x | - -Notes: - -- Blank cells mean "not a valid target type for `setObject`/`setNull`"; `x` means the driver must support the - conversion. -- JDBC 4.1+ adds the `LONGNVARCHAR` column (`String` also targets it), plus rows for `BigInteger`, `java.util.Calendar`, - and `java.util.Date`; JDBC 4.2+ adds `java.time` rows. -- `java.sql.Date`/`Time` do not cross-map to each other — only `Timestamp` targets all three of `DATE`, `TIME`, - `TIMESTAMP`. - -# TABLE B-6 Type Conversions Supported by ResultSet getter Methods - -This table shows which JDBC types may be returned by ResultSet getter methods. A -bold X indicates the method recommended for retrieving a JDBC type. A plain x -indicates for which JDBC types it is possible to use a getter method. - -That image is the JDBC spec's **Table B-6 — Use of `ResultSet` getter Methods to Retrieve JDBC Data Types**. Converted -(`**X**` = recommended getter, `x` = supported conversion, blank = not supported): - -| ResultSet getter | 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 | -|---------------------|---------|----------|---------|--------|-------|-------|--------|---------|---------|-------|---------|-------|---------|-------------|--------|-----------|---------------|-------|-------|-----------|-------|-------|-------|-------|----------|--------|-------------|-------|-------|----------|--------------|-------|--------| -| getByte | **X** | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | x | | | | | | -| getShort | x | **X** | x | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | | -| getInt | x | x | **X** | x | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | | -| getLong | x | x | x | **X** | x | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | | -| getFloat | x | x | x | x | **X** | x | x | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | | -| getDouble | x | x | x | x | x | **X** | **X** | x | x | x | x | x | x | x | | | | | | | | | | | | | | | | | | | | -| getBigDecimal | x | x | x | x | x | x | x | **X** | **X** | x | x | x | x | x | | | | | | | | | | | | | | | | | | | | -| getBoolean | x | x | x | x | x | x | x | x | x | **X** | **X** | x | x | x | | | | | | | | | | | | | | | | | | | | -| getString | x | x | x | x | x | x | x | x | x | x | x | **X** | **X** | x | x | x | x | x | x | x | | | | | x | | | | x | x | x | | | -| getNString | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | | | | | x | | | | **X** | **X** | x | | | -| getBytes | | | | | | | | | | | | | | | **X** | **X** | x | | | | | | | | | | | | | | | | | -| getDate | | | | | | | | | | | | x | x | x | | | | **X** | | x | | | | | | | | | | | | | | -| getTime | | | | | | | | | | | | x | x | x | | | | | **X** | x | | | | | | | | | | | | | | -| getTimestamp | | | | | | | | | | | | x | x | x | | | | x | x | **X** | | | | | | | | | | | | | | -| getAsciiStream | | | | | | | | | | | | x | x | **X** | x | x | x | | | | x | | | | | | | | | | | x | | -| getBinaryStream | | | | | | | | | | | | | | | x | x | **X** | | | | | x | | | | | | | | | | | x | -| getCharacterStream | | | | | | | | | | | | x | x | **X** | x | x | x | | | | x | | | | | | | | x | x | x | x | x | -| getNCharacterStream | | | | | | | | | | | | x | x | x | x | x | x | | | | x | | | | | | | | x | x | **X** | x | x | -| getClob | | | | | | | | | | | | | | | | | | | | | **X** | | | | | | | | | | | x | | -| getNClob | | | | | | | | | | | | | | | | | | | | | x | | | | | | | | | | | **X** | | -| getBlob | | | | | | | | | | | | | | | | | | | | | | **X** | | | | | | | | | | | | -| getArray | | | | | | | | | | | | | | | | | | | | | | | **X** | | | | | | | | | | | -| getRef | | | | | | | | | | | | | | | | | | | | | | | | **X** | | | | | | | | | | -| getURL | | | | | | | | | | | | | | | | | | | | | | | | | **X** | | | | | | | | | -| getObject | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | x | **X** | **X** | x | x | x | x | x | x | -| getRowId | | | | | | | | | | | | | | | | | | | | | | | | | | | | **X** | | | | | | -| getSQLXML | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **X** | - -Notes: - -- Read a column as: "this JDBC type can be fetched with this getter"; the bold `X` is the JDBC-recommended getter for - that SQL type. -- Worth flagging because it surprises people: `getObject` accepts every listed SQL type; `getString` covers all numeric - types plus binary and date/time; and `getBinaryStream` in this revision lists `BLOB` and `SQLXML` (later revisions - narrowed it to the binary types). -- The column order here follows the printed table (`CLOB, BLOB, ARRAY, REF, …`); the current JDBC 4.3 spec presents the - same content as prose rather than a matrix. \ No newline at end of file +| 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 | From 6a9e74a426dd99cefbb98f3ee57022991afdb322 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:54:37 +0200 Subject: [PATCH 03/29] test(jdbc): add the JDBC 4.3 Appendix B oracle suite The suite encodes Tables B-1 through B-6 from the JDBC 4.3 specification. It fails on the current driver by design and becomes the evidence for the compliance stack. --- .../itest/Jdbc4AppendixBTypeMappingIT.java | 1181 +++++++++++++++++ .../libsql/jdbc/Jdbc4AppendixBReadTest.java | 614 +++++++++ .../jdbc/Jdbc4AppendixBSetObjectTest.java | 295 ++++ 3 files changed, 2090 insertions(+) create mode 100644 libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java create mode 100644 libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java create mode 100644 libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBSetObjectTest.java 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; + } +} From 5d4e6aa0b16c3725b3a86b2422ee4c1eab1b3909 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:05:21 +0100 Subject: [PATCH 04/29] feat(jdbc): add the declared JDBC type registry --- .../magnusp/libsql/jdbc/DeclaredType.java | 17 +- .../magnusp/libsql/jdbc/JdbcTypeMapping.java | 147 ++++++++++++++++++ 2 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/DeclaredType.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/DeclaredType.java index d31a623..1c56428 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/DeclaredType.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/DeclaredType.java @@ -35,13 +35,18 @@ boolean isDeclared() { return declType != null; } - String typeName() { - if (declType != null) { - Matcher matcher = COLUMN_TYPENAME.matcher(declType); - matcher.find(); - return matcher.group(1).toUpperCase(Locale.ENGLISH); + String declaredTypeName() { + if (declType == null) { + return null; } - return "NUMERIC"; + Matcher matcher = COLUMN_TYPENAME.matcher(declType); + matcher.find(); + return matcher.group(1).toUpperCase(Locale.ENGLISH); + } + + String typeName() { + String name = declaredTypeName(); + return name != null ? name : "NUMERIC"; } int precision() { diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java new file mode 100644 index 0000000..f253e18 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java @@ -0,0 +1,147 @@ +package com.github.magnusp.libsql.jdbc; + +import com.github.magnusp.libsql.hrana.codec.Col; +import com.github.magnusp.libsql.hrana.codec.Value; + +import java.math.BigDecimal; +import java.net.URL; +import java.sql.Blob; +import java.sql.Clob; +import java.sql.Date; +import java.sql.NClob; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.Map; + +/** + * Resolves the JDBC type of a result-set column from its declared type. + * + *

A column whose {@code decltype} names a type in the registry resolves once, independently of + * the stored value. A column with no declared type, or with a declared type outside the registry, + * falls back to the storage class of the value and marks the result {@link Support#FALLBACK} so a + * caller can record the exception. + */ +final class JdbcTypeMapping { + + enum Support { + SUPPORTED, + FALLBACK, + UNSUPPORTED + } + + enum StorageClass { + INTEGER, + FLOAT, + TEXT, + BLOB, + NULL + } + + record JdbcType(int sqlType, Class javaClass, Class objectClass, StorageClass storage, Support support) {} + + private static final JdbcType INTEGER_FALLBACK = + fallback(Types.INTEGER, int.class, Integer.class, StorageClass.INTEGER); + private static final JdbcType BIGINT_FALLBACK = + fallback(Types.BIGINT, long.class, Long.class, StorageClass.INTEGER); + private static final JdbcType FLOAT_FALLBACK = + fallback(Types.FLOAT, double.class, Double.class, StorageClass.FLOAT); + private static final JdbcType TEXT_FALLBACK = + fallback(Types.VARCHAR, String.class, String.class, StorageClass.TEXT); + private static final JdbcType BLOB_FALLBACK = + fallback(Types.VARBINARY, byte[].class, byte[].class, StorageClass.BLOB); + private static final JdbcType NULL_FALLBACK = + fallback(Types.NUMERIC, Object.class, Object.class, StorageClass.NULL); + + private static final Map DECLARED = declaredTypes(); + + private JdbcTypeMapping() {} + + static JdbcType resolve(Col col, Value value) { + if (col != null) { + String name = DeclaredType.of(col).declaredTypeName(); + if (name != null) { + JdbcType declared = DECLARED.get(name); + if (declared != null) { + return declared; + } + } + } + return fallback(value); + } + + private static JdbcType fallback(Value value) { + return switch (storageClass(value)) { + case INTEGER -> integerFallback(value); + case FLOAT -> FLOAT_FALLBACK; + case TEXT -> TEXT_FALLBACK; + case BLOB -> BLOB_FALLBACK; + case NULL -> NULL_FALLBACK; + }; + } + + private static JdbcType integerFallback(Value value) { + long number = integer(value); + return number >= Integer.MIN_VALUE && number <= Integer.MAX_VALUE ? INTEGER_FALLBACK : BIGINT_FALLBACK; + } + + static StorageClass storageClass(Value value) { + if (value instanceof Value.Integer) { + return StorageClass.INTEGER; + } + if (value instanceof Value.Float) { + return StorageClass.FLOAT; + } + if (value instanceof Value.Blob) { + return StorageClass.BLOB; + } + if (value instanceof Value.Text) { + return StorageClass.TEXT; + } + return StorageClass.NULL; + } + + static long integer(Value value) { + return value instanceof Value.Integer i ? i.value() : 0L; + } + + private static JdbcType fallback(int sqlType, Class javaClass, Class objectClass, StorageClass storage) { + return new JdbcType(sqlType, javaClass, objectClass, storage, Support.FALLBACK); + } + + private static Map declaredTypes() { + return Map.ofEntries( + entry("TINYINT", Types.TINYINT, byte.class, Integer.class, StorageClass.INTEGER), + entry("SMALLINT", Types.SMALLINT, short.class, Integer.class, StorageClass.INTEGER), + entry("INTEGER", Types.INTEGER, int.class, Integer.class, StorageClass.INTEGER), + entry("BIGINT", Types.BIGINT, long.class, Long.class, StorageClass.INTEGER), + entry("REAL", Types.REAL, float.class, Float.class, StorageClass.FLOAT), + entry("FLOAT", Types.FLOAT, double.class, Double.class, StorageClass.FLOAT), + entry("DOUBLE", Types.DOUBLE, double.class, Double.class, StorageClass.FLOAT), + entry("NUMERIC", Types.NUMERIC, BigDecimal.class, BigDecimal.class, StorageClass.FLOAT), + entry("DECIMAL", Types.DECIMAL, BigDecimal.class, BigDecimal.class, StorageClass.FLOAT), + entry("BIT", Types.BIT, boolean.class, Boolean.class, StorageClass.INTEGER), + entry("BOOLEAN", Types.BOOLEAN, boolean.class, Boolean.class, StorageClass.INTEGER), + entry("CHAR", Types.CHAR, String.class, String.class, StorageClass.TEXT), + entry("VARCHAR", Types.VARCHAR, String.class, String.class, StorageClass.TEXT), + entry("LONGVARCHAR", Types.LONGVARCHAR, String.class, String.class, StorageClass.TEXT), + entry("NCHAR", Types.NCHAR, String.class, String.class, StorageClass.TEXT), + entry("NVARCHAR", Types.NVARCHAR, String.class, String.class, StorageClass.TEXT), + entry("LONGNVARCHAR", Types.LONGNVARCHAR, String.class, String.class, StorageClass.TEXT), + entry("BINARY", Types.BINARY, byte[].class, byte[].class, StorageClass.BLOB), + entry("VARBINARY", Types.VARBINARY, byte[].class, byte[].class, StorageClass.BLOB), + entry("LONGVARBINARY", Types.LONGVARBINARY, byte[].class, byte[].class, StorageClass.BLOB), + entry("DATE", Types.DATE, Date.class, Date.class, StorageClass.TEXT), + entry("TIME", Types.TIME, Time.class, Time.class, StorageClass.TEXT), + entry("TIMESTAMP", Types.TIMESTAMP, Timestamp.class, Timestamp.class, StorageClass.TEXT), + entry("CLOB", Types.CLOB, Clob.class, Clob.class, StorageClass.TEXT), + entry("NCLOB", Types.NCLOB, NClob.class, NClob.class, StorageClass.TEXT), + entry("BLOB", Types.BLOB, Blob.class, Blob.class, StorageClass.BLOB), + entry("DATALINK", Types.DATALINK, URL.class, URL.class, StorageClass.TEXT)); + } + + private static Map.Entry entry( + String name, int sqlType, Class javaClass, Class objectClass, StorageClass storage) { + return Map.entry(name, new JdbcType(sqlType, javaClass, objectClass, storage, Support.SUPPORTED)); + } +} From 7fa7c5ca255adbf6f4e49135f9e99c4b67643171 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:05:21 +0100 Subject: [PATCH 05/29] feat(jdbc): resolve metadata and getObject from the declared type --- .../magnusp/libsql/jdbc/LibsqlResultSet.java | 94 +++++++++++- .../libsql/jdbc/LibsqlResultSetMetaData.java | 14 +- .../libsql/jdbc/TableMetadataResolver.java | 4 +- .../magnusp/libsql/jdbc/XerialColumnType.java | 143 ------------------ 4 files changed, 97 insertions(+), 158 deletions(-) delete mode 100644 libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/XerialColumnType.java diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java index 10955b5..7ea5367 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java @@ -185,13 +185,24 @@ public String getString(int columnIndex) throws SQLException { } /** - * Converts every storage class through {@link #getInt(int)}, matching sqlite-jdbc. NULL and values - * whose integer coercion is zero are false: non-numeric text such as {@code "true"}, REAL values - * between -1 and 1, and empty or non-numeric blobs. Integers, REAL values truncated to a nonzero - * integer, and text or blob numeric prefixes such as {@code "2"} or {@code x'313233'} are true. + * Converts every storage class through {@link #getInt(int)}, matching sqlite-jdbc, except that + * the text values {@code true} and {@code false} are parsed as booleans. NULL and values whose + * integer coercion is zero are false: numeric text such as {@code "0"}, REAL values between -1 + * and 1, and empty or non-numeric blobs. Integers, REAL values truncated to a nonzero integer, + * and text or blob numeric prefixes such as {@code "2"} or {@code x'313233'} are true. */ @Override public boolean getBoolean(int columnIndex) throws SQLException { + Value value = getCurrentValue(columnIndex); + if (value instanceof Value.Text text) { + String string = text.value().trim(); + if ("true".equalsIgnoreCase(string)) { + return true; + } + if ("false".equalsIgnoreCase(string)) { + return false; + } + } return getInt(columnIndex) != 0; } @@ -299,8 +310,61 @@ public BigDecimal getBigDecimal(int columnIndex) throws SQLException { @Override public Object getObject(int columnIndex) throws SQLException { - Value val = getCurrentValue(columnIndex); - return switch (val) { + Value value = getCurrentValue(columnIndex); + if (value instanceof Value.Null) { + return null; + } + return objectValue(JdbcTypeMapping.resolve(cols.get(columnIndex - 1), value), columnIndex); + } + + private Object objectValue(JdbcTypeMapping.JdbcType type, int columnIndex) throws SQLException { + Class objectClass = type.objectClass(); + if (objectClass == Integer.class) { + return checkedInt(columnIndex, type.sqlType()); + } + if (objectClass == Long.class) { + return getLong(columnIndex); + } + if (objectClass == BigDecimal.class) { + return getBigDecimal(columnIndex); + } + if (objectClass == Boolean.class) { + return getBoolean(columnIndex); + } + if (objectClass == Float.class) { + return getFloat(columnIndex); + } + if (objectClass == Double.class) { + return getDouble(columnIndex); + } + if (objectClass == String.class) { + return getString(columnIndex); + } + if (objectClass == byte[].class) { + return getBytes(columnIndex); + } + if (objectClass == Date.class) { + return getDate(columnIndex); + } + if (objectClass == Time.class) { + return getTime(columnIndex); + } + if (objectClass == Timestamp.class) { + return getTimestamp(columnIndex); + } + if (objectClass == Clob.class) { + return getClob(columnIndex); + } + if (objectClass == Blob.class) { + return getBlob(columnIndex); + } + // NCLOB and DATALINK resolve to their declared wrapper types, but the wrapper getters + // arrive in PR-3, so the value keeps its storage-class representation until then. + return storedObject(getCurrentValue(columnIndex)); + } + + private static Object storedObject(Value value) { + return switch (value) { case Value.Null n -> null; case Value.Integer i -> i.value() >= Integer.MIN_VALUE && i.value() <= Integer.MAX_VALUE ? (Object) (int) i.value() @@ -311,6 +375,24 @@ public Object getObject(int columnIndex) throws SQLException { }; } + private Integer checkedInt(int columnIndex, int sqlType) throws SQLException { + long value = getLong(columnIndex); + if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + throw new SQLException( + "Value " + value + " does not fit the declared " + sqlTypeName(sqlType) + " type"); + } + return (int) value; + } + + private static String sqlTypeName(int sqlType) { + return switch (sqlType) { + case java.sql.Types.TINYINT -> "TINYINT"; + case java.sql.Types.SMALLINT -> "SMALLINT"; + case java.sql.Types.INTEGER -> "INTEGER"; + default -> "numeric"; + }; + } + private static void rejectTypeMap(Map> map) throws SQLException { if (map != null && !map.isEmpty()) { throw new SQLFeatureNotSupportedException("Type maps are not supported"); diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaData.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaData.java index afc57d0..6415ddd 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaData.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaData.java @@ -100,7 +100,7 @@ public boolean isSigned(int column) throws SQLException { } // An expression column has no declared type, so signedness follows the // storage class of the current value, as xerial does. - return switch (XerialColumnType.storageClass(runtimeValue(column))) { + return switch (JdbcTypeMapping.storageClass(runtimeValue(column))) { case INTEGER, FLOAT, NULL -> true; case TEXT, BLOB -> false; }; @@ -153,10 +153,7 @@ public String getCatalogName(int column) throws SQLException { @Override public int getColumnType(int column) throws SQLException { - getCol(column); - String typeName = getColumnTypeName(column); - Value value = runtimeValue(column); - return XerialColumnType.jdbcType(value, typeName); + return resolvedType(column).sqlType(); } @Override @@ -184,8 +181,11 @@ public boolean isDefinitelyWritable(int column) throws SQLException { @Override public String getColumnClassName(int column) throws SQLException { - getCol(column); - return XerialColumnType.className(runtimeValue(column)); + return resolvedType(column).objectClass().getName(); + } + + private JdbcTypeMapping.JdbcType resolvedType(int column) throws SQLException { + return JdbcTypeMapping.resolve(getCol(column), runtimeValue(column)); } private String columnName(int column) throws SQLException { diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/TableMetadataResolver.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/TableMetadataResolver.java index 3e7c124..792c6d4 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/TableMetadataResolver.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/TableMetadataResolver.java @@ -157,8 +157,8 @@ private static TableInfo toTableInfo(StmtResult result, String fallbackName) { columns.put( columnName.toLowerCase(Locale.ROOT), new ColumnInfo( - XerialColumnType.integer(values.get(3)) != 0, - XerialColumnType.integer(values.get(4)) != 0)); + JdbcTypeMapping.integer(values.get(3)) != 0, + JdbcTypeMapping.integer(values.get(4)) != 0)); } } return new TableInfo(tableName != null ? tableName : fallbackName, createSql, columns); diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/XerialColumnType.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/XerialColumnType.java deleted file mode 100644 index 2c985b4..0000000 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/XerialColumnType.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.github.magnusp.libsql.jdbc; - -import com.github.magnusp.libsql.hrana.codec.Value; - -import java.sql.Types; - -/** xerial 3.53.4.0 column type mapping. */ -final class XerialColumnType { - - private XerialColumnType() {} - - static int jdbcType(Value value, String typeName) { - StorageClass valueType = storageClass(value); - - if (valueType == StorageClass.INTEGER || valueType == StorageClass.NULL) { - if ("BOOLEAN".equals(typeName)) { - return Types.BOOLEAN; - } - if ("TINYINT".equals(typeName)) { - return Types.TINYINT; - } - if ("SMALLINT".equals(typeName) || "INT2".equals(typeName)) { - return Types.SMALLINT; - } - if ("BIGINT".equals(typeName) - || "INT8".equals(typeName) - || "UNSIGNED BIG INT".equals(typeName)) { - return Types.BIGINT; - } - if ("DATE".equals(typeName)) { - return Types.DATE; - } - if ("DATETIME".equals(typeName) || "TIMESTAMP".equals(typeName)) { - return Types.TIMESTAMP; - } - if (valueType == StorageClass.INTEGER - || "INT".equals(typeName) - || "INTEGER".equals(typeName) - || "MEDIUMINT".equals(typeName)) { - long val = integer(value); - return val > Integer.MAX_VALUE || val < Integer.MIN_VALUE ? Types.BIGINT : Types.INTEGER; - } - } - - if (valueType == StorageClass.FLOAT || valueType == StorageClass.NULL) { - if ("DECIMAL".equals(typeName)) { - return Types.DECIMAL; - } - if ("DOUBLE".equals(typeName) || "DOUBLE PRECISION".equals(typeName)) { - return Types.DOUBLE; - } - if ("NUMERIC".equals(typeName)) { - return Types.NUMERIC; - } - if ("REAL".equals(typeName)) { - return Types.REAL; - } - if (valueType == StorageClass.FLOAT || "FLOAT".equals(typeName)) { - return Types.FLOAT; - } - } - - if (valueType == StorageClass.TEXT || valueType == StorageClass.NULL) { - if ("CHARACTER".equals(typeName) - || "NCHAR".equals(typeName) - || "NATIVE CHARACTER".equals(typeName) - || "CHAR".equals(typeName)) { - return Types.CHAR; - } - if ("CLOB".equals(typeName)) { - return Types.CLOB; - } - if ("DATE".equals(typeName)) { - return Types.DATE; - } - if ("DATETIME".equals(typeName) || "TIMESTAMP".equals(typeName)) { - return Types.TIMESTAMP; - } - if (valueType == StorageClass.TEXT - || "VARCHAR".equals(typeName) - || "VARYING CHARACTER".equals(typeName) - || "NVARCHAR".equals(typeName) - || "TEXT".equals(typeName)) { - return Types.VARCHAR; - } - } - - if (valueType == StorageClass.BLOB || valueType == StorageClass.NULL) { - if ("BINARY".equals(typeName)) { - return Types.BINARY; - } - if (valueType == StorageClass.BLOB || "BLOB".equals(typeName)) { - return Types.BLOB; - } - } - - return Types.NUMERIC; - } - - static String className(Value value) { - if (value instanceof Value.Integer i) { - long val = i.value(); - return val > Integer.MAX_VALUE || val < Integer.MIN_VALUE - ? Long.class.getName() - : Integer.class.getName(); - } - if (value instanceof Value.Float) { - return Double.class.getName(); - } - if (value instanceof Value.Text) { - return String.class.getName(); - } - return Object.class.getName(); - } - - static StorageClass storageClass(Value value) { - if (value instanceof Value.Integer) { - return StorageClass.INTEGER; - } - if (value instanceof Value.Float) { - return StorageClass.FLOAT; - } - if (value instanceof Value.Blob) { - return StorageClass.BLOB; - } - if (value instanceof Value.Text) { - return StorageClass.TEXT; - } - return StorageClass.NULL; - } - - static long integer(Value value) { - return value instanceof Value.Integer i ? i.value() : 0L; - } - - enum StorageClass { - INTEGER, - FLOAT, - BLOB, - TEXT, - NULL - } -} From 4c98a639ffa527a74586c2c311ab24027d075491 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:05:21 +0100 Subject: [PATCH 06/29] test(jdbc): pin declared types and boolean text parsing --- .../jdbc/LibsqlResultSetGetBooleanTest.java | 20 ++++++--- .../jdbc/LibsqlResultSetMetaDataTest.java | 44 ++++++++++--------- .../libsql/jdbc/LibsqlResultSetTest.java | 6 ++- 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetGetBooleanTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetGetBooleanTest.java index 9bba86e..b207628 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetGetBooleanTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetGetBooleanTest.java @@ -20,7 +20,8 @@ class LibsqlResultSetGetBooleanTest { static Stream storageClasses() { return Stream.of( - Arguments.of("text 'true'", new Value.Text("true"), false), + Arguments.of("text 'true'", new Value.Text("true"), true), + Arguments.of("text 'false'", new Value.Text("false"), false), Arguments.of("text '0'", new Value.Text("0"), false), Arguments.of("text '2'", new Value.Text("2"), true), Arguments.of("real 0.5", new Value.Float(0.5), false), @@ -34,17 +35,26 @@ static Stream storageClasses() { @ParameterizedTest(name = "{0}") @MethodSource("storageClasses") - void getBooleanConvertsThroughIntegerLikeXerial(String name, Value value, boolean expected) + void getBooleanConvertsStorageClasses(String name, Value value, boolean expected) throws SQLException { LibsqlResultSet rs = resultSet(value); assertThat(rs.next()).isTrue(); assertThat(rs.getBoolean(1)).isEqualTo(expected); - assertThat(rs.getBoolean(1)).isEqualTo(rs.getInt(1) != 0); } @Test - void reproductionValuesMatchSqliteJdbc() throws SQLException { + void getBooleanParsesTextTrueAndFalse() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("true"), new Value.Text("false")); + + assertThat(rs.next()).isTrue(); + + assertThat(rs.getBoolean(1)).isTrue(); + assertThat(rs.getBoolean(2)).isFalse(); + } + + @Test + void textTrueIsTrueAndNumericTextKeepsIntegerCoercion() throws SQLException { LibsqlResultSet rs = resultSet( new Value.Text("true"), new Value.Text("2"), @@ -52,7 +62,7 @@ void reproductionValuesMatchSqliteJdbc() throws SQLException { assertThat(rs.next()).isTrue(); - assertThat(rs.getBoolean(1)).isFalse(); + assertThat(rs.getBoolean(1)).isTrue(); assertThat(rs.getBoolean(2)).isTrue(); assertThat(rs.getBoolean(3)).isFalse(); } diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java index e4b1b95..8bbe574 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java @@ -7,6 +7,7 @@ import com.github.magnusp.libsql.hrana.codec.Value; import org.junit.jupiter.api.Test; +import java.sql.Blob; import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Types; @@ -175,7 +176,7 @@ void ignoresQuotedIdentifierNamedAutoincrement() throws SQLException { } @Test - void usesFirstNonNullValueForIntegerTypeAndClass() throws SQLException { + void usesDeclaredIntegerTypeForEveryValueMagnitude() throws SQLException { LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData( List.of(new Col("wide", "INTEGER"), new Col("narrow", "INTEGER")), List.of( @@ -184,14 +185,14 @@ void usesFirstNonNullValueForIntegerTypeAndClass() throws SQLException { "SELECT 1", null); - assertThat(metadata.getColumnClassName(1)).isEqualTo(Long.class.getName()); - assertThat(metadata.getColumnType(1)).isEqualTo(Types.BIGINT); + assertThat(metadata.getColumnClassName(1)).isEqualTo(Integer.class.getName()); + assertThat(metadata.getColumnType(1)).isEqualTo(Types.INTEGER); assertThat(metadata.getColumnClassName(2)).isEqualTo(Integer.class.getName()); assertThat(metadata.getColumnType(2)).isEqualTo(Types.INTEGER); } @Test - void usesRuntimeValueMappingForNonIntegerValuesInIntegerColumn() throws SQLException { + void usesDeclaredTypeForNonNumericValuesInIntegerColumn() throws SQLException { LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData( List.of(new Col("value", "INTEGER")), List.of( @@ -200,12 +201,12 @@ void usesRuntimeValueMappingForNonIntegerValuesInIntegerColumn() throws SQLExcep "SELECT 1", null); - assertThat(metadata.getColumnClassName(1)).isEqualTo(String.class.getName()); - assertThat(metadata.getColumnType(1)).isEqualTo(Types.VARCHAR); + assertThat(metadata.getColumnClassName(1)).isEqualTo(Integer.class.getName()); + assertThat(metadata.getColumnType(1)).isEqualTo(Types.INTEGER); } @Test - void usesRuntimeStorageClassForDeclaredTypes() throws SQLException { + void usesDeclaredTypesInsteadOfRuntimeStorageClass() throws SQLException { StmtResult result = new StmtResult( List.of( new Col("bi", "BIGINT"), @@ -218,10 +219,10 @@ void usesRuntimeStorageClassForDeclaredTypes() throws SQLException { LibsqlResultSet resultSet = new LibsqlResultSet(null, result); ResultSetMetaData metadata = resultSet.getMetaData(); - assertRuntimeTypes(metadata); + assertDeclaredTypes(metadata); assertThat(resultSet.next()).isTrue(); - assertRuntimeTypes(metadata); + assertDeclaredTypes(metadata); } @Test @@ -360,11 +361,19 @@ void reportsObjectClassNameForEmptyResultSet() throws SQLException { null); LibsqlResultSet resultSet = new LibsqlResultSet(null, result); ResultSetMetaData metadata = resultSet.getMetaData(); + List expected = List.of( + Integer.class.getName(), + Long.class.getName(), + Object.class.getName(), + Blob.class.getName(), + Boolean.class.getName(), + Float.class.getName(), + Object.class.getName()); for (int column = 1; column <= metadata.getColumnCount(); column++) { assertThat(metadata.getColumnClassName(column)) .as("column %d before the first row", column) - .isEqualTo(Object.class.getName()); + .isEqualTo(expected.get(column - 1)); } assertThat(resultSet.next()).isFalse(); @@ -372,7 +381,7 @@ void reportsObjectClassNameForEmptyResultSet() throws SQLException { for (int column = 1; column <= metadata.getColumnCount(); column++) { assertThat(metadata.getColumnClassName(column)) .as("column %d after the last row", column) - .isEqualTo(Object.class.getName()); + .isEqualTo(expected.get(column - 1)); } } @@ -473,7 +482,7 @@ void reportsClassNameForCurrentRowOutsideIntRange() throws SQLException { } @Test - void reflectsStorageClassChangesBetweenRows() throws SQLException { + void keepsDeclaredBigintTypeAcrossStorageClassChanges() throws SQLException { StmtResult result = new StmtResult( List.of(new Col("bi", "BIGINT")), List.of(new Row(List.of(Value.of(1.5))), new Row(List.of(Value.of(2L)))), @@ -482,10 +491,10 @@ void reflectsStorageClassChangesBetweenRows() throws SQLException { LibsqlResultSet resultSet = new LibsqlResultSet(null, result); ResultSetMetaData metadata = resultSet.getMetaData(); - assertThat(metadata.getColumnType(1)).isEqualTo(Types.FLOAT); + assertThat(metadata.getColumnType(1)).isEqualTo(Types.BIGINT); assertThat(resultSet.next()).isTrue(); - assertThat(metadata.getColumnType(1)).isEqualTo(Types.FLOAT); + assertThat(metadata.getColumnType(1)).isEqualTo(Types.BIGINT); assertThat(resultSet.next()).isTrue(); assertThat(metadata.getColumnType(1)).isEqualTo(Types.BIGINT); @@ -494,13 +503,6 @@ void reflectsStorageClassChangesBetweenRows() throws SQLException { assertThat(metadata.getColumnType(1)).isEqualTo(Types.BIGINT); } - private static void assertRuntimeTypes(ResultSetMetaData metadata) throws SQLException { - assertThat(metadata.getColumnType(1)).isEqualTo(Types.FLOAT); - assertThat(metadata.getColumnType(2)).isEqualTo(Types.VARCHAR); - assertThat(metadata.getColumnType(3)).isEqualTo(Types.INTEGER); - assertThat(metadata.getColumnType(4)).isEqualTo(Types.INTEGER); - } - private static void assertDeclaredTypes(ResultSetMetaData metadata) throws SQLException { assertThat(metadata.getColumnType(1)).isEqualTo(Types.BIGINT); assertThat(metadata.getColumnType(2)).isEqualTo(Types.BOOLEAN); diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java index 35b01b6..fae61d1 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java @@ -44,13 +44,15 @@ class LibsqlResultSetTest { null); @Test - void getObjectReturnsIntegerInRangeAndLongOutside() throws SQLException { + void getObjectReturnsDeclaredIntegerAndRejectsAValueThatDoesNotFit() throws SQLException { LibsqlResultSet resultSet = new LibsqlResultSet(new LibsqlStatement(null), INTEGER_ROWS); assertThat(resultSet.next()).isTrue(); assertThat(resultSet.getObject(1)).isInstanceOf(Integer.class).isEqualTo(42); assertThat(resultSet.next()).isTrue(); - assertThat(resultSet.getObject(1)).isInstanceOf(Long.class).isEqualTo(5_000_000_000L); + assertThatThrownBy(() -> resultSet.getObject(1)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("INTEGER"); } @Test From 2aab9bd87462400b56f83b2889c66cfcb6421dbd Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:54:11 +0100 Subject: [PATCH 07/29] perf(jdbc): resolve each column's declared type once per result set getObject resolved the declared type on every call: it re-parsed the Col decltype with a regular expression and hit the registry map for each row. A million-row scan of INTEGER, REAL and VARCHAR columns fell from about 64M rows/s to 4.3M rows/s, and an INTEGER-only scan from about 105M to 21M. Resolve all columns once when the result set is built and read the cached JdbcType by index. getObject now converts from the Value it has already read instead of fetching the column again, and the metadata reads the same cache. An expression column keeps a null entry and falls back to the storage class of the value, which is a class test and a switch rather than another DeclaredType parse. --- .../magnusp/libsql/jdbc/JdbcTypeMapping.java | 41 +++-- .../magnusp/libsql/jdbc/LibsqlResultSet.java | 159 +++++++++++++----- .../libsql/jdbc/LibsqlResultSetMetaData.java | 7 +- 3 files changed, 148 insertions(+), 59 deletions(-) diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java index f253e18..b2297ad 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java @@ -12,6 +12,7 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; +import java.util.List; import java.util.Map; /** @@ -53,24 +54,38 @@ record JdbcType(int sqlType, Class javaClass, Class objectClass, StorageCl private static final JdbcType NULL_FALLBACK = fallback(Types.NUMERIC, Object.class, Object.class, StorageClass.NULL); - private static final Map DECLARED = declaredTypes(); + private static final Map DECLARED = declaredTypeRegistry(); private JdbcTypeMapping() {} - static JdbcType resolve(Col col, Value value) { - if (col != null) { - String name = DeclaredType.of(col).declaredTypeName(); - if (name != null) { - JdbcType declared = DECLARED.get(name); - if (declared != null) { - return declared; - } - } + /** + * Resolves one column's JDBC type once for the life of a result set. + * + *

The declared type of a column never changes within a result set, so the registry is + * consulted when the result set is built. {@code getObject} and the metadata then read the + * entry. An expression column has no declared type and keeps a null entry; resolving such a + * column is a class test and a switch over the current {@link Value}. + */ + static JdbcType[] declaredTypes(List cols) { + JdbcType[] declared = new JdbcType[cols.size()]; + for (int i = 0; i < declared.length; i++) { + declared[i] = declared(cols.get(i)); } - return fallback(value); + return declared; } - private static JdbcType fallback(Value value) { + private static JdbcType declared(Col col) { + if (col == null) { + return null; + } + String name = DeclaredType.of(col).declaredTypeName(); + if (name == null) { + return null; + } + return DECLARED.get(name); + } + + static JdbcType fallback(Value value) { return switch (storageClass(value)) { case INTEGER -> integerFallback(value); case FLOAT -> FLOAT_FALLBACK; @@ -109,7 +124,7 @@ private static JdbcType fallback(int sqlType, Class javaClass, Class objec return new JdbcType(sqlType, javaClass, objectClass, storage, Support.FALLBACK); } - private static Map declaredTypes() { + private static Map declaredTypeRegistry() { return Map.ofEntries( entry("TINYINT", Types.TINYINT, byte.class, Integer.class, StorageClass.INTEGER), entry("SMALLINT", Types.SMALLINT, short.class, Integer.class, StorageClass.INTEGER), diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java index 7ea5367..ff2a8cd 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java @@ -59,6 +59,7 @@ public class LibsqlResultSet implements ResultSet { private final String sql; private final LibsqlConnection connection; private final boolean closeStatementOnClose; + private final JdbcTypeMapping.JdbcType[] declaredTypes; private int currentRowIndex = -1; private int lastColumnIndex = 0; private boolean closed = false; @@ -81,6 +82,11 @@ public LibsqlResultSet(Statement statement, StmtResult result) { ? libsqlStatement.connection : null; this.closeStatementOnClose = closeStatementOnClose; + this.declaredTypes = JdbcTypeMapping.declaredTypes(this.cols); + } + + JdbcTypeMapping.JdbcType[] declaredTypes() { + return declaredTypes; } private void checkClosed() throws SQLException { @@ -174,7 +180,10 @@ public boolean wasNull() throws SQLException { */ @Override public String getString(int columnIndex) throws SQLException { - Value val = getCurrentValue(columnIndex); + return stringValue(getCurrentValue(columnIndex)); + } + + private static String stringValue(Value val) { return switch (val) { case Value.Null n -> null; case Value.Integer i -> Long.toString(i.value()); @@ -193,7 +202,10 @@ public String getString(int columnIndex) throws SQLException { */ @Override public boolean getBoolean(int columnIndex) throws SQLException { - Value value = getCurrentValue(columnIndex); + return booleanValue(getCurrentValue(columnIndex)); + } + + private static boolean booleanValue(Value value) { if (value instanceof Value.Text text) { String string = text.value().trim(); if ("true".equalsIgnoreCase(string)) { @@ -203,7 +215,7 @@ public boolean getBoolean(int columnIndex) throws SQLException { return false; } } - return getInt(columnIndex) != 0; + return (int) toLong(value) != 0; } @Override @@ -267,7 +279,10 @@ public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException @Override public byte[] getBytes(int columnIndex) throws SQLException { - Value val = getCurrentValue(columnIndex); + return bytesValue(getCurrentValue(columnIndex)); + } + + private static byte[] bytesValue(Value val) { return switch (val) { case Value.Null n -> null; case Value.Blob b -> b.bytes(); @@ -279,25 +294,41 @@ public byte[] getBytes(int columnIndex) throws SQLException { @Override public Date getDate(int columnIndex) throws SQLException { - LocalDateTime value = dateTimeValue(columnIndex, "Date"); - return value == null ? null : Date.valueOf(value.toLocalDate()); + return dateValue(getCurrentValue(columnIndex)); + } + + private static Date dateValue(Value value) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Date"); + return dateTime == null ? null : Date.valueOf(dateTime.toLocalDate()); } @Override public Time getTime(int columnIndex) throws SQLException { - LocalDateTime value = dateTimeValue(columnIndex, "Time"); - return value == null ? null : Time.valueOf(value.toLocalTime()); + return timeValue(getCurrentValue(columnIndex)); + } + + private static Time timeValue(Value value) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Time"); + return dateTime == null ? null : Time.valueOf(dateTime.toLocalTime()); } @Override public Timestamp getTimestamp(int columnIndex) throws SQLException { - LocalDateTime value = dateTimeValue(columnIndex, "Timestamp"); - return value == null ? null : Timestamp.valueOf(value); + return timestampValue(getCurrentValue(columnIndex)); + } + + private static Timestamp timestampValue(Value value) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Timestamp"); + return dateTime == null ? null : Timestamp.valueOf(dateTime); } @Override public BigDecimal getBigDecimal(int columnIndex) throws SQLException { - String s = getString(columnIndex); + return bigDecimalValue(getCurrentValue(columnIndex)); + } + + private static BigDecimal bigDecimalValue(Value value) throws SQLException { + String s = stringValue(value); if (s == null) { return null; } @@ -314,53 +345,83 @@ public Object getObject(int columnIndex) throws SQLException { if (value instanceof Value.Null) { return null; } - return objectValue(JdbcTypeMapping.resolve(cols.get(columnIndex - 1), value), columnIndex); + JdbcTypeMapping.JdbcType type = declaredTypes[columnIndex - 1]; + if (type == null) { + type = JdbcTypeMapping.fallback(value); + } + // Shortcuts for a value already stored as the declared Java box. They mirror the storage + // switch trunk used, so a matching column costs one class test and one type test. + if (value instanceof Value.Integer integer && type.objectClass() == Integer.class) { + return checkedInt(integer.value(), type.sqlType()); + } + if (value instanceof Value.Float floating) { + if (type.objectClass() == Float.class) { + return (float) floating.value(); + } + if (type.objectClass() == Double.class) { + return floating.value(); + } + } + if (value instanceof Value.Text text && type.objectClass() == String.class) { + return text.value(); + } + return objectValue(value, type); } - private Object objectValue(JdbcTypeMapping.JdbcType type, int columnIndex) throws SQLException { + private static Object objectValue(Value value, JdbcTypeMapping.JdbcType type) throws SQLException { Class objectClass = type.objectClass(); if (objectClass == Integer.class) { - return checkedInt(columnIndex, type.sqlType()); + return checkedInt(value, type.sqlType()); } if (objectClass == Long.class) { - return getLong(columnIndex); - } - if (objectClass == BigDecimal.class) { - return getBigDecimal(columnIndex); + return toLong(value); } - if (objectClass == Boolean.class) { - return getBoolean(columnIndex); + if (objectClass == Double.class) { + return toDouble(value); } if (objectClass == Float.class) { - return getFloat(columnIndex); - } - if (objectClass == Double.class) { - return getDouble(columnIndex); + return (float) toDouble(value); } if (objectClass == String.class) { - return getString(columnIndex); + return stringValue(value); + } + return convertedObject(value, type); + } + + /** + * Converts every declared type outside the numeric and text fast path. It stays out of {@link + * #getObject} so the common reads stay small enough for the JIT to inline and scalar-replace + * their boxes. + */ + private static Object convertedObject(Value value, JdbcTypeMapping.JdbcType type) throws SQLException { + Class objectClass = type.objectClass(); + if (objectClass == BigDecimal.class) { + return bigDecimalValue(value); + } + if (objectClass == Boolean.class) { + return booleanValue(value); } if (objectClass == byte[].class) { - return getBytes(columnIndex); + return bytesValue(value); } if (objectClass == Date.class) { - return getDate(columnIndex); + return dateValue(value); } if (objectClass == Time.class) { - return getTime(columnIndex); + return timeValue(value); } if (objectClass == Timestamp.class) { - return getTimestamp(columnIndex); + return timestampValue(value); } if (objectClass == Clob.class) { - return getClob(columnIndex); + return clobValue(value); } if (objectClass == Blob.class) { - return getBlob(columnIndex); + return blobValue(value); } // NCLOB and DATALINK resolve to their declared wrapper types, but the wrapper getters // arrive in PR-3, so the value keeps its storage-class representation until then. - return storedObject(getCurrentValue(columnIndex)); + return storedObject(value); } private static Object storedObject(Value value) { @@ -375,13 +436,16 @@ private static Object storedObject(Value value) { }; } - private Integer checkedInt(int columnIndex, int sqlType) throws SQLException { - long value = getLong(columnIndex); - if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + private static Integer checkedInt(Value value, int sqlType) throws SQLException { + return checkedInt(toLong(value), sqlType); + } + + private static Integer checkedInt(long number, int sqlType) throws SQLException { + if (number < Integer.MIN_VALUE || number > Integer.MAX_VALUE) { throw new SQLException( - "Value " + value + " does not fit the declared " + sqlTypeName(sqlType) + " type"); + "Value " + number + " does not fit the declared " + sqlTypeName(sqlType) + " type"); } - return (int) value; + return (int) number; } private static String sqlTypeName(int sqlType) { @@ -648,11 +712,17 @@ public void setFetchSize(int rows) throws SQLException { } @Override public Ref getRef(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public Blob getBlob(int columnIndex) throws SQLException { - byte[] bytes = getBytes(columnIndex); + return blobValue(getCurrentValue(columnIndex)); + } + private static Blob blobValue(Value value) { + byte[] bytes = bytesValue(value); return bytes == null ? null : new LibsqlBlob(bytes); } @Override public Clob getClob(int columnIndex) throws SQLException { - String string = getString(columnIndex); + return clobValue(getCurrentValue(columnIndex)); + } + private static Clob clobValue(Value value) { + String string = stringValue(value); return string == null ? null : new LibsqlClob(string); } @Override public Array getArray(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @@ -860,25 +930,24 @@ public T getObject(int columnIndex, Class type) throws SQLException { } private LocalDate getLocalDate(int columnIndex) throws SQLException { - LocalDateTime value = dateTimeValue(columnIndex, "LocalDate"); + LocalDateTime value = dateTimeValue(getCurrentValue(columnIndex), "LocalDate"); return value == null ? null : value.toLocalDate(); } private LocalTime getLocalTime(int columnIndex) throws SQLException { - LocalDateTime value = dateTimeValue(columnIndex, "LocalTime"); + LocalDateTime value = dateTimeValue(getCurrentValue(columnIndex), "LocalTime"); return value == null ? null : value.toLocalTime(); } private LocalDateTime getLocalDateTime(int columnIndex) throws SQLException { - return dateTimeValue(columnIndex, "LocalDateTime"); + return dateTimeValue(getCurrentValue(columnIndex), "LocalDateTime"); } /** - * Reads a column as a local date and time using SQLite storage-class conventions: INTEGER is + * Reads a value as a local date and time using SQLite storage-class conventions: INTEGER is * epoch milliseconds, FLOAT is a Julian day, and TEXT is a SQLite date/time string. */ - private LocalDateTime dateTimeValue(int columnIndex, String targetType) throws SQLException { - Value value = getCurrentValue(columnIndex); + private static LocalDateTime dateTimeValue(Value value, String targetType) throws SQLException { return SqliteDateTimeValues.from(value, targetType); } diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaData.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaData.java index 6415ddd..1120178 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaData.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaData.java @@ -17,6 +17,7 @@ public class LibsqlResultSetMetaData implements ResultSetMetaData { private final LibsqlConnection connection; private final LibsqlResultSet resultSet; private final TableMetadataResolver tableMetadata; + private final JdbcTypeMapping.JdbcType[] declaredTypes; public LibsqlResultSetMetaData(List cols) { this(cols, List.of(), null, null, null); @@ -34,6 +35,8 @@ public LibsqlResultSetMetaData(List cols) { this.connection = connection; this.resultSet = resultSet; this.tableMetadata = new TableMetadataResolver(connection, sql, this.cols.size()); + this.declaredTypes = + resultSet != null ? resultSet.declaredTypes() : JdbcTypeMapping.declaredTypes(this.cols); } @Override @@ -185,7 +188,9 @@ public String getColumnClassName(int column) throws SQLException { } private JdbcTypeMapping.JdbcType resolvedType(int column) throws SQLException { - return JdbcTypeMapping.resolve(getCol(column), runtimeValue(column)); + getCol(column); + JdbcTypeMapping.JdbcType declared = declaredTypes[column - 1]; + return declared != null ? declared : JdbcTypeMapping.fallback(runtimeValue(column)); } private String columnName(int column) throws SQLException { From 594727c9d1ff3a849a5052e53d672c97353a328d Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:54:26 +0100 Subject: [PATCH 08/29] perf(jdbc): cache the current row values for the column getters The getters resolved the current row again on every call: row index branch, rows.size(), rows.get and Row.values. A result set now keeps the value list of the row the getters read, so next() swaps it once per row and getCurrentValue is a single list read. This pays back the per-column lookup the declared-type resolution added to getObject: the three-column scan runs at trunk speed while the declared type is still read from the per-result-set cache. The documented quirks are kept: before the first next() the getters read the first row, and after the last row the list is null so they return SQL NULL. --- .../magnusp/libsql/jdbc/LibsqlResultSet.java | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java index ff2a8cd..32098ab 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java @@ -66,6 +66,11 @@ public class LibsqlResultSet implements ResultSet { private int fetchSize = 0; private LibsqlResultSetMetaData metadata; + // Values of the row the getters read. Before the first next() call this is the first row, + // matching sqlite-jdbc; after the last row it is null. next() swaps it with the row it lands + // on so a getter never has to resolve the row again. + private List currentValues; + // O(1) column label lookup; the map is built lazily on first use. private Map columnNameToIndex; @@ -83,6 +88,7 @@ public LibsqlResultSet(Statement statement, StmtResult result) { : null; this.closeStatementOnClose = closeStatementOnClose; this.declaredTypes = JdbcTypeMapping.declaredTypes(this.cols); + this.currentValues = this.rows.isEmpty() ? null : this.rows.get(0).values(); } JdbcTypeMapping.JdbcType[] declaredTypes() { @@ -96,11 +102,8 @@ private void checkClosed() throws SQLException { } private Value valueAtCursor(int columnIndex) { - int rowIndex = currentRowIndex < 0 ? 0 : currentRowIndex; - if (rowIndex >= rows.size()) { - return Value.ofNull(); - } - return rows.get(rowIndex).values().get(columnIndex - 1); + List values = currentValues; + return values == null ? Value.ofNull() : values.get(columnIndex - 1); } private Value getCurrentValue(int columnIndex) throws SQLException { @@ -133,7 +136,12 @@ public boolean next() throws SQLException { } lastColumnIndex = -1; currentRowIndex++; - return currentRowIndex < rows.size(); + if (currentRowIndex < rows.size()) { + currentValues = rows.get(currentRowIndex).values(); + return true; + } + currentValues = null; + return false; } @Override From d900f6363d2d128d51faa0795bc6532f51781320 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:54:41 +0100 Subject: [PATCH 09/29] fix(jdbc): keep sqlite-jdbc metadata for SQLite type aliases Removing the xerial column mapping dropped the SQLite spellings that Appendix B does not name: DATETIME, INT, INT2, INT8, MEDIUMINT, UNSIGNED BIG INT, DOUBLE PRECISION, TEXT, CHARACTER, NATIVE CHARACTER and VARYING CHARACTER all became unknown and fell back to the storage class. Users saw DATETIME report VARCHAR instead of TIMESTAMP, INT8 report INTEGER instead of BIGINT, and DOUBLE PRECISION report FLOAT instead of DOUBLE. Keep the Appendix B registry at its 27 canonical names and add an alias table that points each SQLite spelling at the registry entry it stands for. Aliases resolve through the declared type, so INT8 stays BIGINT for a small value, and they share the canonical JdbcType instance, so getObject and the metadata agree. A test pins every alias. TEXT now resolves to VARCHAR, so the empty-result-set class name for a TEXT column is String rather than Object. --- .../magnusp/libsql/jdbc/JdbcTypeMapping.java | 43 ++++++++++++- .../jdbc/LibsqlResultSetMetaDataTest.java | 60 ++++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java index b2297ad..b2883f8 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java @@ -12,6 +12,7 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -22,6 +23,11 @@ * the stored value. A column with no declared type, or with a declared type outside the registry, * falls back to the storage class of the value and marks the result {@link Support#FALLBACK} so a * caller can record the exception. + * + *

The registry holds the 27 canonical JDBC 4.3 Appendix B type names. A separate alias table + * maps the SQLite spellings that Appendix B does not name, such as {@code INT8} and {@code + * DATETIME}, to the registry entry they stand for, preserving the sqlite-jdbc metadata that + * existing users see. Both tables resolve through the declared type, never through the value. */ final class JdbcTypeMapping { @@ -55,6 +61,7 @@ record JdbcType(int sqlType, Class javaClass, Class objectClass, StorageCl fallback(Types.NUMERIC, Object.class, Object.class, StorageClass.NULL); private static final Map DECLARED = declaredTypeRegistry(); + private static final Map ALIASES = aliasTypes(); private JdbcTypeMapping() {} @@ -82,7 +89,8 @@ private static JdbcType declared(Col col) { if (name == null) { return null; } - return DECLARED.get(name); + JdbcType type = DECLARED.get(name); + return type != null ? type : ALIASES.get(name); } static JdbcType fallback(Value value) { @@ -159,4 +167,37 @@ private static Map.Entry entry( String name, int sqlType, Class javaClass, Class objectClass, StorageClass storage) { return Map.entry(name, new JdbcType(sqlType, javaClass, objectClass, storage, Support.SUPPORTED)); } + + /** + * Maps the SQLite spellings that sqlite-jdbc 3.53.4.0 reported with a canonical JDBC type to + * the registry entry for that type. + * + *

Appendix B does not name these spellings, so sqlite-jdbc parity decides their metadata. + * The alias resolves to the same {@link JdbcType} instance as its canonical name, which keeps + * the {@code getObject} conversion and the metadata in agreement with the canonical type. An + * alias never inspects the value: {@code INT8} is {@code BIGINT} even when the value is small. + */ + private static Map aliasTypes() { + Map aliases = new HashMap<>(); + alias(aliases, "INT", "INTEGER"); + alias(aliases, "INT2", "SMALLINT"); + alias(aliases, "INT8", "BIGINT"); + alias(aliases, "MEDIUMINT", "INTEGER"); + alias(aliases, "UNSIGNED BIG INT", "BIGINT"); + alias(aliases, "DATETIME", "TIMESTAMP"); + alias(aliases, "DOUBLE PRECISION", "DOUBLE"); + alias(aliases, "TEXT", "VARCHAR"); + alias(aliases, "CHARACTER", "CHAR"); + alias(aliases, "NATIVE CHARACTER", "CHAR"); + alias(aliases, "VARYING CHARACTER", "VARCHAR"); + return Map.copyOf(aliases); + } + + private static void alias(Map aliases, String spelling, String canonical) { + JdbcType type = DECLARED.get(canonical); + if (type == null) { + throw new IllegalStateException("Unknown canonical type " + canonical + " for alias " + spelling); + } + aliases.put(spelling, type); + } } diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java index 8bbe574..1de9cb2 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java @@ -264,6 +264,64 @@ void usesDeclaredTypesForEmptyResultSet() throws SQLException { assertDeclaredTypes(metadata); } + @Test + void resolvesSqliteAliasSpellingsThroughTheDeclaredType() throws SQLException { + StmtResult result = new StmtResult( + List.of( + new Col("dt", "DATETIME"), + new Col("i", "INT"), + new Col("i2", "INT2"), + new Col("i8", "INT8"), + new Col("mi", "MEDIUMINT"), + new Col("ubi", "UNSIGNED BIG INT"), + new Col("dp", "DOUBLE PRECISION"), + new Col("txt", "TEXT"), + new Col("ch", "CHARACTER"), + new Col("nch", "NATIVE CHARACTER"), + new Col("vch", "VARYING CHARACTER"), + new Col("v20", "VARCHAR(20)")), + List.of(new Row(List.of( + Value.of("2024-01-02 03:04:05"), + Value.of(42L), + Value.of(42L), + Value.of(42L), + Value.of(42L), + Value.of(42L), + Value.of(42.5), + Value.of("abc"), + Value.of("abc"), + Value.of("abc"), + Value.of("abc"), + Value.of("abc")))), + 0, + null); + LibsqlResultSet resultSet = new LibsqlResultSet(null, result); + ResultSetMetaData metadata = resultSet.getMetaData(); + + assertThat(metadata.getColumnTypeName(1)).isEqualTo("DATETIME"); + assertThat(metadata.getColumnType(1)).isEqualTo(Types.TIMESTAMP); + assertThat(metadata.getColumnType(2)).as("INT").isEqualTo(Types.INTEGER); + assertThat(metadata.getColumnType(3)).as("INT2").isEqualTo(Types.SMALLINT); + assertThat(metadata.getColumnType(4)).as("INT8").isEqualTo(Types.BIGINT); + assertThat(metadata.getColumnType(5)).as("MEDIUMINT").isEqualTo(Types.INTEGER); + assertThat(metadata.getColumnType(6)).as("UNSIGNED BIG INT").isEqualTo(Types.BIGINT); + assertThat(metadata.getColumnType(7)).as("DOUBLE PRECISION").isEqualTo(Types.DOUBLE); + assertThat(metadata.getColumnType(8)).as("TEXT").isEqualTo(Types.VARCHAR); + assertThat(metadata.getColumnType(9)).as("CHARACTER").isEqualTo(Types.CHAR); + assertThat(metadata.getColumnType(10)).as("NATIVE CHARACTER").isEqualTo(Types.CHAR); + assertThat(metadata.getColumnType(11)).as("VARYING CHARACTER").isEqualTo(Types.VARCHAR); + assertThat(metadata.getColumnType(12)).as("VARCHAR(20)").isEqualTo(Types.VARCHAR); + + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getObject(1)).isInstanceOf(java.sql.Timestamp.class); + assertThat(resultSet.getObject(2)).isInstanceOf(Integer.class); + assertThat(resultSet.getObject(3)).isInstanceOf(Integer.class); + assertThat(resultSet.getObject(4)).as("INT8 keeps BIGINT for a small value").isInstanceOf(Long.class); + assertThat(resultSet.getObject(6)).isInstanceOf(Long.class); + assertThat(resultSet.getObject(7)).isInstanceOf(Double.class); + assertThat(resultSet.getObject(8)).isInstanceOf(String.class); + } + @Test void isSignedMatchesXerialNarrowTypeNameSet() throws SQLException { LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData( @@ -364,7 +422,7 @@ void reportsObjectClassNameForEmptyResultSet() throws SQLException { List expected = List.of( Integer.class.getName(), Long.class.getName(), - Object.class.getName(), + String.class.getName(), Blob.class.getName(), Boolean.class.getName(), Float.class.getName(), From ace98f70ed67f067e6731f8f710ccd47e0a36af3 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:09:49 +0100 Subject: [PATCH 10/29] feat(jdbc): add the JDBC value converter The converter turns a Java value into the Hrana value for a target JDBC type per Appendix B Table B-5, and follows Table B-4 when no target is given. Unlisted pairs throw SQLFeatureNotSupportedException so the target is never ignored. --- .../magnusp/libsql/jdbc/JdbcTypeMapping.java | 43 ++ .../libsql/jdbc/JdbcValueConverter.java | 528 ++++++++++++++++++ 2 files changed, 571 insertions(+) create mode 100644 libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcValueConverter.java diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java index b2883f8..83ddc7f 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java @@ -200,4 +200,47 @@ private static void alias(Map aliases, String spelling, String } aliases.put(spelling, type); } + + /** Names a JDBC type for an error message or the compliance profile. */ + static String sqlTypeName(int sqlType) { + return switch (sqlType) { + case Types.TINYINT -> "TINYINT"; + case Types.SMALLINT -> "SMALLINT"; + case Types.INTEGER -> "INTEGER"; + case Types.BIGINT -> "BIGINT"; + case Types.REAL -> "REAL"; + case Types.FLOAT -> "FLOAT"; + case Types.DOUBLE -> "DOUBLE"; + case Types.NUMERIC -> "NUMERIC"; + case Types.DECIMAL -> "DECIMAL"; + case Types.BIT -> "BIT"; + case Types.BOOLEAN -> "BOOLEAN"; + case Types.CHAR -> "CHAR"; + case Types.VARCHAR -> "VARCHAR"; + case Types.LONGVARCHAR -> "LONGVARCHAR"; + case Types.NCHAR -> "NCHAR"; + case Types.NVARCHAR -> "NVARCHAR"; + case Types.LONGNVARCHAR -> "LONGNVARCHAR"; + case Types.BINARY -> "BINARY"; + case Types.VARBINARY -> "VARBINARY"; + case Types.LONGVARBINARY -> "LONGVARBINARY"; + case Types.DATE -> "DATE"; + case Types.TIME -> "TIME"; + case Types.TIMESTAMP -> "TIMESTAMP"; + case Types.CLOB -> "CLOB"; + case Types.NCLOB -> "NCLOB"; + case Types.BLOB -> "BLOB"; + case Types.DATALINK -> "DATALINK"; + case Types.ARRAY -> "ARRAY"; + case Types.STRUCT -> "STRUCT"; + case Types.REF -> "REF"; + case Types.JAVA_OBJECT -> "JAVA_OBJECT"; + case Types.ROWID -> "ROWID"; + case Types.SQLXML -> "SQLXML"; + case Types.DISTINCT -> "DISTINCT"; + case Types.TIME_WITH_TIMEZONE -> "TIME_WITH_TIMEZONE"; + case Types.TIMESTAMP_WITH_TIMEZONE -> "TIMESTAMP_WITH_TIMEZONE"; + default -> "SQL type " + sqlType; + }; + } } diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcValueConverter.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcValueConverter.java new file mode 100644 index 0000000..d639305 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcValueConverter.java @@ -0,0 +1,528 @@ +package com.github.magnusp.libsql.jdbc; + +import com.github.magnusp.libsql.hrana.codec.Value; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.math.BigDecimal; +import java.math.BigInteger; +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.RowId; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +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.ZoneId; +import java.util.Calendar; + +/** + * Converts a Java value to the Hrana value bound for a parameter. + * + *

{@link #toValue(Object, int)} implements JDBC 4.3 Appendix B, Table B-5: the value is first + * converted to the target JDBC type and then to the Hrana value of that type's storage class. A + * pair that Table B-5 does not list throws {@link SQLFeatureNotSupportedException}, so the target + * type is never silently ignored. {@link #toValue(Object)} implements Table B-4 for the + * {@code setObject} call that has no target type. + * + *

The converter is a pure function of the value and the target type. It holds no statement or + * result-set state. LOB content is read through {@link Clob#getCharacterStream()} and {@link + * Blob#getBinaryStream()}; a LOB is never converted with {@code toString}. + * + *

A text target uses the Java {@code toString} form, with one documented exception: a {@link + * Boolean} binds {@code "true"} or {@code "false"} rather than {@code "TRUE"} or {@code "FALSE"}, + * so the text round trips through {@code getBoolean}. Numeric targets narrow with Java primitive + * conversion rules. Integral and boolean targets bind Hrana integers, and decimal targets bind + * decimal text. Date and time targets bind the SQLite text format that {@code getDate}, {@code + * getTime}, and {@code getTimestamp} parse. + */ +final class JdbcValueConverter { + + private JdbcValueConverter() {} + + /** Converts a value with no target type, per Table B-4. */ + static Value toValue(Object value) throws SQLException { + if (value == null) { + return Value.ofNull(); + } + if (value instanceof String text) { + return Value.of(text); + } + if (value instanceof BigDecimal decimal) { + return Value.of(decimal.toString()); + } + if (value instanceof BigInteger integer) { + return Value.of(bigIntegerLong(integer)); + } + if (value instanceof Boolean bool) { + return Value.of(bool ? 1L : 0L); + } + if (value instanceof Byte || value instanceof Short + || value instanceof Integer || value instanceof Long) { + return Value.of(((Number) value).longValue()); + } + if (value instanceof Float floating) { + return Value.of((double) floating); + } + if (value instanceof Double floating) { + return Value.of((double) floating); + } + if (value instanceof byte[] bytes) { + return Value.of(bytes); + } + if (value instanceof Timestamp timestamp) { + return Value.of(formatTimestamp(timestamp.toLocalDateTime())); + } + if (value instanceof Time time) { + return Value.of(formatTime(time.toLocalTime())); + } + if (value instanceof Date date) { + return Value.of(date.toString()); + } + if (value instanceof LocalDate date) { + return Value.of(date.toString()); + } + if (value instanceof LocalTime time) { + return Value.of(formatTime(time)); + } + if (value instanceof LocalDateTime dateTime) { + return Value.of(formatTimestamp(dateTime)); + } + if (value instanceof OffsetTime time) { + return Value.of(time.toString()); + } + if (value instanceof OffsetDateTime dateTime) { + return Value.of(dateTime.toString()); + } + if (value instanceof Calendar calendar) { + return Value.of(formatTimestamp(localDateTime(calendar.toInstant()))); + } + if (value instanceof java.util.Date date) { + return Value.of(formatTimestamp(localDateTime(date.toInstant()))); + } + if (value instanceof Clob clob) { + return Value.of(characterText(clob)); + } + if (value instanceof Blob blob) { + return Value.of(binaryBytes(blob)); + } + // Table B-4 maps every other Java class to JAVA_OBJECT. PR-5 decides whether those types + // are supported; until then they keep the sqlite-jdbc text binding. + return Value.of(value.toString()); + } + + /** Converts a value to the target JDBC type, then to the Hrana value of that type. */ + static Value toValue(Object value, int targetSqlType) throws SQLException { + if (value == null) { + return Value.ofNull(); + } + return switch (targetSqlType) { + case Types.TINYINT -> Value.of((long) numeric(value, targetSqlType).byteValue()); + case Types.SMALLINT -> Value.of((long) numeric(value, targetSqlType).shortValue()); + case Types.INTEGER -> Value.of((long) numeric(value, targetSqlType).intValue()); + case Types.BIGINT -> Value.of(bigInt(value)); + case Types.REAL -> Value.of((double) numeric(value, targetSqlType).floatValue()); + case Types.FLOAT, Types.DOUBLE -> Value.of(numeric(value, targetSqlType).doubleValue()); + case Types.DECIMAL, Types.NUMERIC -> Value.of(numeric(value, targetSqlType).toString()); + case Types.BIT, Types.BOOLEAN -> Value.of(bit(value, targetSqlType)); + case Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR -> Value.of(text(value, targetSqlType)); + case Types.NCHAR, Types.NVARCHAR, Types.LONGNVARCHAR -> + Value.of(nationalText(value, targetSqlType)); + case Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY -> + Value.of(binary(value, targetSqlType)); + case Types.BLOB -> Value.of(blobBytes(value)); + case Types.CLOB -> Value.of(clobText(value)); + case Types.NCLOB -> Value.of(nclobText(value)); + case Types.DATE -> Value.of(dateText(value, targetSqlType)); + case Types.TIME -> Value.of(timeText(value, targetSqlType)); + case Types.TIMESTAMP -> Value.of(timestampText(value, targetSqlType)); + case Types.DATALINK -> Value.of(datalinkText(value)); + case Types.ARRAY -> Value.of(arrayText(value)); + case Types.STRUCT -> Value.of(structText(value)); + case Types.REF -> Value.of(refText(value)); + case Types.JAVA_OBJECT -> Value.of(javaObjectText(value)); + case Types.ROWID -> Value.of(rowIdText(value)); + case Types.SQLXML -> Value.of(sqlXmlText(value)); + case Types.TIME_WITH_TIMEZONE -> Value.of(offsetTimeText(value)); + case Types.TIMESTAMP_WITH_TIMEZONE -> Value.of(offsetDateTimeText(value)); + case Types.DISTINCT -> Value.of(value.toString()); + default -> throw unsupported(value, targetSqlType); + }; + } + + private static long bigIntegerLong(BigInteger value) throws SQLException { + try { + return value.longValueExact(); + } catch (ArithmeticException e) { + throw new SQLException("BigInteger value does not fit a BIGINT: " + value, e); + } + } + + /** + * Narrows a numeric value with the Java primitive conversion rules. {@link + * BigDecimal#byteValue()} and its siblings already perform the same narrowing as a Java cast, + * so routing every numeric source through BigDecimal keeps the rules in one place. + */ + private static BigDecimal numeric(Object value, int targetSqlType) throws SQLException { + if (value instanceof BigDecimal decimal) { + return decimal; + } + if (value instanceof Boolean bool) { + return bool ? BigDecimal.ONE : BigDecimal.ZERO; + } + if (value instanceof Byte || value instanceof Short + || value instanceof Integer || value instanceof Long) { + return BigDecimal.valueOf(((Number) value).longValue()); + } + if (value instanceof Float || value instanceof Double) { + return BigDecimal.valueOf(((Number) value).doubleValue()); + } + if (value instanceof String text) { + String trimmed = text.trim(); + if ("true".equalsIgnoreCase(trimmed)) { + return BigDecimal.ONE; + } + if ("false".equalsIgnoreCase(trimmed)) { + return BigDecimal.ZERO; + } + try { + return new BigDecimal(trimmed); + } catch (NumberFormatException e) { + throw new SQLException( + "Cannot convert \"" + text + "\" to " + JdbcTypeMapping.sqlTypeName(targetSqlType), + e); + } + } + throw unsupported(value, targetSqlType); + } + + private static long bigInt(Object value) throws SQLException { + if (value instanceof BigInteger integer) { + return bigIntegerLong(integer); + } + return numeric(value, Types.BIGINT).longValue(); + } + + private static long bit(Object value, int targetSqlType) throws SQLException { + return numeric(value, targetSqlType).signum() == 0 ? 0L : 1L; + } + + private static String text(Object value, int targetSqlType) throws SQLException { + if (value instanceof String text) { + return text; + } + if (value instanceof Boolean bool) { + return bool ? "true" : "false"; + } + if (value instanceof BigDecimal || value instanceof BigInteger + || value instanceof Byte || value instanceof Short + || value instanceof Integer || value instanceof Long + || value instanceof Float || value instanceof Double) { + return value.toString(); + } + if (value instanceof java.util.Date + || value instanceof Calendar + || value instanceof LocalDate + || value instanceof LocalTime + || value instanceof LocalDateTime + || value instanceof OffsetTime + || value instanceof OffsetDateTime) { + return value.toString(); + } + throw unsupported(value, targetSqlType); + } + + private static String nationalText(Object value, int targetSqlType) throws SQLException { + if (value instanceof String text) { + return text; + } + throw unsupported(value, targetSqlType); + } + + private static byte[] binary(Object value, int targetSqlType) throws SQLException { + if (value instanceof byte[] bytes) { + return bytes; + } + if (value instanceof String text) { + return text.getBytes(StandardCharsets.UTF_8); + } + throw unsupported(value, targetSqlType); + } + + private static byte[] blobBytes(Object value) throws SQLException { + if (value instanceof Blob blob) { + return binaryBytes(blob); + } + throw unsupported(value, Types.BLOB); + } + + private static String clobText(Object value) throws SQLException { + // NClob is a Clob, but Table B-5 lists it only for the NCLOB target. + if (value instanceof Clob clob && !(value instanceof NClob)) { + return characterText(clob); + } + throw unsupported(value, Types.CLOB); + } + + private static String nclobText(Object value) throws SQLException { + if (value instanceof NClob clob) { + return characterText(clob); + } + throw unsupported(value, Types.NCLOB); + } + + private static String dateText(Object value, int targetSqlType) throws SQLException { + LocalDate date; + if (value instanceof Timestamp timestamp) { + date = timestamp.toLocalDateTime().toLocalDate(); + } else if (value instanceof Date sqlDate) { + date = sqlDate.toLocalDate(); + } else if (value instanceof LocalDate localDate) { + date = localDate; + } else if (value instanceof LocalDateTime localDateTime) { + date = localDateTime.toLocalDate(); + } else if (value instanceof java.sql.Time) { + throw unsupported(value, targetSqlType); + } else if (value instanceof Calendar calendar) { + date = LocalDate.ofInstant(calendar.toInstant(), ZoneId.systemDefault()); + } else if (value instanceof java.util.Date utilDate) { + date = LocalDate.ofInstant(utilDate.toInstant(), ZoneId.systemDefault()); + } else if (value instanceof String text) { + // A string is already in the target type's text form. Binding it unchanged keeps the + // B-5 acceptance rule and lets the getter reject malformed text. + return text; + } else { + throw unsupported(value, targetSqlType); + } + return date.toString(); + } + + private static String timeText(Object value, int targetSqlType) throws SQLException { + LocalTime time; + if (value instanceof Time sqlTime) { + time = sqlTime.toLocalTime(); + } else if (value instanceof Timestamp timestamp) { + time = timestamp.toLocalDateTime().toLocalTime(); + } else if (value instanceof LocalTime localTime) { + time = localTime; + } else if (value instanceof LocalDateTime localDateTime) { + time = localDateTime.toLocalTime(); + } else if (value instanceof Date) { + throw unsupported(value, targetSqlType); + } else if (value instanceof Calendar calendar) { + time = LocalTime.ofInstant(calendar.toInstant(), ZoneId.systemDefault()); + } else if (value instanceof java.util.Date utilDate) { + time = LocalTime.ofInstant(utilDate.toInstant(), ZoneId.systemDefault()); + } else if (value instanceof String text) { + return text; + } else { + throw unsupported(value, targetSqlType); + } + return formatTime(time); + } + + private static String timestampText(Object value, int targetSqlType) throws SQLException { + LocalDateTime dateTime; + if (value instanceof Timestamp timestamp) { + dateTime = timestamp.toLocalDateTime(); + } else if (value instanceof Date date) { + dateTime = date.toLocalDate().atStartOfDay(); + } else if (value instanceof LocalDateTime localDateTime) { + dateTime = localDateTime; + } else if (value instanceof Time time) { + // The date part of a TIME to TIMESTAMP conversion is implementation defined; + // sqlite-jdbc and this driver use the epoch date. + dateTime = LocalDate.of(1970, 1, 1).atTime(time.toLocalTime()); + } else if (value instanceof Calendar calendar) { + dateTime = localDateTime(calendar.toInstant()); + } else if (value instanceof java.util.Date utilDate) { + dateTime = localDateTime(utilDate.toInstant()); + } else if (value instanceof String text) { + return text; + } else { + throw unsupported(value, targetSqlType); + } + return formatTimestamp(dateTime); + } + + private static String datalinkText(Object value) throws SQLException { + if (value instanceof URL url) { + return url.toString(); + } + throw unsupported(value, Types.DATALINK); + } + + private static String arrayText(Object value) throws SQLException { + if (value instanceof Array array) { + return array.toString(); + } + // Table B-5 lists ARRAY for Calendar and java.util.Date, but not for their SQL subclasses. + if (value instanceof Calendar || value.getClass() == java.util.Date.class) { + return value.toString(); + } + throw unsupported(value, Types.ARRAY); + } + + private static String structText(Object value) throws SQLException { + if (value instanceof Struct struct) { + return struct.toString(); + } + throw unsupported(value, Types.STRUCT); + } + + private static String refText(Object value) throws SQLException { + if (value instanceof Ref ref) { + return ref.toString(); + } + throw unsupported(value, Types.REF); + } + + private static String rowIdText(Object value) throws SQLException { + if (value instanceof RowId rowId) { + return rowId.toString(); + } + throw unsupported(value, Types.ROWID); + } + + private static String sqlXmlText(Object value) throws SQLException { + if (value instanceof SQLXML xml) { + return xml.toString(); + } + throw unsupported(value, Types.SQLXML); + } + + private static String javaObjectText(Object value) throws SQLException { + if (knownSourceFamily(value)) { + throw unsupported(value, Types.JAVA_OBJECT); + } + return value.toString(); + } + + private static String offsetTimeText(Object value) throws SQLException { + if (value instanceof OffsetTime time) { + return time.toString(); + } + if (value instanceof OffsetDateTime dateTime) { + return dateTime.toOffsetTime().toString(); + } + throw unsupported(value, Types.TIME_WITH_TIMEZONE); + } + + private static String offsetDateTimeText(Object value) throws SQLException { + if (value instanceof OffsetDateTime dateTime) { + return dateTime.toString(); + } + throw unsupported(value, Types.TIMESTAMP_WITH_TIMEZONE); + } + + private static boolean knownSourceFamily(Object value) { + return value instanceof String + || value instanceof Number + || value instanceof Boolean + || value instanceof byte[] + || value instanceof java.util.Date + || value instanceof Calendar + || value instanceof LocalDate + || value instanceof LocalTime + || value instanceof LocalDateTime + || value instanceof OffsetTime + || value instanceof OffsetDateTime + || value instanceof Clob + || value instanceof Blob + || value instanceof Array + || value instanceof Struct + || value instanceof Ref + || value instanceof URL + || value instanceof RowId + || value instanceof SQLXML; + } + + private static LocalDateTime localDateTime(java.time.Instant instant) { + return LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); + } + + private static String formatTimestamp(LocalDateTime value) { + return value.toLocalDate() + " " + formatTime(value.toLocalTime()); + } + + private static String formatTime(LocalTime value) { + StringBuilder text = new StringBuilder(18); + appendTwoDigits(text, value.getHour()); + text.append(':'); + appendTwoDigits(text, value.getMinute()); + text.append(':'); + appendTwoDigits(text, value.getSecond()); + int nanos = value.getNano(); + if (nanos != 0) { + String fraction = Integer.toString(1_000_000_000 + nanos).substring(1); + int end = fraction.length(); + while (end > 0 && fraction.charAt(end - 1) == '0') { + end--; + } + text.append('.').append(fraction, 0, end); + } + return text.toString(); + } + + private static void appendTwoDigits(StringBuilder text, int value) { + text.append((char) ('0' + value / 10)).append((char) ('0' + value % 10)); + } + + private static String characterText(Clob clob) throws SQLException { + Reader reader = clob.getCharacterStream(); + if (reader == null) { + return null; + } + try { + StringBuilder text = new StringBuilder(); + char[] buffer = new char[8192]; + int read; + while ((read = reader.read(buffer)) != -1) { + text.append(buffer, 0, read); + } + return text.toString(); + } catch (IOException e) { + throw new SQLException("Cannot read from character stream: " + e.getMessage(), e); + } + } + + private static byte[] binaryBytes(Blob blob) throws SQLException { + InputStream in = blob.getBinaryStream(); + if (in == null) { + return null; + } + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } catch (IOException e) { + throw new SQLException("Cannot read from binary stream: " + e.getMessage(), e); + } + } + + private static SQLFeatureNotSupportedException unsupported(Object value, int targetSqlType) { + return new SQLFeatureNotSupportedException( + "Cannot convert " + value.getClass().getName() + " to " + + JdbcTypeMapping.sqlTypeName(targetSqlType)); + } +} From 4ae213a99488b948ac47894e439fc841b1dcd88e Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:09:52 +0100 Subject: [PATCH 11/29] feat(jdbc): convert setObject values to the target JDBC type setObject(x, targetSqlType) now converts to the target type before binding, instead of ignoring the target and delegating to setObject(x). --- .../libsql/jdbc/LibsqlPreparedStatement.java | 27 +++---------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java index 2176622..64ad113 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java @@ -258,37 +258,18 @@ public void setTimestamp(int parameterIndex, Timestamp x) throws SQLException { @Override public void setObject(int parameterIndex, Object x) throws SQLException { - if (x == null) { - setNull(parameterIndex, 0); - } else if (x instanceof String s) { - setString(parameterIndex, s); - } else if (x instanceof Long l) { - setLong(parameterIndex, l); - } else if (x instanceof Integer i) { - setInt(parameterIndex, i); - } else if (x instanceof Double d) { - setDouble(parameterIndex, d); - } else if (x instanceof Float f) { - setFloat(parameterIndex, f); - } else if (x instanceof Boolean b) { - setBoolean(parameterIndex, b); - } else if (x instanceof byte[] bytes) { - setBytes(parameterIndex, bytes); - } else if (x instanceof BigDecimal bd) { - setBigDecimal(parameterIndex, bd); - } else { - setString(parameterIndex, x.toString()); - } + setParam(parameterIndex, JdbcValueConverter.toValue(x)); } @Override public void setObject(int parameterIndex, Object x, int targetSqlType) throws SQLException { - setObject(parameterIndex, x); + setParam(parameterIndex, JdbcValueConverter.toValue(x, targetSqlType)); } @Override public void setObject(int parameterIndex, Object x, int targetSqlType, int scaleOrLength) throws SQLException { - setObject(parameterIndex, x); + // Hrana has no scale or length for a parameter; the target type still decides the value. + setParam(parameterIndex, JdbcValueConverter.toValue(x, targetSqlType)); } @Override From 0ea436c540a4b16e156d3e03e5b9cf38424b4f1b Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:09:52 +0100 Subject: [PATCH 12/29] test(jdbc): pin setObject conversions per target family Each Table B-4 and B-5 family asserts the Hrana value kind and the Boolean text format without a server. --- .../jdbc/LibsqlPreparedStatementTest.java | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java index 8546e2c..8446d83 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java @@ -9,14 +9,26 @@ import java.io.ByteArrayInputStream; import java.io.StringReader; +import java.math.BigDecimal; +import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.sql.Date; +import java.sql.NClob; import java.sql.ParameterMetaData; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; +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.ZoneId; import java.util.ArrayList; +import java.util.Calendar; import java.util.List; +import java.util.TimeZone; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -448,6 +460,147 @@ void streamSettersWithoutALengthReadToEndOfStream() throws SQLException { Value.of("ascii")); } + @Test + void integralTargetsBindHranaIntegers() throws SQLException { + assertThat(bind("42", Types.TINYINT)).isEqualTo(Value.of(42L)); + assertThat(bind((short) 42, Types.SMALLINT)).isEqualTo(Value.of(42L)); + assertThat(bind(42L, Types.INTEGER)).isEqualTo(Value.of(42L)); + assertThat(bind(BigInteger.valueOf(42), Types.BIGINT)).isEqualTo(Value.of(42L)); + // Java narrowing applies to an integral target. + assertThat(bind(258, Types.TINYINT)).isEqualTo(Value.of(2L)); + } + + @Test + void realTargetsBindHranaFloats() throws SQLException { + assertThat(bind("42", Types.REAL)).isEqualTo(Value.of(42.0)); + assertThat(bind(42L, Types.FLOAT)).isEqualTo(Value.of(42.0)); + assertThat(bind(42L, Types.DOUBLE)).isEqualTo(Value.of(42.0)); + } + + @Test + void decimalTargetsBindDecimalText() throws SQLException { + assertThat(bind(new BigDecimal("7"), Types.DECIMAL)).isEqualTo(Value.of("7")); + assertThat(bind(7, Types.NUMERIC)).isEqualTo(Value.of("7")); + } + + @Test + void booleanTargetsBindZeroOrOneIntegers() throws SQLException { + assertThat(bind(true, Types.BIT)).isEqualTo(Value.of(1L)); + assertThat(bind("false", Types.BOOLEAN)).isEqualTo(Value.of(0L)); + assertThat(bind(new BigDecimal("0.5"), Types.BOOLEAN)).isEqualTo(Value.of(1L)); + } + + @Test + void textTargetsBindTheJavaTextForm() throws SQLException { + assertThat(bind(7, Types.VARCHAR)).isEqualTo(Value.of("7")); + assertThat(bind("x", Types.NCHAR)).isEqualTo(Value.of("x")); + assertThat(bind("x", Types.LONGNVARCHAR)).isEqualTo(Value.of("x")); + // The documented Boolean text format is "true" / "false", not "TRUE" / "FALSE". + assertThat(bind(true, Types.CHAR)).isEqualTo(Value.of("true")); + assertThat(bind(false, Types.VARCHAR)).isEqualTo(Value.of("false")); + } + + @Test + void binaryTargetsBindHranaBlobs() throws SQLException { + byte[] bytes = {1, 2, 3}; + assertThat(bind(bytes, Types.VARBINARY)).isEqualTo(Value.of(bytes)); + assertThat(bind("abc", Types.BINARY)) + .isEqualTo(Value.of("abc".getBytes(StandardCharsets.UTF_8))); + assertThat(bind(new LibsqlBlob(bytes), Types.BLOB)).isEqualTo(Value.of(bytes)); + } + + @Test + void temporalTargetsBindSqliteText() throws SQLException { + Calendar calendar = calendar2024(); + java.util.Date utilDate = new java.util.Date(calendar.getTimeInMillis()); + String expectedTimestamp = timestampText(LocalDateTime.ofInstant( + calendar.toInstant(), ZoneId.systemDefault())); + + assertThat(bind(Date.valueOf("2024-01-02"), Types.DATE)) + .isEqualTo(Value.of("2024-01-02")); + assertThat(bind(Time.valueOf("03:04:05"), Types.TIME)) + .isEqualTo(Value.of("03:04:05")); + assertThat(bind(Timestamp.valueOf("2024-01-02 03:04:05"), Types.TIMESTAMP)) + .isEqualTo(Value.of("2024-01-02 03:04:05")); + assertThat(bind(Timestamp.valueOf("2024-01-02 03:04:05"), Types.DATE)) + .isEqualTo(Value.of("2024-01-02")); + assertThat(bind(LocalDateTime.of(2024, 1, 2, 3, 4, 5), Types.TIME)) + .isEqualTo(Value.of("03:04:05")); + assertThat(bind(calendar, Types.TIMESTAMP)).isEqualTo(Value.of(expectedTimestamp)); + assertThat(bind(utilDate, Types.TIMESTAMP)).isEqualTo(Value.of(expectedTimestamp)); + } + + @Test + void lobTargetsReadTheirStreams() throws SQLException { + byte[] bytes = {1, 2, 3}; + assertThat(bind(new LibsqlClob("abc"), Types.CLOB)).isEqualTo(Value.of("abc")); + assertThat(bind(new LibsqlBlob(bytes), Types.BLOB)).isEqualTo(Value.of(bytes)); + assertThat(bind(nclob("nclob"), Types.NCLOB)).isEqualTo(Value.of("nclob")); + } + + @Test + void unlistedTargetPairThrowsFeatureNotSupported() { + assertThatThrownBy(() -> bind("42", Types.ARRAY)) + .isInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessageContaining("ARRAY"); + assertThatThrownBy(() -> bind(7, Types.BLOB)) + .isInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessageContaining("BLOB"); + } + + @Test + void setObjectWithoutATargetFollowsTableB4() throws SQLException { + assertThat(bind(BigInteger.valueOf(7))).isEqualTo(Value.of(7L)); + assertThat(bind((byte) 7)).isEqualTo(Value.of(7L)); + assertThat(bind((short) 7)).isEqualTo(Value.of(7L)); + assertThat(bind(true)).isEqualTo(Value.of(1L)); + assertThat(bind(LocalDate.of(2024, 1, 2))).isEqualTo(Value.of("2024-01-02")); + assertThat(bind(LocalDateTime.of(2024, 1, 2, 3, 4, 5))) + .isEqualTo(Value.of("2024-01-02 03:04:05")); + } + + private static Value bind(Object value) throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "insert into t values (?)")) { + statement.setObject(1, value); + statement.executeUpdate(); + } + return connection.executed.get(0).args().get(0); + } + + private static Value bind(Object value, int targetSqlType) throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "insert into t values (?)")) { + statement.setObject(1, value, targetSqlType); + statement.executeUpdate(); + } + return connection.executed.get(0).args().get(0); + } + + 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 String timestampText(LocalDateTime value) { + return value.toLocalDate() + " " + String.format( + "%02d:%02d:%02d", + value.getHour(), value.getMinute(), value.getSecond()); + } + + private static NClob nclob(String text) { + return (NClob) java.lang.reflect.Proxy.newProxyInstance( + NClob.class.getClassLoader(), + new Class[] {NClob.class}, + (proxy, method, args) -> method.getName().equals("getCharacterStream") + ? new StringReader(text) + : null); + } + private static final class RecordingConnection extends LibsqlConnection { private final StmtResult result; private final List executedSql = new ArrayList<>(); From bc1039e41b524e615013b4ba265d1f2bb3dce2be Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:33:02 +0100 Subject: [PATCH 13/29] feat(jdbc): add NClob, SQLXML, and RowId wrappers Each wrapper holds the materialized value of one result column and rejects every read once freed. NClob reuses the Clob text holder because SQLite stores national and regular text identically; SQLXML and RowId are read-only views over the same text and bytes. --- .../magnusp/libsql/jdbc/LibsqlClob.java | 2 +- .../magnusp/libsql/jdbc/LibsqlNClob.java | 16 ++++ .../magnusp/libsql/jdbc/LibsqlRowId.java | 34 ++++++++ .../magnusp/libsql/jdbc/LibsqlSQLXML.java | 79 +++++++++++++++++++ 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlNClob.java create mode 100644 libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlRowId.java create mode 100644 libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlSQLXML.java diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlClob.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlClob.java index b1b917b..5c145f8 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlClob.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlClob.java @@ -12,7 +12,7 @@ import java.sql.SQLFeatureNotSupportedException; /** Read-only {@link Clob} over the materialized text of one result column. */ -final class LibsqlClob implements Clob { +class LibsqlClob implements Clob { private String text; private boolean freed; diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlNClob.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlNClob.java new file mode 100644 index 0000000..97f0333 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlNClob.java @@ -0,0 +1,16 @@ +package com.github.magnusp.libsql.jdbc; + +import java.sql.NClob; + +/** + * Read-only {@link NClob} over the materialized text of one result column. + * + *

SQLite stores national and regular character data as the same UTF-8 text, so the national + * variant only adds the {@link NClob} interface to {@link LibsqlClob}. + */ +final class LibsqlNClob extends LibsqlClob implements NClob { + + LibsqlNClob(String text) { + super(text); + } +} diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlRowId.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlRowId.java new file mode 100644 index 0000000..ee51178 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlRowId.java @@ -0,0 +1,34 @@ +package com.github.magnusp.libsql.jdbc; + +import java.sql.RowId; +import java.util.Arrays; + +/** {@link RowId} over the materialized bytes of one result column. */ +final class LibsqlRowId implements RowId { + + private final byte[] bytes; + + LibsqlRowId(byte[] bytes) { + this.bytes = bytes; + } + + @Override + public byte[] getBytes() { + return bytes.clone(); + } + + @Override + public boolean equals(Object other) { + return other instanceof LibsqlRowId rowId && Arrays.equals(bytes, rowId.bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + + @Override + public String toString() { + return "LibsqlRowId" + Arrays.toString(bytes); + } +} diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlSQLXML.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlSQLXML.java new file mode 100644 index 0000000..387fef1 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlSQLXML.java @@ -0,0 +1,79 @@ +package com.github.magnusp.libsql.jdbc; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.StringReader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.SQLXML; + +import javax.xml.transform.Result; +import javax.xml.transform.Source; + +/** Read-only {@link SQLXML} over the materialized text of one result column. */ +final class LibsqlSQLXML implements SQLXML { + + private String text; + private boolean freed; + + LibsqlSQLXML(String text) { + this.text = text; + } + + private String data() throws SQLException { + if (freed) { + throw new SQLException("SQLXML has been freed"); + } + return text; + } + + @Override + public void free() throws SQLException { + freed = true; + text = null; + } + + @Override + public String getString() throws SQLException { + return data(); + } + + @Override + public InputStream getBinaryStream() throws SQLException { + return new ByteArrayInputStream(data().getBytes(StandardCharsets.UTF_8)); + } + + @Override + public Reader getCharacterStream() throws SQLException { + return new StringReader(data()); + } + + @Override + public T getSource(Class sourceClass) throws SQLException { + throw new SQLFeatureNotSupportedException("SQLXML sources are not supported"); + } + + @Override + public OutputStream setBinaryStream() throws SQLException { + throw new SQLFeatureNotSupportedException("SQLXML is read-only"); + } + + @Override + public Writer setCharacterStream() throws SQLException { + throw new SQLFeatureNotSupportedException("SQLXML is read-only"); + } + + @Override + public void setString(String value) throws SQLException { + throw new SQLFeatureNotSupportedException("SQLXML is read-only"); + } + + @Override + public T setResult(Class resultClass) throws SQLException { + throw new SQLFeatureNotSupportedException("SQLXML is read-only"); + } +} From d9bbb220c580a4d2c532b6533eafb2c3da3078cf Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:33:05 +0100 Subject: [PATCH 14/29] feat(jdbc): read URL, LOB, row id, and NCHAR result columns getURL parses DATALINK text and throws on text that is not a URL. getNClob, getSQLXML, and getRowId wrap the stored text or bytes, and getObject routes NCLOB, SQLXML, ROWID, and DATALINK to the same wrappers. getAsciiStream reads text as US-ASCII and getNCharacterStream delegates to getNString. SQLXML and ROWID join the declared-type registry so the metadata and getObject agree on their object type. --- .../magnusp/libsql/jdbc/JdbcTypeMapping.java | 4 + .../magnusp/libsql/jdbc/LibsqlResultSet.java | 84 +++++++++++++++---- 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java index 83ddc7f..1244c07 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java @@ -9,6 +9,8 @@ import java.sql.Clob; import java.sql.Date; import java.sql.NClob; +import java.sql.RowId; +import java.sql.SQLXML; import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; @@ -159,6 +161,8 @@ private static Map declaredTypeRegistry() { entry("TIMESTAMP", Types.TIMESTAMP, Timestamp.class, Timestamp.class, StorageClass.TEXT), entry("CLOB", Types.CLOB, Clob.class, Clob.class, StorageClass.TEXT), entry("NCLOB", Types.NCLOB, NClob.class, NClob.class, StorageClass.TEXT), + entry("SQLXML", Types.SQLXML, SQLXML.class, SQLXML.class, StorageClass.TEXT), + entry("ROWID", Types.ROWID, RowId.class, RowId.class, StorageClass.TEXT), entry("BLOB", Types.BLOB, Blob.class, Blob.class, StorageClass.BLOB), entry("DATALINK", Types.DATALINK, URL.class, URL.class, StorageClass.TEXT)); } diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java index 32098ab..cb9b721 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java @@ -10,6 +10,8 @@ import java.io.Reader; import java.io.StringReader; import java.math.BigDecimal; +import java.net.MalformedURLException; +import java.net.URI; import java.net.URL; import java.sql.Array; import java.sql.Blob; @@ -424,11 +426,21 @@ private static Object convertedObject(Value value, JdbcTypeMapping.JdbcType type if (objectClass == Clob.class) { return clobValue(value); } + if (objectClass == NClob.class) { + return nclobValue(value); + } if (objectClass == Blob.class) { return blobValue(value); } - // NCLOB and DATALINK resolve to their declared wrapper types, but the wrapper getters - // arrive in PR-3, so the value keeps its storage-class representation until then. + if (objectClass == RowId.class) { + return rowIdValue(value); + } + if (objectClass == SQLXML.class) { + return sqlxmlValue(value); + } + if (objectClass == URL.class) { + return urlValue(value); + } return storedObject(value); } @@ -624,13 +636,19 @@ public boolean isLast() throws SQLException { throw new SQLFeatureNotSupportedException("not supported by sqlite"); } - @Override public InputStream getAsciiStream(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } + @Override + public InputStream getAsciiStream(int columnIndex) throws SQLException { + String text = getString(columnIndex); + return text == null + ? null + : new ByteArrayInputStream(text.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } @Override @Deprecated @SuppressWarnings("deprecation") public InputStream getUnicodeStream(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public InputStream getBinaryStream(int columnIndex) throws SQLException { byte[] bytes = getBytes(columnIndex); return bytes == null ? null : new ByteArrayInputStream(bytes); } - @Override public InputStream getAsciiStream(String columnLabel) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } + @Override public InputStream getAsciiStream(String columnLabel) throws SQLException { return getAsciiStream(findColumn(columnLabel)); } @Override @Deprecated @SuppressWarnings("deprecation") public InputStream getUnicodeStream(String columnLabel) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public InputStream getBinaryStream(String columnLabel) throws SQLException { return getBinaryStream(findColumn(columnLabel)); } @Override public SQLWarning getWarnings() throws SQLException { return null; } @@ -748,8 +766,25 @@ private static Clob clobValue(Value value) { @Override public Time getTime(String columnLabel, Calendar cal) throws SQLException { return getTime(columnLabel); } @Override public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException { return getTimestamp(columnIndex); } @Override public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLException { return getTimestamp(columnLabel); } - @Override public URL getURL(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } - @Override public URL getURL(String columnLabel) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } + @Override + public URL getURL(int columnIndex) throws SQLException { + return urlValue(getCurrentValue(columnIndex)); + } + + private static URL urlValue(Value value) throws SQLException { + String text = stringValue(value); + if (text == null) { + return null; + } + try { + // URI.toURL replaces the deprecated URL(String) constructor and rejects the same text. + return URI.create(text).toURL(); + } catch (IllegalArgumentException | MalformedURLException e) { + throw new SQLException("Bad value for type URL: " + text, e); + } + } + + @Override public URL getURL(String columnLabel) throws SQLException { return getURL(findColumn(columnLabel)); } @Override public void updateRef(int columnIndex, Ref x) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public void updateRef(String columnLabel, Ref x) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public void updateBlob(int columnIndex, Blob x) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @@ -758,8 +793,14 @@ private static Clob clobValue(Value value) { @Override public void updateClob(String columnLabel, Clob x) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public void updateArray(int columnIndex, Array x) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public void updateArray(String columnLabel, Array x) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } - @Override public RowId getRowId(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } - @Override public RowId getRowId(String columnLabel) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } + @Override public RowId getRowId(int columnIndex) throws SQLException { + return rowIdValue(getCurrentValue(columnIndex)); + } + private static RowId rowIdValue(Value value) { + byte[] bytes = bytesValue(value); + return bytes == null ? null : new LibsqlRowId(bytes); + } + @Override public RowId getRowId(String columnLabel) throws SQLException { return getRowId(findColumn(columnLabel)); } @Override public void updateRowId(int columnIndex, RowId x) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public void updateRowId(String columnLabel, RowId x) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public int getHoldability() throws SQLException { return CLOSE_CURSORS_AT_COMMIT; } @@ -767,16 +808,31 @@ private static Clob clobValue(Value value) { @Override public void updateNString(String columnLabel, String nString) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public void updateNClob(int columnIndex, NClob nClob) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public void updateNClob(String columnLabel, NClob nClob) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } - @Override public NClob getNClob(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } - @Override public NClob getNClob(String columnLabel) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } - @Override public SQLXML getSQLXML(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } - @Override public SQLXML getSQLXML(String columnLabel) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } + @Override public NClob getNClob(int columnIndex) throws SQLException { + return nclobValue(getCurrentValue(columnIndex)); + } + private static NClob nclobValue(Value value) { + String text = stringValue(value); + return text == null ? null : new LibsqlNClob(text); + } + @Override public NClob getNClob(String columnLabel) throws SQLException { return getNClob(findColumn(columnLabel)); } + @Override public SQLXML getSQLXML(int columnIndex) throws SQLException { + return sqlxmlValue(getCurrentValue(columnIndex)); + } + private static SQLXML sqlxmlValue(Value value) { + String text = stringValue(value); + return text == null ? null : new LibsqlSQLXML(text); + } + @Override public SQLXML getSQLXML(String columnLabel) throws SQLException { return getSQLXML(findColumn(columnLabel)); } @Override public void updateSQLXML(int columnIndex, SQLXML xmlObject) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public void updateSQLXML(String columnLabel, SQLXML xmlObject) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public String getNString(int columnIndex) throws SQLException { return getString(columnIndex); } @Override public String getNString(String columnLabel) throws SQLException { return getString(columnLabel); } - @Override public Reader getNCharacterStream(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } - @Override public Reader getNCharacterStream(String columnLabel) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } + @Override public Reader getNCharacterStream(int columnIndex) throws SQLException { + String text = getNString(columnIndex); + return text == null ? null : new StringReader(text); + } + @Override public Reader getNCharacterStream(String columnLabel) throws SQLException { return getNCharacterStream(findColumn(columnLabel)); } @Override public void updateNCharacterStream(int columnIndex, Reader x, long length) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public void updateNCharacterStream(String columnLabel, Reader reader, long length) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public void updateAsciiStream(int columnIndex, InputStream x, long length) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } From 58ef0e368e575a45b25e37e1962f1b63da7ea7cd Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:33:07 +0100 Subject: [PATCH 15/29] feat(jdbc): bind SQLXML and RowId parameters setSQLXML binds the SQLXML content as text. setRowId binds the bytes as a BLOB, which is the only representation getRowId reads back unchanged. setArray and setRef still throw. --- .../libsql/jdbc/LibsqlPreparedStatement.java | 6 ++- .../jdbc/LibsqlPreparedStatementTest.java | 46 +++++++++++++++---- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java index 64ad113..a954611 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java @@ -359,7 +359,9 @@ public void setURL(int parameterIndex, URL x) throws SQLException { @Override public void setRowId(int parameterIndex, RowId x) throws SQLException { - throw new SQLFeatureNotSupportedException("RowId parameters are not supported"); + // SQLite has no ROWID parameter type; BLOB binds the bytes unchanged, so getRowId reads + // back exactly the bytes the caller supplied. + setParam(parameterIndex, Value.of(x == null ? null : x.getBytes())); } @Override @@ -394,7 +396,7 @@ public void setNClob(int parameterIndex, Reader reader, long length) throws SQLE @Override public void setSQLXML(int parameterIndex, SQLXML xmlObject) throws SQLException { - throw new SQLFeatureNotSupportedException("SQLXML parameters are not supported"); + setParam(parameterIndex, Value.of(xmlObject == null ? null : xmlObject.getString())); } @Override diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java index 8446d83..f574042 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java @@ -559,21 +559,51 @@ void setObjectWithoutATargetFollowsTableB4() throws SQLException { .isEqualTo(Value.of("2024-01-02 03:04:05")); } + @Test + void rowIdAndSqlXmlParametersBindTheirContent() throws SQLException { + byte[] bytes = {1, 2, 3}; + assertThat(bindParameter(statement -> statement.setRowId(1, new LibsqlRowId(bytes)))) + .isEqualTo(Value.of(bytes)); + assertThat(bindParameter(statement -> statement.setRowId(1, null))) + .isEqualTo(Value.ofNull()); + assertThat(bindParameter(statement -> statement.setSQLXML(1, new LibsqlSQLXML("")))) + .isEqualTo(Value.of("")); + assertThat(bindParameter(statement -> statement.setSQLXML(1, null))) + .isEqualTo(Value.ofNull()); + } + + @Test + void rowIdBoundAsBlobReadsBackTheSameBytes() throws SQLException { + byte[] bytes = "rowid-1".getBytes(StandardCharsets.UTF_8); + Value bound = bindParameter(statement -> statement.setRowId(1, new LibsqlRowId(bytes))); + + LibsqlResultSet rows = new LibsqlResultSet( + new LibsqlStatement(null), + new StmtResult( + List.of(new Col("c", "ROWID")), List.of(new Row(List.of(bound))), 0, null)); + + assertThat(rows.next()).isTrue(); + assertThat(rows.getRowId(1).getBytes()).isEqualTo(bytes); + } + private static Value bind(Object value) throws SQLException { - RecordingConnection connection = new RecordingConnection(NO_ROWS); - try (LibsqlPreparedStatement statement = - new LibsqlPreparedStatement(connection, "insert into t values (?)")) { - statement.setObject(1, value); - statement.executeUpdate(); - } - return connection.executed.get(0).args().get(0); + return bindParameter(statement -> statement.setObject(1, value)); } private static Value bind(Object value, int targetSqlType) throws SQLException { + return bindParameter(statement -> statement.setObject(1, value, targetSqlType)); + } + + @FunctionalInterface + private interface ParameterBinder { + void bind(LibsqlPreparedStatement statement) throws SQLException; + } + + private static Value bindParameter(ParameterBinder binder) throws SQLException { RecordingConnection connection = new RecordingConnection(NO_ROWS); try (LibsqlPreparedStatement statement = new LibsqlPreparedStatement(connection, "insert into t values (?)")) { - statement.setObject(1, value, targetSqlType); + binder.bind(statement); statement.executeUpdate(); } return connection.executed.get(0).args().get(0); From 4067d4257148eb54863d2340495d64ff6f0dabb7 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:33:10 +0100 Subject: [PATCH 16/29] test(jdbc): cover wrapper free, length, and stream reads B-1 and B-3 now check the URL, NClob, SQLXML, RowId, ASCII, and NCHAR getters end to end. A freed wrapper must reject every further read. --- .../libsql/jdbc/Jdbc4AppendixBReadTest.java | 117 ++++++++++++++++++ .../libsql/jdbc/LibsqlResultSetTest.java | 12 -- 2 files changed, 117 insertions(+), 12 deletions(-) diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java index cce5622..a6e8feb 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import java.io.IOException; import java.io.InputStream; import java.io.Reader; import java.math.BigDecimal; @@ -36,6 +37,7 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.fail; /** @@ -465,6 +467,107 @@ void b6TemporalGettersRejectNonDateText() { } } + // --------------------------------------------------------------------- + // PR-3 wrappers: URL, NClob, SQLXML, RowId, and the ASCII/Unicode streams. + // --------------------------------------------------------------------- + + @Test + void b1UrlGetterParsesAbsoluteTextAndRejectsTheRest() throws SQLException { + LibsqlResultSet absolute = oneRow(jdbcType("DATALINK"), TEXT_URL); + assertThat(absolute.next()).isTrue(); + assertThat(absolute.getURL(1).toExternalForm()).isEqualTo("https://example.com"); + assertThat(absolute.getURL("c").toExternalForm()).isEqualTo("https://example.com"); + + LibsqlResultSet malformed = oneRow(jdbcType("DATALINK"), Value.of("not a url")); + assertThat(malformed.next()).isTrue(); + assertThatThrownBy(() -> malformed.getURL(1)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("URL"); + } + + @Test + void b3NewWrappersReadBackTheirContent() throws Exception { + LibsqlResultSet nclobRow = oneRow(jdbcType("NCLOB"), TEXT_ABC); + assertThat(nclobRow.next()).isTrue(); + NClob nclob = nclobRow.getNClob(1); + assertThat(nclob.length()).isEqualTo(3); + assertThat(nclob.getSubString(1, 2)).isEqualTo("ab"); + assertThat(readAll(nclob.getCharacterStream())).isEqualTo("abc"); + assertThat(nclobRow.getNClob("c").getSubString(2, 1)).isEqualTo("b"); + + LibsqlResultSet xmlRow = oneRow(jdbcType("SQLXML"), TEXT_XML); + assertThat(xmlRow.next()).isTrue(); + SQLXML xml = xmlRow.getSQLXML(1); + assertThat(xml.getString()).isEqualTo(""); + assertThat(readAll(xml.getCharacterStream())).isEqualTo(""); + assertThat(readAll(xml.getBinaryStream())).isEqualTo("".getBytes(StandardCharsets.UTF_8)); + assertThat(xmlRow.getSQLXML("c").getString()).isEqualTo(""); + + LibsqlResultSet rowIdRow = oneRow(jdbcType("ROWID"), TEXT_ROWID); + assertThat(rowIdRow.next()).isTrue(); + assertThat(rowIdRow.getRowId(1).getBytes()) + .isEqualTo("rowid-1".getBytes(StandardCharsets.UTF_8)); + assertThat(rowIdRow.getRowId("c").getBytes()) + .isEqualTo("rowid-1".getBytes(StandardCharsets.UTF_8)); + + LibsqlResultSet textRow = oneRow(jdbcType("VARCHAR"), TEXT_ABC); + assertThat(textRow.next()).isTrue(); + assertThat(readAll(textRow.getAsciiStream(1))) + .isEqualTo("abc".getBytes(StandardCharsets.US_ASCII)); + assertThat(readAll(textRow.getAsciiStream("c"))) + .isEqualTo("abc".getBytes(StandardCharsets.US_ASCII)); + assertThat(readAll(textRow.getNCharacterStream(1))).isEqualTo("abc"); + assertThat(readAll(textRow.getNCharacterStream("c"))).isEqualTo("abc"); + } + + @Test + void b3FreedWrappersRejectFurtherReads() throws SQLException { + LibsqlResultSet nclobRow = oneRow(jdbcType("NCLOB"), TEXT_ABC); + assertThat(nclobRow.next()).isTrue(); + NClob nclob = nclobRow.getNClob(1); + nclob.free(); + assertThatThrownBy(nclob::length).isInstanceOf(SQLException.class); + assertThatThrownBy(() -> nclob.getSubString(1, 1)).isInstanceOf(SQLException.class); + assertThatThrownBy(nclob::getCharacterStream).isInstanceOf(SQLException.class); + + LibsqlResultSet xmlRow = oneRow(jdbcType("SQLXML"), TEXT_XML); + assertThat(xmlRow.next()).isTrue(); + SQLXML xml = xmlRow.getSQLXML(1); + xml.free(); + assertThatThrownBy(xml::getString).isInstanceOf(SQLException.class); + assertThatThrownBy(xml::getCharacterStream).isInstanceOf(SQLException.class); + assertThatThrownBy(xml::getBinaryStream).isInstanceOf(SQLException.class); + + LibsqlResultSet clobRow = oneRow(jdbcType("CLOB"), TEXT_ABC); + assertThat(clobRow.next()).isTrue(); + Clob clob = clobRow.getClob(1); + clob.free(); + assertThatThrownBy(clob::length).isInstanceOf(SQLException.class); + assertThatThrownBy(clob::getCharacterStream).isInstanceOf(SQLException.class); + assertThatThrownBy(clob::getAsciiStream).isInstanceOf(SQLException.class); + + LibsqlResultSet blobRow = oneRow(jdbcType("BLOB"), BYTES_ABC); + assertThat(blobRow.next()).isTrue(); + Blob blob = blobRow.getBlob(1); + blob.free(); + assertThatThrownBy(blob::length).isInstanceOf(SQLException.class); + assertThatThrownBy(blob::getBinaryStream).isInstanceOf(SQLException.class); + assertThatThrownBy(() -> blob.getBytes(1, 1)).isInstanceOf(SQLException.class); + } + + @Test + void b3RowIdComparesAndCopiesItsBytes() { + LibsqlRowId rowId = new LibsqlRowId(new byte[] {1, 2}); + byte[] returned = rowId.getBytes(); + returned[0] = 9; + + assertThat(rowId.getBytes()).containsExactly(1, 2); + assertThat(rowId) + .isEqualTo(new LibsqlRowId(new byte[] {1, 2})) + .hasSameHashCodeAs(new LibsqlRowId(new byte[] {1, 2})) + .isNotEqualTo(new LibsqlRowId(new byte[] {1, 3})); + } + // --------------------------------------------------------------------- // Fixtures and helpers. // --------------------------------------------------------------------- @@ -502,6 +605,20 @@ private static LibsqlResultSet oneRow(JdbcType type, Value value) { return oneRow(type.name(), value); } + private static String readAll(Reader reader) throws IOException { + StringBuilder text = new StringBuilder(); + char[] buffer = new char[256]; + int count; + while ((count = reader.read(buffer)) != -1) { + text.append(buffer, 0, count); + } + return text.toString(); + } + + private static byte[] readAll(InputStream stream) throws IOException { + return stream.readAllBytes(); + } + private static LibsqlResultSet oneRow(String declaredType, Value value) { return new LibsqlResultSet( new LibsqlStatement(null), diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java index fae61d1..41cb4d7 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java @@ -472,24 +472,12 @@ void everyUnsupportedMethodThrowsSqlFeatureNotSupportedException() throws SQLExc Reader reader = null; List calls = List.of( - new UnsupportedCall("getAsciiStream(int)", () -> rs.getAsciiStream(1)), - new UnsupportedCall("getAsciiStream(String)", () -> rs.getAsciiStream("c1")), new UnsupportedCall("getUnicodeStream(int)", () -> rs.getUnicodeStream(1)), new UnsupportedCall("getUnicodeStream(String)", () -> rs.getUnicodeStream("c1")), new UnsupportedCall("getRef(int)", () -> rs.getRef(1)), new UnsupportedCall("getRef(String)", () -> rs.getRef("c1")), new UnsupportedCall("getArray(int)", () -> rs.getArray(1)), new UnsupportedCall("getArray(String)", () -> rs.getArray("c1")), - new UnsupportedCall("getURL(int)", () -> rs.getURL(1)), - new UnsupportedCall("getURL(String)", () -> rs.getURL("c1")), - new UnsupportedCall("getRowId(int)", () -> rs.getRowId(1)), - new UnsupportedCall("getRowId(String)", () -> rs.getRowId("c1")), - new UnsupportedCall("getNClob(int)", () -> rs.getNClob(1)), - new UnsupportedCall("getNClob(String)", () -> rs.getNClob("c1")), - new UnsupportedCall("getSQLXML(int)", () -> rs.getSQLXML(1)), - new UnsupportedCall("getSQLXML(String)", () -> rs.getSQLXML("c1")), - new UnsupportedCall("getNCharacterStream(int)", () -> rs.getNCharacterStream(1)), - new UnsupportedCall("getNCharacterStream(String)", () -> rs.getNCharacterStream("c1")), new UnsupportedCall("updateNull(int)", () -> rs.updateNull(1)), new UnsupportedCall("updateBoolean(int)", () -> rs.updateBoolean(1, true)), new UnsupportedCall("updateByte(int)", () -> rs.updateByte(1, (byte) 1)), From 9e8c42d29df904d5ccb399b58f60f4adc330a908 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:43:57 +0100 Subject: [PATCH 17/29] feat(jdbc): read timezone-aware temporal columns Map TIME_WITH_TIMEZONE to OffsetTime and TIMESTAMP_WITH_TIMEZONE to OffsetDateTime, parse ISO-8601 offset text, and reject unlisted getters on the new declared types. --- .../magnusp/libsql/jdbc/JdbcTypeMapping.java | 23 ++++- .../magnusp/libsql/jdbc/LibsqlResultSet.java | 92 ++++++++++++++++++- 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java index 1244c07..214369c 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java @@ -14,6 +14,8 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; +import java.time.OffsetDateTime; +import java.time.OffsetTime; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -26,10 +28,11 @@ * falls back to the storage class of the value and marks the result {@link Support#FALLBACK} so a * caller can record the exception. * - *

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

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

{@code Short} and {@code Byte} are rejected rather than narrowed from the numeric * targets, preserving the sqlite-jdbc 3.53.4.0 contract. @@ -962,6 +1042,12 @@ public T getObject(int columnIndex, Class type) throws SQLException { if (type == LocalDateTime.class) { return type.cast(getLocalDateTime(columnIndex)); } + if (type == OffsetTime.class) { + return type.cast(offsetTimeValue(getCurrentValue(columnIndex))); + } + if (type == OffsetDateTime.class) { + return type.cast(offsetDateTimeValue(getCurrentValue(columnIndex))); + } Value stored = getCurrentValue(columnIndex); boolean numeric = stored instanceof Value.Integer || stored instanceof Value.Float; From cf6a031be3c0319a823317d2a8908aec9e728418 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:44:00 +0100 Subject: [PATCH 18/29] test(jdbc): cover offset parsing and declared offset reads Assert Z and +01:00 parse to OffsetTime and OffsetDateTime, malformed offset text raises SQLException, and declared offset columns return the offset classes. --- .../libsql/jdbc/Jdbc4AppendixBReadTest.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java index a6e8feb..8c3208e 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java @@ -29,6 +29,9 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -568,6 +571,51 @@ void b3RowIdComparesAndCopiesItsBytes() { .isNotEqualTo(new LibsqlRowId(new byte[] {1, 3})); } + // --------------------------------------------------------------------- + // PR-4 offset types: TIME_WITH_TIMEZONE and TIMESTAMP_WITH_TIMEZONE. + // --------------------------------------------------------------------- + + @Test + void b6DeclaredOffsetTypesReturnOffsetJavaTypes() throws SQLException { + LibsqlResultSet timeRow = oneRow(jdbcType("TIME_WITH_TIMEZONE"), Value.of("03:04:05+01:00")); + assertThat(timeRow.next()).isTrue(); + assertThat(timeRow.getObject(1)) + .isEqualTo(OffsetTime.of(3, 4, 5, 0, ZoneOffset.ofHours(1))); + assertThat(timeRow.getMetaData().getColumnType(1)).isEqualTo(Types.TIME_WITH_TIMEZONE); + assertThat(timeRow.getMetaData().getColumnClassName(1)) + .isEqualTo(OffsetTime.class.getName()); + + LibsqlResultSet timestampRow = oneRow( + jdbcType("TIMESTAMP_WITH_TIMEZONE"), Value.of("2024-01-02 03:04:05+01:00")); + assertThat(timestampRow.next()).isTrue(); + assertThat(timestampRow.getObject(1)) + .isEqualTo(OffsetDateTime.of(2024, 1, 2, 3, 4, 5, 0, ZoneOffset.ofHours(1))); + assertThat(timestampRow.getMetaData().getColumnType(1)) + .isEqualTo(Types.TIMESTAMP_WITH_TIMEZONE); + assertThat(timestampRow.getMetaData().getColumnClassName(1)) + .isEqualTo(OffsetDateTime.class.getName()); + } + + @Test + void offsetTextParsesZuluAndPlusOneAndRejectsMalformedText() throws SQLException { + LibsqlResultSet timeRow = oneRow(jdbcType("TIME_WITH_TIMEZONE"), Value.of("03:04:05Z")); + assertThat(timeRow.next()).isTrue(); + assertThat(timeRow.getObject(1, OffsetTime.class)) + .isEqualTo(OffsetTime.of(3, 4, 5, 0, ZoneOffset.UTC)); + + LibsqlResultSet timestampRow = oneRow( + jdbcType("TIMESTAMP_WITH_TIMEZONE"), Value.of("2024-01-02T03:04:05+01:00")); + assertThat(timestampRow.next()).isTrue(); + assertThat(timestampRow.getObject(1, OffsetDateTime.class)) + .isEqualTo(OffsetDateTime.of(2024, 1, 2, 3, 4, 5, 0, ZoneOffset.ofHours(1))); + + LibsqlResultSet malformed = oneRow(jdbcType("TIME_WITH_TIMEZONE"), Value.of("not-a-time")); + assertThat(malformed.next()).isTrue(); + assertThatThrownBy(() -> malformed.getObject(1, OffsetTime.class)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("OffsetTime"); + } + // --------------------------------------------------------------------- // Fixtures and helpers. // --------------------------------------------------------------------- From 3d11c0cb71f890733b0709f75a2fa6ef71aa9272 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:44:05 +0100 Subject: [PATCH 19/29] test(jdbc): verify declared offset reads against sqld Add the missing B-6 getObject cells for TIME_WITH_TIMEZONE and TIMESTAMP_WITH_TIMEZONE to the server suite. --- .../magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java index d3d7223..ba0e29c 100644 --- a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java +++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java @@ -264,6 +264,10 @@ record GetterCell( new GetterCell("getURL", "DATALINK", TEXT_URL, ResultSet::getURL, URL.class), new GetterCell("getObject", "JAVA_OBJECT", TEXT_ABC, (rs, i) -> rs.getObject(i), Object.class), + new GetterCell("getObject", "TIME_WITH_TIMEZONE", "03:04:05Z", + (rs, i) -> rs.getObject(i), OffsetTime.class), + new GetterCell("getObject", "TIMESTAMP_WITH_TIMEZONE", "2024-01-02T03:04:05Z", + (rs, i) -> rs.getObject(i), OffsetDateTime.class), new GetterCell("getRowId", "ROWID", TEXT_ROWID, ResultSet::getRowId, RowId.class), new GetterCell("getSQLXML", "SQLXML", TEXT_XML, ResultSet::getSQLXML, SQLXML.class)); From bb72ef6c06d65aa836837da32b82009678ed1edd Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:58:00 +0100 Subject: [PATCH 20/29] feat(jdbc): record n/a types and table b-6 getter support --- .../magnusp/libsql/jdbc/JdbcTypeMapping.java | 205 +++++++++++++++++- 1 file changed, 201 insertions(+), 4 deletions(-) diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java index 214369c..798e127 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java @@ -5,20 +5,27 @@ import java.math.BigDecimal; import java.net.URL; +import java.sql.Array; import java.sql.Blob; import java.sql.Clob; import java.sql.Date; import java.sql.NClob; +import java.sql.Ref; import java.sql.RowId; import java.sql.SQLXML; +import java.sql.Struct; import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; import java.time.OffsetDateTime; import java.time.OffsetTime; +import java.util.EnumMap; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; /** * Resolves the JDBC type of a result-set column from its declared type. @@ -33,6 +40,12 @@ * {@code INT8} and {@code DATETIME}, to the registry entry they stand for, preserving the * sqlite-jdbc metadata that existing users see. Both tables resolve through the declared type, * never through the value. + * + *

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

This is the policy claim, stated once: {@code ARRAY}, {@code STRUCT}, {@code REF}, {@code + * JAVA_OBJECT}, and {@code DISTINCT} are N/A and throw {@link SQLFeatureNotSupportedException}. + * Every other declared type the driver resolves is supported. An expression column has no declared + * type and keeps the permissive storage fallback. + */ +class Jdbc4AppendixBProfileTest { + + private static final Set NA_TYPES = + Set.of("ARRAY", "STRUCT", "REF", "JAVA_OBJECT", "DISTINCT"); + + private static final Set SUPPORTED_TYPES = Set.of( + "TINYINT", + "SMALLINT", + "INTEGER", + "BIGINT", + "REAL", + "FLOAT", + "DOUBLE", + "NUMERIC", + "DECIMAL", + "BIT", + "BOOLEAN", + "CHAR", + "VARCHAR", + "LONGVARCHAR", + "NCHAR", + "NVARCHAR", + "LONGNVARCHAR", + "BINARY", + "VARBINARY", + "LONGVARBINARY", + "DATE", + "TIME", + "TIMESTAMP", + "TIME_WITH_TIMEZONE", + "TIMESTAMP_WITH_TIMEZONE", + "CLOB", + "NCLOB", + "SQLXML", + "ROWID", + "BLOB", + "DATALINK"); + + @Test + void profileListsExactlyTheUnsupportedTypes() { + assertThat(JdbcTypeMapping.unsupportedTypeNames()) + .as("N/A JDBC types") + .containsExactlyInAnyOrderElementsOf(NA_TYPES); + } + + @Test + void profileListsExactlyTheSupportedTypes() { + assertThat(JdbcTypeMapping.supportedTypeNames()) + .as("supported JDBC types") + .containsExactlyInAnyOrderElementsOf(SUPPORTED_TYPES); + } + + @Test + void everyUnsupportedDeclaredTypeThrowsFeatureNotSupported() throws SQLException { + for (String type : NA_TYPES) { + LibsqlResultSet resultSet = oneRow(type, Value.of("42")); + assertThat(resultSet.next()).isTrue(); + assertThatThrownBy(() -> resultSet.getObject(1)) + .as("getObject on declared %s", type) + .isInstanceOf(SQLFeatureNotSupportedException.class); + assertThatThrownBy(() -> resultSet.getArray(1)) + .as("getArray on declared %s", type) + .isInstanceOf(SQLFeatureNotSupportedException.class); + assertThatThrownBy(() -> resultSet.getRef(1)) + .as("getRef on declared %s", type) + .isInstanceOf(SQLFeatureNotSupportedException.class); + } + } + + @Test + void expressionFallbackStaysPermissive() throws Exception { + LibsqlResultSet resultSet = oneRow(null, Value.of("not-a-number")); + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)) + .as("an expression column keeps SQLite's zero coercion") + .isZero(); + } + + private static LibsqlResultSet oneRow(String declaredType, Value value) { + return new LibsqlResultSet( + new LibsqlStatement(null), + new StmtResult( + List.of(new Col("c", declaredType)), + List.of(new Row(List.of(value))), + 0, + null)); + } +} diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBSetObjectTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBSetObjectTest.java index d64cbc0..0ac7c6a 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBSetObjectTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBSetObjectTest.java @@ -18,6 +18,7 @@ import java.sql.Ref; import java.sql.RowId; import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; import java.sql.SQLXML; import java.sql.Struct; import java.sql.Time; @@ -36,6 +37,7 @@ import java.util.TimeZone; import java.util.stream.Stream; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.fail; @@ -92,7 +94,12 @@ record Target(String name, int sqlType) {} new Target("NCLOB", Types.NCLOB), new Target("SQLXML", Types.SQLXML), new Target("TIME_WITH_TIMEZONE", Types.TIME_WITH_TIMEZONE), - new Target("TIMESTAMP_WITH_TIMEZONE", Types.TIMESTAMP_WITH_TIMEZONE)); + new Target("TIMESTAMP_WITH_TIMEZONE", Types.TIMESTAMP_WITH_TIMEZONE), + new Target("DISTINCT", Types.DISTINCT)); + + /** The N/A policy: these target JDBC types are rejected by setObject. */ + private static final Set NA_TARGETS = Set.of( + Types.ARRAY, Types.STRUCT, Types.REF, Types.JAVA_OBJECT, Types.DISTINCT); record JavaRow(String name, Object value, Set targets) {} @@ -211,7 +218,7 @@ static Stream javaRows() { void b5SetObjectRejectsUnlistedTargetTypes(JavaRow row) { List checks = new ArrayList<>(); for (Target target : TARGETS) { - if (row.targets().contains(target.sqlType())) { + if (row.targets().contains(target.sqlType()) || NA_TARGETS.contains(target.sqlType())) { continue; } checks.add(() -> { @@ -232,7 +239,8 @@ void b5SetObjectRejectsUnlistedTargetTypes(JavaRow row) { void b5SetObjectAcceptsListedTargetTypes() throws SQLException { for (JavaRow row : ROWS) { for (Target target : TARGETS) { - if (!row.targets().contains(target.sqlType())) { + if (!row.targets().contains(target.sqlType()) + || NA_TARGETS.contains(target.sqlType())) { continue; } LibsqlPreparedStatement statement = new LibsqlPreparedStatement(null, "?"); @@ -241,6 +249,29 @@ void b5SetObjectAcceptsListedTargetTypes() throws SQLException { } } + @Test + void b5SetObjectRejectsUnsupportedTypesAndValues() { + for (Target target : TARGETS) { + if (!NA_TARGETS.contains(target.sqlType())) { + continue; + } + assertThatThrownBy(() -> new LibsqlPreparedStatement(null, "?") + .setObject(1, "42", target.sqlType())) + .as("B-5: setObject(%s) is N/A", target.name()) + .isInstanceOf(SQLFeatureNotSupportedException.class); + } + + // Table B-4 maps these Java object types to the N/A JDBC types. + Object[] unsupportedValues = { + stub(Array.class), stub(Struct.class), stub(Ref.class), new Object() + }; + for (Object value : unsupportedValues) { + assertThatThrownBy(() -> new LibsqlPreparedStatement(null, "?").setObject(1, value)) + .as("B-4: setObject(%s) is N/A", value.getClass().getName()) + .isInstanceOf(SQLFeatureNotSupportedException.class); + } + } + @Test void b5SetNullAcceptsEveryTargetType() throws SQLException { for (Target target : TARGETS) { From 068e91d9cfc87d18b4fe73d5c3506eabee484d52 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:58:00 +0100 Subject: [PATCH 23/29] test(jdbc): assert n/a rows against sqld --- .../itest/Jdbc4AppendixBTypeMappingIT.java | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java index ba0e29c..49ab0c5 100644 --- a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java +++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java @@ -24,6 +24,7 @@ import java.sql.ResultSet; import java.sql.RowId; import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; import java.sql.SQLXML; import java.sql.Statement; import java.sql.Struct; @@ -51,6 +52,7 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.fail; @@ -140,6 +142,17 @@ private static JdbcType jdbc( return new JdbcType(name, sqlType, b1, b3, sample); } + /** The N/A policy: these declared and target types throw SQLFeatureNotSupportedException. */ + private static final Set NA_TYPES = + Set.of("ARRAY", "STRUCT", "REF", "JAVA_OBJECT", "DISTINCT"); + + private static final Set NA_TARGETS = + Set.of(Types.ARRAY, Types.STRUCT, Types.REF, Types.JAVA_OBJECT, Types.DISTINCT); + + /** The Table B-2/B-4 Java object types that map to an N/A JDBC type. */ + private static final Set NA_VALUES = + Set.of("java.sql.Array", "java.sql.Struct", "java.sql.Ref", "Java class"); + interface SqlRead { Object read(ResultSet resultSet, int column) throws SQLException; } @@ -190,7 +203,11 @@ void b1AndB3ReadPathAgainstRealDdl(JdbcType type) throws SQLException { "CREATE TABLE " + table + " (v \"" + type.name() + "\")"); try (PreparedStatement insert = connection.prepareStatement("INSERT INTO " + table + " (v) VALUES (?)")) { - insert.setObject(1, type.sample()); + if (NA_TYPES.contains(type.name())) { + insert.setString(1, String.valueOf(type.sample())); + } else { + insert.setObject(1, type.sample()); + } insert.executeUpdate(); } try (Statement query = connection.createStatement(); @@ -200,6 +217,16 @@ void b1AndB3ReadPathAgainstRealDdl(JdbcType type) throws SQLException { .as("server reported declared type for %s", type.name()) .isEqualTo(type.name()); + if (NA_TYPES.contains(type.name())) { + assertThatThrownBy(() -> NATURAL_GETTER.get(type.name()).read(resultSet, 1)) + .as("B-1: %s is N/A", type.name()) + .isInstanceOf(SQLFeatureNotSupportedException.class); + assertThatThrownBy(() -> resultSet.getObject(1)) + .as("B-3: %s is N/A", type.name()) + .isInstanceOf(SQLFeatureNotSupportedException.class); + return; + } + List checks = new ArrayList<>(); checks.add(() -> { Object natural; @@ -285,6 +312,12 @@ void b6GetterReadsRecommendedTypeFromRealDdl(GetterCell cell) throws SQLExceptio try (Statement query = connection.createStatement(); ResultSet resultSet = query.executeQuery("SELECT v FROM " + table)) { assertThat(resultSet.next()).isTrue(); + if (NA_TYPES.contains(cell.declaredType())) { + assertThatThrownBy(() -> cell.reader().read(resultSet, 1)) + .as("B-6: %s on %s is N/A", cell.getter(), cell.declaredType()) + .isInstanceOf(SQLFeatureNotSupportedException.class); + return; + } Object value; try { value = cell.reader().read(resultSet, 1); @@ -397,6 +430,12 @@ void b2StandardSettersMapJavaTypeToJdbcType(WriteRow row) throws SQLException { statement.execute("CREATE TABLE " + table + " (v \"" + row.targetName() + "\")"); try (PreparedStatement insert = connection.prepareStatement("INSERT INTO " + table + " (v) VALUES (?)")) { + if (NA_VALUES.contains(row.name())) { + assertThatThrownBy(() -> bindB2(insert, row)) + .as("B-2: %s -> %s is N/A", row.name(), row.targetName()) + .isInstanceOf(SQLFeatureNotSupportedException.class); + return; + } try { bindB2(insert, row); } catch (SQLException e) { @@ -537,6 +576,12 @@ void b4SetObjectMapsJavaObjectTypeToJdbcType(WriteRow row) throws SQLException { statement.execute("CREATE TABLE " + table + " (v \"" + row.targetName() + "\")"); try (PreparedStatement insert = connection.prepareStatement("INSERT INTO " + table + " (v) VALUES (?)")) { + if (NA_VALUES.contains(row.name())) { + assertThatThrownBy(() -> insert.setObject(1, row.value())) + .as("B-4: %s -> %s is N/A", row.name(), row.targetName()) + .isInstanceOf(SQLFeatureNotSupportedException.class); + return; + } insert.setObject(1, row.value()); insert.executeUpdate(); } @@ -686,7 +731,16 @@ void b5SetObjectConvertsToExplicitTargetType(JavaRow row) throws SQLException { + placeholders + ")")) { int parameter = 1; for (Target target : TARGETS) { - if (row.targets().contains(target.sqlType())) { + if (row.targets().contains(target.sqlType()) + && NA_TARGETS.contains(target.sqlType())) { + int index = parameter; + assertThatThrownBy( + () -> insert.setObject( + index, sourceFor(row, target), target.sqlType())) + .as("B-5: %s -> %s is N/A", row.name(), target.name()) + .isInstanceOf(SQLFeatureNotSupportedException.class); + insert.setNull(parameter++, target.sqlType()); + } else if (row.targets().contains(target.sqlType())) { insert.setObject(parameter++, sourceFor(row, target), target.sqlType()); } else { insert.setNull(parameter++, target.sqlType()); @@ -708,6 +762,13 @@ void b5SetObjectConvertsToExplicitTargetType(JavaRow row) throws SQLException { if (!row.targets().contains(target.sqlType())) { continue; } + if (NA_TARGETS.contains(target.sqlType())) { + checks.add(() -> assertThatThrownBy( + () -> readFor(target).read(resultSet, valueColumn)) + .as("B-5: reading %s for %s is N/A", target.name(), row.name()) + .isInstanceOf(SQLFeatureNotSupportedException.class)); + continue; + } checks.add(() -> { Set storage = storageOf(target.name()); if (!storage.isEmpty()) { From ab67b9f7caf85b5de6e66783636903a896994f10 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:10:31 +0100 Subject: [PATCH 24/29] fix(itests): supply real rowid and sqlxml fixtures The RowId and SQLXML stubs returned null bytes and null text, so the B-2 rows bound null. Return the rowid-1 bytes and the text. Read the NClob wrapper directly so the B-5 row proves the wrapper round trip. --- .../itest/Jdbc4AppendixBTypeMappingIT.java | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java index 49ab0c5..2933d69 100644 --- a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java +++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java @@ -415,11 +415,11 @@ static Expected any(Class type) { 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, + new WriteRow("java.sql.RowId", rowId(TEXT_ROWID), "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, + new WriteRow("java.sql.SQLXML", sqlxml(TEXT_XML), "SQLXML", Types.SQLXML, UNSPECIFIED, ResultSet::getSQLXML, Expected.any(SQLXML.class))); @ParameterizedTest(name = "B-2 {0} -> {2}") @@ -881,10 +881,7 @@ private static SqlRead readFor(Target target) { 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 "NCLOB" -> ResultSet::getNClob; case "ARRAY" -> ResultSet::getArray; case "STRUCT" -> (rs, i) -> rs.getObject(i); case "REF" -> ResultSet::getRef; @@ -1219,6 +1216,36 @@ private static NClob nclob(String text) { }); } + private static RowId rowId(String text) { + byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + return (RowId) Proxy.newProxyInstance( + RowId.class.getClassLoader(), + new Class[] {RowId.class}, + (proxy, method, args) -> switch (method.getName()) { + case "toString" -> text; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + case "getBytes" -> bytes; + default -> defaultValue(method.getReturnType()); + }); + } + + private static SQLXML sqlxml(String text) { + return (SQLXML) Proxy.newProxyInstance( + SQLXML.class.getClassLoader(), + new Class[] {SQLXML.class}, + (proxy, method, args) -> switch (method.getName()) { + case "toString" -> text; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + case "getString" -> text; + case "getCharacterStream" -> new StringReader(text); + case "getBinaryStream" -> + new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)); + default -> defaultValue(method.getReturnType()); + }); + } + private static URL url(String value) { try { return URI.create(value).toURL(); From 6da78cb24a4fce3dd226ad4be3271437baaeba40 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:10:31 +0100 Subject: [PATCH 25/29] test(conformance): quarantine xerial tests replaced by appendix b Four selected xerial methods assert behavior that Appendix B replaces. Exclude them from the libSQL run without editing the vendored tree. The xerial-control profile still runs them. --- libsql-conformance/README.md | 20 +++++- .../libsql/conformance/XerialHarnessTest.java | 63 +++++++++++++++++++ .../libsql/conformance/XerialQuarantine.java | 59 +++++++++++++++++ ...unit.platform.launcher.PostDiscoveryFilter | 1 + 4 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialQuarantine.java create mode 100644 libsql-conformance/src/test/resources/META-INF/services/org.junit.platform.launcher.PostDiscoveryFilter diff --git a/libsql-conformance/README.md b/libsql-conformance/README.md index d079b32..cf14eb5 100644 --- a/libsql-conformance/README.md +++ b/libsql-conformance/README.md @@ -47,8 +47,24 @@ with: ./mvnw -pl libsql-conformance -am verify ``` -Last green result: 272 tests, 7 skipped, with `SavepointTest` included. The -parity queue (#54) closed at 268 tests, 7 skipped. +Last green result: 270 tests, 7 skipped, with `SavepointTest` included. The +count includes the four Appendix B quarantines below and two harness tests for +the quarantine. + +### Appendix B quarantines + +JDBC 4.3 Appendix B is the oracle. Four selected xerial test methods assert +behavior that Appendix B replaces. They stay in the vendored tree and still +run in the `xerial-control` profile, but `XerialQuarantine` excludes them from +the libSQL run. The full type profile is in +[`../docs/jdbc-appendix-b-compliance.md`](../docs/jdbc-appendix-b-compliance.md). + +| Test | Why the xerial expectation is quarantined | +|---|---| +| `org.sqlite.StatementTest#bytesTest` | xerial allows `getBytes` on a declared `BLOB`. Table B-6 lists `getBytes` only for `BINARY`, `VARBINARY`, and `LONGVARBINARY`. | +| `org.sqlite.RSMetaDataTest#columnTypes` | xerial reports a declared `NCHAR` as `Types.CHAR`. Appendix B defines `NCHAR` as its own JDBC type, so the driver reports `Types.NCHAR`. | +| `org.sqlite.RSMetaDataTest#testGetColumnClassName` | xerial reports a declared `BLOB` as `java.lang.Object`. Table B-3 maps `BLOB` to `java.sql.Blob`. | +| `org.sqlite.PrepStmtTest#getObject` | xerial returns an `Integer` for `getObject` on a declared `BIT`. Table B-3 maps `BIT` to `Boolean`. | ### Three-layer contract diff --git a/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialHarnessTest.java b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialHarnessTest.java index 5714051..50fdf88 100644 --- a/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialHarnessTest.java +++ b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialHarnessTest.java @@ -16,6 +16,8 @@ import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.platform.engine.TestDescriptor; +import org.junit.platform.engine.support.descriptor.MethodSource; /** * Exercises the conformance harness itself, without a running sqld container where possible. @@ -63,6 +65,54 @@ void fileMemoryUriIsNotRetained() { assertThat(memoryMode.retained()).isFalse(); } + @Test + void quarantineExcludesOnlyTheAppendixBConflicts() { + XerialQuarantine quarantine = new XerialQuarantine(); + + for (XerialQuarantine.Quarantined quarantined : XerialQuarantine.QUARANTINED) { + assertThat( + quarantine + .apply( + descriptor( + quarantined.className(), + quarantined.methodName())) + .excluded()) + .as("%s#%s", quarantined.className(), quarantined.methodName()) + .isTrue(); + } + + assertThat(quarantine.apply(descriptor("org.sqlite.ResultSetTest", "next")).included()) + .as("an unconflicted xerial test") + .isTrue(); + } + + @Test + void quarantineIsInactiveDuringTheXerialControlRun() { + String previous = System.getProperty(XerialControl.ENABLED_PROPERTY); + System.setProperty(XerialControl.ENABLED_PROPERTY, "xerial"); + try { + XerialQuarantine quarantine = new XerialQuarantine(); + for (XerialQuarantine.Quarantined quarantined : XerialQuarantine.QUARANTINED) { + assertThat( + quarantine + .apply( + descriptor( + quarantined.className(), + quarantined.methodName())) + .included()) + .as("control run keeps %s#%s", quarantined.className(), + quarantined.methodName()) + .isTrue(); + } + } finally { + if (previous == null) { + System.clearProperty(XerialControl.ENABLED_PROPERTY); + } else { + System.setProperty(XerialControl.ENABLED_PROPERTY, previous); + } + } + } + @Test void queryStringIsPartOfTheFileNamespace() { XerialConformanceDriver.Target readOnly = @@ -163,6 +213,19 @@ void stopReportsASwallowedCleanupFailure() { } } + private static TestDescriptor descriptor(String className, String methodName) { + MethodSource source = MethodSource.from(className, methodName); + return (TestDescriptor) + Proxy.newProxyInstance( + TestDescriptor.class.getClassLoader(), + new Class[] {TestDescriptor.class}, + (proxy, method, args) -> + switch (method.getName()) { + case "getSource" -> java.util.Optional.of(source); + default -> null; + }); + } + private static Connection wrap(Connection connection, String namespace) { return wrap(connection, namespace, ignored -> {}); } diff --git a/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialQuarantine.java b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialQuarantine.java new file mode 100644 index 0000000..88d64f2 --- /dev/null +++ b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialQuarantine.java @@ -0,0 +1,59 @@ +package com.github.magnusp.libsql.conformance; + +import java.util.List; +import org.junit.platform.engine.FilterResult; +import org.junit.platform.engine.TestDescriptor; +import org.junit.platform.engine.support.descriptor.MethodSource; +import org.junit.platform.launcher.PostDiscoveryFilter; + +/** + * Quarantines the xerial tests whose expectations Appendix B replaces. + * + *

These tests assert sqlite-jdbc behavior that JDBC 4.3 Appendix B defines differently. They + * stay in the vendored tree and still run in the {@code xerial-control} profile, where the + * reference driver is the subject. They are excluded only from the libSQL run, and the reason is + * kept here so the exclusion cannot become an unexplained skip. + */ +public final class XerialQuarantine implements PostDiscoveryFilter { + + /** One upstream test method whose xerial expectation conflicts with Appendix B. */ + record Quarantined(String className, String methodName, String reason) {} + + static final List QUARANTINED = + List.of( + new Quarantined( + "org.sqlite.StatementTest", + "bytesTest", + "xerial allows getBytes on a declared BLOB; Table B-6 lists getBytes" + + " only for BINARY, VARBINARY and LONGVARBINARY"), + new Quarantined( + "org.sqlite.RSMetaDataTest", + "columnTypes", + "xerial reports a declared NCHAR as Types.CHAR; Appendix B defines" + + " NCHAR as its own JDBC type, so the driver reports Types.NCHAR"), + new Quarantined( + "org.sqlite.RSMetaDataTest", + "testGetColumnClassName", + "xerial reports a declared BLOB as java.lang.Object; Table B-3 maps" + + " BLOB to java.sql.Blob"), + new Quarantined( + "org.sqlite.PrepStmtTest", + "getObject", + "xerial returns an Integer for getObject on a declared BIT; Table B-3" + + " maps BIT to Boolean")); + + @Override + public FilterResult apply(TestDescriptor descriptor) { + if (XerialControl.isEnabled() + || !(descriptor.getSource().orElse(null) instanceof MethodSource source)) { + return FilterResult.included("Appendix B quarantine is inactive for this run"); + } + for (Quarantined quarantined : QUARANTINED) { + if (quarantined.className().equals(source.getClassName()) + && quarantined.methodName().equals(source.getMethodName())) { + return FilterResult.excluded(quarantined.reason()); + } + } + return FilterResult.included("not quarantined"); + } +} diff --git a/libsql-conformance/src/test/resources/META-INF/services/org.junit.platform.launcher.PostDiscoveryFilter b/libsql-conformance/src/test/resources/META-INF/services/org.junit.platform.launcher.PostDiscoveryFilter new file mode 100644 index 0000000..c1e5331 --- /dev/null +++ b/libsql-conformance/src/test/resources/META-INF/services/org.junit.platform.launcher.PostDiscoveryFilter @@ -0,0 +1 @@ +com.github.magnusp.libsql.conformance.XerialQuarantine From 234186d45c68cd8932006594b4b706f57303214c Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:10:31 +0100 Subject: [PATCH 26/29] docs(jdbc): publish the appendix b compliance profile Give every Appendix B row a status, a reason, and the test that proves it. Record the expression fallback as an exception and the policy in AGENTS.md. Add the verification commands to README.md. --- AGENTS.md | 11 ++ README.md | 35 +++++ docs/jdbc-appendix-b-compliance.md | 245 +++++++++++++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 docs/jdbc-appendix-b-compliance.md diff --git a/AGENTS.md b/AGENTS.md index 014dd81..b4964c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,6 +134,17 @@ DOCKER_CONFIG="$PWD/libsql-conformance/src/test/docker-config" \ Use the absolute path shown. A relative `DOCKER_CONFIG` is resolved against the fork's working directory, not the shell's. +### JDBC 4.3 Appendix B policy + +JDBC 4.3 Appendix B is the oracle for type mapping. The source is [`vendor/JDBC4-TYPE-MAPPING.md`](vendor/JDBC4-TYPE-MAPPING.md). + +- When Appendix B and sqlite-jdbc disagree, Appendix B wins. A conformance test that encodes the xerial behavior is quarantined in `libsql-conformance`, not copied. +- Keep sqlite-jdbc parity only where Appendix B is silent. Parity never overrides a defined Appendix B conversion. +- `ARRAY`, `STRUCT`, `REF`, `JAVA_OBJECT`, and `DISTINCT` are N/A. The driver throws `SQLFeatureNotSupportedException` and does not emulate them. +- An expression column has no declared type. Its storage-class fallback is a documented exception in [`docs/jdbc-appendix-b-compliance.md`](docs/jdbc-appendix-b-compliance.md), not a supported Appendix B row. + +The compliance profile is [`docs/jdbc-appendix-b-compliance.md`](docs/jdbc-appendix-b-compliance.md). Run the commands in [`README.md`](README.md) before changing a type mapping. + --- ## 5. Coding Standards & Conventions diff --git a/README.md b/README.md index e785563..7dc663e 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,41 @@ mise exec -- ./mvnw clean verify mise exec -- ./mvnw spotless:apply ``` +### JDBC 4.3 Appendix B checks + +Run the Appendix B unit suite. + +```bash +./mvnw -q -pl libsql-jdbc -Dtest='Jdbc4AppendixB*' -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Run the full `libsql-jdbc` unit suite. + +```bash +./mvnw -q -pl libsql-jdbc test +``` + +Run the Appendix B server suite. The install step puts the current driver in the local repository. Under the nono sandbox, the Docker config path is required. + +```bash +./mvnw -q -DskipTests install -pl libsql-hrana-codec,libsql-client,libsql-jdbc +DOCKER_CONFIG="$PWD/libsql-conformance/src/test/docker-config" ./mvnw -pl libsql-itests -Dit.test=Jdbc4AppendixBTypeMappingIT -Dtest=NoSuchTest -Dsurefire.failIfNoSpecifiedTests=false verify +``` + +Run the xerial conformance suite. + +```bash +./mvnw -pl libsql-conformance -am verify +``` + +Run the full gate. + +```bash +DOCKER_CONFIG="$PWD/libsql-conformance/src/test/docker-config" ./mvnw clean verify +``` + +The compliance profile is in [`docs/jdbc-appendix-b-compliance.md`](docs/jdbc-appendix-b-compliance.md). + ## Contributing We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and development workflow, and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community standards. diff --git a/docs/jdbc-appendix-b-compliance.md b/docs/jdbc-appendix-b-compliance.md new file mode 100644 index 0000000..bbb702a --- /dev/null +++ b/docs/jdbc-appendix-b-compliance.md @@ -0,0 +1,245 @@ +# JDBC 4.3 Appendix B compliance profile + +This page records the JDBC 4.3 Appendix B status of `libsql-jdbc`. The oracle is the specification in [`../vendor/JDBC4-TYPE-MAPPING.md`](../vendor/JDBC4-TYPE-MAPPING.md), not sqlite-jdbc. Every row below names the test that proves it. + +## How to read this profile + +- `supported` means the driver maps the row to a Hrana value kind and its declared type directly. +- `emulated` means SQLite has no distinct storage for the JDBC type. The driver supplies the Java type through a wrapper or a text or integer codec, and the observable Java type matches the appendix. +- `N/A` means SQLite has no interoperable representation. The driver throws `SQLFeatureNotSupportedException`. + +Five types are N/A. They are `ARRAY`, `STRUCT`, `REF`, `JAVA_OBJECT`, and `DISTINCT`. + +The unit suites are `Jdbc4AppendixBReadTest`, `Jdbc4AppendixBSetObjectTest`, and `Jdbc4AppendixBProfileTest`. The server suite is `Jdbc4AppendixBTypeMappingIT`. Both run from a clean checkout with the commands in the root [`README.md`](../README.md). + +## Table B-1, JDBC types mapped to Java types + +Table B-1 is observed through the natural ResultSet getter for each JDBC type. + +| Appendix B row | Status | Reason | Test | +|---|---|---|---| +| `CHAR` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `VARCHAR` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `LONGVARCHAR` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `NUMERIC` | emulated | SQLite has no decimal type. The driver reads the stored value as BigDecimal. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `DECIMAL` | emulated | SQLite has no decimal type. The driver reads the stored value as BigDecimal. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `BIT` | emulated | SQLite has no boolean type. The driver reads integer 0 or 1 as Boolean. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `BOOLEAN` | emulated | SQLite has no boolean type. The driver reads integer 0 or 1 as Boolean. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `TINYINT` | supported | A narrowed Hrana integer maps directly to byte. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `SMALLINT` | supported | A narrowed Hrana integer maps directly to short. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `INTEGER` | supported | A Hrana integer maps directly to int. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `BIGINT` | supported | A Hrana integer maps directly to long. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `REAL` | supported | A Hrana real maps directly to float. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `FLOAT` | supported | A Hrana real maps directly to double. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `DOUBLE` | supported | A Hrana real maps directly to double. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `BINARY` | supported | A Hrana blob maps directly to byte[]. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `VARBINARY` | supported | A Hrana blob maps directly to byte[]. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `LONGVARBINARY` | supported | A Hrana blob maps directly to byte[]. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `DATE` | emulated | SQLite has no date type. The driver parses ISO text into java.sql.Date. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `TIME` | emulated | SQLite has no time type. The driver parses text into java.sql.Time. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `TIMESTAMP` | emulated | SQLite has no timestamp type. The driver parses text into java.sql.Timestamp. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `CLOB` | emulated | The driver wraps stored text in java.sql.Clob. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `BLOB` | supported | A Hrana blob maps directly to java.sql.Blob. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `ARRAY` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `DISTINCT` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `STRUCT` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `REF` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `DATALINK` | emulated | The driver parses stored text into java.net.URL. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `JAVA_OBJECT` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `ROWID` | emulated | The driver wraps stored text or blob bytes in java.sql.RowId. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `NCHAR` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `NVARCHAR` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `LONGNVARCHAR` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `NCLOB` | emulated | The driver wraps stored text in java.sql.NClob. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `SQLXML` | emulated | The driver wraps stored text in java.sql.SQLXML. | `Jdbc4AppendixBReadTest#b1NaturalGetterReturnsDeclaredJavaType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | + +## Table B-2, Java types mapped to JDBC types + +Table B-2 is observed through the standard setter for each Java type. + +| Appendix B row | Status | Reason | Test | +|---|---|---|---| +| `String` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `java.math.BigDecimal` | emulated | Binds decimal text. SQLite has no decimal type. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `boolean` | emulated | Binds integer 0 or 1. SQLite has no boolean type. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `byte` | supported | Binds a narrowed Hrana integer. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `short` | supported | Binds a narrowed Hrana integer. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `int` | supported | Binds a Hrana integer. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `long` | supported | Binds a Hrana integer. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `float` | supported | Binds a Hrana real. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `double` | supported | Binds a Hrana real. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `byte[]` | supported | Binds a Hrana blob. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `java.sql.Date` | emulated | Binds SQLite date text. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `java.sql.Time` | emulated | Binds SQLite time text. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `java.sql.Timestamp` | emulated | Binds SQLite timestamp text. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `java.sql.Clob` | emulated | Reads the character stream and binds text. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `java.sql.Blob` | supported | Reads the binary stream and binds a blob. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `java.sql.Array` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.sql.Struct` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.sql.Ref` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.net.URL` | emulated | Binds the URL as text. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `Java class` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.sql.RowId` | emulated | Binds the RowId bytes as a Hrana blob. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `java.sql.NClob` | emulated | Reads the character stream and binds text. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `java.sql.SQLXML` | emulated | Reads the SQLXML text and binds it. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | + +## Table B-3, JDBC types mapped to Java object types + +Table B-3 is observed through `getObject(int)` for each declared type. + +| Appendix B row | Status | Reason | Test | +|---|---|---|---| +| `CHAR` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `VARCHAR` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `LONGVARCHAR` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `NUMERIC` | emulated | SQLite has no decimal type. The driver reads the stored value as BigDecimal. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `DECIMAL` | emulated | SQLite has no decimal type. The driver reads the stored value as BigDecimal. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `BIT` | emulated | SQLite has no boolean type. The driver reads integer 0 or 1 as Boolean. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `BOOLEAN` | emulated | SQLite has no boolean type. The driver reads integer 0 or 1 as Boolean. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `TINYINT` | supported | A narrowed Hrana integer maps directly to byte. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `SMALLINT` | supported | A narrowed Hrana integer maps directly to short. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `INTEGER` | supported | A Hrana integer maps directly to int. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `BIGINT` | supported | A Hrana integer maps directly to long. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `REAL` | supported | A Hrana real maps directly to float. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `FLOAT` | supported | A Hrana real maps directly to double. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `DOUBLE` | supported | A Hrana real maps directly to double. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `BINARY` | supported | A Hrana blob maps directly to byte[]. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `VARBINARY` | supported | A Hrana blob maps directly to byte[]. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `LONGVARBINARY` | supported | A Hrana blob maps directly to byte[]. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `DATE` | emulated | SQLite has no date type. The driver parses ISO text into java.sql.Date. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `TIME` | emulated | SQLite has no time type. The driver parses text into java.sql.Time. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `TIMESTAMP` | emulated | SQLite has no timestamp type. The driver parses text into java.sql.Timestamp. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `DISTINCT` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `CLOB` | emulated | The driver wraps stored text in java.sql.Clob. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `BLOB` | supported | A Hrana blob maps directly to java.sql.Blob. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `ARRAY` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `STRUCT` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `REF` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `DATALINK` | emulated | The driver parses stored text into java.net.URL. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `JAVA_OBJECT` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `ROWID` | emulated | The driver wraps stored text or blob bytes in java.sql.RowId. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `NCHAR` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `NVARCHAR` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `LONGNVARCHAR` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `NCLOB` | emulated | The driver wraps stored text in java.sql.NClob. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | +| `SQLXML` | emulated | The driver wraps stored text in java.sql.SQLXML. | `Jdbc4AppendixBReadTest#b3GetObjectReturnsDeclaredObjectType`, `Jdbc4AppendixBTypeMappingIT#b1AndB3ReadPathAgainstRealDdl` | + +## Table B-4, Java object types mapped to JDBC types + +Table B-4 is observed through `setObject(int, Object)` with no target type. + +| Appendix B row | Status | Reason | Test | +|---|---|---|---| +| `String` | supported | SQLite text maps directly to String. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.math.BigDecimal` | emulated | Binds decimal text. SQLite has no decimal type. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `Boolean` | emulated | Binds integer 0 or 1. SQLite has no boolean type. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `Byte` | supported | Binds a narrowed Hrana integer. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `Short` | supported | Binds a narrowed Hrana integer. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `Integer` | supported | Binds a Hrana integer. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `Long` | supported | Binds a Hrana integer. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `Float` | supported | Binds a Hrana real. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `Double` | supported | Binds a Hrana real. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `byte[]` | supported | Binds a Hrana blob. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.math.BigInteger` | supported | Binds a Hrana integer after an exact long range check. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.sql.Date` | emulated | Binds SQLite date text. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.sql.Time` | emulated | Binds SQLite time text. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.sql.Timestamp` | emulated | Binds SQLite timestamp text. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.sql.Clob` | emulated | Reads the character stream and binds text. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.sql.Blob` | supported | Reads the binary stream and binds a blob. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.sql.Array` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.sql.Struct` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.sql.Ref` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.net.URL` | emulated | Binds the URL as text. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `Java class` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.sql.RowId` | emulated | Binds the RowId bytes as a Hrana blob. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.sql.NClob` | emulated | Reads the character stream and binds text. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.sql.SQLXML` | emulated | Reads the SQLXML text and binds it. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.util.Calendar` | emulated | Binds SQLite timestamp text from the calendar instant. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.util.Date` | emulated | Binds SQLite timestamp text from the date instant. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.time.LocalDate` | emulated | Binds SQLite date text. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.time.LocalTime` | emulated | Binds SQLite time text. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.time.LocalDateTime` | emulated | Binds SQLite timestamp text. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.time.OffsetTime` | emulated | Binds ISO-8601 text with an offset. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.time.OffsetDateTime` | emulated | Binds ISO-8601 text with an offset. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | + +## Table B-5, conversions by setObject and setNull + +Table B-5 is observed through `setObject(int, Object, int)`. A listed pair must convert. An unlisted pair must throw. + +| Appendix B row | Status | Reason | Test | +|---|---|---|---| +| `String` | supported | Every listed target is a defined conversion. Binary targets bind text bytes. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.math.BigDecimal` | emulated | Numeric targets narrow through BigDecimal. DECIMAL and NUMERIC bind decimal text. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `Boolean` | emulated | Binds integer 0 or 1 for numeric and boolean targets. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `Byte` | supported | Every listed target is a defined numeric or text conversion. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `Short` | supported | Every listed target is a defined numeric or text conversion. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `Integer` | supported | Every listed target is a defined numeric or text conversion. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `Long` | supported | Every listed target is a defined numeric or text conversion. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `Float` | supported | Every listed target is a defined numeric or text conversion. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `Double` | supported | Every listed target is a defined numeric or text conversion. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `byte[]` | supported | BINARY, VARBINARY and LONGVARBINARY bind the blob bytes. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.math.BigInteger` | supported | BIGINT binds an integer. Text targets bind the decimal form. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.sql.Date` | emulated | DATE and TIMESTAMP bind the SQLite text form. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.sql.Time` | emulated | TIME and TIMESTAMP bind the SQLite text form. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.sql.Timestamp` | emulated | DATE, TIME and TIMESTAMP bind the SQLite text form. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.sql.Array` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.sql.Blob` | supported | BLOB binds the blob bytes. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.sql.Clob` | emulated | CLOB reads the character stream and binds text. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.sql.Struct` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.sql.Ref` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.net.URL` | emulated | DATALINK binds the URL text. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `Java class` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.sql.RowId` | emulated | ROWID binds the RowId bytes. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.sql.NClob` | emulated | NCLOB reads the character stream and binds text. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.sql.SQLXML` | emulated | SQLXML binds the XML text. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.util.Calendar` | emulated | Temporal targets bind text. The ARRAY target is N/A. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.util.Date` | emulated | Temporal targets bind text. The ARRAY target is N/A. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.time.LocalDate` | emulated | DATE and text targets bind the SQLite text form. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.time.LocalTime` | emulated | TIME and text targets bind the SQLite text form. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.time.LocalDateTime` | emulated | DATE, TIME, TIMESTAMP and text targets bind the SQLite text form. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.time.OffsetTime` | emulated | TIME_WITH_TIMEZONE and text targets bind ISO-8601 text with an offset. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | +| `java.time.OffsetDateTime` | emulated | TIME_WITH_TIMEZONE, TIMESTAMP_WITH_TIMEZONE and text targets bind ISO-8601 text with an offset. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | + +## Table B-6, ResultSet getter conversions + +Table B-6 is observed through each getter against its recommended declared type. + +| Appendix B row | Status | Reason | Test | +|---|---|---|---| +| `getByte` | supported | Reads a numeric, boolean or text value as a byte. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getShort` | supported | Reads a numeric, boolean or text value as a short. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getInt` | supported | Reads a numeric, boolean or text value as an int. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getLong` | supported | Reads a numeric, boolean or text value as a long. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getFloat` | supported | Reads a numeric, boolean or text value as a float. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getDouble` | supported | Reads a numeric, boolean or text value as a double. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getBigDecimal` | supported | Reads a numeric, boolean or text value as BigDecimal. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getBoolean` | emulated | Reads integer 0 or 1, and the text true or false, as Boolean. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getString` | supported | Reads text and the listed scalar types as String. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getNString` | supported | Reads text and the listed scalar types as String. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getBytes` | supported | Reads binary values and the listed types as byte[]. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getDate` | emulated | Parses the supported text forms into java.sql.Date. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getTime` | emulated | Parses the supported text forms into java.sql.Time. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getTimestamp` | emulated | Parses the supported text forms into java.sql.Timestamp. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getAsciiStream` | emulated | Returns the text bytes as a US-ASCII stream. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getBinaryStream` | supported | Returns the blob bytes as a stream. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getCharacterStream` | emulated | Returns a reader over the stored text or bytes. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getNCharacterStream` | emulated | Returns a reader over the stored text or bytes. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getClob` | emulated | Wraps stored text in java.sql.Clob. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getNClob` | emulated | Wraps stored text in java.sql.NClob. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getBlob` | supported | Wraps stored bytes in java.sql.Blob. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getArray` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `getRef` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `getURL` | emulated | Parses stored text into java.net.URL. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getObject` | supported | Returns the Table B-3 object type for each supported declared type. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getRowId` | emulated | Wraps stored text or bytes in java.sql.RowId. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | +| `getSQLXML` | emulated | Wraps stored text in java.sql.SQLXML. | `Jdbc4AppendixBReadTest#b6GetterHonoursSupportedJdbcTypes`, `Jdbc4AppendixBTypeMappingIT#b6GetterReadsRecommendedTypeFromRealDdl` | + +## Documented exception + +An expression column has no declared type. `SELECT 1 + 1` reports a null `decltype`, so the driver cannot resolve a JDBC type before it sees the value. The driver falls back to the storage class of the value and stays permissive. This fallback is an exception, not a supported Appendix B row, because the appendix assumes a declared JDBC type. + +The fallback is covered by `Jdbc4AppendixBProfileTest#expressionFallbackStaysPermissive`. The same exception is recorded in `JdbcTypeMapping.Support.FALLBACK`. + +## Conformance quarantines + +The xerial conformance suite keeps its selected tests except for four methods whose expectations Appendix B replaces. The list and the reason for each one are in [`../libsql-conformance/README.md`](../libsql-conformance/README.md). They still run in the `xerial-control` profile. From 0a8ec03256ddb1098538053df7f04692cab8a209 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:32:09 +0100 Subject: [PATCH 27/29] test(itests): assert the nclob wrapper and its content The B-5 reader returned the NClob wrapper while the expectation checked only the type, so the stored text was no longer verified. Read the wrapper, assert its type, and compare getSubString(1, length) with the source text in B-2, B-4, and B-5. --- .../itest/Jdbc4AppendixBTypeMappingIT.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java index 2933d69..2e5c094 100644 --- a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java +++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java @@ -418,7 +418,7 @@ static Expected any(Class type) { new WriteRow("java.sql.RowId", rowId(TEXT_ROWID), "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)), + Jdbc4AppendixBTypeMappingIT::nclobText, Expected.of("abc")), new WriteRow("java.sql.SQLXML", sqlxml(TEXT_XML), "SQLXML", Types.SQLXML, UNSPECIFIED, ResultSet::getSQLXML, Expected.any(SQLXML.class))); @@ -542,7 +542,7 @@ private static Calendar calendar2024() { 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)), + Jdbc4AppendixBTypeMappingIT::nclobText, Expected.of("abc")), 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, @@ -881,7 +881,7 @@ private static SqlRead readFor(Target target) { case "TIMESTAMP" -> ResultSet::getTimestamp; case "CLOB" -> Jdbc4AppendixBTypeMappingIT::clobText; case "BLOB" -> Jdbc4AppendixBTypeMappingIT::blobBytes; - case "NCLOB" -> ResultSet::getNClob; + case "NCLOB" -> Jdbc4AppendixBTypeMappingIT::nclobText; case "ARRAY" -> ResultSet::getArray; case "STRUCT" -> (rs, i) -> rs.getObject(i); case "REF" -> ResultSet::getRef; @@ -926,7 +926,7 @@ private static Expected expectedFor(JavaRow row, Target target) { case "TIME" -> timeExpected(source); case "TIMESTAMP" -> timestampExpected(source); case "CLOB" -> Expected.of(clobTextValue(source)); - case "NCLOB" -> Expected.any(NClob.class); + case "NCLOB" -> Expected.of(clobTextValue(source)); case "BLOB" -> Expected.of(bytesForm(source)); case "BINARY", "VARBINARY", "LONGVARBINARY" -> Expected.of(bytesForm(source)); case "DATALINK" -> Expected.any(URL.class); @@ -1127,6 +1127,14 @@ private static String clobText(ResultSet resultSet, int column) throws SQLExcept return clob == null ? null : clob.getSubString(1, (int) clob.length()); } + private static String nclobText(ResultSet resultSet, int column) throws SQLException { + NClob nclob = resultSet.getNClob(column); + assertThat(nclob) + .as("NCLOB column must read as an NClob wrapper") + .isInstanceOf(NClob.class); + return nclob.getSubString(1, (int) nclob.length()); + } + private static byte[] blobBytes(ResultSet resultSet, int column) throws SQLException { Blob blob = resultSet.getBlob(column); if (blob == null) { From c3190ff4f82ec319c59f0eed2c6c192f152735aa Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:32:12 +0100 Subject: [PATCH 28/29] docs(conformance): note the checks the method quarantines drop The quarantine table names the conflicting assertion for each method but not the non-conflicting checks the method-level exclusion also removes. List those checks per method and correct the count note: 270 excludes the four quarantines. --- libsql-conformance/README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/libsql-conformance/README.md b/libsql-conformance/README.md index cf14eb5..8546c45 100644 --- a/libsql-conformance/README.md +++ b/libsql-conformance/README.md @@ -48,8 +48,8 @@ with: ``` Last green result: 270 tests, 7 skipped, with `SavepointTest` included. The -count includes the four Appendix B quarantines below and two harness tests for -the quarantine. +count excludes the four Appendix B quarantines below and includes the two +harness tests for the quarantine. ### Appendix B quarantines @@ -66,6 +66,14 @@ the libSQL run. The full type profile is in | `org.sqlite.RSMetaDataTest#testGetColumnClassName` | xerial reports a declared `BLOB` as `java.lang.Object`. Table B-3 maps `BLOB` to `java.sql.Blob`. | | `org.sqlite.PrepStmtTest#getObject` | xerial returns an `Integer` for `getObject` on a declared `BIT`. Table B-3 maps `BIT` to `Boolean`. | +The exclusion is method-level, so each method also loses checks that do not +conflict with Appendix B: + +- `StatementTest#bytesTest` also loses its BLOB `setBytes`/`getBytes` round trip. +- `RSMetaDataTest#columnTypes` also checks other column types that do not conflict, including the INTEGER family, text, numeric, boolean, date and timestamp types, plus DECIMAL precision and scale. +- `RSMetaDataTest#testGetColumnClassName` also checks the INTEGER, VARCHAR, and DOUBLE class names. +- `PrepStmtTest#getObject` also checks other `getObject` rows (INTEGER, FLOAT, untyped, VARCHAR and BLOB columns) and the typed getters above them. + ### Three-layer contract When the layers disagree, the contract is: From 0861960e4fab7f48e74868af7e3f0b086675b6da Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:32:16 +0100 Subject: [PATCH 29/29] docs(jdbc): point setter n/a rows at setter tests The profile test only exercises getObject, getArray, and getRef on declared columns, so it cannot prove the B-2, B-4, or B-5 setter rows. Cite the integration rows and the setObject rejection test instead, and note that the timezone types belong to B-5 and B-6 rather than B-1 or B-3. --- docs/jdbc-appendix-b-compliance.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/jdbc-appendix-b-compliance.md b/docs/jdbc-appendix-b-compliance.md index bbb702a..e24bcc4 100644 --- a/docs/jdbc-appendix-b-compliance.md +++ b/docs/jdbc-appendix-b-compliance.md @@ -14,7 +14,7 @@ The unit suites are `Jdbc4AppendixBReadTest`, `Jdbc4AppendixBSetObjectTest`, and ## Table B-1, JDBC types mapped to Java types -Table B-1 is observed through the natural ResultSet getter for each JDBC type. +Table B-1 is observed through the natural ResultSet getter for each JDBC type. `TIME_WITH_TIMEZONE` and `TIMESTAMP_WITH_TIMEZONE` are not Table B-1 rows; Tables B-5 and B-6 cover them, so the profile test listing them as supported does not conflict with this table. | Appendix B row | Status | Reason | Test | |---|---|---|---| @@ -74,18 +74,18 @@ Table B-2 is observed through the standard setter for each Java type. | `java.sql.Timestamp` | emulated | Binds SQLite timestamp text. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | | `java.sql.Clob` | emulated | Reads the character stream and binds text. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | | `java.sql.Blob` | supported | Reads the binary stream and binds a blob. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | -| `java.sql.Array` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | -| `java.sql.Struct` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | -| `java.sql.Ref` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.sql.Array` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `java.sql.Struct` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | +| `java.sql.Ref` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | | `java.net.URL` | emulated | Binds the URL as text. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | -| `Java class` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `Java class` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | | `java.sql.RowId` | emulated | Binds the RowId bytes as a Hrana blob. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | | `java.sql.NClob` | emulated | Reads the character stream and binds text. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | | `java.sql.SQLXML` | emulated | Reads the SQLXML text and binds it. | `Jdbc4AppendixBTypeMappingIT#b2StandardSettersMapJavaTypeToJdbcType` | ## Table B-3, JDBC types mapped to Java object types -Table B-3 is observed through `getObject(int)` for each declared type. +Table B-3 is observed through `getObject(int)` for each declared type. `TIME_WITH_TIMEZONE` and `TIMESTAMP_WITH_TIMEZONE` are not Table B-3 rows; Tables B-5 and B-6 cover them, so the profile test listing them as supported does not conflict with this table. | Appendix B row | Status | Reason | Test | |---|---|---|---| @@ -146,11 +146,11 @@ Table B-4 is observed through `setObject(int, Object)` with no target type. | `java.sql.Timestamp` | emulated | Binds SQLite timestamp text. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | | `java.sql.Clob` | emulated | Reads the character stream and binds text. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | | `java.sql.Blob` | supported | Reads the binary stream and binds a blob. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | -| `java.sql.Array` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | -| `java.sql.Struct` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | -| `java.sql.Ref` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.sql.Array` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.sql.Struct` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | +| `java.sql.Ref` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | | `java.net.URL` | emulated | Binds the URL as text. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | -| `Java class` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `Java class` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | | `java.sql.RowId` | emulated | Binds the RowId bytes as a Hrana blob. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | | `java.sql.NClob` | emulated | Reads the character stream and binds text. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | | `java.sql.SQLXML` | emulated | Reads the SQLXML text and binds it. | `Jdbc4AppendixBTypeMappingIT#b4SetObjectMapsJavaObjectTypeToJdbcType` | @@ -182,13 +182,13 @@ Table B-5 is observed through `setObject(int, Object, int)`. A listed pair must | `java.sql.Date` | emulated | DATE and TIMESTAMP bind the SQLite text form. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | | `java.sql.Time` | emulated | TIME and TIMESTAMP bind the SQLite text form. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | | `java.sql.Timestamp` | emulated | DATE, TIME and TIMESTAMP bind the SQLite text form. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | -| `java.sql.Array` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.sql.Array` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnsupportedTypesAndValues` | | `java.sql.Blob` | supported | BLOB binds the blob bytes. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | | `java.sql.Clob` | emulated | CLOB reads the character stream and binds text. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | -| `java.sql.Struct` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | -| `java.sql.Ref` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `java.sql.Struct` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnsupportedTypesAndValues` | +| `java.sql.Ref` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnsupportedTypesAndValues` | | `java.net.URL` | emulated | DATALINK binds the URL text. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | -| `Java class` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBProfileTest#everyUnsupportedDeclaredTypeThrowsFeatureNotSupported` | +| `Java class` | N/A | SQLite has no interoperable representation. The driver throws SQLFeatureNotSupportedException. | `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnsupportedTypesAndValues` | | `java.sql.RowId` | emulated | ROWID binds the RowId bytes. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | | `java.sql.NClob` | emulated | NCLOB reads the character stream and binds text. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` | | `java.sql.SQLXML` | emulated | SQLXML binds the XML text. | `Jdbc4AppendixBTypeMappingIT#b5SetObjectConvertsToExplicitTargetType`, `Jdbc4AppendixBSetObjectTest#b5SetObjectRejectsUnlistedTargetTypes` |