Skip to content

Commit 5229187

Browse files
committed
feat: add opt-in metadata catalog name escaping
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
1 parent 9eb024c commit 5229187

8 files changed

Lines changed: 208 additions & 4 deletions

File tree

‎CONNECTION_PARAMETERS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ to change without notice.
166166
| `schema` | `str` | ✅ | ✅ | `None` | Initial schema for the session (DBR 9.0+). |
167167
| `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. |
168168
| `enable_metric_view_metadata` | `bool` | ✅ | ✅ | `False` | Sets `spark.sql.thriftserver.metadata.metricview.enabled` via session config so metric-view metadata surfaces. |
169+
| `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. |
169170
| `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. |
170171
| `ignore_transactions` | `bool` | ✅ | ✅ | `True` | When `True`: `commit()` is a no-op, `rollback()` raises `NotSupportedError`, and setting `autocommit` is a no-op. |
171172
| `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
@@ -267,6 +268,7 @@ def __init__(
267268
self._telemetry_options = kwargs.get("telemetry_options") or {}
268269
self._catalog = catalog
269270
self._schema = schema
271+
self._escape_catalog_name = kwargs.get("escape_catalog_name", False)
270272
# ``_use_arrow_native_complex_types`` is the connector-side
271273
# toggle for whether complex columns (ARRAY / MAP / STRUCT)
272274
# are surfaced as native Arrow shapes or as compact JSON
@@ -1019,7 +1021,9 @@ def get_tables(
10191021
# do the work — no connector-side drain + refilter. Passing it
10201022
# through preserves streaming for large schemas.
10211023
stream = self._kernel_session.metadata().list_tables(
1022-
catalog=_catalog_or_none(catalog_name),
1024+
catalog=escape_metadata_catalog_name(
1025+
_catalog_or_none(catalog_name), self._escape_catalog_name
1026+
),
10231027
schema_pattern=schema_name,
10241028
table_pattern=table_name,
10251029
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
@@ -52,6 +52,7 @@
5252
convert_arrow_based_set_to_arrow_table,
5353
convert_decimals_in_arrow_table,
5454
convert_column_based_set_to_arrow_table,
55+
escape_metadata_catalog_name,
5556
serialize_query_tags,
5657
)
5758
from databricks.sql.types import SSLOptions
@@ -177,6 +178,7 @@ def __init__(
177178
raise ValueError("No valid connection settings.")
178179

179180
self._host = server_hostname
181+
self._escape_catalog_name = kwargs.get("escape_catalog_name", False)
180182
self._initialize_retry_args(kwargs)
181183
self._use_arrow_native_complex_types = kwargs.get(
182184
"_use_arrow_native_complex_types", True
@@ -1193,7 +1195,9 @@ def get_schemas(
11931195
getDirectResults=ttypes.TSparkGetDirectResults(
11941196
maxRows=max_rows, maxBytes=max_bytes
11951197
),
1196-
catalogName=catalog_name,
1198+
catalogName=escape_metadata_catalog_name(
1199+
catalog_name, self._escape_catalog_name
1200+
),
11971201
schemaName=schema_name,
11981202
)
11991203
resp = self.make_request(self._client.GetSchemas, req)
@@ -1239,7 +1243,9 @@ def get_tables(
12391243
getDirectResults=ttypes.TSparkGetDirectResults(
12401244
maxRows=max_rows, maxBytes=max_bytes
12411245
),
1242-
catalogName=catalog_name,
1246+
catalogName=escape_metadata_catalog_name(
1247+
catalog_name, self._escape_catalog_name
1248+
),
12431249
schemaName=schema_name,
12441250
tableName=table_name,
12451251
tableTypes=table_types,
@@ -1287,7 +1293,9 @@ def get_columns(
12871293
getDirectResults=ttypes.TSparkGetDirectResults(
12881294
maxRows=max_rows, maxBytes=max_bytes
12891295
),
1290-
catalogName=catalog_name,
1296+
catalogName=escape_metadata_catalog_name(
1297+
catalog_name, self._escape_catalog_name
1298+
),
12911299
schemaName=schema_name,
12921300
tableName=table_name,
12931301
columnName=column_name,

‎src/databricks/sql/client.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,11 @@ def read(self) -> Optional[OAuthToken]:
315315
This allows
316316
1. cursor.tables() to return METRIC_VIEW table type
317317
2. cursor.columns() to return "measure" column type
318+
:param escape_catalog_name: `bool`, optional (default is False)
319+
Escape unescaped underscores in catalog names for schemas(), tables(),
320+
and columns() metadata requests. Existing escapes and % wildcards are
321+
preserved. Kernel schemas() and columns() already use exact catalog
322+
identifiers, so their catalog names are passed through unchanged.
318323
:param fetch_autocommit_from_server: `bool`, optional (default is False)
319324
When True, the connection.autocommit property queries the server for current state
320325
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
@@ -333,6 +333,7 @@ def _create_backend(
333333
request_timeout_secs=kwargs.get("_socket_timeout"),
334334
max_connections=kwargs.get("_pool_maxsize") or None,
335335
telemetry_options=kernel_telemetry_options,
336+
escape_catalog_name=kwargs.get("escape_catalog_name", False),
336337
)
337338

338339
# 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+
"""Escape unescaped underscores, preserving existing escapes and % wildcards."""
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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1812,6 +1812,91 @@ def test_sync_execute_leaves_rowcount_default_when_num_modified_rows_none():
18121812
# ---------------------------------------------------------------------------
18131813

18141814

1815+
@pytest.mark.parametrize(
1816+
"options",
1817+
[{}, {"escape_catalog_name": False}, {"escape_catalog_name": True}],
1818+
ids=["default", "disabled", "enabled"],
1819+
)
1820+
@pytest.mark.parametrize(
1821+
"method, filters, kernel_filters",
1822+
[
1823+
("schemas", {"schema_name": "schema_%"}, {"schema_pattern": "schema_%"}),
1824+
(
1825+
"tables",
1826+
{
1827+
"schema_name": "schema_%",
1828+
"table_name": "table_%",
1829+
"table_types": ["TABLE"],
1830+
},
1831+
{
1832+
"schema_pattern": "schema_%",
1833+
"table_pattern": "table_%",
1834+
"table_types": ["TABLE"],
1835+
},
1836+
),
1837+
(
1838+
"columns",
1839+
{
1840+
"schema_name": "schema_%",
1841+
"table_name": "table_%",
1842+
"column_name": "column_%",
1843+
},
1844+
{
1845+
"schema_pattern": "schema_%",
1846+
"table_pattern": "table_%",
1847+
"column_pattern": "column_%",
1848+
},
1849+
),
1850+
],
1851+
)
1852+
@pytest.mark.parametrize(
1853+
"catalog_name, escaped",
1854+
[
1855+
(None, None),
1856+
("", ""),
1857+
("%", "%"),
1858+
("*", "*"),
1859+
("simple", "simple"),
1860+
("my_catalog", r"my\_catalog"),
1861+
("a_b_c", r"a\_b\_c"),
1862+
(r"my\_catalog", r"my\_catalog"),
1863+
(r"a\_b_c%", r"a\_b\_c%"),
1864+
],
1865+
)
1866+
def test_metadata_catalog_escaping(
1867+
monkeypatch, options, method, filters, kernel_filters, catalog_name, escaped
1868+
):
1869+
from databricks import sql
1870+
1871+
kernel_session = MagicMock()
1872+
kernel_session.session_id = "session-id"
1873+
rpc = getattr(kernel_session.metadata.return_value, f"list_{method}")
1874+
rpc.return_value = _stream_with_schema()
1875+
monkeypatch.setattr(
1876+
kernel_client._kernel, "Session", lambda **kwargs: kernel_session
1877+
)
1878+
1879+
with sql.connect(
1880+
server_hostname="example.cloud.databricks.com",
1881+
http_path="/sql/1.0/warehouses/abc",
1882+
access_token="test-token",
1883+
use_kernel=True,
1884+
enable_telemetry=False,
1885+
**options,
1886+
) as connection:
1887+
cursor = connection.cursor()
1888+
assert getattr(cursor, method)(catalog_name=catalog_name, **filters) is cursor
1889+
1890+
expected = (
1891+
escaped
1892+
if method == "tables" and options.get("escape_catalog_name", False)
1893+
else catalog_name
1894+
)
1895+
if expected in ("%", "*"):
1896+
expected = None
1897+
rpc.assert_called_once_with(catalog=expected, **kernel_filters)
1898+
1899+
18151900
@pytest.mark.parametrize("catalog_wildcard", ["%", "*"])
18161901
def test_get_columns_normalizes_all_catalog_wildcard(catalog_wildcard):
18171902
c = _make_client()

‎tests/unit/test_thrift_backend.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,97 @@ 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+
],
77+
)
78+
def test_metadata_catalog_escaping(
79+
options, method, rpc_name, filters, catalog_name, escaped
80+
):
81+
with (
82+
patch(
83+
"databricks.sql.backend.thrift_backend.TCLIService.Client", autospec=True
84+
) as client_class,
85+
patch("databricks.sql.result_set.ThriftResultSet"),
86+
patch.object(
87+
ThriftDatabricksClient,
88+
"_handle_execute_response",
89+
return_value=(Mock(), False),
90+
),
91+
):
92+
rpc_client = client_class.return_value
93+
rpc_client.OpenSession.return_value = ThriftBackendTestSuite.open_session_resp
94+
rpc = getattr(rpc_client, rpc_name)
95+
rpc.return_value = Mock(
96+
status=ThriftBackendTestSuite.okay_status, directResults=None
97+
)
98+
99+
with databricks.sql.connect(
100+
server_hostname="example.cloud.databricks.com",
101+
http_path="/sql/1.0/warehouses/abc",
102+
access_token="test-token",
103+
enable_telemetry=False,
104+
**options,
105+
) as connection:
106+
cursor = connection.cursor()
107+
assert (
108+
getattr(cursor, method)(catalog_name=catalog_name, **filters) is cursor
109+
)
110+
111+
request = rpc.call_args.args[0]
112+
expected = (
113+
escaped if options.get("escape_catalog_name", False) else catalog_name
114+
)
115+
assert request.catalogName == expected
116+
for arg, value in filters.items():
117+
thrift_field = {
118+
"schema_name": "schemaName",
119+
"table_name": "tableName",
120+
"column_name": "columnName",
121+
"table_types": "tableTypes",
122+
}[arg]
123+
assert getattr(request, thrift_field) == value
124+
125+
35126
@pytest.mark.skipif(pyarrow is None, reason="PyArrow is not installed")
36127
class ThriftBackendTestSuite(unittest.TestCase):
37128
okay_status = ttypes.TStatus(statusCode=ttypes.TStatusCode.SUCCESS_STATUS)

0 commit comments

Comments
 (0)