Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Release History

# Unreleased
- Kernel metadata filters no longer collapse empty strings to `None`; empty patterns therefore match nothing. Existing `%`/`*` catalog wildcard handling is unchanged (PECOBLR-4221).
- 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.
- 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)
- 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)
Expand Down
29 changes: 20 additions & 9 deletions src/databricks/sql/backend/databricks_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,11 @@ def get_schemas(
max_rows: Maximum number of rows to fetch in a single batch
max_bytes: Maximum number of bytes to fetch in a single batch
cursor: The cursor object that will handle the results
catalog_name: Optional catalog name pattern to filter by
schema_name: Optional schema name pattern to filter by
catalog_name: Optional exact catalog name. ``None`` leaves the
filter unset; ``%`` and ``*`` select all catalogs; an empty

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — This docstring lives on the abstract DatabricksClient base class, which is the shared contract for both the Thrift and kernel backends. It now states catalog_name is an "Optional exact catalog name" where "% and * select all catalogs." That semantics is kernel-only: the Thrift backend passes catalogName=catalog_name straight through (thrift_backend.py:1160/1206/1254) with no %/* normalization, so on Thrift % is a literal catalog name — as the removed _catalog_or_none comment itself noted ("This intentionally diverges from raw-Thrift literalness (Thrift treats % as a literal catalog name)").

A reader of the base contract (and Thrift users) will be misled into thinking catalog_name='%' matches all catalogs on every backend. Consider scoping the wildcard note to the kernel backend, or clarifying that it is a kernel-specific normalization. The same wording appears at databricks_client.py:332 (get_columns) and in the public Cursor docstrings at client.py:1583-1584 and 1641-1642, which are likewise backend-agnostic and user-facing.

string is preserved.
schema_name: Optional schema name pattern to filter by. ``None``
leaves the filter unset; an empty string matches nothing.

Returns:
ResultSet: An object containing the schema metadata
Expand Down Expand Up @@ -284,10 +287,13 @@ def get_tables(
max_bytes: Maximum number of bytes to fetch in a single batch
cursor: The cursor object that will handle the results
catalog_name: Optional catalog name pattern to filter by
if catalog_name is None, we fetch across all catalogs
if catalog_name is None, we fetch across all catalogs; an empty
Comment thread
vuanhphung marked this conversation as resolved.
string matches nothing
schema_name: Optional schema name pattern to filter by
if schema_name is None, we fetch across all schemas
table_name: Optional table name pattern to filter by
if schema_name is None, we fetch across all schemas; an empty
string matches nothing
table_name: Optional table name pattern to filter by. ``None``
leaves the filter unset; an empty string matches nothing.
table_types: Optional list of table types to filter by (e.g., ['TABLE', 'VIEW'])

Returns:
Expand Down Expand Up @@ -322,11 +328,16 @@ def get_columns(
max_rows: Maximum number of rows to fetch in a single batch
max_bytes: Maximum number of bytes to fetch in a single batch
cursor: The cursor object that will handle the results
catalog_name: Optional catalog name pattern to filter by
schema_name: Optional schema name pattern to filter by
catalog_name: Optional exact catalog name. ``None`` leaves the
filter unset; ``%`` and ``*`` select all catalogs; an empty
string is preserved.
schema_name: Optional schema name pattern to filter by. ``None``
leaves the filter unset; an empty string matches nothing.
table_name: Optional table name pattern to filter by
if table_name is None, we fetch across all tables
column_name: Optional column name pattern to filter by
if table_name is None, we fetch across all tables; an empty
string matches nothing
column_name: Optional column name pattern to filter by. ``None``
leaves the filter unset; an empty string matches nothing.

Returns:
ResultSet: An object containing the column metadata
Expand Down
42 changes: 8 additions & 34 deletions src/databricks/sql/backend/kernel/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,35 +117,9 @@ def _is_not_found(exc: BaseException) -> bool:
)


def _none_if_blank(value: Optional[str]) -> Optional[str]:
"""Map an empty/whitespace-only metadata filter to ``None``
("match all"), matching the Thrift backend's effective behaviour.

The kernel's ``Identifier`` / ``LikePattern`` reject ``""`` with
``InvalidArgument`` (-> ``ProgrammingError``); ``None`` is the
kernel's canonical "match all". Applied to schema / table / column
*pattern* args (which otherwise keep ``%`` / ``_`` as real LIKE
wildcards)."""
if value is None:
return None
return value if value.strip() else None


def _catalog_or_none(value: Optional[str]) -> Optional[str]:
"""Normalise a catalog filter: ``None`` / blank / ``'%'`` / ``'*'``
all mean "all catalogs" -> ``None``.

This makes ``columns(catalog='%')`` behave like
``tables(catalog='%')`` / ``schemas(catalog='%')`` — the kernel
already treats blank/``%``/``*`` as "all catalogs" for SHOW SCHEMAS
/ SHOW TABLES (``is_null_or_wildcard``) but treats the catalog as an
exact identifier for SHOW COLUMNS, so the three diverged. Normalising
connector-side makes them symmetric. This intentionally diverges from
raw-Thrift literalness (Thrift treats ``%`` as a literal catalog
name) in favour of JDBC "catalog is exact-or-all, not a pattern" +
internal consistency. Catalog is the only arg normalised this way;
schema/table/column patterns keep ``%`` / ``*`` as LIKE wildcards."""
if value is None or not value.strip() or value in ("%", "*"):
"""Map supported all-catalog wildcards to the kernel's unset filter."""
if value is None or value in ("%", "*"):
return None
return value

Expand Down Expand Up @@ -938,7 +912,7 @@ def get_schemas(
try:
stream = self._kernel_session.metadata().list_schemas(
catalog=_catalog_or_none(catalog_name),
schema_pattern=_none_if_blank(schema_name),
schema_pattern=schema_name,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — The change now forwards empty-string pattern filters (schema_pattern, table_pattern, column_pattern) to the kernel verbatim instead of collapsing them to None. The PR description, the base/Cursor docstrings, and the e2e tests all assert that an empty pattern matches nothing (fetchall() == []).

But the previous helper's own docstring documented the opposite kernel behavior: Identifier/LikePattern reject "" with InvalidArgument, which the connector maps to ProgrammingError. If the pinned kernel (databricks-sql-kernel = "^0.2.0", pyproject.toml:62) still rejects "", then empty-string filters now raise ProgrammingError at runtime rather than matching nothing — a regression relative to the documented/asserted contract.

The unit tests (test_get_schemas_preserves_empty_pattern, etc.) mock the kernel session, so they only verify the connector passes "" through — they cannot detect that the real kernel rejects it. Only the real-wheel e2e tests would, and those run in a separate CI step. Please confirm this depends on a coordinated kernel change that accepts "" as match-nothing, and bump the minimum databricks-sql-kernel version (^0.2.0) accordingly so a user on an older kernel doesn't silently get ProgrammingError where the docs promise an empty result set. Applies equally to the call sites at lines 945 and 975-977.

(Anchored to the nearest changed line — see the description for the exact location.)

)
return self._make_result_set(stream, cursor, self._synthetic_command_id())
except Exception as exc:
Expand All @@ -965,8 +939,8 @@ def get_tables(
# through preserves streaming for large schemas.
stream = self._kernel_session.metadata().list_tables(
catalog=_catalog_or_none(catalog_name),
schema_pattern=_none_if_blank(schema_name),
table_pattern=_none_if_blank(table_name),
schema_pattern=schema_name,
Comment thread
vuanhphung marked this conversation as resolved.
table_pattern=table_name,
table_types=table_types if table_types else None,
)
return self._make_result_set(stream, cursor, self._synthetic_command_id())
Expand Down Expand Up @@ -995,9 +969,9 @@ def get_columns(
# the user's perspective.
stream = self._kernel_session.metadata().list_columns(
catalog=_catalog_or_none(catalog_name),
schema_pattern=_none_if_blank(schema_name),
table_pattern=_none_if_blank(table_name),
column_pattern=_none_if_blank(column_name),
schema_pattern=schema_name,
table_pattern=table_name,
column_pattern=column_name,
)
return self._make_result_set(stream, cursor, self._synthetic_command_id())
except Exception as exc:
Expand Down
11 changes: 8 additions & 3 deletions src/databricks/sql/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1577,7 +1577,9 @@ def schemas(
"""
Get schemas corresponding to the catalog_name and schema_name.

Names can contain % wildcards.
Empty strings are preserved; ``None`` leaves a filter unset.
``catalog_name`` is exact except that % and * select all catalogs.
``schema_name`` is a pattern; an empty pattern matches nothing.
:returns self
"""
self._check_not_closed()
Expand All @@ -1603,7 +1605,8 @@ def tables(
"""
Get tables corresponding to the catalog_name, schema_name and table_name.

Names can contain % wildcards.
Empty strings are preserved; ``None`` leaves a filter unset. Names are
patterns, can contain % wildcards, and empty patterns match nothing.
:returns self
"""
self._check_not_closed()
Expand Down Expand Up @@ -1632,7 +1635,9 @@ def columns(
"""
Get columns corresponding to the catalog_name, schema_name, table_name and column_name.

Names can contain % wildcards.
Empty strings are preserved; ``None`` leaves a filter unset.
``catalog_name`` is exact except that % and * select all catalogs.
Other names are patterns; empty patterns match nothing.

``catalog_name=None`` is accepted on all backends and matches
columns across every catalog (the kernel issues ``SHOW COLUMNS``
Expand Down
28 changes: 21 additions & 7 deletions tests/e2e/test_kernel_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,17 +356,31 @@ def test_metadata_columns(conn):
assert len(rows) > 0


# ── Metadata filter normalization (batch 3) ───────────────────────
# ── Metadata filter semantics ─────────────────────────────────────


def test_schemas_with_empty_string_filter_matches_all(conn):
"""An empty-string schema pattern normalizes to match-all rather
than raising ``ProgrammingError`` (kernel rejects ``""``) — locks
``_none_if_blank`` on the pattern args."""
def test_schemas_with_empty_string_filter_matches_nothing(conn):
"""An empty string is a real pattern, distinct from absent ``None``."""
with conn.cursor() as cur:
cur.schemas(catalog_name="main", schema_name="")
rows = cur.fetchall()
assert len(rows) > 0
assert cur.fetchall() == []


@pytest.mark.parametrize(
"empty_filter", ["schema_name", "table_name", "column_name"]
)
def test_columns_with_empty_string_filter_matches_nothing(conn, empty_filter):
filters = {
"catalog_name": "system",
"schema_name": "information_schema",
"table_name": "tables",
"column_name": "table_catalog",
}
filters[empty_filter] = ""

with conn.cursor() as cur:
cur.columns(**filters)
assert cur.fetchall() == []


def test_tables_table_types_filter_is_case_insensitive(conn):
Expand Down
151 changes: 139 additions & 12 deletions tests/unit/test_kernel_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1560,15 +1560,12 @@ def test_sync_execute_leaves_rowcount_default_when_num_modified_rows_none():


# ---------------------------------------------------------------------------
# Metadata filter normalization — wildcard catalog + empty-string patterns
# Metadata filter semantics
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("wildcard", ["%", "*", "", " "])
def test_get_columns_normalizes_wildcard_catalog_to_none(wildcard):
"""``catalog_name`` of ``%``/``*``/blank → ``None`` (all-catalogs),
matching JDBC exact-or-all semantics and keeping the three metadata
methods symmetric."""
@pytest.mark.parametrize("catalog_wildcard", ["%", "*"])
def test_get_columns_normalizes_all_catalog_wildcard(catalog_wildcard):
c = _make_client()
c._kernel_session = MagicMock()
list_columns = c._kernel_session.metadata.return_value.list_columns
Expand All @@ -1582,7 +1579,7 @@ def test_get_columns_normalizes_wildcard_catalog_to_none(wildcard):
max_rows=1,
max_bytes=1,
cursor=cursor,
catalog_name=wildcard,
catalog_name=catalog_wildcard,
schema_name="s",
table_name="t",
column_name="c",
Expand All @@ -1596,10 +1593,8 @@ def test_get_columns_normalizes_wildcard_catalog_to_none(wildcard):
)


def test_get_schemas_normalizes_blank_pattern_to_none():
"""An empty-string schema pattern → ``None`` (match-all), mapping
the kernel's ``InvalidArgument``-on-``""`` to Thrift's effective
match-all. ``%``/``*`` stay as real LIKE wildcards on patterns."""
def test_get_schemas_preserves_empty_pattern():
"""An empty pattern is distinct from the absent ``None`` filter."""
c = _make_client()
c._kernel_session = MagicMock()
list_schemas = c._kernel_session.metadata.return_value.list_schemas
Expand All @@ -1617,7 +1612,139 @@ def test_get_schemas_normalizes_blank_pattern_to_none():
schema_name="",
)

list_schemas.assert_called_once_with(catalog="main", schema_pattern=None)
list_schemas.assert_called_once_with(catalog="main", schema_pattern="")


def test_get_schemas_preserves_empty_catalog():
c = _make_client()
c._kernel_session = MagicMock()
list_schemas = c._kernel_session.metadata.return_value.list_schemas
list_schemas.return_value = _stream_with_schema()
cursor = MagicMock()
cursor.arraysize = 100
cursor.buffer_size_bytes = 1024

c.get_schemas(
session_id=MagicMock(),
max_rows=1,
max_bytes=1,
cursor=cursor,
catalog_name="",
schema_name="ignored",
)

list_schemas.assert_called_once_with(catalog="", schema_pattern="ignored")


def test_get_tables_preserves_empty_patterns():
Comment thread
vuanhphung marked this conversation as resolved.
c = _make_client()
c._kernel_session = MagicMock()
list_tables = c._kernel_session.metadata.return_value.list_tables
list_tables.return_value = _stream_with_schema()
cursor = MagicMock()
cursor.arraysize = 100
cursor.buffer_size_bytes = 1024

c.get_tables(
session_id=MagicMock(),
max_rows=1,
max_bytes=1,
cursor=cursor,
catalog_name="",
schema_name="",
table_name="",
)

list_tables.assert_called_once_with(
catalog="",
schema_pattern="",
table_pattern="",
table_types=None,
)


def test_get_columns_preserves_empty_patterns():
c = _make_client()
c._kernel_session = MagicMock()
list_columns = c._kernel_session.metadata.return_value.list_columns
list_columns.return_value = _stream_with_schema()
cursor = MagicMock()
cursor.arraysize = 100
cursor.buffer_size_bytes = 1024

c.get_columns(
session_id=MagicMock(),
max_rows=1,
max_bytes=1,
cursor=cursor,
catalog_name="main",
schema_name="",
table_name="",
column_name="",
)

list_columns.assert_called_once_with(
catalog="main",
schema_pattern="",
table_pattern="",
column_pattern="",
)


def test_get_columns_preserves_empty_catalog():
c = _make_client()
c._kernel_session = MagicMock()
list_columns = c._kernel_session.metadata.return_value.list_columns
list_columns.return_value = _stream_with_schema()
cursor = MagicMock()
cursor.arraysize = 100
cursor.buffer_size_bytes = 1024

c.get_columns(
session_id=MagicMock(),
max_rows=1,
max_bytes=1,
cursor=cursor,
catalog_name="",
schema_name="ignored",
table_name="table",
column_name="column",
)

list_columns.assert_called_once_with(
catalog="",
schema_pattern="ignored",
table_pattern="table",
column_pattern="column",
)


def test_get_columns_preserves_whitespace_for_kernel_validation():
c = _make_client()
c._kernel_session = MagicMock()
list_columns = c._kernel_session.metadata.return_value.list_columns
list_columns.return_value = _stream_with_schema()
cursor = MagicMock()
cursor.arraysize = 100
cursor.buffer_size_bytes = 1024

c.get_columns(
session_id=MagicMock(),
max_rows=1,
max_bytes=1,
cursor=cursor,
catalog_name=" ",
schema_name=" ",
table_name=" ",
column_name=" ",
)

list_columns.assert_called_once_with(
catalog=" ",
schema_pattern=" ",
table_pattern=" ",
column_pattern=" ",
)


def test_get_schemas_keeps_wildcard_pattern():
Expand Down
Loading