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;