From ec0a906017d3521d4cbb10a423144d3631e26f8e Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Mon, 19 Oct 2026 12:09:57 +0200 Subject: [PATCH] feat(db): case-tolerant schema/table filters in DBMetadataInspector JDBC metadata lookups match filters exactly; H2 v2 stores unquoted identifiers UPPERCASE (postgres lowercase) so a wrong-case filter silently yielded empty metadata. Probe as-given first (behavior and ordering unchanged when it matches), then UPPER, then lower. --- .../ijava/magics/DBMetadataInspector.java | 59 ++++++++++++++----- .../magics/DBMetadataInspectorCaseTest.java | 57 ++++++++++++++++++ 2 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 src/test/java/io/github/spencerpark/ijava/magics/DBMetadataInspectorCaseTest.java diff --git a/src/main/java/io/github/spencerpark/ijava/magics/DBMetadataInspector.java b/src/main/java/io/github/spencerpark/ijava/magics/DBMetadataInspector.java index e8e327a..b02efd8 100644 --- a/src/main/java/io/github/spencerpark/ijava/magics/DBMetadataInspector.java +++ b/src/main/java/io/github/spencerpark/ijava/magics/DBMetadataInspector.java @@ -53,8 +53,32 @@ public static TableMetadata inspect(Connection conn, String schema, String table TableMetadata meta = new TableMetadata(schema, table); DatabaseMetaData md = conn.getMetaData(); + // Case-tolerant identifier filter resolution. JDBC metadata lookups match + // schema/table filters EXACTLY (no case folding). Unquoted identifiers are + // stored UPPERCASE by H2 v2 and other SQL-standard-compliant databases but + // natively lowercase by postgres; a filter in the wrong case silently yields + // an empty metadata set. Probe as-given first -- when it already matches + // (postgres and any correctly-cased call) behavior and ordering are + // unchanged -- then retry UPPER, then lower. The display names recorded in + // `meta` intentionally keep the as-given values. + String fSchema = schema; + String fTable = table; + if (table != null && !hasColumns(md, schema, table)) { + String[][] candidates = { + {schema == null ? null : schema.toUpperCase(Locale.ROOT), table.toUpperCase(Locale.ROOT)}, + {schema == null ? null : schema.toLowerCase(Locale.ROOT), table.toLowerCase(Locale.ROOT)}, + }; + for (String[] cand : candidates) { + if (hasColumns(md, cand[0], cand[1])) { + fSchema = cand[0]; + fTable = cand[1]; + break; + } + } + } + // columns - try (ResultSet cols = md.getColumns(null, schema, table, null)) { + try (ResultSet cols = md.getColumns(null, fSchema, fTable, null)) { while (cols.next()) { String name = cols.getString("COLUMN_NAME"); String type = cols.getString("TYPE_NAME"); @@ -68,12 +92,12 @@ public static TableMetadata inspect(Connection conn, String schema, String table } // primary keys - try (ResultSet pk = md.getPrimaryKeys(null, schema, table)) { + try (ResultSet pk = md.getPrimaryKeys(null, fSchema, fTable)) { while (pk.next()) meta.primaryKeys.add(pk.getString("COLUMN_NAME")); } // indexes - try (ResultSet ix = md.getIndexInfo(null, schema, table, false, false)) { + try (ResultSet ix = md.getIndexInfo(null, fSchema, fTable, false, false)) { while (ix.next()) { String iname = ix.getString("INDEX_NAME"); String col = ix.getString("COLUMN_NAME"); @@ -85,7 +109,7 @@ public static TableMetadata inspect(Connection conn, String schema, String table } // foreign keys - try (ResultSet fk = md.getImportedKeys(null, schema, table)) { + try (ResultSet fk = md.getImportedKeys(null, fSchema, fTable)) { while (fk.next()) { String pkTable = fk.getString("PKTABLE_NAME"); String pkCol = fk.getString("PKCOLUMN_NAME"); @@ -95,7 +119,7 @@ public static TableMetadata inspect(Connection conn, String schema, String table } // table remarks (best-effort via getTables) - try (ResultSet t = md.getTables(null, schema, table, null)) { + try (ResultSet t = md.getTables(null, fSchema, fTable, null)) { if (t.next()) { meta.tableRemarks = t.getString("REMARKS"); } @@ -108,12 +132,12 @@ public static TableMetadata inspect(Connection conn, String schema, String table if (schema != null) { qc = "SELECT constraint_name, constraint_type FROM information_schema.table_constraints WHERE table_schema = ? AND table_name = ?"; ps = conn.prepareStatement(qc); - ps.setString(1, schema); - ps.setString(2, table); + ps.setString(1, fSchema); + ps.setString(2, fTable); } else { qc = "SELECT constraint_name, constraint_type FROM information_schema.table_constraints WHERE table_name = ?"; ps = conn.prepareStatement(qc); - ps.setString(1, table); + ps.setString(1, fTable); } try (ResultSet cr = ps.executeQuery()) { while (cr.next()) meta.constraints.add(cr.getString("constraint_name") + ": " + cr.getString("constraint_type")); @@ -127,12 +151,12 @@ public static TableMetadata inspect(Connection conn, String schema, String table if (schema != null) { qc = "SELECT cc.constraint_name, cc.check_clause FROM information_schema.check_constraints cc JOIN information_schema.table_constraints tc ON cc.constraint_name = tc.constraint_name WHERE tc.table_schema = ? AND tc.table_name = ?"; ps = conn.prepareStatement(qc); - ps.setString(1, schema); - ps.setString(2, table); + ps.setString(1, fSchema); + ps.setString(2, fTable); } else { qc = "SELECT cc.constraint_name, cc.check_clause FROM information_schema.check_constraints cc JOIN information_schema.table_constraints tc ON cc.constraint_name = tc.constraint_name WHERE tc.table_name = ?"; ps = conn.prepareStatement(qc); - ps.setString(1, table); + ps.setString(1, fTable); } try (ResultSet cr = ps.executeQuery()) { while (cr.next()) meta.checks.add(cr.getString("constraint_name") + ": " + cr.getString("check_clause")); @@ -144,10 +168,10 @@ public static TableMetadata inspect(Connection conn, String schema, String table "SELECT column_name, domain_name, udt_name FROM information_schema.columns WHERE table_schema = ? AND table_name = ?" : "SELECT column_name, domain_name, udt_name FROM information_schema.columns WHERE table_name = ?")) { if (schema != null) { - ps.setString(1, schema); - ps.setString(2, table); + ps.setString(1, fSchema); + ps.setString(2, fTable); } else { - ps.setString(1, table); + ps.setString(1, fTable); } try (ResultSet cr = ps.executeQuery()) { while (cr.next()) { @@ -163,4 +187,11 @@ public static TableMetadata inspect(Connection conn, String schema, String table return meta; } + + /** True when the JDBC metadata column lookup for this filter pair yields at least one row. */ + private static boolean hasColumns(DatabaseMetaData md, String schema, String table) throws SQLException { + try (ResultSet cols = md.getColumns(null, schema, table, null)) { + return cols.next(); + } + } } diff --git a/src/test/java/io/github/spencerpark/ijava/magics/DBMetadataInspectorCaseTest.java b/src/test/java/io/github/spencerpark/ijava/magics/DBMetadataInspectorCaseTest.java new file mode 100644 index 0000000..f96ac99 --- /dev/null +++ b/src/test/java/io/github/spencerpark/ijava/magics/DBMetadataInspectorCaseTest.java @@ -0,0 +1,57 @@ +package io.github.spencerpark.ijava.magics; + +import org.junit.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; + +import static org.junit.Assert.*; + +/** + * Regression tests for case-tolerant metadata filter resolution + * (H2 v2 stores unquoted identifiers UPPERCASE; the metadata filters + * used to be passed verbatim, silently yielding empty metadata). + */ +public class DBMetadataInspectorCaseTest { + + private static Connection h2WithEmployees() throws Exception { + Connection conn = DriverManager.getConnection("jdbc:h2:mem:casetest" + System.nanoTime() + + ";DB_CLOSE_DELAY=-1"); + try (Statement st = conn.createStatement()) { + st.execute("CREATE TABLE employees (id INT PRIMARY KEY, name VARCHAR(64))"); + st.execute("INSERT INTO employees VALUES (1, 'alice')"); + } + return conn; + } + + @Test + public void lowercaseFilterResolvesToUpperStoredTable() throws Exception { + try (Connection conn = h2WithEmployees()) { + DBMetadataInspector.TableMetadata meta = DBMetadataInspector.inspect(conn, null, "employees"); + assertEquals("wrong-case filter must fall back to the UPPER-stored table", + 2, meta.columns.size()); + assertFalse(meta.primaryKeys.isEmpty()); + // display name stays as given + assertEquals("employees", meta.table); + } + } + + @Test + public void asGivenMatchWinsAndIsUnchanged() throws Exception { + try (Connection conn = h2WithEmployees()) { + DBMetadataInspector.TableMetadata meta = DBMetadataInspector.inspect(conn, null, "EMPLOYEES"); + assertEquals(2, meta.columns.size()); + assertEquals("ID", meta.columns.get(0).name); // ordinal order preserved + assertEquals("EMPLOYEES", meta.table); + } + } + + @Test + public void missingTableStaysEmpty() throws Exception { + try (Connection conn = h2WithEmployees()) { + DBMetadataInspector.TableMetadata meta = DBMetadataInspector.inspect(conn, null, "nosuchtable"); + assertTrue(meta.columns.isEmpty()); + } + } +}