Skip to content

Commit c961f91

Browse files
committed
Merge main and preserve geospatial and catalog escape changelog entries
Signed-off-by: Cathleen Yan <58714163+cathleeny@users.noreply.github.com>
2 parents d7df7eb + b1978d4 commit c961f91

9 files changed

Lines changed: 231 additions & 4 deletions

File tree

‎CHANGELOG.md‎

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

33
# Unreleased
44
- Add the default-enabled, kernel-only `enable_geospatial_support` connection option. GEOMETRY / GEOGRAPHY results are exposed as `{"srid": int, "wkb": bytes}` values when true or WKT / EWKT strings when false.
5+
- Add the `escape_catalog_name` connection parameter (default `False`) to opt in to escaping unescaped `_` in metadata catalog names on Thrift and kernel connections. Existing escapes and `%` wildcards are preserved.
56

67
# 4.6.0 (2026-09-24)
78
- Upgrade Databricks SQL Kernel to 1.1.0; the kernel dependency is now stable and no longer experimental.

‎CONNECTION_PARAMETERS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ to change without notice.
167167
| `schema` | `str` | ✅ | ✅ | `None` | Initial schema for the session (DBR 9.0+). |
168168
| `query_tags` | `Dict[str, Optional[str]]` | ✅ | ✅ | `None` | Connection-level key/value tags serialized into the reserved `QUERY_TAGS` session configuration on both backends. Cursor-level `query_tags` are applied per statement separately. |
169169
| `enable_metric_view_metadata` | `bool` | ✅ | ✅ | `False` | Sets `spark.sql.thriftserver.metadata.metricview.enabled` via session config so metric-view metadata surfaces. |
170+
| `escape_catalog_name` | `bool` | ✅ | ✅ | `False` | Escape unescaped `_` in metadata catalog names, preserving existing `\_` escapes and `%` wildcards. Applies to `schemas()`, `tables()`, and `columns()` on Thrift and `tables()` on Kernel; Kernel schemas/columns already use exact catalog identifiers. |
170171
| `use_inline_params` | `bool` \| `"silent"` | ✅ | ✅ | `False` | Render parameters inline (legacy) vs. native bound params (DBR 14.1+). Shared cursor code performs the rendering before dispatch, so both backends honor it identically. |
171172
| `ignore_transactions` | `bool` | ✅ | ✅ | `True` | When `True`: `commit()` is a no-op, `rollback()` raises `NotSupportedError`, and setting `autocommit` is a no-op. |
172173
| `fetch_autocommit_from_server`| `bool` | ✅ | ✅ | `False` | Query the server (`SET AUTOCOMMIT`) for autocommit state instead of returning the cached value. |

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
ProgrammingError,
5050
)
5151
from databricks.sql.telemetry.telemetry_client import TelemetryHelper
52+
from databricks.sql.utils import escape_metadata_catalog_name
5253

5354
if TYPE_CHECKING:
5455
from databricks.sql.client import Cursor
@@ -293,6 +294,7 @@ def __init__(
293294
self._telemetry_options = kwargs.get("telemetry_options") or {}
294295
self._catalog = catalog
295296
self._schema = schema
297+
self._escape_catalog_name = kwargs.get("escape_catalog_name", False)
296298
# ``_use_arrow_native_complex_types`` is the connector-side
297299
# toggle for whether complex columns (ARRAY / MAP / STRUCT)
298300
# are surfaced as native Arrow shapes or as compact JSON
@@ -1049,7 +1051,9 @@ def get_tables(
10491051
# do the work — no connector-side drain + refilter. Passing it
10501052
# through preserves streaming for large schemas.
10511053
stream = self._kernel_session.metadata().list_tables(
1052-
catalog=_catalog_or_none(catalog_name),
1054+
catalog=escape_metadata_catalog_name(
1055+
_catalog_or_none(catalog_name), self._escape_catalog_name
1056+
),
10531057
schema_pattern=schema_name,
10541058
table_pattern=table_name,
10551059
table_types=table_types if table_types else None,

‎src/databricks/sql/backend/thrift_backend.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
convert_arrow_based_set_to_arrow_table,
5454
convert_decimals_in_arrow_table,
5555
convert_column_based_set_to_arrow_table,
56+
escape_metadata_catalog_name,
5657
serialize_query_tags,
5758
)
5859
from databricks.sql.types import SSLOptions
@@ -178,6 +179,7 @@ def __init__(
178179
raise ValueError("No valid connection settings.")
179180

180181
self._host = server_hostname
182+
self._escape_catalog_name = kwargs.get("escape_catalog_name", False)
181183
self._initialize_retry_args(kwargs)
182184
self._use_arrow_native_complex_types = kwargs.get(
183185
"_use_arrow_native_complex_types", True
@@ -1206,7 +1208,9 @@ def get_schemas(
12061208
getDirectResults=ttypes.TSparkGetDirectResults(
12071209
maxRows=max_rows, maxBytes=max_bytes
12081210
),
1209-
catalogName=catalog_name,
1211+
catalogName=escape_metadata_catalog_name(
1212+
catalog_name, self._escape_catalog_name
1213+
),
12101214
schemaName=schema_name,
12111215
)
12121216
resp = self.make_request(self._client.GetSchemas, req)
@@ -1252,7 +1256,9 @@ def get_tables(
12521256
getDirectResults=ttypes.TSparkGetDirectResults(
12531257
maxRows=max_rows, maxBytes=max_bytes
12541258
),
1255-
catalogName=catalog_name,
1259+
catalogName=escape_metadata_catalog_name(
1260+
catalog_name, self._escape_catalog_name
1261+
),
12561262
schemaName=schema_name,
12571263
tableName=table_name,
12581264
tableTypes=table_types,
@@ -1300,7 +1306,9 @@ def get_columns(
13001306
getDirectResults=ttypes.TSparkGetDirectResults(
13011307
maxRows=max_rows, maxBytes=max_bytes
13021308
),
1303-
catalogName=catalog_name,
1309+
catalogName=escape_metadata_catalog_name(
1310+
catalog_name, self._escape_catalog_name
1311+
),
13041312
schemaName=schema_name,
13051313
tableName=table_name,
13061314
columnName=column_name,

‎src/databricks/sql/client.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,11 @@ def read(self) -> Optional[OAuthToken]:
322322
This allows
323323
1. cursor.tables() to return METRIC_VIEW table type
324324
2. cursor.columns() to return "measure" column type
325+
:param escape_catalog_name: `bool`, optional (default is False)
326+
Escape unescaped underscores in catalog names for schemas(), tables(),
327+
and columns() metadata requests. Existing escapes and % wildcards are
328+
preserved. Kernel schemas() and columns() already use exact catalog
329+
identifiers, so their catalog names are passed through unchanged.
325330
:param fetch_autocommit_from_server: `bool`, optional (default is False)
326331
When True, the connection.autocommit property queries the server for current state
327332
using SET AUTOCOMMIT instead of returning cached value.

‎src/databricks/sql/session.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ def _create_backend(
334334
max_connections=kwargs.get("_pool_maxsize") or None,
335335
enable_geospatial_support=kwargs.get("enable_geospatial_support", True),
336336
telemetry_options=kernel_telemetry_options,
337+
escape_catalog_name=kwargs.get("escape_catalog_name", False),
337338
)
338339

339340
# These reference the lazily-resolved module attributes defined via

‎src/databricks/sql/utils.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@
4646
logger = logging.getLogger(__name__)
4747

4848

49+
def escape_metadata_catalog_name(
50+
catalog_name: Optional[str], enabled: bool
51+
) -> Optional[str]:
52+
r"""Match JDBC: escape _ unless immediately preceded by \; preserve %."""
53+
if not enabled or catalog_name is None:
54+
return catalog_name
55+
return re.sub(r"(?<!\\)_", r"\\_", catalog_name)
56+
57+
4958
def get_session_config_value(
5059
session_configuration: Optional[Dict[str, Any]], key: str
5160
) -> Optional[str]:

‎tests/unit/test_kernel_client.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1903,6 +1903,102 @@ def test_sync_execute_leaves_rowcount_default_when_num_modified_rows_none():
19031903
# ---------------------------------------------------------------------------
19041904

19051905

1906+
@pytest.mark.parametrize(
1907+
"options",
1908+
[{}, {"escape_catalog_name": False}, {"escape_catalog_name": True}],
1909+
ids=["default", "disabled", "enabled"],
1910+
)
1911+
@pytest.mark.parametrize(
1912+
"method, filters, kernel_filters",
1913+
[
1914+
("schemas", {"schema_name": "schema_%"}, {"schema_pattern": "schema_%"}),
1915+
(
1916+
"tables",
1917+
{
1918+
"schema_name": "schema_%",
1919+
"table_name": "table_%",
1920+
"table_types": ["TABLE"],
1921+
},
1922+
{
1923+
"schema_pattern": "schema_%",
1924+
"table_pattern": "table_%",
1925+
"table_types": ["TABLE"],
1926+
},
1927+
),
1928+
(
1929+
"columns",
1930+
{
1931+
"schema_name": "schema_%",
1932+
"table_name": "table_%",
1933+
"column_name": "column_%",
1934+
},
1935+
{
1936+
"schema_pattern": "schema_%",
1937+
"table_pattern": "table_%",
1938+
"column_pattern": "column_%",
1939+
},
1940+
),
1941+
],
1942+
)
1943+
@pytest.mark.parametrize(
1944+
"catalog_name, escaped",
1945+
[
1946+
(None, None),
1947+
("", ""),
1948+
("%", "%"),
1949+
("*", "*"),
1950+
("simple", "simple"),
1951+
("my_catalog", r"my\_catalog"),
1952+
("a_b_c", r"a\_b\_c"),
1953+
(r"my\_catalog", r"my\_catalog"),
1954+
(r"a\_b_c%", r"a\_b\_c%"),
1955+
# Match JDBC: preserve the final \_ pair regardless of backslash parity.
1956+
pytest.param(
1957+
r"my\\_cat_tail",
1958+
r"my\\_cat\_tail",
1959+
id="jdbc-even-backslash-run",
1960+
),
1961+
pytest.param(
1962+
r"my\\\_cat_tail",
1963+
r"my\\\_cat\_tail",
1964+
id="jdbc-odd-backslash-run",
1965+
),
1966+
],
1967+
)
1968+
def test_metadata_catalog_escaping(
1969+
monkeypatch, options, method, filters, kernel_filters, catalog_name, escaped
1970+
):
1971+
from databricks import sql
1972+
1973+
kernel_session = MagicMock()
1974+
kernel_session.session_id = "session-id"
1975+
rpc = getattr(kernel_session.metadata.return_value, f"list_{method}")
1976+
rpc.return_value = _stream_with_schema()
1977+
monkeypatch.setattr(
1978+
kernel_client._kernel, "Session", lambda **kwargs: kernel_session
1979+
)
1980+
1981+
with sql.connect(
1982+
server_hostname="example.cloud.databricks.com",
1983+
http_path="/sql/1.0/warehouses/abc",
1984+
access_token="test-token",
1985+
use_kernel=True,
1986+
enable_telemetry=False,
1987+
**options,
1988+
) as connection:
1989+
cursor = connection.cursor()
1990+
assert getattr(cursor, method)(catalog_name=catalog_name, **filters) is cursor
1991+
1992+
expected = (
1993+
escaped
1994+
if method == "tables" and options.get("escape_catalog_name", False)
1995+
else catalog_name
1996+
)
1997+
if expected in ("%", "*"):
1998+
expected = None
1999+
rpc.assert_called_once_with(catalog=expected, **kernel_filters)
2000+
2001+
19062002
@pytest.mark.parametrize("catalog_wildcard", ["%", "*"])
19072003
def test_get_columns_normalizes_all_catalog_wildcard(catalog_wildcard):
19082004
c = _make_client()

‎tests/unit/test_thrift_backend.py‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,108 @@ def retry_policy_factory():
3232
}
3333

3434

35+
@pytest.mark.parametrize(
36+
"options",
37+
[{}, {"escape_catalog_name": False}, {"escape_catalog_name": True}],
38+
ids=["default", "disabled", "enabled"],
39+
)
40+
@pytest.mark.parametrize(
41+
"method, rpc_name, filters",
42+
[
43+
("schemas", "GetSchemas", {"schema_name": "schema_%"}),
44+
(
45+
"tables",
46+
"GetTables",
47+
{
48+
"schema_name": "schema_%",
49+
"table_name": "table_%",
50+
"table_types": ["TABLE"],
51+
},
52+
),
53+
(
54+
"columns",
55+
"GetColumns",
56+
{
57+
"schema_name": "schema_%",
58+
"table_name": "table_%",
59+
"column_name": "column_%",
60+
},
61+
),
62+
],
63+
)
64+
@pytest.mark.parametrize(
65+
"catalog_name, escaped",
66+
[
67+
(None, None),
68+
("", ""),
69+
("%", "%"),
70+
("*", "*"),
71+
("simple", "simple"),
72+
("my_catalog", r"my\_catalog"),
73+
("a_b_c", r"a\_b\_c"),
74+
(r"my\_catalog", r"my\_catalog"),
75+
(r"a\_b_c%", r"a\_b\_c%"),
76+
# Match JDBC: preserve the final \_ pair regardless of backslash parity.
77+
pytest.param(
78+
r"my\\_cat_tail",
79+
r"my\\_cat\_tail",
80+
id="jdbc-even-backslash-run",
81+
),
82+
pytest.param(
83+
r"my\\\_cat_tail",
84+
r"my\\\_cat\_tail",
85+
id="jdbc-odd-backslash-run",
86+
),
87+
],
88+
)
89+
def test_metadata_catalog_escaping(
90+
options, method, rpc_name, filters, catalog_name, escaped
91+
):
92+
with (
93+
patch(
94+
"databricks.sql.backend.thrift_backend.TCLIService.Client", autospec=True
95+
) as client_class,
96+
patch("databricks.sql.result_set.ThriftResultSet"),
97+
patch.object(
98+
ThriftDatabricksClient,
99+
"_handle_execute_response",
100+
return_value=(Mock(), False),
101+
),
102+
):
103+
rpc_client = client_class.return_value
104+
rpc_client.OpenSession.return_value = ThriftBackendTestSuite.open_session_resp
105+
rpc = getattr(rpc_client, rpc_name)
106+
rpc.return_value = Mock(
107+
status=ThriftBackendTestSuite.okay_status, directResults=None
108+
)
109+
110+
with databricks.sql.connect(
111+
server_hostname="example.cloud.databricks.com",
112+
http_path="/sql/1.0/warehouses/abc",
113+
access_token="test-token",
114+
enable_telemetry=False,
115+
**options,
116+
) as connection:
117+
cursor = connection.cursor()
118+
assert (
119+
getattr(cursor, method)(catalog_name=catalog_name, **filters) is cursor
120+
)
121+
122+
request = rpc.call_args.args[0]
123+
expected = (
124+
escaped if options.get("escape_catalog_name", False) else catalog_name
125+
)
126+
assert request.catalogName == expected
127+
for arg, value in filters.items():
128+
thrift_field = {
129+
"schema_name": "schemaName",
130+
"table_name": "tableName",
131+
"column_name": "columnName",
132+
"table_types": "tableTypes",
133+
}[arg]
134+
assert getattr(request, thrift_field) == value
135+
136+
35137
@pytest.mark.skipif(pyarrow is None, reason="PyArrow is not installed")
36138
class ThriftBackendTestSuite(unittest.TestCase):
37139
okay_status = ttypes.TStatus(statusCode=ttypes.TStatusCode.SUCCESS_STATUS)

0 commit comments

Comments
 (0)