Skip to content

Commit c4dbac4

Browse files
authored
feat(clickhouse): support DataLake catalog source mappings (#6153)
1 parent ccf6678 commit c4dbac4

13 files changed

Lines changed: 1444 additions & 4 deletions

File tree

‎.github/workflows/pr.yaml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ jobs:
267267
trino,
268268
spark,
269269
clickhouse,
270+
clickhouse-datalake,
270271
risingwave,
271272
starrocks,
272273
]
@@ -293,6 +294,9 @@ jobs:
293294
run: |
294295
source .venv/bin/activate
295296
make ${{ matrix.engine }}-test
297+
- name: Stop DataLake services
298+
if: ${{ always() && matrix.engine == 'clickhouse-datalake' }}
299+
run: make clickhouse-datalake-down
296300
- name: Upload test results
297301
uses: actions/upload-artifact@v7
298302
if: ${{ !cancelled() }}

‎Makefile‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.PHONY: docs
2+
.PHONY: clickhouse-datalake-up clickhouse-datalake-test clickhouse-datalake-down
23

34
ifdef UV
45
PIP := uv pip
@@ -192,6 +193,21 @@ engine-%-up: engine-%-install engine-docker-%-up
192193
engine-%-down:
193194
docker compose -f ./tests/core/engine_adapter/integration/docker/compose.${*}.yaml down -v
194195

196+
CLICKHOUSE_DATALAKE_COMPOSE = docker compose --project-name sqlmesh-clickhouse-datalake -f ./tests/core/engine_adapter/integration/docker/compose.clickhouse-datalake.yaml
197+
198+
clickhouse-datalake-up:
199+
$(CLICKHOUSE_DATALAKE_COMPOSE) up -d --wait --wait-timeout 120 clickhouse minio-init rest
200+
$(CLICKHOUSE_DATALAKE_COMPOSE) run --build --rm seed
201+
202+
clickhouse-datalake-test:
203+
docker info >/dev/null
204+
$(MAKE) engine-clickhouse-install
205+
$(MAKE) clickhouse-datalake-up
206+
SQLMESH_RUN_CLICKHOUSE_DATALAKE=1 pytest -n 0 -m "docker and clickhouse_datalake" tests/core/engine_adapter/integration/test_integration_clickhouse_datalake.py --junitxml=test-results/junit-clickhouse-datalake.xml
207+
208+
clickhouse-datalake-down:
209+
$(CLICKHOUSE_DATALAKE_COMPOSE) down -v --remove-orphans
210+
195211
##################
196212
# Docker Engines #
197213
##################

‎docs/integrations/engines/clickhouse.md‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,57 @@ gateways:
470470

471471
With this configuration, ClickHouse models will appear as `ch_virtual.mydb.mytable` in plan output instead of `__clickhouse__.mydb.mytable`.
472472

473+
### Reading catalog-backed physical tables
474+
475+
When ClickHouse exposes a source catalog through a [`DataLakeCatalog`](https://clickhouse.com/docs/engines/database-engines/datalake), map that source catalog to its ClickHouse database in the connection configuration:
476+
477+
```yaml
478+
gateways:
479+
clickhouse:
480+
connection:
481+
type: clickhouse
482+
host: my-clickhouse-host
483+
username: default
484+
datalake_catalog_mapping:
485+
lake: iceberg
486+
```
487+
488+
SQLMesh renders `lake.analytics.orders` as `iceberg."analytics.orders"`. It combines the source
489+
schema and table into one quoted table name because ClickHouse uses a two-level namespace. The
490+
`iceberg` database must already exist and use the `DataLakeCatalog` engine. SQLMesh does not create
491+
the database or manage its metastore or storage permissions.
492+
493+
For parsed queries that read data without writing the result to a ClickHouse table, SQLMesh rewrites
494+
mapped source catalogs and passes other foreign catalog-qualified references through unchanged.
495+
During rendering, SQLMesh strips the configured virtual catalog prefix, even before injection. For
496+
`INSERT ... SELECT` and other query materializations, SQLMesh rewrites mapped sources and validates
497+
the remaining catalog-qualified sources. With a non-empty mapping, each source catalog must appear
498+
in the mapping. When SQLMesh has injected a virtual catalog, a source can also use that catalog.
499+
SQLMesh raises an error for other catalogs. Without a non-empty mapping or an injected virtual
500+
catalog, SQLMesh does not apply DataLake mapping or catalog validation during materialization.
501+
502+
This mapping rewrites parsed query sources. It does not rewrite table names used for metadata
503+
operations such as `columns()` or `table_exists()`. The `sqlmesh create_external_models` command also
504+
uses the source name as supplied when it fetches column types, so it cannot infer columns for a
505+
source named `lake.analytics.orders` through the mapping. In a ClickHouse-only project, registering
506+
that three-part logical name as an external model can also fail SQLMesh's schema nesting check,
507+
because ClickHouse project models use two-part names.
508+
509+
To register column metadata until mapped names are supported there, use ClickHouse's physical
510+
two-part name in both the external model and the query. For example, use
511+
`iceberg."analytics.orders"` for the table exposed by database `iceberg`. This matches ClickHouse's
512+
`database."schema.table"` name and keeps the model at ClickHouse's two-part nesting level. The
513+
mapping still lets SQLMesh render queries that use mapped logical names. Without registered
514+
column metadata, SQLMesh cannot fully optimize those queries or produce column-level lineage.
515+
516+
An injected virtual catalog takes precedence over a mapping key with the same name. SQLMesh treats
517+
that name as its virtual catalog. Use different names for the virtual catalog and DataLakeCatalog
518+
source catalogs. The mapping applies to query source references. It does not rewrite SQL strings
519+
passed directly to the ClickHouse adapter or map `INSERT` targets.
520+
521+
View creation rejects a source that uses another catalog when a virtual catalog is injected, even
522+
if the mapping includes that source. Mapped-source views work when no virtual catalog is injected.
523+
473524
## Local/Built-in Scheduler
474525

475526
**Engine Adapter Type**: `clickhouse`
@@ -498,3 +549,4 @@ With this configuration, ClickHouse models will appear as `ch_virtual.mydb.mytab
498549
| `connection_settings` | Additional [connection settings](https://clickhouse.com/docs/integrations/python#settings-argument) | dict | N |
499550
| `connection_pool_options` | Additional [options](https://clickhouse.com/docs/integrations/python#customizing-the-http-connection-pool) for the HTTP connection pool | dict | N |
500551
| `virtual_catalog` | Override the virtual catalog name used when ClickHouse runs alongside a catalog-aware gateway (e.g. Trino). Defaults to `__{gateway_name}__`. See [Multi-gateway setup](#multi-gateway-setup) for details. | string | N |
552+
| `datalake_catalog_mapping` | Map source catalogs to ClickHouse DataLakeCatalog databases. See [Reading catalog-backed physical tables](#reading-catalog-backed-physical-tables). | dict | N |

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ markers = [
273273
"athena: test for Athena",
274274
"bigquery: test for BigQuery",
275275
"clickhouse: test for Clickhouse (standalone mode / cluster mode)",
276+
"clickhouse_datalake: test for ClickHouse DataLakeCatalog mapping",
276277
"clickhouse_cloud: test for Clickhouse (cloud mode)",
277278
"databricks: test for Databricks",
278279
"db2: test for Db2",

‎sqlmesh/core/config/connection.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2244,6 +2244,7 @@ class ClickhouseConnectionConfig(ConnectionConfig):
22442244
port: t.Optional[int] = None
22452245
cluster: t.Optional[str] = None
22462246
virtual_catalog: t.Optional[str] = None
2247+
datalake_catalog_mapping: t.Dict[str, str] = Field(default_factory=dict)
22472248
connect_timeout: int = 10
22482249
send_receive_timeout: int = 300
22492250
query_limit: int = 0
@@ -2291,6 +2292,21 @@ def validate_virtual_catalog(cls, v: t.Optional[str]) -> t.Optional[str]:
22912292
)
22922293
return v
22932294

2295+
@field_validator("datalake_catalog_mapping")
2296+
def validate_datalake_catalog_mapping(cls, v: t.Dict[str, str]) -> t.Dict[str, str]:
2297+
for source_catalog, target_database in v.items():
2298+
if not source_catalog.strip() or "." in source_catalog:
2299+
raise ConfigError(
2300+
"datalake_catalog_mapping keys must be non-empty single identifiers "
2301+
f"(got: {source_catalog!r})"
2302+
)
2303+
if not target_database.strip() or "." in target_database:
2304+
raise ConfigError(
2305+
"datalake_catalog_mapping values must be non-empty ClickHouse database "
2306+
f"identifiers (got: {target_database!r})"
2307+
)
2308+
return v
2309+
22942310
@property
22952311
def _connection_kwargs_keys(self) -> t.Set[str]:
22962312
kwargs = {
@@ -2357,6 +2373,7 @@ def _extra_engine_config(self) -> t.Dict[str, t.Any]:
23572373
"cluster": self.cluster,
23582374
"cloud_mode": self.cloud_mode,
23592375
"virtual_catalog": self.virtual_catalog,
2376+
"datalake_catalog_mapping": self.datalake_catalog_mapping,
23602377
}
23612378

23622379
@property

‎sqlmesh/core/engine_adapter/clickhouse.py‎

Lines changed: 153 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import re
66
from functools import cached_property
77
from sqlglot import exp, maybe_parse
8+
from sqlglot.optimizer.scope import Scope, find_all_in_scope, traverse_scope
89
from sqlmesh.core.dialect import to_schema
910
from sqlmesh.core.engine_adapter.mixins import LogicalMergeMixin
1011
from sqlmesh.core.engine_adapter.base import EngineAdapterWithIndexSupport
@@ -20,6 +21,7 @@
2021
)
2122
from sqlmesh.core.schema_diff import TableAlterOperation
2223
from sqlmesh.utils import get_source_columns_to_types
24+
from sqlmesh.utils.errors import SQLMeshError
2325

2426
if t.TYPE_CHECKING:
2527
import pandas as pd
@@ -71,17 +73,164 @@ def inject_virtual_catalog(self, gateway: str) -> None:
7173

7274
def _to_sql(self, expression: exp.Expr, quote: bool = True, **kwargs: t.Any) -> str:
7375
"""Render SQL without the virtual catalog, which ClickHouse does not support."""
74-
if self._default_catalog and any(self._virtual_catalog_references(expression)):
76+
virtual_catalog = self._default_catalog or self._extra_config.get("virtual_catalog")
77+
if virtual_catalog and any(self._virtual_catalog_references(expression)):
7578
expression = expression.copy()
7679
for reference in list(self._virtual_catalog_references(expression)):
7780
reference.set("catalog", None)
81+
if isinstance(expression, exp.Query):
82+
expression = self._map_catalog_references(expression, preserve_unmapped=True)
83+
elif isinstance(expression, exp.Delete):
84+
where = expression.args.get("where")
85+
if where and any(where.find_all(exp.Query)):
86+
expression = expression.copy()
87+
where = expression.args["where"]
88+
expression.set(
89+
"where",
90+
where.transform(
91+
lambda node: self._map_catalog_references(node)
92+
if isinstance(node, exp.Query)
93+
else node,
94+
copy=False,
95+
),
96+
)
97+
elif isinstance(expression, exp.Insert) and isinstance(expression.expression, exp.Query):
98+
expression = expression.copy()
99+
expression.set("expression", self._map_catalog_references(expression.expression))
78100
return super()._to_sql(expression, quote=quote, **kwargs)
79101

80-
def _virtual_catalog_references(self, expression: exp.Expr) -> t.Iterator[exp.Expr]:
102+
def _virtual_catalog_references(
103+
self, expression: exp.Expr
104+
) -> t.Iterator[t.Union[exp.Table, exp.Column]]:
105+
virtual_catalog = self._default_catalog or self._extra_config.get("virtual_catalog")
81106
return (
82107
reference
83-
for reference in expression.find_all(exp.Table, exp.Column)
84-
if reference.text("catalog") == self._default_catalog
108+
for reference in self._catalog_references(expression)
109+
if virtual_catalog and reference.text("catalog") == virtual_catalog
110+
)
111+
112+
def _get_source_queries(
113+
self,
114+
query_or_df: QueryOrDF,
115+
target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]],
116+
target_table: TableName,
117+
*,
118+
batch_size: t.Optional[int] = None,
119+
source_columns: t.Optional[t.List[str]] = None,
120+
) -> t.List[SourceQuery]:
121+
if isinstance(query_or_df, exp.Query):
122+
query_or_df = self._map_catalog_references(query_or_df)
123+
return super()._get_source_queries(
124+
query_or_df,
125+
target_columns_to_types,
126+
target_table,
127+
batch_size=batch_size,
128+
source_columns=source_columns,
129+
)
130+
131+
def _map_catalog_references(self, query: Query, *, preserve_unmapped: bool = False) -> Query:
132+
mapping = self._extra_config.get("datalake_catalog_mapping") or {}
133+
if not self._default_catalog and not mapping:
134+
return query
135+
136+
mapped_catalogs = {self._default_catalog, *mapping}
137+
query = query.copy()
138+
for reference in self._catalog_references(query):
139+
if not reference.catalog or (
140+
preserve_unmapped and reference.catalog not in mapped_catalogs
141+
):
142+
continue
143+
if isinstance(reference, exp.Table):
144+
self._map_catalog_reference(reference)
145+
continue
146+
147+
table = self._map_catalog_reference(
148+
exp.Table(
149+
this=reference.args["table"].copy(),
150+
db=reference.args["db"].copy(),
151+
catalog=reference.args["catalog"].copy(),
152+
)
153+
)
154+
for column_key, table_key in (("table", "this"), ("db", "db"), ("catalog", "catalog")):
155+
value = table.args.get(table_key)
156+
reference.set(column_key, value.copy() if value is not None else None)
157+
return query
158+
159+
def _catalog_references(self, expression: exp.Expr) -> t.List[t.Union[exp.Table, exp.Column]]:
160+
"""Use query scopes to distinguish qualified columns from nested-field paths."""
161+
tables = list(expression.find_all(exp.Table))
162+
references: t.List[t.Union[exp.Table, exp.Column]] = list(tables)
163+
root_tables = list(find_all_in_scope(expression, exp.Table))
164+
scoped_columns = set()
165+
virtual_catalog = self._default_catalog or self._extra_config.get("virtual_catalog")
166+
167+
def matches_source_table(column: exp.Column, table: exp.Table) -> bool:
168+
if (column.catalog, column.db, column.table) == (table.catalog, table.db, table.name):
169+
return True
170+
# ClickHouse strips the virtual catalog from DML targets before SQL rendering.
171+
return (
172+
column.catalog == virtual_catalog
173+
and not table.catalog
174+
and (column.db, column.table) == (table.db, table.name)
175+
)
176+
177+
def add_column_reference(column: exp.Column, scope: t.Optional[Scope]) -> None:
178+
if not column.catalog:
179+
return
180+
current_scope = scope
181+
while current_scope is not None:
182+
# The nearest alias or CTE name takes precedence as a nested-field path.
183+
if column.catalog in current_scope.sources:
184+
return
185+
if any(
186+
isinstance(source, exp.Table) and matches_source_table(column, source)
187+
for source in current_scope.sources.values()
188+
):
189+
references.append(column)
190+
return
191+
current_scope = current_scope.parent
192+
193+
if column.catalog in {table.alias_or_name for table in root_tables}:
194+
return
195+
if any(matches_source_table(column, table) for table in root_tables):
196+
references.append(column)
197+
198+
for scope in traverse_scope(expression):
199+
for column in scope.find_all(exp.Column):
200+
scoped_columns.add(id(column))
201+
add_column_reference(column, scope)
202+
203+
for column in find_all_in_scope(expression, exp.Column):
204+
if id(column) not in scoped_columns:
205+
add_column_reference(column, None)
206+
return references
207+
208+
def _map_catalog_reference(self, table: exp.Table) -> exp.Table:
209+
"""Map catalog.schema.table to a ClickHouse DataLakeCatalog database.table reference."""
210+
catalog = table.catalog
211+
if not catalog:
212+
return table
213+
if catalog == self._default_catalog:
214+
table.set("catalog", None)
215+
return table
216+
217+
target_database = (self._extra_config.get("datalake_catalog_mapping") or {}).get(catalog)
218+
if target_database:
219+
if not table.db:
220+
raise SQLMeshError(
221+
f"ClickHouse DataLakeCatalog mapping for '{catalog}' requires a "
222+
"three-level catalog.schema.table reference"
223+
)
224+
source_table = f"{table.db}.{table.name}"
225+
table.set("catalog", None)
226+
table.set("db", exp.to_identifier(target_database))
227+
table.set("this", exp.to_identifier(source_table, quoted=True))
228+
return table
229+
230+
raise SQLMeshError(
231+
f"{self.dialect} requires that all catalog operations be against the virtual "
232+
f"catalog {self._default_catalog!r} or a configured DataLakeCatalog mapping. "
233+
f"Provided catalog: {catalog}"
85234
)
86235

87236
@property
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
3+
FROM python:3.12.13-slim-bookworm@sha256:4766d8b510c428e595d74b9cc5bbb2fae8e26316fffb4adc89908d79aacd58a2
4+
5+
WORKDIR /fixture
6+
7+
COPY requirements.txt .
8+
RUN pip install --no-cache-dir -r requirements.txt
9+
10+
COPY seed.py .
11+
12+
CMD ["python", "seed.py"]

0 commit comments

Comments
 (0)