From fc119cc840ae10535b52a0a57e35ba26af09f566 Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Sat, 24 Oct 2026 19:59:27 +0200
Subject: [PATCH] refactor(jdbc): extract ForeignKeyMetadata
Move the imported and exported foreign key mappers, the key-name helpers,
and the per-direction row ordering into a package-private ForeignKeyMetadata
class. The two pragma SQL strings stay in MetadataSql.
---
.../libsql/jdbc/ForeignKeyMetadata.java | 291 ++++++++++++++++++
.../libsql/jdbc/LibsqlDatabaseMetaData.java | 215 +------------
2 files changed, 296 insertions(+), 210 deletions(-)
create mode 100644 libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/ForeignKeyMetadata.java
diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/ForeignKeyMetadata.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/ForeignKeyMetadata.java
new file mode 100644
index 0000000..e738fb6
--- /dev/null
+++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/ForeignKeyMetadata.java
@@ -0,0 +1,291 @@
+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.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Shapes {@code pragma_foreign_key_list} rows into the 14-column JDBC foreign key shape. Imported
+ * and exported keys select different projections of the same pragma columns, so each direction maps
+ * {@link PragmaRow} separately.
+ */
+final class ForeignKeyMetadata {
+
+ 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 List
COLUMNS = List.of(
+ new Col("PKTABLE_CAT", "TEXT"),
+ new Col("PKTABLE_SCHEM", "TEXT"),
+ new Col("PKTABLE_NAME", "TEXT"),
+ new Col("PKCOLUMN_NAME", "TEXT"),
+ new Col("FKTABLE_CAT", "TEXT"),
+ new Col("FKTABLE_SCHEM", "TEXT"),
+ new Col("FKTABLE_NAME", "TEXT"),
+ new Col("FKCOLUMN_NAME", "TEXT"),
+ new Col("KEY_SEQ", "INTEGER"),
+ new Col("UPDATE_RULE", "INTEGER"),
+ new Col("DELETE_RULE", "INTEGER"),
+ new Col("FK_NAME", "TEXT"),
+ new Col("PK_NAME", "TEXT"),
+ new Col("DEFERRABILITY", "INTEGER"));
+
+ private static final Comparator IMPORTED_ORDER = Comparator.comparing(
+ JdbcRow::pkTable, Comparator.nullsFirst(Comparator.naturalOrder()))
+ .thenComparingInt(JdbcRow::keySeq);
+
+ private static final Comparator EXPORTED_ORDER = Comparator.comparing(
+ JdbcRow::fkTable, Comparator.nullsFirst(Comparator.naturalOrder()))
+ .thenComparingInt(JdbcRow::keySeq);
+
+ private ForeignKeyMetadata() {}
+
+ static StmtResult empty() {
+ return new StmtResult(COLUMNS, List.of(), 0, null);
+ }
+
+ static StmtResult fromImported(StmtResult raw, String catalog, String schema, String table) {
+ List rows = new ArrayList<>();
+ if (raw != null && raw.rows() != null) {
+ for (Row row : raw.rows()) {
+ PragmaRow pragma = PragmaRow.fromImported(row);
+ String pkColumn = pragma.pkColumn();
+ if (pkColumn == null) {
+ pkColumn = pragma.pkFirstColumn();
+ }
+ rows.add(new JdbcRow(
+ catalog,
+ schema,
+ pragma.pkTable(),
+ pkColumn,
+ catalog,
+ schema,
+ table,
+ pragma.fkColumn(),
+ (int) pragma.seq() + 1,
+ keyRule(pragma.updateRule()),
+ keyRule(pragma.deleteRule()),
+ nameOrEmpty(foreignKeyName(pragma.fkCreateSql(), pragma.fkId())),
+ nameOrEmpty(primaryKeyName(pragma.pkCreateSql()))));
+ }
+ }
+ rows.sort(IMPORTED_ORDER);
+ return foreignKeyResult(rows);
+ }
+
+ static StmtResult fromExported(
+ StmtResult raw, String pkCatalog, String pkSchema, String fkCatalog, String fkSchema) {
+ List rows = new ArrayList<>();
+ if (raw != null && raw.rows() != null) {
+ for (Row row : raw.rows()) {
+ PragmaRow pragma = PragmaRow.fromExported(row);
+ String pkName = primaryKeyName(pragma.pkCreateSql());
+ if (!containsColumn(pragma.pkColumns(), pragma.pkColumn())) {
+ pkName = null;
+ }
+ rows.add(new JdbcRow(
+ pkCatalog,
+ pkSchema,
+ pragma.pkTable(),
+ pragma.pkColumn(),
+ fkCatalog,
+ fkSchema,
+ pragma.fkTable(),
+ pragma.fkColumn(),
+ (int) pragma.seq() + 1,
+ keyRule(pragma.updateRule()),
+ keyRule(pragma.deleteRule()),
+ nameOrEmpty(foreignKeyName(pragma.fkCreateSql(), pragma.fkId())),
+ nameOrEmpty(pkName)));
+ }
+ }
+ rows.sort(EXPORTED_ORDER);
+ return foreignKeyResult(rows);
+ }
+
+ private static StmtResult foreignKeyResult(List rows) {
+ List values = new ArrayList<>(rows.size());
+ for (JdbcRow row : rows) {
+ values.add(row.toRow());
+ }
+ return new StmtResult(COLUMNS, values, 0, null);
+ }
+
+ private static int keyRule(String rule) {
+ if (rule == null) {
+ return DatabaseMetaData.importedKeyNoAction;
+ }
+ return switch (rule.toUpperCase(Locale.ENGLISH)) {
+ case "CASCADE" -> DatabaseMetaData.importedKeyCascade;
+ case "RESTRICT" -> DatabaseMetaData.importedKeyRestrict;
+ case "SET NULL" -> DatabaseMetaData.importedKeySetNull;
+ case "SET DEFAULT" -> DatabaseMetaData.importedKeySetDefault;
+ default -> DatabaseMetaData.importedKeyNoAction;
+ };
+ }
+
+ static String primaryKeyName(String createSql) {
+ if (createSql == null) {
+ return null;
+ }
+ Matcher matcher = NAMED_PRIMARY_KEY.matcher(createSql);
+ return matcher.find() ? unquoteIdentifier(matcher.group(1)) : null;
+ }
+
+ private static String foreignKeyName(String createSql, long fkId) {
+ if (createSql == null) {
+ return null;
+ }
+ List names = new ArrayList<>();
+ Matcher matcher = NAMED_FOREIGN_KEY.matcher(createSql);
+ while (matcher.find()) {
+ names.add(unquoteIdentifier(matcher.group(1)));
+ }
+ // SQLite numbers foreign keys from the last declaration down, so the parsed names are
+ // matched to pragma ids in reverse declaration order.
+ int index = names.size() - 1 - (int) fkId;
+ return index >= 0 && index < names.size() ? names.get(index) : null;
+ }
+
+ private static boolean containsColumn(String columns, String column) {
+ if (columns == null || column == null) {
+ return false;
+ }
+ for (String candidate : columns.split(",")) {
+ if (candidate.trim().equalsIgnoreCase(column)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String nameOrEmpty(String name) {
+ return name == null ? "" : name;
+ }
+
+ private static String unquoteIdentifier(String name) {
+ if (name == null) {
+ return null;
+ }
+ String trimmed = name.trim();
+ if (trimmed.length() > 2) {
+ char first = trimmed.charAt(0);
+ char last = trimmed.charAt(trimmed.length() - 1);
+ if ((first == '"' && last == '"')
+ || (first == '`' && last == '`')
+ || (first == '[' && last == ']')
+ || (first == '\'' && last == '\'')) {
+ return trimmed.substring(1, trimmed.length() - 1);
+ }
+ }
+ return trimmed;
+ }
+
+ /**
+ * One {@code pragma_foreign_key_list} row. The imported and exported selects project different
+ * columns at offsets 7-10, so each direction has its own mapping.
+ */
+ private record PragmaRow(
+ long fkId,
+ long seq,
+ String pkTable,
+ String fkColumn,
+ String pkColumn,
+ String updateRule,
+ String deleteRule,
+ String fkTable,
+ String fkCreateSql,
+ String pkCreateSql,
+ String pkFirstColumn,
+ String pkColumns) {
+
+ private static PragmaRow fromImported(Row row) {
+ List values = row.values();
+ return new PragmaRow(
+ integer(values.get(0)),
+ integer(values.get(1)),
+ text(values.get(2)),
+ text(values.get(3)),
+ text(values.get(4)),
+ text(values.get(5)),
+ text(values.get(6)),
+ null,
+ text(values.get(7)),
+ text(values.get(8)),
+ text(values.get(9)),
+ null);
+ }
+
+ private static PragmaRow fromExported(Row row) {
+ List values = row.values();
+ return new PragmaRow(
+ integer(values.get(0)),
+ integer(values.get(1)),
+ text(values.get(2)),
+ text(values.get(3)),
+ text(values.get(4)),
+ text(values.get(5)),
+ text(values.get(6)),
+ text(values.get(7)),
+ text(values.get(8)),
+ text(values.get(9)),
+ null,
+ text(values.get(10)));
+ }
+ }
+
+ private record JdbcRow(
+ String pkCatalog,
+ String pkSchema,
+ String pkTable,
+ String pkColumn,
+ String fkCatalog,
+ String fkSchema,
+ String fkTable,
+ String fkColumn,
+ int keySeq,
+ int updateRule,
+ int deleteRule,
+ String fkName,
+ String pkName) {
+
+ private Row toRow() {
+ return new Row(List.of(
+ Value.of(pkCatalog),
+ Value.of(pkSchema),
+ Value.of(pkTable),
+ Value.of(pkColumn),
+ Value.of(fkCatalog),
+ Value.of(fkSchema),
+ Value.of(fkTable),
+ Value.of(fkColumn),
+ Value.of(keySeq),
+ Value.of(updateRule),
+ Value.of(deleteRule),
+ Value.of(fkName),
+ Value.of(pkName),
+ Value.of(DatabaseMetaData.importedKeyInitiallyDeferred)));
+ }
+ }
+
+ 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 bd12a6a..b41aacd 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
@@ -16,36 +16,10 @@
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
-import java.util.Locale;
import java.util.function.UnaryOperator;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
public class LibsqlDatabaseMetaData implements DatabaseMetaData {
- 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 List FOREIGN_KEY_COLUMNS = List.of(
- new Col("PKTABLE_CAT", "TEXT"),
- new Col("PKTABLE_SCHEM", "TEXT"),
- new Col("PKTABLE_NAME", "TEXT"),
- new Col("PKCOLUMN_NAME", "TEXT"),
- new Col("FKTABLE_CAT", "TEXT"),
- new Col("FKTABLE_SCHEM", "TEXT"),
- new Col("FKTABLE_NAME", "TEXT"),
- new Col("FKCOLUMN_NAME", "TEXT"),
- new Col("KEY_SEQ", "INTEGER"),
- new Col("UPDATE_RULE", "INTEGER"),
- new Col("DELETE_RULE", "INTEGER"),
- new Col("FK_NAME", "TEXT"),
- 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"),
@@ -513,7 +487,7 @@ private static StmtResult primaryKeysResult(StmtResult raw, String table) {
Value.of(table),
Value.of(text(values.get(0))),
Value.of(integer(values.get(1))),
- Value.of(primaryKeyName(text(values.get(2)))))));
+ Value.of(ForeignKeyMetadata.primaryKeyName(text(values.get(2)))))));
}
}
// xerial orders getPrimaryKeys by column name, not by the declared key sequence.
@@ -522,151 +496,6 @@ private static StmtResult primaryKeysResult(StmtResult raw, String table) {
return new StmtResult(PRIMARY_KEY_COLUMNS, rows, 0, null);
}
- private static StmtResult importedKeysResult(
- StmtResult raw, String catalog, String schema, String table) {
- List rows = new ArrayList<>();
- if (raw != null && raw.rows() != null) {
- for (Row row : raw.rows()) {
- List values = row.values();
- long fkId = integer(values.get(0));
- String pkColumn = text(values.get(4));
- if (pkColumn == null) {
- pkColumn = text(values.get(9));
- }
- rows.add(new ForeignKeyRow(
- catalog,
- schema,
- text(values.get(2)),
- pkColumn,
- catalog,
- schema,
- table,
- text(values.get(3)),
- (int) integer(values.get(1)) + 1,
- keyRule(text(values.get(5))),
- keyRule(text(values.get(6))),
- nameOrEmpty(foreignKeyName(text(values.get(7)), fkId)),
- nameOrEmpty(primaryKeyName(text(values.get(8))))));
- }
- }
- rows.sort(Comparator.comparing(
- ForeignKeyRow::pkTable, Comparator.nullsFirst(Comparator.naturalOrder()))
- .thenComparingInt(ForeignKeyRow::keySeq));
- return foreignKeyResult(rows);
- }
-
- private static StmtResult exportedKeysResult(
- StmtResult raw, String pkCatalog, String pkSchema, String fkCatalog, String fkSchema) {
- List rows = new ArrayList<>();
- if (raw != null && raw.rows() != null) {
- for (Row row : raw.rows()) {
- List values = row.values();
- long fkId = integer(values.get(0));
- String pkColumn = text(values.get(4));
- String pkName = primaryKeyName(text(values.get(9)));
- if (!containsColumn(text(values.get(10)), pkColumn)) {
- pkName = null;
- }
- rows.add(new ForeignKeyRow(
- pkCatalog,
- pkSchema,
- text(values.get(2)),
- pkColumn,
- fkCatalog,
- fkSchema,
- text(values.get(7)),
- text(values.get(3)),
- (int) integer(values.get(1)) + 1,
- keyRule(text(values.get(5))),
- keyRule(text(values.get(6))),
- nameOrEmpty(foreignKeyName(text(values.get(8)), fkId)),
- nameOrEmpty(pkName)));
- }
- }
- rows.sort(Comparator.comparing(
- ForeignKeyRow::fkTable, Comparator.nullsFirst(Comparator.naturalOrder()))
- .thenComparingInt(ForeignKeyRow::keySeq));
- return foreignKeyResult(rows);
- }
-
- private static StmtResult foreignKeyResult(List rows) {
- List values = new ArrayList<>(rows.size());
- for (ForeignKeyRow row : rows) {
- values.add(row.toRow());
- }
- return new StmtResult(FOREIGN_KEY_COLUMNS, values, 0, null);
- }
-
- private static int keyRule(String rule) {
- if (rule == null) {
- return DatabaseMetaData.importedKeyNoAction;
- }
- return switch (rule.toUpperCase(Locale.ENGLISH)) {
- case "CASCADE" -> DatabaseMetaData.importedKeyCascade;
- case "RESTRICT" -> DatabaseMetaData.importedKeyRestrict;
- case "SET NULL" -> DatabaseMetaData.importedKeySetNull;
- case "SET DEFAULT" -> DatabaseMetaData.importedKeySetDefault;
- default -> DatabaseMetaData.importedKeyNoAction;
- };
- }
-
- private static String primaryKeyName(String createSql) {
- if (createSql == null) {
- return null;
- }
- Matcher matcher = NAMED_PRIMARY_KEY.matcher(createSql);
- return matcher.find() ? unquoteIdentifier(matcher.group(1)) : null;
- }
-
- private static String foreignKeyName(String createSql, long fkId) {
- if (createSql == null) {
- return null;
- }
- List names = new ArrayList<>();
- Matcher matcher = NAMED_FOREIGN_KEY.matcher(createSql);
- while (matcher.find()) {
- names.add(unquoteIdentifier(matcher.group(1)));
- }
- // SQLite numbers foreign keys from the last declaration down, so the parsed names are
- // matched to pragma ids in reverse declaration order.
- int index = names.size() - 1 - (int) fkId;
- return index >= 0 && index < names.size() ? names.get(index) : null;
- }
-
- private static boolean containsColumn(String columns, String column) {
- if (columns == null || column == null) {
- return false;
- }
- for (String candidate : columns.split(",")) {
- if (candidate.trim().equalsIgnoreCase(column)) {
- return true;
- }
- }
- return false;
- }
-
- private static String nameOrEmpty(String name) {
- return name == null ? "" : name;
- }
-
- private static String unquoteIdentifier(String name) {
- if (name == null) {
- return null;
- }
- String trimmed = name.trim();
- if (trimmed.length() > 2) {
- char first = trimmed.charAt(0);
- char last = trimmed.charAt(trimmed.length() - 1);
- if ((first == '"' && last == '"')
- || (first == '`' && last == '`')
- || (first == '[' && last == ']')
- || (first == '\'' && last == '\'')) {
- return trimmed.substring(1, trimmed.length() - 1);
- }
- }
- return trimmed;
- }
-
private static Row typeInfoRow(
String typeName, int dataType, int caseSensitive, int unsigned, int autoIncrement) {
return new Row(List.of(
@@ -690,40 +519,6 @@ private static Row typeInfoRow(
Value.of(10)));
}
- private record ForeignKeyRow(
- String pkCatalog,
- String pkSchema,
- String pkTable,
- String pkColumn,
- String fkCatalog,
- String fkSchema,
- String fkTable,
- String fkColumn,
- int keySeq,
- int updateRule,
- int deleteRule,
- String fkName,
- String pkName) {
-
- private Row toRow() {
- return new Row(List.of(
- Value.of(pkCatalog),
- Value.of(pkSchema),
- Value.of(pkTable),
- Value.of(pkColumn),
- Value.of(fkCatalog),
- Value.of(fkSchema),
- Value.of(fkTable),
- Value.of(fkColumn),
- Value.of(keySeq),
- Value.of(updateRule),
- Value.of(deleteRule),
- Value.of(fkName),
- Value.of(pkName),
- Value.of(DatabaseMetaData.importedKeyInitiallyDeferred)));
- }
- }
-
@Override public ResultSet getPrimaryKeys(String catalog, String schema, String table) throws SQLException {
return executeMetadataQuery(MetadataSql.primaryKeys(table), null, raw -> primaryKeysResult(raw, table));
}
@@ -745,12 +540,12 @@ public Connection getConnection() throws SQLException {
@Override public ResultSet getVersionColumns(String catalog, String schema, String table) throws SQLException { return metadataResult(VERSION_COLUMNS_RESULT); }
@Override public ResultSet getImportedKeys(String catalog, String schema, String table) throws SQLException {
return executeMetadataQuery(MetadataSql.importedKeys(table), null,
- raw -> importedKeysResult(raw, catalog, schema, table));
+ raw -> ForeignKeyMetadata.fromImported(raw, catalog, schema, table));
}
@Override public ResultSet getExportedKeys(String catalog, String schema, String table) throws SQLException {
return executeMetadataQuery(MetadataSql.exportedKeys(table, null), null,
- raw -> exportedKeysResult(raw, catalog, schema, catalog, schema));
+ raw -> ForeignKeyMetadata.fromExported(raw, catalog, schema, catalog, schema));
}
@Override
@@ -767,7 +562,7 @@ public ResultSet getCrossReference(
|| requestsNamespace(foreignCatalog)
|| requestsNamespace(foreignSchema)) {
// The server reports no catalog and no schema, so a specific request matches nothing.
- return metadataResult(emptyMetadataResult(FOREIGN_KEY_COLUMNS));
+ return metadataResult(ForeignKeyMetadata.empty());
}
if (parentTable == null) {
return getExportedKeys(foreignCatalog, foreignSchema, foreignTable);
@@ -776,7 +571,7 @@ public ResultSet getCrossReference(
return getImportedKeys(parentCatalog, parentSchema, parentTable);
}
return executeMetadataQuery(MetadataSql.exportedKeys(parentTable, foreignTable), null,
- raw -> exportedKeysResult(raw, parentCatalog, parentSchema, foreignCatalog, foreignSchema));
+ raw -> ForeignKeyMetadata.fromExported(raw, parentCatalog, parentSchema, foreignCatalog, foreignSchema));
}
private static boolean requestsNamespace(String name) {