From e61ca82f2a3f43b4e7ec7534a3a03e444d00fdf3 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:46:07 +0200 Subject: [PATCH] refactor(jdbc): shape getColumns rows through named records --- .../magnusp/libsql/jdbc/ColumnMetadata.java | 177 ++++++++++++++++++ .../libsql/jdbc/LibsqlDatabaseMetaData.java | 159 +++------------- 2 files changed, 205 insertions(+), 131 deletions(-) create mode 100644 libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/ColumnMetadata.java diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/ColumnMetadata.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/ColumnMetadata.java new file mode 100644 index 0000000..4d61355 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/ColumnMetadata.java @@ -0,0 +1,177 @@ +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.StmtResult; +import com.github.magnusp.libsql.hrana.codec.Value; + +import java.sql.DatabaseMetaData; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Shapes {@code pragma_table_xinfo} rows into the 24-column JDBC {@link DatabaseMetaData#getColumns} + * shape. The pragma select list is positional by contract; from {@link PragmaRow} onward every value + * has a name. + */ +final class ColumnMetadata { + + 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 ColumnMetadata() {} + + static StmtResult columns(StmtResult raw, List columns) { + List rows = new ArrayList<>(); + if (raw != null && raw.rows() != null) { + for (Row row : raw.rows()) { + rows.add(JdbcRow.from(PragmaRow.fromRow(row)).toRow()); + } + } + return new StmtResult(columns, rows, 0, null); + } + + /** One {@code pragma_table_xinfo} row, in the order {@code MetadataSql.columns} selects it. */ + private record PragmaRow( + String tableName, + String createSql, + long cid, + String columnName, + String declaredType, + boolean notNull, + String columnDefault, + boolean primaryKey, + long hidden) { + + private static PragmaRow fromRow(Row row) { + List values = row.values(); + return new PragmaRow( + text(values.get(0)), + text(values.get(1)), + integer(values.get(2)), + text(values.get(3)), + text(values.get(4)), + integer(values.get(5)) != 0, + text(values.get(6)), + integer(values.get(7)) != 0, + integer(values.get(8))); + } + } + + private record JdbcRow( + String tableName, + String columnName, + ColumnShape shape, + long ordinalPosition, + boolean notNull, + String columnDefault, + boolean autoIncrement, + boolean generated) { + + private static JdbcRow from(PragmaRow pragma) { + return new JdbcRow( + pragma.tableName(), + pragma.columnName(), + columnShape(pragma.declaredType()), + pragma.cid() + 1, + pragma.notNull(), + pragma.columnDefault(), + pragma.primaryKey() + && SqlAutoIncrement.isAutoIncrementColumn( + pragma.createSql(), pragma.columnName()), + pragma.hidden() == 2 || pragma.hidden() == 3); + } + + private Row toRow() { + return new Row(List.of( + Value.ofNull(), // TABLE_CAT + Value.ofNull(), // TABLE_SCHEM + Value.of(tableName), + Value.of(columnName), + Value.of(shape.jdbcType()), + Value.of(shape.typeName()), + Value.of(shape.columnSize()), + Value.of(UNKNOWN_COLUMN_SIZE), // BUFFER_LENGTH + Value.of(shape.decimalDigits()), + Value.of(10), // NUM_PREC_RADIX is 10 by JDBC, not DECIMAL_DIGITS + Value.of(notNull ? DatabaseMetaData.columnNoNulls : DatabaseMetaData.columnNullable), + Value.ofNull(), // REMARKS stays null; libSQL has no column comments + Value.of(columnDefault), + Value.of(0), // SQL_DATA_TYPE + Value.of(0), // SQL_DATETIME_SUB + Value.of(UNKNOWN_COLUMN_SIZE), // CHAR_OCTET_LENGTH + Value.of(ordinalPosition), + Value.of(notNull ? "NO" : "YES"), + Value.ofNull(), // SCOPE_CATALOG + Value.ofNull(), // SCOPE_SCHEMA + Value.ofNull(), // SCOPE_TABLE + Value.ofNull(), // SOURCE_DATA_TYPE + Value.of(autoIncrement ? "YES" : "NO"), + Value.of(generated ? "YES" : "NO"))); + } + } + + private record ColumnShape(int jdbcType, String typeName, int columnSize, int decimalDigits) {} + + 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; + } + + 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; + } +} 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 bd10b28..bd12a6a 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 @@ -23,17 +23,12 @@ 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 Pattern NAMED_PRIMARY_KEY = Pattern.compile( "\\bCONSTRAINT\\s*(\"[^\"]+\"|`[^`]+`|\\[[^\\]]+\\]|'[^']+'|[^\\s]+)\\s*PRIMARY\\s+KEY\\b", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); private static final Pattern NAMED_FOREIGN_KEY = Pattern.compile( "\\bCONSTRAINT\\s+(\"[^\"]+\"|`[^`]+`|\\[[^\\]]+\\]|'[^']+'|[^\\s]+)\\s+FOREIGN\\s+KEY\\b", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); - private static final int UNKNOWN_COLUMN_SIZE = 2_000_000_000; - private static final int DEFAULT_DECIMAL_DIGITS = 10; private static final List FOREIGN_KEY_COLUMNS = List.of( new Col("PKTABLE_CAT", "TEXT"), @@ -51,6 +46,32 @@ public class LibsqlDatabaseMetaData implements DatabaseMetaData { new Col("PK_NAME", "TEXT"), new Col("DEFERRABILITY", "INTEGER")); + private static final List COLUMN_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")); + private static final List PRIMARY_KEY_COLUMNS = List.of( new Col("TABLE_CAT", "TEXT"), new Col("TABLE_SCHEM", "TEXT"), @@ -458,130 +479,8 @@ public ResultSet getColumns(String catalog, String schemaPattern, String tableNa String columnPattern = columnNamePattern == null || columnNamePattern.isEmpty() ? "%" : columnNamePattern; // getColumns synthesizes its column labels, so the raw SQL must not drive column-origin // resolution for the result set. - return executeMetadataQuery(MetadataSql.columns(tablePattern, columnPattern), null, LibsqlDatabaseMetaData::columnsResult); - } - - 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 - && SqlAutoIncrement.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")))); - } - } - 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; - } - - 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); + return executeMetadataQuery(MetadataSql.columns(tablePattern, columnPattern), null, + raw -> ColumnMetadata.columns(raw, COLUMN_COLUMNS)); } private static String text(Value value) { @@ -825,8 +724,6 @@ private Row toRow() { } } - private record ColumnShape(int jdbcType, String typeName, int columnSize, int decimalDigits) {} - @Override public ResultSet getPrimaryKeys(String catalog, String schema, String table) throws SQLException { return executeMetadataQuery(MetadataSql.primaryKeys(table), null, raw -> primaryKeysResult(raw, table)); }