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