From a1289b1dd761a09098b070e0de1f6da30b91d71f Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Thu, 22 Oct 2026 01:48:02 +0200
Subject: [PATCH] fix(jdbc): report declared types and sizes from getColumns
---
.../libsql/jdbc/LibsqlDatabaseMetaData.java | 181 +++++++++++++++---
.../jdbc/LibsqlDatabaseMetaDataTest.java | 103 ++++++++++
2 files changed, 261 insertions(+), 23 deletions(-)
diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaData.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaData.java
index 28d7ac0..00584c8 100644
--- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaData.java
+++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaData.java
@@ -1,13 +1,30 @@
package com.github.magnusp.libsql.jdbc;
+import com.github.magnusp.libsql.hrana.codec.Col;
+import com.github.magnusp.libsql.hrana.codec.Row;
+import com.github.magnusp.libsql.hrana.codec.Stmt;
+import com.github.magnusp.libsql.hrana.codec.StmtResult;
+import com.github.magnusp.libsql.hrana.codec.Value;
+
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.RowIdLifetime;
import java.sql.SQLException;
+import java.sql.Types;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Pattern;
public class LibsqlDatabaseMetaData implements DatabaseMetaData {
+ private static final Pattern INTEGER_TYPE = Pattern.compile(".*(INT|BOOL).*");
+ private static final Pattern VARCHAR_TYPE = Pattern.compile(".*(CHAR|CLOB|TEXT|BLOB).*");
+ private static final Pattern FLOAT_TYPE = Pattern.compile(".*(REAL|FLOA|DOUB|DEC|NUM).*");
+ private static final int UNKNOWN_COLUMN_SIZE = 2_000_000_000;
+ private static final int DEFAULT_DECIMAL_DIGITS = 10;
+
private final LibsqlConnection connection;
private String cachedDatabaseProductVersion;
@@ -314,33 +331,151 @@ public ResultSet getTables(String catalog, String schemaPattern, String tableNam
@Override
public ResultSet getColumns(String catalog, String schemaPattern, String tableNamePattern, String columnNamePattern) throws SQLException {
- String sql = "SELECT NULL AS TABLE_CAT, NULL AS TABLE_SCHEM, m.name AS TABLE_NAME, p.name AS COLUMN_NAME, " +
- "CASE UPPER(p.type) " +
- " WHEN 'INTEGER' THEN " + java.sql.Types.BIGINT + " " +
- " WHEN 'INT' THEN " + java.sql.Types.BIGINT + " " +
- " WHEN 'TEXT' THEN " + java.sql.Types.VARCHAR + " " +
- " WHEN 'BLOB' THEN " + java.sql.Types.BLOB + " " +
- " WHEN 'REAL' THEN " + java.sql.Types.DOUBLE + " " +
- " ELSE " + java.sql.Types.OTHER + " " +
- "END AS DATA_TYPE, " +
- "p.type AS TYPE_NAME, 0 AS COLUMN_SIZE, 0 AS BUFFER_LENGTH, 0 AS DECIMAL_DIGITS, 10 AS NUM_PREC_RADIX, " +
- "CASE p.\"notnull\" WHEN 1 THEN 0 ELSE 1 END AS NULLABLE, " +
- "NULL AS REMARKS, p.dflt_value AS COLUMN_DEF, 0 AS SQL_DATA_TYPE, 0 AS SQL_DATETIME_SUB, 0 AS CHAR_OCTET_LENGTH, " +
- "p.cid + 1 AS ORDINAL_POSITION, " +
- "CASE p.\"notnull\" WHEN 1 THEN 'NO' ELSE 'YES' END AS IS_NULLABLE, " +
- "NULL AS SCOPE_CATALOG, NULL AS SCOPE_SCHEMA, NULL AS SCOPE_TABLE, NULL AS SOURCE_DATA_TYPE, 'NO' AS IS_AUTOINCREMENT, 'NO' AS IS_GENERATEDCOLUMN " +
- "FROM sqlite_master m JOIN pragma_table_info(m.name) p " +
- "WHERE 1=1";
- if (tableNamePattern != null && !tableNamePattern.equals("%")) {
- sql += " AND m.name LIKE '" + escapeLiteral(tableNamePattern) + "'";
+ String tablePattern = tableNamePattern == null || tableNamePattern.isEmpty() ? "%" : tableNamePattern;
+ String columnPattern = columnNamePattern == null || columnNamePattern.isEmpty() ? "%" : columnNamePattern;
+ String sql = "SELECT m.name AS TABLE_NAME, m.sql AS CREATE_SQL, p.cid AS CID, p.name AS COLUMN_NAME, "
+ + "p.type AS DECLARED_TYPE, p.\"notnull\" AS NOT_NULL, p.dflt_value AS COLUMN_DEF, p.pk AS PK, "
+ + "p.hidden AS HIDDEN FROM sqlite_master m JOIN pragma_table_xinfo(m.name) p "
+ + "WHERE m.type IN ('table', 'view')"
+ + " AND m.name LIKE '" + escapeLiteral(tablePattern) + "' ESCAPE '\\'"
+ + " AND p.name LIKE '" + escapeLiteral(columnPattern) + "' ESCAPE '\\'"
+ + " ORDER BY m.name, p.cid";
+ return new LibsqlResultSet(null, columnsResult(connection.executeStatement(new Stmt(sql))));
+ }
+
+ private static StmtResult columnsResult(StmtResult raw) {
+ List
columns = List.of(
+ new Col("TABLE_CAT", "TEXT"),
+ new Col("TABLE_SCHEM", "TEXT"),
+ new Col("TABLE_NAME", "TEXT"),
+ new Col("COLUMN_NAME", "TEXT"),
+ new Col("DATA_TYPE", "INTEGER"),
+ new Col("TYPE_NAME", "TEXT"),
+ new Col("COLUMN_SIZE", "INTEGER"),
+ new Col("BUFFER_LENGTH", "INTEGER"),
+ new Col("DECIMAL_DIGITS", "INTEGER"),
+ new Col("NUM_PREC_RADIX", "INTEGER"),
+ new Col("NULLABLE", "INTEGER"),
+ new Col("REMARKS", "TEXT"),
+ new Col("COLUMN_DEF", "TEXT"),
+ new Col("SQL_DATA_TYPE", "INTEGER"),
+ new Col("SQL_DATETIME_SUB", "INTEGER"),
+ new Col("CHAR_OCTET_LENGTH", "INTEGER"),
+ new Col("ORDINAL_POSITION", "INTEGER"),
+ new Col("IS_NULLABLE", "TEXT"),
+ new Col("SCOPE_CATALOG", "TEXT"),
+ new Col("SCOPE_SCHEMA", "TEXT"),
+ new Col("SCOPE_TABLE", "TEXT"),
+ new Col("SOURCE_DATA_TYPE", "INTEGER"),
+ new Col("IS_AUTOINCREMENT", "TEXT"),
+ new Col("IS_GENERATEDCOLUMN", "TEXT"));
+ List rows = new ArrayList<>();
+ if (raw != null && raw.rows() != null) {
+ for (Row row : raw.rows()) {
+ List values = row.values();
+ String tableName = text(values.get(0));
+ String createSql = text(values.get(1));
+ long cid = integer(values.get(2));
+ String columnName = text(values.get(3));
+ String declaredType = text(values.get(4));
+ boolean notNull = integer(values.get(5)) != 0;
+ String columnDefault = text(values.get(6));
+ boolean primaryKey = integer(values.get(7)) != 0;
+ long hidden = integer(values.get(8));
+
+ ColumnShape shape = columnShape(declaredType);
+ boolean autoIncrement = primaryKey
+ && LibsqlResultSetMetaData.isAutoIncrementColumn(createSql, columnName);
+ boolean generated = hidden == 2 || hidden == 3;
+
+ rows.add(new Row(List.of(
+ Value.ofNull(),
+ Value.ofNull(),
+ Value.of(tableName),
+ Value.of(columnName),
+ Value.of(shape.jdbcType()),
+ Value.of(shape.typeName()),
+ Value.of(shape.columnSize()),
+ Value.of(UNKNOWN_COLUMN_SIZE),
+ Value.of(shape.decimalDigits()),
+ Value.of(10),
+ Value.of(notNull ? DatabaseMetaData.columnNoNulls : DatabaseMetaData.columnNullable),
+ Value.ofNull(),
+ Value.of(columnDefault),
+ Value.of(0),
+ Value.of(0),
+ Value.of(UNKNOWN_COLUMN_SIZE),
+ Value.of(cid + 1),
+ Value.of(notNull ? "NO" : "YES"),
+ Value.ofNull(),
+ Value.ofNull(),
+ Value.ofNull(),
+ Value.ofNull(),
+ Value.of(autoIncrement ? "YES" : "NO"),
+ Value.of(generated ? "YES" : "NO"))));
+ }
}
- if (columnNamePattern != null && !columnNamePattern.equals("%")) {
- sql += " AND p.name LIKE '" + escapeLiteral(columnNamePattern) + "'";
+ return new StmtResult(columns, rows, 0, null);
+ }
+
+ private static ColumnShape columnShape(String declaredType) {
+ String typeName = declaredType == null ? "TEXT" : declaredType.toUpperCase(Locale.ENGLISH);
+ int jdbcType;
+ int decimalDigits = DEFAULT_DECIMAL_DIGITS;
+ if (INTEGER_TYPE.matcher(typeName).find()) {
+ jdbcType = Types.INTEGER;
+ decimalDigits = 0;
+ } else if (VARCHAR_TYPE.matcher(typeName).find()) {
+ jdbcType = Types.VARCHAR;
+ decimalDigits = 0;
+ } else if (FLOAT_TYPE.matcher(typeName).find()) {
+ jdbcType = Types.FLOAT;
+ } else {
+ jdbcType = Types.VARCHAR;
}
- sql += " ORDER BY TABLE_NAME, ORDINAL_POSITION";
- return executeMetadataQuery(sql);
+
+ int columnSize = UNKNOWN_COLUMN_SIZE;
+ int startOfDimension = typeName.indexOf('(');
+ if (startOfDimension > 0) {
+ int endOfDimension = typeName.indexOf(')', startOfDimension);
+ if (endOfDimension > 0) {
+ int dimensionSeparator = typeName.indexOf(',', startOfDimension);
+ String integerPart;
+ String decimalPart;
+ if (dimensionSeparator > 0) {
+ integerPart = typeName.substring(startOfDimension + 1, dimensionSeparator);
+ decimalPart = typeName.substring(dimensionSeparator + 1, endOfDimension);
+ } else {
+ integerPart = typeName.substring(startOfDimension + 1, endOfDimension);
+ decimalPart = null;
+ }
+ try {
+ int integerValue = Integer.parseUnsignedInt(integerPart.trim());
+ if (decimalPart != null) {
+ decimalDigits = Integer.parseUnsignedInt(decimalPart.trim());
+ columnSize = integerValue + decimalDigits;
+ } else {
+ decimalDigits = 0;
+ columnSize = integerValue;
+ }
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ typeName = typeName.substring(0, startOfDimension).trim();
+ }
+ return new ColumnShape(jdbcType, typeName, columnSize, decimalDigits);
}
+ private static String text(Value value) {
+ return value instanceof Value.Text text ? text.value() : null;
+ }
+
+ private static long integer(Value value) {
+ return value instanceof Value.Integer integer ? integer.value() : 0L;
+ }
+
+ private record ColumnShape(int jdbcType, String typeName, int columnSize, int decimalDigits) {}
+
@Override
public ResultSet getPrimaryKeys(String catalog, String schema, String table) throws SQLException {
String escapedTable = escapeLiteral(table == null ? "" : table);
diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaDataTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaDataTest.java
index 005aa88..886655f 100644
--- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaDataTest.java
+++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaDataTest.java
@@ -1,12 +1,16 @@
package com.github.magnusp.libsql.jdbc;
import com.github.magnusp.libsql.hrana.codec.Col;
+import com.github.magnusp.libsql.hrana.codec.Row;
import com.github.magnusp.libsql.hrana.codec.Stmt;
import com.github.magnusp.libsql.hrana.codec.StmtResult;
+import com.github.magnusp.libsql.hrana.codec.Value;
import org.junit.jupiter.api.Test;
+import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
+import java.sql.Types;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -116,10 +120,109 @@ void getTablesWithEmptyTableNamePatternMatchesEverything() throws SQLException {
assertThat(connection.lastSql).contains("TABLE_NAME LIKE '%'");
}
+ @Test
+ void getColumnsReportsDeclaredTypesSizesNullabilityAndAutoIncrement() throws SQLException {
+ LibsqlDatabaseMetaData metadata = new LibsqlDatabaseMetaData(new StmtResultConnection(columnMetadataRows()));
+
+ try (ResultSet rs = metadata.getColumns(null, null, "c", null)) {
+ assertThat(rs.next()).isTrue();
+ assertThat(rs.getString("TABLE_NAME")).isEqualTo("c");
+ assertThat(rs.getString("COLUMN_NAME")).isEqualTo("i");
+ assertThat(rs.getInt("DATA_TYPE")).isEqualTo(Types.INTEGER);
+ assertThat(rs.getString("TYPE_NAME")).isEqualTo("INTEGER");
+ assertThat(rs.getInt("COLUMN_SIZE")).isEqualTo(2_000_000_000);
+ assertThat(rs.getInt("DECIMAL_DIGITS")).isZero();
+ assertThat(rs.getInt("NULLABLE")).isEqualTo(DatabaseMetaData.columnNullable);
+ assertThat(rs.getString("IS_NULLABLE")).isEqualTo("YES");
+ assertThat(rs.getString("IS_AUTOINCREMENT")).isEqualTo("NO");
+ assertThat(rs.getInt("ORDINAL_POSITION")).isEqualTo(1);
+
+ assertThat(rs.next()).isTrue();
+ assertThat(rs.getString("COLUMN_NAME")).isEqualTo("d");
+ assertThat(rs.getInt("DATA_TYPE")).isEqualTo(Types.FLOAT);
+ assertThat(rs.getString("TYPE_NAME")).isEqualTo("DECIMAL");
+ assertThat(rs.getInt("COLUMN_SIZE")).isEqualTo(15);
+ assertThat(rs.getInt("DECIMAL_DIGITS")).isEqualTo(5);
+ assertThat(rs.getInt("ORDINAL_POSITION")).isEqualTo(2);
+
+ assertThat(rs.next()).isTrue();
+ assertThat(rs.getString("COLUMN_NAME")).isEqualTo("k");
+ assertThat(rs.getInt("DATA_TYPE")).isEqualTo(Types.INTEGER);
+ assertThat(rs.getString("IS_AUTOINCREMENT")).isEqualTo("YES");
+
+ assertThat(rs.next()).isTrue();
+ assertThat(rs.getString("COLUMN_NAME")).isEqualTo("n");
+ assertThat(rs.getInt("NULLABLE")).isEqualTo(DatabaseMetaData.columnNoNulls);
+ assertThat(rs.getString("IS_NULLABLE")).isEqualTo("NO");
+ assertThat(rs.getString("IS_AUTOINCREMENT")).isEqualTo("NO");
+
+ assertThat(rs.next()).isFalse();
+ }
+ }
+
+ private static StmtResult columnMetadataRows() {
+ String createSql =
+ "CREATE TABLE c (i INTEGER, d DECIMAL(10,5), k INTEGER PRIMARY KEY AUTOINCREMENT, n TEXT NOT NULL)";
+ return new StmtResult(
+ List.of(
+ new Col("TABLE_NAME", "TEXT"),
+ new Col("CREATE_SQL", "TEXT"),
+ new Col("CID", "INTEGER"),
+ new Col("COLUMN_NAME", "TEXT"),
+ new Col("DECLARED_TYPE", "TEXT"),
+ new Col("NOT_NULL", "INTEGER"),
+ new Col("COLUMN_DEF", "TEXT"),
+ new Col("PK", "INTEGER"),
+ new Col("HIDDEN", "INTEGER")),
+ List.of(
+ columnRow("c", createSql, 0, "i", "INTEGER", 0, null, 0, 0),
+ columnRow("c", createSql, 1, "d", "DECIMAL(10,5)", 0, null, 0, 0),
+ columnRow("c", createSql, 2, "k", "INTEGER", 0, null, 1, 0),
+ columnRow("c", createSql, 3, "n", "TEXT", 1, null, 0, 0)),
+ 0,
+ null);
+ }
+
+ private static Row columnRow(
+ String table,
+ String createSql,
+ int cid,
+ String column,
+ String declaredType,
+ int notNull,
+ String columnDefault,
+ int primaryKey,
+ int hidden) {
+ return new Row(List.of(
+ Value.of(table),
+ Value.of(createSql),
+ Value.of(cid),
+ Value.of(column),
+ Value.of(declaredType),
+ Value.of(notNull),
+ Value.of(columnDefault),
+ Value.of(primaryKey),
+ Value.of(hidden)));
+ }
+
private static StmtResult emptyResult() {
return new StmtResult(List.of(new Col("TABLE_NAME", "TEXT")), List.of(), 0, null);
}
+ private static final class StmtResultConnection extends LibsqlConnection {
+ private final StmtResult result;
+
+ private StmtResultConnection(StmtResult result) {
+ super(null, null);
+ this.result = result;
+ }
+
+ @Override
+ public StmtResult executeStatement(Stmt stmt) {
+ return result;
+ }
+ }
+
private static final class RecordingConnection extends LibsqlConnection {
private final StmtResult result;
private String lastSql;