Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- Fixed `getColumnClassName()` returning null for VARIANT columns in SEA mode by adding VARIANT to the type system.
- Fixed `getColumns()` returning `DATA_TYPE=0` (NULL) for GEOMETRY/GEOGRAPHY columns in Thrift mode. Now returns `Types.VARCHAR` (12) when geospatial is disabled and `Types.OTHER` (1111) when enabled, consistent with SEA mode.
- Fixed `getCrossReference()` returning 0 rows when parent args are passed in uppercase. The client-side filter used case-sensitive comparison against server-returned lowercase names.
- Fixed `getSchemas()` in SEA mode throwing a `DatabricksException` when the catalog parameter is a wildcard pattern (e.g., `%`, `my_%`) or a nonexistent literal. Wildcard patterns are now expanded client-side by matching against the catalog list, and literal nonexistent catalogs return an empty result set per the JDBC spec.

---
*Note: When making changes, please add your change under the appropriate section
Expand Down
36 changes: 36 additions & 0 deletions src/main/java/com/databricks/jdbc/common/util/WildcardUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,42 @@ public static String stripJdbcEscapes(String value) {
return builder.toString();
}

/**
* Returns true if the string is a JDBC search pattern containing unescaped wildcard characters
* ({@code %} or {@code _}). Escaped wildcards ({@code \%}, {@code \_}) are treated as literals
* and do not cause this method to return true.
*
* @param s the string to check
* @return true if the string contains at least one unescaped {@code %} or {@code _}
*/
public static boolean isJdbcPattern(String s) {
if (s == null) {
return false;
}
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
if (ch == '\\' && i + 1 < s.length()) {
i++; // skip the escaped character
continue;
}
if (ch == '%' || ch == '_') {
return true;
}
}
return false;
}

/**
* Returns true if the JDBC catalog pattern matches all catalogs — that is, it is {@code null},
* {@code %}, or a pattern that would match any string.
*
* @param catalog the catalog pattern to check
* @return true if the pattern matches all catalogs
*/
public static boolean isMatchAllCatalogPattern(String catalog) {
return catalog == null || "%".equals(catalog);
}
Comment on lines +120 to +129

public static String jdbcPatternToHive(String pattern) {
if (pattern == null) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
Expand Down Expand Up @@ -97,6 +98,26 @@ public DatabricksResultSet listSchemas(
com.databricks.jdbc.common.CommandName.LIST_SCHEMAS);
}

// Per JDBC spec, a catalog value of "%" means "match any catalog name" — treat it
// the same as null (list schemas across all catalogs). This prevents the driver from
// generating invalid SQL such as SHOW SCHEMAS IN `%` which would throw a server error.
if (WildcardUtil.isMatchAllCatalogPattern(catalog)) {
LOGGER.debug(
"Catalog pattern '{}' matches all catalogs; listing schemas across all catalogs.",
catalog);
catalog = null;
}

// If the catalog is a JDBC pattern (contains unescaped % or _), expand it client-side
// by listing all catalogs and filtering to those that match the pattern, then fetching
// schemas per matching catalog. This avoids passing a pattern as a SQL identifier.
if (WildcardUtil.isJdbcPattern(catalog)) {
LOGGER.debug(
"Catalog '{}' is a JDBC pattern; expanding client-side across matching catalogs.",
catalog);
return fetchSchemasMatchingCatalogPattern(session, catalog, schemaNamePattern);
}

CommandBuilder commandBuilder =
new CommandBuilder(catalog, session).setSchemaPattern(schemaNamePattern);
String SQL = commandBuilder.getSQLString(CommandName.LIST_SCHEMAS);
Expand Down Expand Up @@ -625,6 +646,125 @@ private DatabricksResultSet fetchSchemasAcrossCatalogs(
com.databricks.jdbc.common.CommandName.LIST_SCHEMAS);
}

/**
* Fetches schemas from all catalogs whose names match the given JDBC catalog pattern. The pattern
* may contain unescaped {@code %} (matches any sequence of characters) and {@code _} (matches any
* single character) wildcards, per the JDBC spec. Catalogs not matching the pattern are skipped,
* and server-side errors for individual catalogs are swallowed (logged as warnings) so that a
* single unreachable catalog does not abort the entire request.
*
* @param session the current session
* @param catalogPattern a JDBC search pattern for catalog names (must not be null)
* @param schemaNamePattern a JDBC search pattern for schema names, or null for all schemas
* @return a result set containing TABLE_SCHEM and TABLE_CATALOG columns
*/
private DatabricksResultSet fetchSchemasMatchingCatalogPattern(
IDatabricksSession session, String catalogPattern, String schemaNamePattern)
throws SQLException {
List<String> matchingCatalogs = new ArrayList<>();
try (ResultSet catalogs = session.getDatabricksMetadataClient().listCatalogs(session)) {
while (catalogs.next()) {
String c = catalogs.getString(1);
if (c != null && !c.isEmpty() && jdbcPatternMatches(catalogPattern, c)) {
matchingCatalogs.add(c);
}
}
}

// Process matching catalogs in parallel, gathering schema information
List<List<Object>> schemaRows =
JdbcThreadUtils.parallelFlatMap(
matchingCatalogs,
session.getConnectionContext(),
DEFAULT_MAX_THREADS_METADATA_FETCH,
TASK_TIMEOUT_METADATA_FETCH_SEC,
c -> {
List<List<Object>> rows = new ArrayList<>();
try (ResultSet catalogSchemas =
session.getDatabricksMetadataClient().listSchemas(session, c, schemaNamePattern)) {
while (catalogSchemas.next()) {
Comment on lines +681 to +685
List<Object> schemaRow = new ArrayList<>();
schemaRow.add(catalogSchemas.getString(1)); // TABLE_SCHEM
schemaRow.add(catalogSchemas.getString(2)); // TABLE_CATALOG
rows.add(schemaRow);
}
} catch (SQLException e) {
LOGGER.warn(
"Error fetching schemas for catalog '{}': {}", c, e.getMessage());
}
return rows;
},
getOrCreateMetadataThreadPool());

return metadataResultSetBuilder.getResultSetWithGivenRowsAndColumns(
SCHEMA_COLUMNS,
schemaRows,
METADATA_STATEMENT_ID,
com.databricks.jdbc.common.CommandName.LIST_SCHEMAS);
}

/**
* Returns true if the given catalog name matches the JDBC search pattern. The pattern follows
* JDBC wildcard rules: {@code %} matches any sequence of characters, {@code _} matches any single
* character, and {@code \} escapes the next character. Matching is case-insensitive to align with
* Unity Catalog's case-folding behaviour.
*
* @param pattern the JDBC search pattern (must not be null)
* @param name the catalog name to match against
* @return true if {@code name} matches {@code pattern}
*/
static boolean jdbcPatternMatches(String pattern, String name) {
return jdbcPatternMatchesRecursive(
pattern, 0, name.toLowerCase(Locale.ROOT), 0);
}

private static boolean jdbcPatternMatchesRecursive(
String pattern, int pi, String name, int ni) {
while (pi < pattern.length()) {
char pc = pattern.charAt(pi);
if (pc == '\\' && pi + 1 < pattern.length()) {
// Escaped literal — must match exactly (case-insensitive)
char literal = Character.toLowerCase(pattern.charAt(pi + 1));
if (ni >= name.length() || name.charAt(ni) != literal) {
return false;
}
pi += 2;
ni++;
} else if (pc == '%') {
// Skip consecutive '%' characters
while (pi < pattern.length() && pattern.charAt(pi) == '%') {
pi++;
}
// '%' at end of pattern matches everything remaining
if (pi == pattern.length()) {
return true;
}
// Try matching the rest of the pattern at every position in name
for (int i = ni; i <= name.length(); i++) {
if (jdbcPatternMatchesRecursive(pattern, pi, name, i)) {
return true;
}
}
return false;
} else if (pc == '_') {
// Matches any single character
if (ni >= name.length()) {
return false;
}
pi++;
ni++;
} else {
// Literal character (case-insensitive)
if (ni >= name.length() || Character.toLowerCase(pc) != name.charAt(ni)) {
return false;
}
pi++;
ni++;
}
}
return ni == name.length();
}

private DatabricksResultSet fetchColumnsAcrossCatalogs(
IDatabricksSession session,
String schemaNamePattern,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,42 @@ private static Stream<Arguments> stripJdbcEscapesPatterns() {
void testStripJdbcEscapes(String input, String expected, String errorMessage) {
assertEquals(expected, WildcardUtil.stripJdbcEscapes(input), errorMessage);
}

private static Stream<Arguments> isJdbcPatternCases() {
return Stream.of(
Arguments.of(null, false, "null is not a pattern"),
Arguments.of("", false, "empty string is not a pattern"),
Arguments.of("simple", false, "plain literal is not a pattern"),
Arguments.of("%", true, "bare % is a pattern"),
Arguments.of("_", true, "bare _ is a pattern"),
Arguments.of("cat%", true, "trailing % is a pattern"),
Arguments.of("%log%", true, "leading and trailing % is a pattern"),
Arguments.of("my_cat", true, "underscore is a pattern"),
Arguments.of("\\%", false, "escaped % is NOT a pattern"),
Arguments.of("\\_", false, "escaped _ is NOT a pattern"),
Arguments.of("cat\\_main", false, "escaped underscore in literal is not a pattern"),
Arguments.of("cat\\_main%", true, "escaped underscore but bare % makes it a pattern"));
}

@ParameterizedTest
@MethodSource("isJdbcPatternCases")
void testIsJdbcPattern(String input, boolean expected, String message) {
assertEquals(expected, WildcardUtil.isJdbcPattern(input), message);
}

private static Stream<Arguments> isMatchAllCatalogPatternCases() {
return Stream.of(
Arguments.of(null, true, "null matches all"),
Arguments.of("%", true, "% matches all"),
Arguments.of("", false, "empty string does not match all"),
Arguments.of("main", false, "literal catalog does not match all"),
Arguments.of("main%", false, "partial pattern does not match all"),
Arguments.of("%main%", false, "partial pattern does not match all"));
}

@ParameterizedTest
@MethodSource("isMatchAllCatalogPatternCases")
void testIsMatchAllCatalogPattern(String input, boolean expected, String message) {
assertEquals(expected, WildcardUtil.isMatchAllCatalogPattern(input), message);
}
}
Loading