From 5d4e6aa0b16c3725b3a86b2422ee4c1eab1b3909 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:05:21 +0100 Subject: [PATCH 1/6] feat(jdbc): add the declared JDBC type registry --- .../magnusp/libsql/jdbc/DeclaredType.java | 17 +- .../magnusp/libsql/jdbc/JdbcTypeMapping.java | 147 ++++++++++++++++++ 2 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/DeclaredType.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/DeclaredType.java index d31a623..1c56428 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/DeclaredType.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/DeclaredType.java @@ -35,13 +35,18 @@ boolean isDeclared() { return declType != null; } - String typeName() { - if (declType != null) { - Matcher matcher = COLUMN_TYPENAME.matcher(declType); - matcher.find(); - return matcher.group(1).toUpperCase(Locale.ENGLISH); + String declaredTypeName() { + if (declType == null) { + return null; } - return "NUMERIC"; + Matcher matcher = COLUMN_TYPENAME.matcher(declType); + matcher.find(); + return matcher.group(1).toUpperCase(Locale.ENGLISH); + } + + String typeName() { + String name = declaredTypeName(); + return name != null ? name : "NUMERIC"; } int precision() { diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java new file mode 100644 index 0000000..f253e18 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java @@ -0,0 +1,147 @@ +package com.github.magnusp.libsql.jdbc; + +import com.github.magnusp.libsql.hrana.codec.Col; +import com.github.magnusp.libsql.hrana.codec.Value; + +import java.math.BigDecimal; +import java.net.URL; +import java.sql.Blob; +import java.sql.Clob; +import java.sql.Date; +import java.sql.NClob; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.Map; + +/** + * Resolves the JDBC type of a result-set column from its declared type. + * + *

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

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

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

Appendix B does not name these spellings, so sqlite-jdbc parity decides their metadata. + * The alias resolves to the same {@link JdbcType} instance as its canonical name, which keeps + * the {@code getObject} conversion and the metadata in agreement with the canonical type. An + * alias never inspects the value: {@code INT8} is {@code BIGINT} even when the value is small. + */ + private static Map aliasTypes() { + Map aliases = new HashMap<>(); + alias(aliases, "INT", "INTEGER"); + alias(aliases, "INT2", "SMALLINT"); + alias(aliases, "INT8", "BIGINT"); + alias(aliases, "MEDIUMINT", "INTEGER"); + alias(aliases, "UNSIGNED BIG INT", "BIGINT"); + alias(aliases, "DATETIME", "TIMESTAMP"); + alias(aliases, "DOUBLE PRECISION", "DOUBLE"); + alias(aliases, "TEXT", "VARCHAR"); + alias(aliases, "CHARACTER", "CHAR"); + alias(aliases, "NATIVE CHARACTER", "CHAR"); + alias(aliases, "VARYING CHARACTER", "VARCHAR"); + return Map.copyOf(aliases); + } + + private static void alias(Map aliases, String spelling, String canonical) { + JdbcType type = DECLARED.get(canonical); + if (type == null) { + throw new IllegalStateException("Unknown canonical type " + canonical + " for alias " + spelling); + } + aliases.put(spelling, type); + } } diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java index 8bbe574..1de9cb2 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java @@ -264,6 +264,64 @@ void usesDeclaredTypesForEmptyResultSet() throws SQLException { assertDeclaredTypes(metadata); } + @Test + void resolvesSqliteAliasSpellingsThroughTheDeclaredType() throws SQLException { + StmtResult result = new StmtResult( + List.of( + new Col("dt", "DATETIME"), + new Col("i", "INT"), + new Col("i2", "INT2"), + new Col("i8", "INT8"), + new Col("mi", "MEDIUMINT"), + new Col("ubi", "UNSIGNED BIG INT"), + new Col("dp", "DOUBLE PRECISION"), + new Col("txt", "TEXT"), + new Col("ch", "CHARACTER"), + new Col("nch", "NATIVE CHARACTER"), + new Col("vch", "VARYING CHARACTER"), + new Col("v20", "VARCHAR(20)")), + List.of(new Row(List.of( + Value.of("2024-01-02 03:04:05"), + Value.of(42L), + Value.of(42L), + Value.of(42L), + Value.of(42L), + Value.of(42L), + Value.of(42.5), + Value.of("abc"), + Value.of("abc"), + Value.of("abc"), + Value.of("abc"), + Value.of("abc")))), + 0, + null); + LibsqlResultSet resultSet = new LibsqlResultSet(null, result); + ResultSetMetaData metadata = resultSet.getMetaData(); + + assertThat(metadata.getColumnTypeName(1)).isEqualTo("DATETIME"); + assertThat(metadata.getColumnType(1)).isEqualTo(Types.TIMESTAMP); + assertThat(metadata.getColumnType(2)).as("INT").isEqualTo(Types.INTEGER); + assertThat(metadata.getColumnType(3)).as("INT2").isEqualTo(Types.SMALLINT); + assertThat(metadata.getColumnType(4)).as("INT8").isEqualTo(Types.BIGINT); + assertThat(metadata.getColumnType(5)).as("MEDIUMINT").isEqualTo(Types.INTEGER); + assertThat(metadata.getColumnType(6)).as("UNSIGNED BIG INT").isEqualTo(Types.BIGINT); + assertThat(metadata.getColumnType(7)).as("DOUBLE PRECISION").isEqualTo(Types.DOUBLE); + assertThat(metadata.getColumnType(8)).as("TEXT").isEqualTo(Types.VARCHAR); + assertThat(metadata.getColumnType(9)).as("CHARACTER").isEqualTo(Types.CHAR); + assertThat(metadata.getColumnType(10)).as("NATIVE CHARACTER").isEqualTo(Types.CHAR); + assertThat(metadata.getColumnType(11)).as("VARYING CHARACTER").isEqualTo(Types.VARCHAR); + assertThat(metadata.getColumnType(12)).as("VARCHAR(20)").isEqualTo(Types.VARCHAR); + + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getObject(1)).isInstanceOf(java.sql.Timestamp.class); + assertThat(resultSet.getObject(2)).isInstanceOf(Integer.class); + assertThat(resultSet.getObject(3)).isInstanceOf(Integer.class); + assertThat(resultSet.getObject(4)).as("INT8 keeps BIGINT for a small value").isInstanceOf(Long.class); + assertThat(resultSet.getObject(6)).isInstanceOf(Long.class); + assertThat(resultSet.getObject(7)).isInstanceOf(Double.class); + assertThat(resultSet.getObject(8)).isInstanceOf(String.class); + } + @Test void isSignedMatchesXerialNarrowTypeNameSet() throws SQLException { LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData( @@ -364,7 +422,7 @@ void reportsObjectClassNameForEmptyResultSet() throws SQLException { List expected = List.of( Integer.class.getName(), Long.class.getName(), - Object.class.getName(), + String.class.getName(), Blob.class.getName(), Boolean.class.getName(), Float.class.getName(),