Skip to content

Commit 46e620a

Browse files
committed
fix(kernel): preserve empty metadata filters
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
1 parent c77f275 commit 46e620a

6 files changed

Lines changed: 122 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Release History
22

33
# Unreleased
4+
- Kernel metadata filters now preserve empty strings as empty patterns, matching no catalogs, schemas, tables, or columns. Only `None` leaves a filter unset; `%` and `*` retain their existing wildcard behavior (PECOBLR-4221).
45
- Kernel backend (`use_kernel=True`): OAuth **M2M with a JWT private-key client assertion** (RFC 7523) is now supported. Pass `oauth_client_id` + `oauth_jwt_key_file` + `oauth_jwt_kid` (with optional `oauth_jwt_passphrase` for an encrypted PKCS#8 key, `oauth_jwt_algorithm` defaulting to `RS256`, `oauth_scopes`, and `token_url` for the IdP token endpoint) and the connector routes them to the kernel's `auth_type="oauth-m2m-jwt"`, which signs a short-lived assertion with the private key instead of sending a client secret. The kernel owns the token lifecycle. A private-key file is treated as unambiguous JWT M2M intent and is mutually exclusive with `oauth_client_secret` / `credentials_provider` (both raise `NotSupportedError`). Verified end-to-end against an Azure Databricks workspace with the service principal's public certificate registered on its Entra ID app registration. Requires `databricks-sql-kernel >= 0.2.0` with JWT support.
56
- Kernel backend (`use_kernel=True`): OAuth U2M with `auth_type="databricks-oauth"` now forwards the connector's `databricks-sql-python` OAuth-app bundle (`client_id` + `sql offline_access` scopes + redirect port) into the kernel, so a bare U2M connection authenticates as `databricks-sql-python` — parity with the Thrift path — instead of inheriting the kernel's own `databricks-sql-connector` default. A caller-supplied `oauth_client_id` (with its coupled `oauth_redirect_port`) is honored, as is a caller-supplied `oauth_scopes`; absent one, the connector default (`sql offline_access`) is forwarded. Note: the kernel binds a single U2M redirect port, so unlike the Thrift path (which tries the full `8020..8024` range) the kernel path uses only one port and does not fall back to the next port if it is already bound — pass `oauth_redirect_port` (with `oauth_client_id`) to pick a free one on a port collision (PECOBLR-4040)
67
- Kernel backend (`use_kernel=True`): **Azure Entra (Azure AD) service-principal M2M is now supported.** `auth_type="azure-sp-m2m"` forwards `azure_client_id` / `azure_client_secret`; the kernel is the Azure-aware auth core — it builds the Entra v2.0 token endpoint and the `{app_id}/.default` scope, and **auto-discovers the tenant** from the workspace's `/aad/auth` redirect when `azure_tenant_id` is omitted (matching Thrift). The `Authorization` bearer is the Databricks-audience data token, which alone authenticates a workspace-member SP. Set `azure_workspace_resource_id` and the kernel also sends the Azure SP management token (`X-Databricks-Azure-SP-Management-Token`) + `X-Databricks-Azure-Workspace-Resource-Id` header (matching the JDBC driver), so a service principal with an Azure RBAC role but no workspace membership can authenticate; omit it and no ARM management-scope token is fetched. Azure AD **U2M** (`auth_type="azure-oauth"`) now routes to the kernel's OAuth U2M flow, identically to `auth_type="databricks-oauth"`: the kernel runs the in-house workspace-federated browser flow, which Azure workspaces support (the workspace federates login to Entra). It forwards the connector's `databricks-sql-python` OAuth app, not the Thrift Azure app (`96eecda7` / port 8030), which is registered for Thrift's direct-Entra flow the kernel does not perform (PECOBLR-4141; PECOBLR-4120)

src/databricks/sql/backend/databricks_client.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,10 @@ def get_schemas(
248248
max_rows: Maximum number of rows to fetch in a single batch
249249
max_bytes: Maximum number of bytes to fetch in a single batch
250250
cursor: The cursor object that will handle the results
251-
catalog_name: Optional catalog name pattern to filter by
252-
schema_name: Optional schema name pattern to filter by
251+
catalog_name: Optional catalog name pattern to filter by. ``None``
252+
leaves the filter unset; an empty string matches nothing.
253+
schema_name: Optional schema name pattern to filter by. ``None``
254+
leaves the filter unset; an empty string matches nothing.
253255
254256
Returns:
255257
ResultSet: An object containing the schema metadata
@@ -284,10 +286,13 @@ def get_tables(
284286
max_bytes: Maximum number of bytes to fetch in a single batch
285287
cursor: The cursor object that will handle the results
286288
catalog_name: Optional catalog name pattern to filter by
287-
if catalog_name is None, we fetch across all catalogs
289+
if catalog_name is None, we fetch across all catalogs; an empty
290+
string matches nothing
288291
schema_name: Optional schema name pattern to filter by
289-
if schema_name is None, we fetch across all schemas
290-
table_name: Optional table name pattern to filter by
292+
if schema_name is None, we fetch across all schemas; an empty
293+
string matches nothing
294+
table_name: Optional table name pattern to filter by. ``None``
295+
leaves the filter unset; an empty string matches nothing.
291296
table_types: Optional list of table types to filter by (e.g., ['TABLE', 'VIEW'])
292297
293298
Returns:
@@ -322,11 +327,15 @@ def get_columns(
322327
max_rows: Maximum number of rows to fetch in a single batch
323328
max_bytes: Maximum number of bytes to fetch in a single batch
324329
cursor: The cursor object that will handle the results
325-
catalog_name: Optional catalog name pattern to filter by
326-
schema_name: Optional schema name pattern to filter by
330+
catalog_name: Optional catalog name pattern to filter by. ``None``
331+
leaves the filter unset; an empty string matches nothing.
332+
schema_name: Optional schema name pattern to filter by. ``None``
333+
leaves the filter unset; an empty string matches nothing.
327334
table_name: Optional table name pattern to filter by
328-
if table_name is None, we fetch across all tables
329-
column_name: Optional column name pattern to filter by
335+
if table_name is None, we fetch across all tables; an empty
336+
string matches nothing
337+
column_name: Optional column name pattern to filter by. ``None``
338+
leaves the filter unset; an empty string matches nothing.
330339
331340
Returns:
332341
ResultSet: An object containing the column metadata

src/databricks/sql/backend/kernel/client.py

Lines changed: 13 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -117,35 +117,17 @@ def _is_not_found(exc: BaseException) -> bool:
117117
)
118118

119119

120-
def _none_if_blank(value: Optional[str]) -> Optional[str]:
121-
"""Map an empty/whitespace-only metadata filter to ``None``
122-
("match all"), matching the Thrift backend's effective behaviour.
123-
124-
The kernel's ``Identifier`` / ``LikePattern`` reject ``""`` with
125-
``InvalidArgument`` (-> ``ProgrammingError``); ``None`` is the
126-
kernel's canonical "match all". Applied to schema / table / column
127-
*pattern* args (which otherwise keep ``%`` / ``_`` as real LIKE
128-
wildcards)."""
129-
if value is None:
130-
return None
131-
return value if value.strip() else None
132-
133-
134120
def _catalog_or_none(value: Optional[str]) -> Optional[str]:
135-
"""Normalise a catalog filter: ``None`` / blank / ``'%'`` / ``'*'``
136-
all mean "all catalogs" -> ``None``.
121+
"""Map all-catalog wildcards to the kernel's unfiltered representation.
137122
138123
This makes ``columns(catalog='%')`` behave like
139124
``tables(catalog='%')`` / ``schemas(catalog='%')`` — the kernel
140-
already treats blank/``%``/``*`` as "all catalogs" for SHOW SCHEMAS
141-
/ SHOW TABLES (``is_null_or_wildcard``) but treats the catalog as an
142-
exact identifier for SHOW COLUMNS, so the three diverged. Normalising
143-
connector-side makes them symmetric. This intentionally diverges from
144-
raw-Thrift literalness (Thrift treats ``%`` as a literal catalog
145-
name) in favour of JDBC "catalog is exact-or-all, not a pattern" +
146-
internal consistency. Catalog is the only arg normalised this way;
147-
schema/table/column patterns keep ``%`` / ``*`` as LIKE wildcards."""
148-
if value is None or not value.strip() or value in ("%", "*"):
125+
treats the catalog as an exact identifier for SHOW COLUMNS. ``None``
126+
remains the only absent filter. Other strings must be preserved as real
127+
filters; in particular, an empty string matches nothing just as it does
128+
on the Thrift backend.
129+
"""
130+
if value is None or value in ("%", "*"):
149131
return None
150132
return value
151133

@@ -938,7 +920,7 @@ def get_schemas(
938920
try:
939921
stream = self._kernel_session.metadata().list_schemas(
940922
catalog=_catalog_or_none(catalog_name),
941-
schema_pattern=_none_if_blank(schema_name),
923+
schema_pattern=schema_name,
942924
)
943925
return self._make_result_set(stream, cursor, self._synthetic_command_id())
944926
except Exception as exc:
@@ -965,8 +947,8 @@ def get_tables(
965947
# through preserves streaming for large schemas.
966948
stream = self._kernel_session.metadata().list_tables(
967949
catalog=_catalog_or_none(catalog_name),
968-
schema_pattern=_none_if_blank(schema_name),
969-
table_pattern=_none_if_blank(table_name),
950+
schema_pattern=schema_name,
951+
table_pattern=table_name,
970952
table_types=table_types if table_types else None,
971953
)
972954
return self._make_result_set(stream, cursor, self._synthetic_command_id())
@@ -995,9 +977,9 @@ def get_columns(
995977
# the user's perspective.
996978
stream = self._kernel_session.metadata().list_columns(
997979
catalog=_catalog_or_none(catalog_name),
998-
schema_pattern=_none_if_blank(schema_name),
999-
table_pattern=_none_if_blank(table_name),
1000-
column_pattern=_none_if_blank(column_name),
980+
schema_pattern=schema_name,
981+
table_pattern=table_name,
982+
column_pattern=column_name,
1001983
)
1002984
return self._make_result_set(stream, cursor, self._synthetic_command_id())
1003985
except Exception as exc:

src/databricks/sql/client.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1577,7 +1577,8 @@ def schemas(
15771577
"""
15781578
Get schemas corresponding to the catalog_name and schema_name.
15791579
1580-
Names can contain % wildcards.
1580+
``None`` leaves a filter unset. An empty string is a real empty
1581+
pattern and matches nothing. Names can contain % wildcards.
15811582
:returns self
15821583
"""
15831584
self._check_not_closed()
@@ -1603,7 +1604,8 @@ def tables(
16031604
"""
16041605
Get tables corresponding to the catalog_name, schema_name and table_name.
16051606
1606-
Names can contain % wildcards.
1607+
``None`` leaves a filter unset. An empty string is a real empty
1608+
pattern and matches nothing. Names can contain % wildcards.
16071609
:returns self
16081610
"""
16091611
self._check_not_closed()
@@ -1632,7 +1634,8 @@ def columns(
16321634
"""
16331635
Get columns corresponding to the catalog_name, schema_name, table_name and column_name.
16341636
1635-
Names can contain % wildcards.
1637+
``None`` leaves a filter unset. An empty string is a real empty
1638+
pattern and matches nothing. Names can contain % wildcards.
16361639
16371640
``catalog_name=None`` is accepted on all backends and matches
16381641
columns across every catalog (the kernel issues ``SHOW COLUMNS``

tests/e2e/test_kernel_backend.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -356,17 +356,31 @@ def test_metadata_columns(conn):
356356
assert len(rows) > 0
357357

358358

359-
# ── Metadata filter normalization (batch 3) ───────────────────────
359+
# ── Metadata filter semantics ─────────────────────────────────────
360360

361361

362-
def test_schemas_with_empty_string_filter_matches_all(conn):
363-
"""An empty-string schema pattern normalizes to match-all rather
364-
than raising ``ProgrammingError`` (kernel rejects ``""``) — locks
365-
``_none_if_blank`` on the pattern args."""
362+
def test_schemas_with_empty_string_filter_matches_nothing(conn):
363+
"""An empty string is a real pattern, distinct from absent ``None``."""
366364
with conn.cursor() as cur:
367365
cur.schemas(catalog_name="main", schema_name="")
368-
rows = cur.fetchall()
369-
assert len(rows) > 0
366+
assert cur.fetchall() == []
367+
368+
369+
@pytest.mark.parametrize(
370+
"empty_filter", ["catalog_name", "schema_name", "table_name", "column_name"]
371+
)
372+
def test_columns_with_empty_string_filter_matches_nothing(conn, empty_filter):
373+
filters = {
374+
"catalog_name": "system",
375+
"schema_name": "information_schema",
376+
"table_name": "tables",
377+
"column_name": "table_catalog",
378+
}
379+
filters[empty_filter] = ""
380+
381+
with conn.cursor() as cur:
382+
cur.columns(**filters)
383+
assert cur.fetchall() == []
370384

371385

372386
def test_tables_table_types_filter_is_case_insensitive(conn):

tests/unit/test_kernel_client.py

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,15 +1560,13 @@ def test_sync_execute_leaves_rowcount_default_when_num_modified_rows_none():
15601560

15611561

15621562
# ---------------------------------------------------------------------------
1563-
# Metadata filter normalization — wildcard catalog + empty-string patterns
1563+
# Metadata filter semantics
15641564
# ---------------------------------------------------------------------------
15651565

15661566

1567-
@pytest.mark.parametrize("wildcard", ["%", "*", "", " "])
1567+
@pytest.mark.parametrize("wildcard", ["%", "*"])
15681568
def test_get_columns_normalizes_wildcard_catalog_to_none(wildcard):
1569-
"""``catalog_name`` of ``%``/``*``/blank → ``None`` (all-catalogs),
1570-
matching JDBC exact-or-all semantics and keeping the three metadata
1571-
methods symmetric."""
1569+
"""The kernel represents the supported all-catalog wildcards as ``None``."""
15721570
c = _make_client()
15731571
c._kernel_session = MagicMock()
15741572
list_columns = c._kernel_session.metadata.return_value.list_columns
@@ -1596,10 +1594,8 @@ def test_get_columns_normalizes_wildcard_catalog_to_none(wildcard):
15961594
)
15971595

15981596

1599-
def test_get_schemas_normalizes_blank_pattern_to_none():
1600-
"""An empty-string schema pattern → ``None`` (match-all), mapping
1601-
the kernel's ``InvalidArgument``-on-``""`` to Thrift's effective
1602-
match-all. ``%``/``*`` stay as real LIKE wildcards on patterns."""
1597+
def test_get_schemas_preserves_empty_pattern():
1598+
"""An empty pattern is distinct from the absent ``None`` filter."""
16031599
c = _make_client()
16041600
c._kernel_session = MagicMock()
16051601
list_schemas = c._kernel_session.metadata.return_value.list_schemas
@@ -1617,7 +1613,64 @@ def test_get_schemas_normalizes_blank_pattern_to_none():
16171613
schema_name="",
16181614
)
16191615

1620-
list_schemas.assert_called_once_with(catalog="main", schema_pattern=None)
1616+
list_schemas.assert_called_once_with(catalog="main", schema_pattern="")
1617+
1618+
1619+
def test_get_tables_preserves_empty_patterns():
1620+
c = _make_client()
1621+
c._kernel_session = MagicMock()
1622+
list_tables = c._kernel_session.metadata.return_value.list_tables
1623+
list_tables.return_value = _stream_with_schema()
1624+
cursor = MagicMock()
1625+
cursor.arraysize = 100
1626+
cursor.buffer_size_bytes = 1024
1627+
1628+
c.get_tables(
1629+
session_id=MagicMock(),
1630+
max_rows=1,
1631+
max_bytes=1,
1632+
cursor=cursor,
1633+
catalog_name="",
1634+
schema_name="",
1635+
table_name="",
1636+
)
1637+
1638+
list_tables.assert_called_once_with(
1639+
catalog="",
1640+
schema_pattern="",
1641+
table_pattern="",
1642+
table_types=None,
1643+
)
1644+
1645+
1646+
@pytest.mark.parametrize("filter_value", ["", " "])
1647+
def test_get_columns_preserves_blank_filters(filter_value):
1648+
"""Blank strings remain filters instead of becoming match-all ``None``."""
1649+
c = _make_client()
1650+
c._kernel_session = MagicMock()
1651+
list_columns = c._kernel_session.metadata.return_value.list_columns
1652+
list_columns.return_value = _stream_with_schema()
1653+
cursor = MagicMock()
1654+
cursor.arraysize = 100
1655+
cursor.buffer_size_bytes = 1024
1656+
1657+
c.get_columns(
1658+
session_id=MagicMock(),
1659+
max_rows=1,
1660+
max_bytes=1,
1661+
cursor=cursor,
1662+
catalog_name=filter_value,
1663+
schema_name=filter_value,
1664+
table_name=filter_value,
1665+
column_name=filter_value,
1666+
)
1667+
1668+
list_columns.assert_called_once_with(
1669+
catalog=filter_value,
1670+
schema_pattern=filter_value,
1671+
table_pattern=filter_value,
1672+
column_pattern=filter_value,
1673+
)
16211674

16221675

16231676
def test_get_schemas_keeps_wildcard_pattern():

0 commit comments

Comments
 (0)