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..b2883f8 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java @@ -0,0 +1,203 @@ +package com.github.magnusp.libsql.jdbc; + +import com.github.magnusp.libsql.hrana.codec.Col; +import com.github.magnusp.libsql.hrana.codec.Value; + +import java.math.BigDecimal; +import java.net.URL; +import java.sql.Blob; +import java.sql.Clob; +import java.sql.Date; +import java.sql.NClob; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Resolves the JDBC type of a result-set column from its declared type. + * + *

A column whose {@code decltype} names a type in the registry resolves once, independently of + * the stored value. A column with no declared type, or with a declared type outside the registry, + * falls back to the storage class of the value and marks the result {@link Support#FALLBACK} so a + * caller can record the exception. + * + *

The registry holds the 27 canonical JDBC 4.3 Appendix B type names. A separate alias table + * maps the SQLite spellings that Appendix B does not name, such as {@code INT8} and {@code + * DATETIME}, to the registry entry they stand for, preserving the sqlite-jdbc metadata that + * existing users see. Both tables resolve through the declared type, never through the value. + */ +final class JdbcTypeMapping { + + enum Support { + SUPPORTED, + FALLBACK, + UNSUPPORTED + } + + enum StorageClass { + INTEGER, + FLOAT, + TEXT, + BLOB, + NULL + } + + record JdbcType(int sqlType, Class javaClass, Class objectClass, StorageClass storage, Support support) {} + + private static final JdbcType INTEGER_FALLBACK = + fallback(Types.INTEGER, int.class, Integer.class, StorageClass.INTEGER); + private static final JdbcType BIGINT_FALLBACK = + fallback(Types.BIGINT, long.class, Long.class, StorageClass.INTEGER); + private static final JdbcType FLOAT_FALLBACK = + fallback(Types.FLOAT, double.class, Double.class, StorageClass.FLOAT); + private static final JdbcType TEXT_FALLBACK = + fallback(Types.VARCHAR, String.class, String.class, StorageClass.TEXT); + private static final JdbcType BLOB_FALLBACK = + fallback(Types.VARBINARY, byte[].class, byte[].class, StorageClass.BLOB); + private static final JdbcType NULL_FALLBACK = + fallback(Types.NUMERIC, Object.class, Object.class, StorageClass.NULL); + + private static final Map DECLARED = 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 JdbcType fallback(Value value) { + return switch (storageClass(value)) { + case INTEGER -> integerFallback(value); + case FLOAT -> FLOAT_FALLBACK; + case TEXT -> TEXT_FALLBACK; + case BLOB -> BLOB_FALLBACK; + case NULL -> NULL_FALLBACK; + }; + } + + private static JdbcType integerFallback(Value value) { + long number = integer(value); + return number >= Integer.MIN_VALUE && number <= Integer.MAX_VALUE ? INTEGER_FALLBACK : BIGINT_FALLBACK; + } + + static StorageClass storageClass(Value value) { + if (value instanceof Value.Integer) { + return StorageClass.INTEGER; + } + if (value instanceof Value.Float) { + return StorageClass.FLOAT; + } + if (value instanceof Value.Blob) { + return StorageClass.BLOB; + } + if (value instanceof Value.Text) { + return StorageClass.TEXT; + } + return StorageClass.NULL; + } + + static long integer(Value value) { + return value instanceof Value.Integer i ? i.value() : 0L; + } + + private static JdbcType fallback(int sqlType, Class javaClass, Class objectClass, StorageClass storage) { + return new JdbcType(sqlType, javaClass, objectClass, storage, Support.FALLBACK); + } + + private static Map 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("CLOB", Types.CLOB, Clob.class, Clob.class, StorageClass.TEXT), + entry("NCLOB", Types.NCLOB, NClob.class, NClob.class, StorageClass.TEXT), + entry("BLOB", Types.BLOB, Blob.class, Blob.class, StorageClass.BLOB), + entry("DATALINK", Types.DATALINK, URL.class, URL.class, StorageClass.TEXT)); + } + + private static Map.Entry entry( + String name, int sqlType, Class javaClass, Class objectClass, StorageClass storage) { + return Map.entry(name, new JdbcType(sqlType, javaClass, objectClass, storage, Support.SUPPORTED)); + } + + /** + * Maps the SQLite spellings that sqlite-jdbc 3.53.4.0 reported with a canonical JDBC type to + * the registry entry for that type. + * + *

Appendix B does not name these spellings, so sqlite-jdbc parity decides their metadata. + * The alias resolves to the same {@link JdbcType} instance as its canonical name, which keeps + * the {@code getObject} conversion and the metadata in agreement with the canonical type. An + * alias never inspects the value: {@code INT8} is {@code BIGINT} even when the value is small. + */ + private static Map aliasTypes() { + Map aliases = new HashMap<>(); + alias(aliases, "INT", "INTEGER"); + alias(aliases, "INT2", "SMALLINT"); + alias(aliases, "INT8", "BIGINT"); + alias(aliases, "MEDIUMINT", "INTEGER"); + alias(aliases, "UNSIGNED BIG INT", "BIGINT"); + alias(aliases, "DATETIME", "TIMESTAMP"); + alias(aliases, "DOUBLE PRECISION", "DOUBLE"); + alias(aliases, "TEXT", "VARCHAR"); + alias(aliases, "CHARACTER", "CHAR"); + alias(aliases, "NATIVE CHARACTER", "CHAR"); + alias(aliases, "VARYING CHARACTER", "VARCHAR"); + return Map.copyOf(aliases); + } + + private static void alias(Map aliases, String spelling, String canonical) { + JdbcType type = DECLARED.get(canonical); + if (type == null) { + throw new IllegalStateException("Unknown canonical type " + canonical + " for alias " + spelling); + } + aliases.put(spelling, type); + } +} diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java index 10955b5..32098ab 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java @@ -59,12 +59,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 +87,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 +102,8 @@ private void checkClosed() throws SQLException { } private Value valueAtCursor(int columnIndex) { - int rowIndex = currentRowIndex < 0 ? 0 : currentRowIndex; - if (rowIndex >= rows.size()) { - return Value.ofNull(); - } - return rows.get(rowIndex).values().get(columnIndex - 1); + List values = currentValues; + return values == null ? Value.ofNull() : values.get(columnIndex - 1); } private Value getCurrentValue(int columnIndex) throws SQLException { @@ -127,7 +136,12 @@ public boolean next() throws SQLException { } lastColumnIndex = -1; currentRowIndex++; - return currentRowIndex < rows.size(); + if (currentRowIndex < rows.size()) { + currentValues = rows.get(currentRowIndex).values(); + return true; + } + currentValues = null; + return false; } @Override @@ -174,7 +188,10 @@ public boolean wasNull() throws SQLException { */ @Override public String getString(int columnIndex) throws SQLException { - Value val = getCurrentValue(columnIndex); + return stringValue(getCurrentValue(columnIndex)); + } + + private static String stringValue(Value val) { return switch (val) { case Value.Null n -> null; case Value.Integer i -> Long.toString(i.value()); @@ -185,14 +202,28 @@ 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; + 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 @@ -256,7 +287,10 @@ public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException @Override public byte[] getBytes(int columnIndex) throws SQLException { - Value val = getCurrentValue(columnIndex); + return bytesValue(getCurrentValue(columnIndex)); + } + + private static byte[] bytesValue(Value val) { return switch (val) { case Value.Null n -> null; case Value.Blob b -> b.bytes(); @@ -268,25 +302,41 @@ public byte[] getBytes(int columnIndex) throws SQLException { @Override public Date getDate(int columnIndex) throws SQLException { - LocalDateTime value = dateTimeValue(columnIndex, "Date"); - return value == null ? null : Date.valueOf(value.toLocalDate()); + return dateValue(getCurrentValue(columnIndex)); + } + + private static Date dateValue(Value value) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Date"); + return dateTime == null ? null : Date.valueOf(dateTime.toLocalDate()); } @Override public Time getTime(int columnIndex) throws SQLException { - LocalDateTime value = dateTimeValue(columnIndex, "Time"); - return value == null ? null : Time.valueOf(value.toLocalTime()); + return timeValue(getCurrentValue(columnIndex)); + } + + private static Time timeValue(Value value) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Time"); + return dateTime == null ? null : Time.valueOf(dateTime.toLocalTime()); } @Override public Timestamp getTimestamp(int columnIndex) throws SQLException { - LocalDateTime value = dateTimeValue(columnIndex, "Timestamp"); - return value == null ? null : Timestamp.valueOf(value); + return timestampValue(getCurrentValue(columnIndex)); + } + + private static Timestamp timestampValue(Value value) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Timestamp"); + return dateTime == null ? null : Timestamp.valueOf(dateTime); } @Override public BigDecimal getBigDecimal(int columnIndex) throws SQLException { - String s = getString(columnIndex); + return bigDecimalValue(getCurrentValue(columnIndex)); + } + + private static BigDecimal bigDecimalValue(Value value) throws SQLException { + String s = stringValue(value); if (s == null) { return null; } @@ -299,8 +349,91 @@ public BigDecimal getBigDecimal(int columnIndex) throws SQLException { @Override public Object getObject(int columnIndex) throws SQLException { - Value val = getCurrentValue(columnIndex); - return switch (val) { + Value value = getCurrentValue(columnIndex); + if (value instanceof Value.Null) { + return null; + } + 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 == Clob.class) { + return clobValue(value); + } + if (objectClass == Blob.class) { + return blobValue(value); + } + // NCLOB and DATALINK resolve to their declared wrapper types, but the wrapper getters + // arrive in PR-3, so the value keeps its storage-class representation until then. + 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 +444,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"); @@ -566,11 +720,17 @@ public void setFetchSize(int rows) throws SQLException { } @Override public Ref getRef(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public Blob getBlob(int columnIndex) throws SQLException { - byte[] bytes = getBytes(columnIndex); + return blobValue(getCurrentValue(columnIndex)); + } + private static Blob blobValue(Value value) { + byte[] bytes = bytesValue(value); return bytes == null ? null : new LibsqlBlob(bytes); } @Override public Clob getClob(int columnIndex) throws SQLException { - String string = getString(columnIndex); + return clobValue(getCurrentValue(columnIndex)); + } + private static Clob clobValue(Value value) { + String string = stringValue(value); return string == null ? null : new LibsqlClob(string); } @Override public Array getArray(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @@ -778,25 +938,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/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/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..fae61d1 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java @@ -44,13 +44,15 @@ class LibsqlResultSetTest { null); @Test - void getObjectReturnsIntegerInRangeAndLongOutside() throws SQLException { + void getObjectReturnsDeclaredIntegerAndRejectsAValueThatDoesNotFit() throws SQLException { LibsqlResultSet resultSet = new LibsqlResultSet(new LibsqlStatement(null), INTEGER_ROWS); assertThat(resultSet.next()).isTrue(); assertThat(resultSet.getObject(1)).isInstanceOf(Integer.class).isEqualTo(42); assertThat(resultSet.next()).isTrue(); - assertThat(resultSet.getObject(1)).isInstanceOf(Long.class).isEqualTo(5_000_000_000L); + assertThatThrownBy(() -> resultSet.getObject(1)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("INTEGER"); } @Test