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 millisJulian daydate/time textbad 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 |