Skip to content

Commit 5b5ab00

Browse files
authored
fix(kernel): preserve empty metadata filters (#933)
* fix(kernel): preserve empty metadata filters Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> * fix(kernel): handle empty exact catalog filters Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> * refactor(kernel): simplify metadata filter forwarding Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> * refactor(kernel): forward metadata filters unchanged Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> * fix(kernel): retain catalog wildcard normalization Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> * chore(kernel): update pinned revision Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> * chore(kernel): pin merged metadata fix Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> * docs(metadata): clarify table catalog semantics Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> * docs(metadata): scope filter semantics by backend Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> * docs(metadata): keep shared API docs backend-neutral Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> --------- Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
1 parent 1a7eb74 commit 5b5ab00

5 files changed

Lines changed: 170 additions & 54 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 no longer collapse empty strings to `None`; empty patterns therefore match nothing. Existing `%`/`*` catalog wildcard handling is unchanged (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)

KERNEL_REV

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
628abd6f5045897efcadb38ec77a1e9e0c23544e
1+
d64009eb59404c1b082cb020296337f96dc0d4d7

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

Lines changed: 8 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -126,35 +126,9 @@ def _is_not_found(exc: BaseException) -> bool:
126126
)
127127

128128

129-
def _none_if_blank(value: Optional[str]) -> Optional[str]:
130-
"""Map an empty/whitespace-only metadata filter to ``None``
131-
("match all"), matching the Thrift backend's effective behaviour.
132-
133-
The kernel's ``Identifier`` / ``LikePattern`` reject ``""`` with
134-
``InvalidArgument`` (-> ``ProgrammingError``); ``None`` is the
135-
kernel's canonical "match all". Applied to schema / table / column
136-
*pattern* args (which otherwise keep ``%`` / ``_`` as real LIKE
137-
wildcards)."""
138-
if value is None:
139-
return None
140-
return value if value.strip() else None
141-
142-
143129
def _catalog_or_none(value: Optional[str]) -> Optional[str]:
144-
"""Normalise a catalog filter: ``None`` / blank / ``'%'`` / ``'*'``
145-
all mean "all catalogs" -> ``None``.
146-
147-
This makes ``columns(catalog='%')`` behave like
148-
``tables(catalog='%')`` / ``schemas(catalog='%')`` — the kernel
149-
already treats blank/``%``/``*`` as "all catalogs" for SHOW SCHEMAS
150-
/ SHOW TABLES (``is_null_or_wildcard``) but treats the catalog as an
151-
exact identifier for SHOW COLUMNS, so the three diverged. Normalising
152-
connector-side makes them symmetric. This intentionally diverges from
153-
raw-Thrift literalness (Thrift treats ``%`` as a literal catalog
154-
name) in favour of JDBC "catalog is exact-or-all, not a pattern" +
155-
internal consistency. Catalog is the only arg normalised this way;
156-
schema/table/column patterns keep ``%`` / ``*`` as LIKE wildcards."""
157-
if value is None or not value.strip() or value in ("%", "*"):
130+
"""Map supported all-catalog wildcards to the kernel's unset filter."""
131+
if value is None or value in ("%", "*"):
158132
return None
159133
return value
160134

@@ -1024,7 +998,7 @@ def get_schemas(
1024998
try:
1025999
stream = self._kernel_session.metadata().list_schemas(
10261000
catalog=_catalog_or_none(catalog_name),
1027-
schema_pattern=_none_if_blank(schema_name),
1001+
schema_pattern=schema_name,
10281002
)
10291003
return self._make_result_set(stream, cursor, self._synthetic_command_id())
10301004
except Exception as exc:
@@ -1051,8 +1025,8 @@ def get_tables(
10511025
# through preserves streaming for large schemas.
10521026
stream = self._kernel_session.metadata().list_tables(
10531027
catalog=_catalog_or_none(catalog_name),
1054-
schema_pattern=_none_if_blank(schema_name),
1055-
table_pattern=_none_if_blank(table_name),
1028+
schema_pattern=schema_name,
1029+
table_pattern=table_name,
10561030
table_types=table_types if table_types else None,
10571031
)
10581032
return self._make_result_set(stream, cursor, self._synthetic_command_id())
@@ -1081,9 +1055,9 @@ def get_columns(
10811055
# the user's perspective.
10821056
stream = self._kernel_session.metadata().list_columns(
10831057
catalog=_catalog_or_none(catalog_name),
1084-
schema_pattern=_none_if_blank(schema_name),
1085-
table_pattern=_none_if_blank(table_name),
1086-
column_pattern=_none_if_blank(column_name),
1058+
schema_pattern=schema_name,
1059+
table_pattern=table_name,
1060+
column_pattern=column_name,
10871061
)
10881062
return self._make_result_set(stream, cursor, self._synthetic_command_id())
10891063
except Exception as exc:

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", ["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: 139 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1786,15 +1786,12 @@ def test_sync_execute_leaves_rowcount_default_when_num_modified_rows_none():
17861786

17871787

17881788
# ---------------------------------------------------------------------------
1789-
# Metadata filter normalization — wildcard catalog + empty-string patterns
1789+
# Metadata filter semantics
17901790
# ---------------------------------------------------------------------------
17911791

17921792

1793-
@pytest.mark.parametrize("wildcard", ["%", "*", "", " "])
1794-
def test_get_columns_normalizes_wildcard_catalog_to_none(wildcard):
1795-
"""``catalog_name`` of ``%``/``*``/blank → ``None`` (all-catalogs),
1796-
matching JDBC exact-or-all semantics and keeping the three metadata
1797-
methods symmetric."""
1793+
@pytest.mark.parametrize("catalog_wildcard", ["%", "*"])
1794+
def test_get_columns_normalizes_all_catalog_wildcard(catalog_wildcard):
17981795
c = _make_client()
17991796
c._kernel_session = MagicMock()
18001797
list_columns = c._kernel_session.metadata.return_value.list_columns
@@ -1808,7 +1805,7 @@ def test_get_columns_normalizes_wildcard_catalog_to_none(wildcard):
18081805
max_rows=1,
18091806
max_bytes=1,
18101807
cursor=cursor,
1811-
catalog_name=wildcard,
1808+
catalog_name=catalog_wildcard,
18121809
schema_name="s",
18131810
table_name="t",
18141811
column_name="c",
@@ -1822,10 +1819,8 @@ def test_get_columns_normalizes_wildcard_catalog_to_none(wildcard):
18221819
)
18231820

18241821

1825-
def test_get_schemas_normalizes_blank_pattern_to_none():
1826-
"""An empty-string schema pattern → ``None`` (match-all), mapping
1827-
the kernel's ``InvalidArgument``-on-``""`` to Thrift's effective
1828-
match-all. ``%``/``*`` stay as real LIKE wildcards on patterns."""
1822+
def test_get_schemas_preserves_empty_pattern():
1823+
"""An empty pattern is distinct from the absent ``None`` filter."""
18291824
c = _make_client()
18301825
c._kernel_session = MagicMock()
18311826
list_schemas = c._kernel_session.metadata.return_value.list_schemas
@@ -1843,7 +1838,139 @@ def test_get_schemas_normalizes_blank_pattern_to_none():
18431838
schema_name="",
18441839
)
18451840

1846-
list_schemas.assert_called_once_with(catalog="main", schema_pattern=None)
1841+
list_schemas.assert_called_once_with(catalog="main", schema_pattern="")
1842+
1843+
1844+
def test_get_schemas_preserves_empty_catalog():
1845+
c = _make_client()
1846+
c._kernel_session = MagicMock()
1847+
list_schemas = c._kernel_session.metadata.return_value.list_schemas
1848+
list_schemas.return_value = _stream_with_schema()
1849+
cursor = MagicMock()
1850+
cursor.arraysize = 100
1851+
cursor.buffer_size_bytes = 1024
1852+
1853+
c.get_schemas(
1854+
session_id=MagicMock(),
1855+
max_rows=1,
1856+
max_bytes=1,
1857+
cursor=cursor,
1858+
catalog_name="",
1859+
schema_name="ignored",
1860+
)
1861+
1862+
list_schemas.assert_called_once_with(catalog="", schema_pattern="ignored")
1863+
1864+
1865+
def test_get_tables_preserves_empty_patterns():
1866+
c = _make_client()
1867+
c._kernel_session = MagicMock()
1868+
list_tables = c._kernel_session.metadata.return_value.list_tables
1869+
list_tables.return_value = _stream_with_schema()
1870+
cursor = MagicMock()
1871+
cursor.arraysize = 100
1872+
cursor.buffer_size_bytes = 1024
1873+
1874+
c.get_tables(
1875+
session_id=MagicMock(),
1876+
max_rows=1,
1877+
max_bytes=1,
1878+
cursor=cursor,
1879+
catalog_name="",
1880+
schema_name="",
1881+
table_name="",
1882+
)
1883+
1884+
list_tables.assert_called_once_with(
1885+
catalog="",
1886+
schema_pattern="",
1887+
table_pattern="",
1888+
table_types=None,
1889+
)
1890+
1891+
1892+
def test_get_columns_preserves_empty_patterns():
1893+
c = _make_client()
1894+
c._kernel_session = MagicMock()
1895+
list_columns = c._kernel_session.metadata.return_value.list_columns
1896+
list_columns.return_value = _stream_with_schema()
1897+
cursor = MagicMock()
1898+
cursor.arraysize = 100
1899+
cursor.buffer_size_bytes = 1024
1900+
1901+
c.get_columns(
1902+
session_id=MagicMock(),
1903+
max_rows=1,
1904+
max_bytes=1,
1905+
cursor=cursor,
1906+
catalog_name="main",
1907+
schema_name="",
1908+
table_name="",
1909+
column_name="",
1910+
)
1911+
1912+
list_columns.assert_called_once_with(
1913+
catalog="main",
1914+
schema_pattern="",
1915+
table_pattern="",
1916+
column_pattern="",
1917+
)
1918+
1919+
1920+
def test_get_columns_preserves_empty_catalog():
1921+
c = _make_client()
1922+
c._kernel_session = MagicMock()
1923+
list_columns = c._kernel_session.metadata.return_value.list_columns
1924+
list_columns.return_value = _stream_with_schema()
1925+
cursor = MagicMock()
1926+
cursor.arraysize = 100
1927+
cursor.buffer_size_bytes = 1024
1928+
1929+
c.get_columns(
1930+
session_id=MagicMock(),
1931+
max_rows=1,
1932+
max_bytes=1,
1933+
cursor=cursor,
1934+
catalog_name="",
1935+
schema_name="ignored",
1936+
table_name="table",
1937+
column_name="column",
1938+
)
1939+
1940+
list_columns.assert_called_once_with(
1941+
catalog="",
1942+
schema_pattern="ignored",
1943+
table_pattern="table",
1944+
column_pattern="column",
1945+
)
1946+
1947+
1948+
def test_get_columns_preserves_whitespace_for_kernel_validation():
1949+
c = _make_client()
1950+
c._kernel_session = MagicMock()
1951+
list_columns = c._kernel_session.metadata.return_value.list_columns
1952+
list_columns.return_value = _stream_with_schema()
1953+
cursor = MagicMock()
1954+
cursor.arraysize = 100
1955+
cursor.buffer_size_bytes = 1024
1956+
1957+
c.get_columns(
1958+
session_id=MagicMock(),
1959+
max_rows=1,
1960+
max_bytes=1,
1961+
cursor=cursor,
1962+
catalog_name=" ",
1963+
schema_name=" ",
1964+
table_name=" ",
1965+
column_name=" ",
1966+
)
1967+
1968+
list_columns.assert_called_once_with(
1969+
catalog=" ",
1970+
schema_pattern=" ",
1971+
table_pattern=" ",
1972+
column_pattern=" ",
1973+
)
18471974

18481975

18491976
def test_get_schemas_keeps_wildcard_pattern():

0 commit comments

Comments
 (0)