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..e24bcc4
--- /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. `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 |
+|---|---|---|---|
+| `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. | `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. | `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. `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 |
+|---|---|---|---|
+| `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. | `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. | `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` |
+| `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. | `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. | `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. | `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` |
+| `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.
diff --git a/libsql-conformance/README.md b/libsql-conformance/README.md
index d079b32..8546c45 100644
--- a/libsql-conformance/README.md
+++ b/libsql-conformance/README.md
@@ -47,8 +47,32 @@ 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 excludes the four Appendix B quarantines below and includes the 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`. |
+
+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
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
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..2e5c094
--- /dev/null
+++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/Jdbc4AppendixBTypeMappingIT.java
@@ -0,0 +1,1281 @@
+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.SQLFeatureNotSupportedException;
+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.assertj.core.api.Assertions.assertThatThrownBy;
+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);
+ }
+
+ /** 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;
+ }
+
+ 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 (?)")) {
+ 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();
+ 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());
+
+ 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;
+ 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("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));
+
+ @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();
+ 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);
+ } 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", rowId(TEXT_ROWID), "ROWID", Types.ROWID, UNSPECIFIED,
+ ResultSet::getRowId, Expected.any(RowId.class)),
+ new WriteRow("java.sql.NClob", nclob("abc"), "NCLOB", Types.NCLOB, TEXT,
+ Jdbc4AppendixBTypeMappingIT::nclobText, Expected.of("abc")),
+ new WriteRow("java.sql.SQLXML", sqlxml(TEXT_XML), "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 (?)")) {
+ 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) {
+ 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,
+ 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,
+ 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 (?)")) {
+ 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();
+ }
+ 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())
+ && 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());
+ }
+ }
+ 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;
+ }
+ 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()) {
+ 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" -> Jdbc4AppendixBTypeMappingIT::nclobText;
+ 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.of(clobTextValue(source));
+ 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 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) {
+ 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 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();
+ } 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/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..798e127
--- /dev/null
+++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java
@@ -0,0 +1,462 @@
+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.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.
+ *
+ * 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.
+ *
+ *
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.
+ *
+ *
{@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 {
+
+ enum Support {
+ SUPPORTED,
+ FALLBACK,
+ UNSUPPORTED
+ }
+
+ enum StorageClass {
+ INTEGER,
+ FLOAT,
+ TEXT,
+ BLOB,
+ NULL
+ }
+
+ /** 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);
+ 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> GETTER_SUPPORT = getterSupport();
+ private static final Map DECLARED = declaredTypeRegistry();
+ private static final Map ALIASES = aliasTypes();
+
+ private JdbcTypeMapping() {}
+
+ /**
+ * 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 declared;
+ }
+
+ private static JdbcType declared(Col col) {
+ if (col == null) {
+ return null;
+ }
+ String name = DeclaredType.of(col).declaredTypeName();
+ if (name == null) {
+ return null;
+ }
+ JdbcType type = DECLARED.get(name);
+ 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);
+ 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, 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() {
+ 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(
+ "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),
+ 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),
+ 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,
+ 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));
+ }
+
+ /**
+ * 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);
+ }
+
+ /** 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..5a7c2de
--- /dev/null
+++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcValueConverter.java
@@ -0,0 +1,487 @@
+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));
+ }
+ 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. */
+ 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, 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));
+ 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 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 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 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));
+ }
+}
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/LibsqlPreparedStatement.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java
index 2176622..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
@@ -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
@@ -378,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
@@ -413,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/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java
index 10955b5..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
@@ -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;
@@ -30,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;
@@ -59,12 +64,18 @@ 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;
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;
@@ -81,6 +92,12 @@ public LibsqlResultSet(Statement statement, StmtResult result) {
? libsqlStatement.connection
: null;
this.closeStatementOnClose = closeStatementOnClose;
+ this.declaredTypes = JdbcTypeMapping.declaredTypes(this.cols);
+ this.currentValues = this.rows.isEmpty() ? null : this.rows.get(0).values();
+ }
+
+ JdbcTypeMapping.JdbcType[] declaredTypes() {
+ return declaredTypes;
}
private void checkClosed() throws SQLException {
@@ -90,11 +107,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 {
@@ -106,6 +120,31 @@ private Value getCurrentValue(int columnIndex) throws SQLException {
return valueAtCursor(columnIndex);
}
+ /**
+ * 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 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) {
+ return;
+ }
+ if (JdbcTypeMapping.isUnsupported(type)) {
+ throw new SQLFeatureNotSupportedException(
+ "Declared " + JdbcTypeMapping.sqlTypeName(type.sqlType()) + " is not supported");
+ }
+ if (!JdbcTypeMapping.supports(type, getter)) {
+ throw new SQLFeatureNotSupportedException(
+ getter.methodName() + " does not support declared "
+ + JdbcTypeMapping.sqlTypeName(type.sqlType()));
+ }
+ }
+
Value valueForMetadata(int columnIndex) {
int rowIndex = currentRowIndex < 0 ? 0 : currentRowIndex;
if (rowIndex >= rows.size()) {
@@ -127,7 +166,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
@@ -174,7 +218,11 @@ public boolean wasNull() throws SQLException {
*/
@Override
public String getString(int columnIndex) throws SQLException {
- Value val = getCurrentValue(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_STRING);
+ 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());
@@ -185,44 +233,110 @@ 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 {
- return getInt(columnIndex) != 0;
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_BOOLEAN);
+ 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)) {
+ return true;
+ }
+ if ("false".equalsIgnoreCase(string)) {
+ return false;
+ }
+ }
+ return (int) toLong(value) != 0;
}
@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 {
- return (int) toLong(getCurrentValue(columnIndex));
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_INT);
+ return (int) longValue(columnIndex);
}
@Override
public long getLong(int columnIndex) throws SQLException {
- return toLong(getCurrentValue(columnIndex));
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_LONG);
+ return longValue(columnIndex);
}
@Override
public float getFloat(int columnIndex) throws SQLException {
- return (float) toDouble(getCurrentValue(columnIndex));
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_FLOAT);
+ return (float) doubleValue(columnIndex);
}
@Override
public double getDouble(int columnIndex) throws SQLException {
- 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) {
@@ -256,7 +370,11 @@ public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException
@Override
public byte[] getBytes(int columnIndex) throws SQLException {
- Value val = getCurrentValue(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_BYTES);
+ return bytesValue(getCurrentValue(columnIndex));
+ }
+
+ private static byte[] bytesValue(Value val) {
return switch (val) {
case Value.Null n -> null;
case Value.Blob b -> b.bytes();
@@ -268,25 +386,80 @@ 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());
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_DATE);
+ 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());
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_TIME);
+ 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);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_TIMESTAMP);
+ return timestampValue(getCurrentValue(columnIndex));
+ }
+
+ private static Timestamp timestampValue(Value value) throws SQLException {
+ LocalDateTime dateTime = dateTimeValue(value, "Timestamp");
+ 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 {
- String s = getString(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_BIG_DECIMAL);
+ return bigDecimalValue(getCurrentValue(columnIndex));
+ }
+
+ private static BigDecimal bigDecimalValue(Value value) throws SQLException {
+ String s = stringValue(value);
if (s == null) {
return null;
}
@@ -299,8 +472,108 @@ public BigDecimal getBigDecimal(int columnIndex) throws SQLException {
@Override
public Object getObject(int columnIndex) throws SQLException {
- Value val = getCurrentValue(columnIndex);
- return switch (val) {
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_OBJECT);
+ Value value = getCurrentValue(columnIndex);
+ if (value instanceof Value.Null) {
+ return null;
+ }
+ 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 static Object objectValue(Value value, JdbcTypeMapping.JdbcType type) throws SQLException {
+ Class> objectClass = type.objectClass();
+ if (objectClass == Integer.class) {
+ return checkedInt(value, type.sqlType());
+ }
+ if (objectClass == Long.class) {
+ return toLong(value);
+ }
+ if (objectClass == Double.class) {
+ return toDouble(value);
+ }
+ if (objectClass == Float.class) {
+ return (float) toDouble(value);
+ }
+ if (objectClass == String.class) {
+ 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 bytesValue(value);
+ }
+ if (objectClass == Date.class) {
+ return dateValue(value);
+ }
+ if (objectClass == Time.class) {
+ return timeValue(value);
+ }
+ 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);
+ }
+ if (objectClass == NClob.class) {
+ return nclobValue(value);
+ }
+ if (objectClass == Blob.class) {
+ return blobValue(value);
+ }
+ if (objectClass == RowId.class) {
+ return rowIdValue(value);
+ }
+ if (objectClass == SQLXML.class) {
+ return sqlxmlValue(value);
+ }
+ if (objectClass == URL.class) {
+ return urlValue(value);
+ }
+ return storedObject(value);
+ }
+
+ 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 +584,27 @@ public Object getObject(int columnIndex) throws SQLException {
};
}
+ 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 " + number + " does not fit the declared " + sqlTypeName(sqlType) + " type");
+ }
+ return (int) number;
+ }
+
+ 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");
@@ -470,20 +764,29 @@ 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 {
+ 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 { 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; }
@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)); }
@@ -564,16 +867,30 @@ 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 {
- byte[] bytes = getBytes(columnIndex);
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_BLOB);
+ 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);
+ 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);
@@ -588,8 +905,26 @@ public void setFetchSize(int rows) throws SQLException {
@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 {
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_URL);
+ 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"); }
@@ -598,8 +933,15 @@ public void setFetchSize(int rows) throws SQLException {
@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 {
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_ROW_ID);
+ 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; }
@@ -607,16 +949,37 @@ public void setFetchSize(int rows) throws SQLException {
@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 {
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_N_CLOB);
+ 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 {
+ checkGetter(columnIndex, JdbcTypeMapping.Getter.GET_SQLXML);
+ 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 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 {
+ 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)); }
@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"); }
@@ -686,7 +1049,7 @@ public void setFetchSize(int rows) throws SQLException {
*
*
* | {@code Date}, {@code Time}, {@code Timestamp}, {@code LocalDate}, {@code LocalTime},
- * {@code LocalDateTime} |
+ * {@code LocalDateTime}, {@code OffsetTime}, {@code OffsetDateTime}
* epoch millis | Julian day | date/time text | bad value |
* {@code null} |
*
@@ -700,8 +1063,10 @@ public void setFetchSize(int rows) throws SQLException {
* 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.
@@ -716,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));
}
@@ -746,6 +1112,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;
@@ -778,25 +1150,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 afc57d0..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
@@ -100,7 +103,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 +156,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 +184,13 @@ public boolean isDefinitelyWritable(int column) throws SQLException {
@Override
public String getColumnClassName(int column) throws SQLException {
+ return resolvedType(column).objectClass().getName();
+ }
+
+ private JdbcTypeMapping.JdbcType resolvedType(int column) throws SQLException {
getCol(column);
- return XerialColumnType.className(runtimeValue(column));
+ JdbcTypeMapping.JdbcType declared = declaredTypes[column - 1];
+ return declared != null ? declared : JdbcTypeMapping.fallback(runtimeValue(column));
}
private String columnName(int column) throws SQLException {
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");
+ }
+}
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
- }
-}
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/Jdbc4AppendixBReadTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java
new file mode 100644
index 0000000..e91bef3
--- /dev/null
+++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBReadTest.java
@@ -0,0 +1,818 @@
+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.IOException;
+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.SQLFeatureNotSupportedException;
+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.time.ZoneOffset;
+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.assertj.core.api.Assertions.assertThatThrownBy;
+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);
+ }
+
+ /** 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.
+ // ---------------------------------------------------------------------
+
+ @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();
+
+ 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);
+ } 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();
+
+ 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());
+ }
+
+ @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();
+ 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();
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // 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 (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 {
+ 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.
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // 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}));
+ }
+
+ // ---------------------------------------------------------------------
+ // 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.
+ // ---------------------------------------------------------------------
+
+ 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 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),
+ 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..0ac7c6a
--- /dev/null
+++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/Jdbc4AppendixBSetObjectTest.java
@@ -0,0 +1,326 @@
+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.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.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.assertj.core.api.Assertions.assertThatThrownBy;
+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),
+ 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) {}
+
+ 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()) || NA_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())
+ || NA_TARGETS.contains(target.sqlType())) {
+ continue;
+ }
+ LibsqlPreparedStatement statement = new LibsqlPreparedStatement(null, "?");
+ statement.setObject(1, row.value(), target.sqlType());
+ }
+ }
+ }
+
+ @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) {
+ LibsqlPreparedStatement statement = new LibsqlPreparedStatement(null, "?");
+ statement.setNull(1, target.sqlType());
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static T stub(Class type) {
+ return (T) Proxy.newProxyInstance(
+ type.getClassLoader(),
+ new Class>[] {type},
+ (proxy, method, args) -> switch (method.getName()) {
+ case "toString" -> "stub";
+ case "hashCode" -> System.identityHashCode(proxy);
+ case "equals" -> proxy == args[0];
+ default -> defaultValue(method.getReturnType());
+ });
+ }
+
+ private static Object defaultValue(Class> type) {
+ if (!type.isPrimitive()) {
+ return null;
+ }
+ if (type == boolean.class) {
+ return false;
+ }
+ if (type == char.class) {
+ return '\0';
+ }
+ if (type == byte.class) {
+ return (byte) 0;
+ }
+ if (type == short.class) {
+ return (short) 0;
+ }
+ if (type == int.class) {
+ return 0;
+ }
+ if (type == long.class) {
+ return 0L;
+ }
+ if (type == float.class) {
+ return 0.0f;
+ }
+ if (type == double.class) {
+ return 0.0d;
+ }
+ return null;
+ }
+}
diff --git a/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..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
@@ -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,177 @@ 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"));
+ }
+
+ @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 {
+ 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 (?)")) {
+ binder.bind(statement);
+ 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<>();
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..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
@@ -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
@@ -263,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(
@@ -360,11 +419,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(),
+ String.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 +439,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 +540,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 +549,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 +561,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..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
@@ -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
@@ -470,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)),
diff --git a/vendor/JDBC4-TYPE-MAPPING.md b/vendor/JDBC4-TYPE-MAPPING.md
new file mode 100644
index 0000000..47fae2e
--- /dev/null
+++ b/vendor/JDBC4-TYPE-MAPPING.md
@@ -0,0 +1,274 @@
+# JDBC 4.3 Appendix B — Data Type Conversion Tables
+
+Source: JDBC 4.3 Specification (JSR 000221, Maintenance Release 3, July 2017),
+Appendix B, pages 189–201.
+
+Reproduced as published, with the typographical errors listed below corrected.
+Tables B-5 and B-6 are prose tables in 4.3; they were matrices in 4.0/4.1.
+
+## Errata corrected in this copy
+
+| Published | Corrected | Location |
+|--------------------------------------------|------------------------------|--------------------|
+| `java.sql.array` | `java.sql.Array` | Table B-1 |
+| `LONVARBINARY` | `LONGVARBINARY` | Tables B-5 and B-6 |
+| `java.time.OffsetDatetime` | `java.time.OffsetDateTime` | Tables B-4 and B-5 |
+| `Java Object Type` (column of getter names) | `ResultSet getter` | Table B-6 |
+
+## B.1 JDBC Types Mapped to Java Types
+
+TABLE B-1 shows the conceptual correspondence between JDBC types and Java types.
+A programmer should write code with this mapping in mind. For example, if a value
+in the database is a SMALLINT, a short should be the data type used in a JDBC
+application.
+
+All CallableStatement getter methods except for getObject use this mapping. The
+getObject methods for both the CallableStatement and ResultSet interfaces use the
+mapping in TABLE B-3.
+
+**TABLE B-1 — JDBC Types Mapped to Java Types**
+
+| JDBC Type | Java Type |
+|---------------|----------------------------|
+| CHAR | String |
+| VARCHAR | String |
+| LONGVARCHAR | String |
+| NUMERIC | java.math.BigDecimal |
+| DECIMAL | java.math.BigDecimal |
+| BIT | boolean |
+| BOOLEAN | boolean |
+| TINYINT | byte |
+| SMALLINT | short |
+| INTEGER | int |
+| BIGINT | long |
+| REAL | float |
+| FLOAT | double |
+| DOUBLE | double |
+| BINARY | byte[] |
+| VARBINARY | byte[] |
+| LONGVARBINARY | byte[] |
+| DATE | java.sql.Date |
+| TIME | java.sql.Time |
+| TIMESTAMP | java.sql.Timestamp |
+| CLOB | java.sql.Clob |
+| BLOB | java.sql.Blob |
+| ARRAY | java.sql.Array |
+| DISTINCT | Mapping of underlying type |
+| STRUCT | java.sql.Struct |
+| REF | java.sql.Ref |
+| DATALINK | java.net.URL |
+| JAVA_OBJECT | Underlying Java class |
+| ROWID | java.sql.RowId |
+| NCHAR | String |
+| NVARCHAR | String |
+| LONGNVARCHAR | String |
+| NCLOB | java.sql.NClob |
+| SQLXML | java.sql.SQLXML |
+
+## B.2 Java Types Mapped to JDBC Types
+
+TABLE B-2 shows the mapping a driver should use for the updater methods in the
+ResultSet interface and for IN parameters. PreparedStatement setter methods and
+RowSet setter methods use this table for mapping an IN parameter, which is a Java
+type, to the JDBC type that will be sent to the database. Note that the setObject
+methods for these two interfaces use the mapping shown in TABLE B-4.
+
+**TABLE B-2 — Standard Mapping from Java Types to JDBC Types**
+
+| Java Type | JDBC Type |
+|----------------------|-------------------------------------------------------------|
+| String | CHAR, VARCHAR, LONGVARCHAR, NCHAR, NVARCHAR or LONGNVARCHAR |
+| java.math.BigDecimal | NUMERIC |
+| boolean | BIT or BOOLEAN |
+| byte | TINYINT |
+| short | SMALLINT |
+| int | INTEGER |
+| long | BIGINT |
+| float | REAL |
+| double | DOUBLE |
+| byte[] | BINARY, VARBINARY, or LONGVARBINARY |
+| java.sql.Date | DATE |
+| java.sql.Time | TIME |
+| java.sql.Timestamp | TIMESTAMP |
+| java.sql.Clob | CLOB |
+| java.sql.Blob | BLOB |
+| java.sql.Array | ARRAY |
+| java.sql.Struct | STRUCT |
+| java.sql.Ref | REF |
+| java.net.URL | DATALINK |
+| Java class | JAVA_OBJECT |
+| java.sql.RowId | ROWID |
+| java.sql.NClob | NCLOB |
+| java.sql.SQLXML | SQLXML |
+
+## B.3 JDBC Types Mapped to Java Object Types
+
+ResultSet.getObject and CallableStatement.getObject use the mapping shown in
+TABLE B-3 for standard mappings.
+
+> **Note** — The JDBC 1.0 specification defined the Java object mapping for the
+> SMALLINT and TINYINT JDBC types to be Integer. The Java language did not
+> include the Byte and Short data types when the JDBC 1.0 specification was
+> finalized. The mapping of SMALLINT and TINYINT to Integer is maintained to
+> preserve backwards compatibility.
+
+**TABLE B-3 — Mapping from JDBC Types to Java Object Types**
+
+| JDBC Type | Java Object Type |
+|---------------|-------------------------------------|
+| CHAR | String |
+| VARCHAR | String |
+| LONGVARCHAR | String |
+| NUMERIC | java.math.BigDecimal |
+| DECIMAL | java.math.BigDecimal |
+| BIT | Boolean |
+| BOOLEAN | Boolean |
+| TINYINT | Integer |
+| SMALLINT | Integer |
+| INTEGER | Integer |
+| BIGINT | Long |
+| REAL | Float |
+| FLOAT | Double |
+| DOUBLE | Double |
+| BINARY | byte[] |
+| VARBINARY | byte[] |
+| LONGVARBINARY | byte[] |
+| DATE | java.sql.Date |
+| TIME | java.sql.Time |
+| TIMESTAMP | java.sql.Timestamp |
+| DISTINCT | Object type of underlying type |
+| CLOB | java.sql.Clob |
+| BLOB | java.sql.Blob |
+| ARRAY | java.sql.Array |
+| STRUCT | java.sql.Struct or java.sql.SQLData |
+| REF | java.sql.Ref |
+| DATALINK | java.net.URL |
+| JAVA_OBJECT | Underlying Java class |
+| ROWID | java.sql.RowId |
+| NCHAR | String |
+| NVARCHAR | String |
+| LONGNVARCHAR | String |
+| NCLOB | java.sql.NClob |
+| SQLXML | java.sql.SQLXML |
+
+## B.4 Java Object Types Mapped to JDBC Types
+
+PreparedStatement.setObject, PreparedStatement.setNull, RowSet.setNull and
+RowSet.setObject use the mapping shown in TABLE B-4 when no parameter specifying
+a target JDBC type is provided.
+
+**TABLE B-4 — Mapping from Java Object Types to JDBC Types**
+
+| Java Object Type | JDBC Type |
+|----------------------|-------------------------------------------------------------|
+| String | CHAR, VARCHAR, LONGVARCHAR, NCHAR, NVARCHAR or LONGNVARCHAR |
+| java.math.BigDecimal | NUMERIC |
+| Boolean | BIT or BOOLEAN |
+| Byte | TINYINT |
+| Short | SMALLINT |
+| Integer | INTEGER |
+| Long | BIGINT |
+| Float | REAL |
+| Double | DOUBLE |
+| byte[] | BINARY, VARBINARY, or LONGVARBINARY |
+| java.math.BigInteger | BIGINT |
+| java.sql.Date | DATE |
+| java.sql.Time | TIME |
+| java.sql.Timestamp | TIMESTAMP |
+| java.sql.Clob | CLOB |
+| java.sql.Blob | BLOB |
+| java.sql.Array | ARRAY |
+| java.sql.Struct | STRUCT |
+| java.sql.Ref | REF |
+| java.net.URL | DATALINK |
+| Java class | JAVA_OBJECT |
+| java.sql.RowId | ROWID |
+| java.sql.NClob | NCLOB |
+| java.sql.SQLXML | SQLXML |
+| java.util.Calendar | TIMESTAMP |
+| java.util.Date | TIMESTAMP |
+| java.time.LocalDate | DATE |
+| java.time.LocalTime | TIME |
+| java.time.LocalDateTime | TIMESTAMP |
+| java.time.OffsetTime | TIME_WITH_TIMEZONE |
+| java.time.OffsetDateTime | TIMESTAMP_WITH_TIMEZONE |
+
+## B.5 Conversions by setObject and setNull from Java Object Types to JDBC Types
+
+TABLE B-5 shows which JDBC types may be specified as the target JDBC type to the
+methods PreparedStatement.setObject, PreparedStatement.setNull,
+RowSet.setNull, and RowSet.setObject.
+
+**TABLE B-5 — Conversions Performed by setObject and setNull Between Java Object Types and Target JDBC Types**
+
+| Java Object Type | Supported JDBC Type |
+|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| String | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, DATE, TIME, TIMESTAMP, NCHAR, NVARCHAR, LONGNVARCHAR |
+| java.math.BigDecimal | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Boolean | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Byte | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Short | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Integer | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Long | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Float | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| Double | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| byte[] | BINARY, VARBINARY, or LONGVARBINARY |
+| java.math.BigInteger | BIGINT, CHAR, VARCHAR, LONGVARCHAR |
+| java.sql.Date | CHAR, VARCHAR, LONGVARCHAR, DATE, TIMESTAMP |
+| java.sql.Time | CHAR, VARCHAR, LONGVARCHAR, TIME, TIMESTAMP |
+| java.sql.Timestamp | CHAR, VARCHAR, LONGVARCHAR, DATE, TIME, TIMESTAMP |
+| java.sql.Array | ARRAY |
+| java.sql.Blob | BLOB |
+| java.sql.Clob | CLOB |
+| java.sql.Struct | STRUCT |
+| java.sql.Ref | REF |
+| java.net.URL | DATALINK |
+| Java class | JAVA_OBJECT |
+| java.sql.RowId | ROWID |
+| java.sql.NClob | NCLOB |
+| java.sql.SQLXML | SQLXML |
+| java.util.Calendar | CHAR, VARCHAR, LONGVARCHAR, DATE, TIME, TIMESTAMP, ARRAY |
+| java.util.Date | CHAR, VARCHAR, LONGVARCHAR, DATE, TIME, TIMESTAMP, ARRAY |
+| java.time.LocalDate | CHAR, VARCHAR, LONGVARCHAR, DATE |
+| java.time.LocalTime | CHAR, VARCHAR, LONGVARCHAR, TIME |
+| java.time.LocalDateTime | CHAR, VARCHAR, LONGVARCHAR, DATE, TIME, TIMESTAMP |
+| java.time.OffsetTime | CHAR, VARCHAR, LONGVARCHAR, TIME_WITH_TIMEZONE |
+| java.time.OffsetDateTime | CHAR, VARCHAR, LONGVARCHAR, TIME_WITH_TIMEZONE, TIMESTAMP_WITH_TIMEZONE |
+
+## B.6 Type Conversions Supported by ResultSet getter Methods
+
+TABLE B-6 shows which JDBC types may be returned by ResultSet getter methods.
+This table also shows the conversions used by the SQLInput reader methods, except
+that they use only the recommended conversions.
+
+**TABLE B-6 — Use of ResultSet getter Methods to Retrieve JDBC Data Types**
+
+| ResultSet getter | Recommended JDBC Type | Supported JDBC Type |
+|---------------------|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| getByte | TINYINT | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR, ROWID |
+| getShort | SMALLINT | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getInt | INTEGER | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getLong | BIGINT | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getFloat | REAL | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getDouble | FLOAT, DOUBLE | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getBigDecimal | DECIMAL, NUMERIC | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getBoolean | BIT, BOOLEAN | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR |
+| getString | CHAR, VARCHAR | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, DATE, TIME, TIMESTAMP, DATALINK, NCHAR, NVARCHAR, LONGNVARCHAR |
+| getNString | NCHAR, NVARCHAR | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, DATE, TIME, TIMESTAMP, DATALINK, NCHAR, NVARCHAR, LONGNVARCHAR |
+| getBytes | BINARY, VARBINARY | BINARY, VARBINARY, LONGVARBINARY |
+| getDate | DATE | CHAR, VARCHAR, LONGVARCHAR, DATE, TIMESTAMP |
+| getTime | TIME | CHAR, VARCHAR, LONGVARCHAR, TIME, TIMESTAMP |
+| getTimestamp | TIMESTAMP | CHAR, VARCHAR, LONGVARCHAR, DATE, TIME, TIMESTAMP |
+| getAsciiStream | LONGVARCHAR | CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, CLOB, NCLOB |
+| getBinaryStream | LONGVARBINARY | BINARY, VARBINARY, LONGVARBINARY |
+| getCharacterStream | LONGVARCHAR | CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, CLOB, NCHAR, NVARCHAR, LONGNVARCHAR, NCLOB, SQLXML |
+| getNCharacterStream | LONGNVARCHAR | CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, CLOB, NCHAR, NVARCHAR, LONGNVARCHAR, NCLOB, SQLXML |
+| getClob | CLOB | CLOB, NCLOB |
+| getNClob | NCLOB | CLOB, NCLOB |
+| getBlob | BLOB | BLOB |
+| getArray | ARRAY | ARRAY |
+| getRef | REF | REF |
+| getURL | DATALINK | DATALINK |
+| getObject | STRUCT, JAVA_OBJECT | TINYINT, SMALLINT, INTEGER, BIGINT, REAL, FLOAT, DOUBLE, DECIMAL, NUMERIC, BIT, BOOLEAN, CHAR, VARCHAR, LONGVARCHAR, BINARY, VARBINARY, LONGVARBINARY, DATE, TIME, TIMESTAMP, CLOB, BLOB, ARRAY, REF, DATALINK, STRUCT, JAVA_OBJECT, ROWID, NCHAR, NVARCHAR, LONGNVARCHAR, NCLOB, SQLXML, TIME_WITH_TIMEZONE, TIMESTAMP_WITH_TIMEZONE |
+| getRowId | ROWID | ROWID |
+| getSQLXML | SQLXML | SQLXML |