Skip to content

Commit 89696e5

Browse files
committed
harden virtual catalog injection and config validation
1 parent 212efc0 commit 89696e5

5 files changed

Lines changed: 55 additions & 10 deletions

File tree

sqlmesh/core/config/connection.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2120,6 +2120,15 @@ class ClickhouseConnectionConfig(ConnectionConfig):
21202120

21212121
_engine_import_validator = _get_engine_import_validator("clickhouse_connect", "clickhouse")
21222122

2123+
@field_validator("virtual_catalog")
2124+
def validate_virtual_catalog(cls, v: t.Optional[str]) -> t.Optional[str]:
2125+
if v is not None and not v.strip():
2126+
raise ConfigError(
2127+
"virtual_catalog cannot be an empty string. "
2128+
"Omit the field to use the default synthetic prefix (__<gateway_name>__)."
2129+
)
2130+
return v
2131+
21232132
@property
21242133
def _connection_kwargs_keys(self) -> t.Set[str]:
21252134
kwargs = {

sqlmesh/core/context.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -487,12 +487,7 @@ def engine_adapter(self) -> EngineAdapter:
487487
@property
488488
def snapshot_evaluator(self) -> SnapshotEvaluator:
489489
if not self._snapshot_evaluator:
490-
# Ensure virtual catalog injection (via default_catalog_per_gateway) has run before
491-
# cloning adapters with with_settings(). Adapters that support virtual catalogs (e.g.
492-
# ClickHouse alongside catalog-aware gateways) mutate _default_catalog during
493-
# get_default_catalog_per_gateway. with_settings() forwards _default_catalog to the
494-
# clone, so the mutation must happen first or the clones will miss the virtual catalog.
495-
self.default_catalog_per_gateway # noqa: B018
490+
self._ensure_virtual_catalog_injection()
496491
self._snapshot_evaluator = SnapshotEvaluator(
497492
{
498493
gateway: adapter.with_settings(execute_log_level=logging.INFO)
@@ -503,6 +498,15 @@ def snapshot_evaluator(self) -> SnapshotEvaluator:
503498
)
504499
return self._snapshot_evaluator
505500

501+
def _ensure_virtual_catalog_injection(self) -> None:
502+
"""Ensure virtual catalog injection has run before adapters are cloned for SnapshotEvaluator.
503+
504+
Injection is a side effect of get_default_catalog_per_gateway. In normal usage it fires
505+
earlier (default_catalog is accessed during model loading), but this guard covers the edge
506+
case where snapshot_evaluator is accessed directly on a fresh context before any model ops.
507+
"""
508+
_ = self.default_catalog_per_gateway
509+
506510
def execution_context(
507511
self,
508512
deployability_index: t.Optional[DeployabilityIndex] = None,

sqlmesh/core/engine_adapter/clickhouse.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,9 @@ class ClickhouseEngineAdapter(EngineAdapterWithIndexSupport, LogicalMergeMixin):
4545

4646
@property
4747
def catalog_support(self) -> CatalogSupport:
48-
# When a virtual catalog has been injected via inject_virtual_catalog() (to align
49-
# nesting levels with catalog-aware gateways in the same project), treat ClickHouse as
50-
# SINGLE_CATALOG_ONLY so the set_catalog decorator strips the virtual catalog from DDL
51-
# expressions instead of raising UnsupportedCatalogOperationError.
48+
# This property is intentionally dynamic: it transitions from UNSUPPORTED to
49+
# SINGLE_CATALOG_ONLY after inject_virtual_catalog() sets _default_catalog. Callers must
50+
# not cache the result — always read it live so they see the post-injection state.
5251
if self._default_catalog:
5352
return CatalogSupport.SINGLE_CATALOG_ONLY
5453
return CatalogSupport.UNSUPPORTED

tests/core/engine_adapter/test_clickhouse.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1474,3 +1474,19 @@ def test_clickhouse_connection_config_virtual_catalog_extra_engine_config():
14741474
host="localhost", username="user", virtual_catalog="my_catalog"
14751475
)
14761476
assert config._extra_engine_config.get("virtual_catalog") == "my_catalog"
1477+
1478+
1479+
def test_clickhouse_connection_config_virtual_catalog_empty_string_rejected():
1480+
"""virtual_catalog: "" is a footgun — the empty string propagates to _default_catalog,
1481+
which is falsy, so catalog_support stays UNSUPPORTED and the nesting error persists.
1482+
Reject it at config parse time with a clear message."""
1483+
import pytest
1484+
1485+
from sqlmesh.core.config.connection import ClickhouseConnectionConfig
1486+
from sqlmesh.utils.errors import ConfigError
1487+
1488+
with pytest.raises(ConfigError, match="virtual_catalog cannot be an empty string"):
1489+
ClickhouseConnectionConfig(host="localhost", username="user", virtual_catalog="")
1490+
1491+
with pytest.raises(ConfigError, match="virtual_catalog cannot be an empty string"):
1492+
ClickhouseConnectionConfig(host="localhost", username="user", virtual_catalog=" ")

tests/core/test_context.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,23 @@ def test_multi_gateway_clickhouse_custom_virtual_catalog(tmp_path: Path, mocker)
541541
assert ch_adapter.catalog_support == CatalogSupport.SINGLE_CATALOG_ONLY
542542

543543

544+
def test_snapshot_evaluator_calls_ensure_virtual_catalog_injection(mocker):
545+
"""snapshot_evaluator must call _ensure_virtual_catalog_injection before cloning adapters.
546+
547+
This guards the edge case where snapshot_evaluator is the first property accessed on a fresh
548+
context — before default_catalog fires during model loading — and ensures virtual catalog
549+
injection still happens even in that order.
550+
"""
551+
ctx = Context(config=Config())
552+
ctx._snapshot_evaluator = None # force re-initialization
553+
554+
inject_spy = mocker.patch.object(ctx, "_ensure_virtual_catalog_injection")
555+
556+
_ = ctx.snapshot_evaluator
557+
558+
inject_spy.assert_called_once()
559+
560+
544561
def test_plan_execution_time():
545562
context = Context(config=Config())
546563
context.upsert_model(

0 commit comments

Comments
 (0)