Skip to content

Commit a6b637b

Browse files
Merge branch 'main' into fix/920-sqlalchemy-internal-formats-r1
2 parents 814075f + 32c93d8 commit a6b637b

39 files changed

Lines changed: 1911 additions & 92 deletions

.github/workflows/on_push.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ jobs:
7676
CLICKHOUSE_CONNECT_SKIP_CYTHON=1 python -m build --sdist --outdir "$RUNNER_TEMP/dist"
7777
CLICKHOUSE_CONNECT_SKIP_CYTHON=1 "$RUNNER_TEMP/consumer/bin/pip" install "$RUNNER_TEMP"/dist/*.tar.gz
7878
# Stub packages are intentionally unpinned. If the ratchet count shifts without a repo change, pin them here.
79-
"$RUNNER_TEMP/consumer/bin/pip" install numpy pandas pandas-stubs pyarrow polars
79+
"$RUNNER_TEMP/consumer/bin/pip" install numpy pandas pandas-stubs pyarrow polars "sqlalchemy>=2.0,<3.0"
8080
"$RUNNER_TEMP/consumer/bin/pip" install "mypy==2.1.0" "pyright==1.1.408"
8181
- name: Confirm py.typed shipped with the install
8282
run: test -f "$RUNNER_TEMP"/consumer/lib/python*/site-packages/clickhouse_connect/py.typed
@@ -86,10 +86,11 @@ jobs:
8686
cd "$RUNNER_TEMP"
8787
"$RUNNER_TEMP/consumer/bin/mypy" --strict --follow-imports=silent consumer_smoke.py
8888
- name: Public type completeness ratchet
89-
run: python scripts/check_public_types.py clickhouse_connect --max-untyped 1116 --python "$RUNNER_TEMP/consumer/bin/python"
89+
# SQLAlchemy must be installed while verifytypes scans the public cc_sqlalchemy annotations.
90+
# The pre-feature baseline is 1120 with that optional surface resolved.
91+
run: python scripts/check_public_types.py clickhouse_connect --max-untyped 1120 --python "$RUNNER_TEMP/consumer/bin/python"
9092
- name: SQLAlchemy Select typing smoke test
9193
run: |
92-
"$RUNNER_TEMP/consumer/bin/pip" install "sqlalchemy>=2.0,<3.0"
9394
cp tests/type_check/sqlalchemy_select_smoke.py "$RUNNER_TEMP"
9495
cd "$RUNNER_TEMP"
9596
"$RUNNER_TEMP/consumer/bin/mypy" --strict --follow-imports=silent sqlalchemy_select_smoke.py
@@ -281,7 +282,7 @@ jobs:
281282
CLICKHOUSE_CONNECT_TEST_TLS: 1
282283
CLICKHOUSE_CONNECT_TEST_DOCKER: 'False'
283284
SQLALCHEMY_SILENCE_UBER_WARNING: 1
284-
run: pytest -n 4 tests/integration_tests/test_sqlalchemy tests/unit_tests/test_sqlalchemy/test_ch_select.py
285+
run: pytest -n 4 tests/integration_tests/test_sqlalchemy tests/unit_tests/test_sqlalchemy/test_ch_select.py tests/unit_tests/test_sqlalchemy/test_materialized_cte.py
285286
- name: Stop ClickHouse
286287
if: ${{ always() && hashFiles('docker-compose.yml') != '' }}
287288
env:

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,29 @@
22

33
## UNRELEASED
44

5+
### Improvements
6+
7+
- SQLAlchemy: added support for materialized common table expressions. `cc_sqlalchemy.select(...).cte("name", materialized=True)` emits `WITH name AS MATERIALIZED (...)`, so a CTE referenced more than once is computed once instead of being inlined and re-executed at each reference. A module-level `cc_sqlalchemy.cte(statement, "name", materialized=True)` does the same for a statement built with the standard `sqlalchemy.select`. The keyword renders only on the ClickHouse dialect. The server materializes the CTE only when the experimental `enable_materialized_cte` setting is also enabled for the query and the analyzer is enabled. Materialized CTEs require ClickHouse 26.3 or later. The SQLAlchemy helpers reject `recursive=True` with `materialized=True` because ClickHouse does not support recursive materialized CTEs. Closes [#900](https://github.com/ClickHouse/clickhouse-connect/issues/900).
8+
- Added the global `naive_datetime_insert` setting for Python object inserts, including naive ISO strings accepted by `DateTime64`. Set it to `"server"` to interpret a naive `datetime` in the timezone declared by the `DateTime` or `DateTime64` column, or in the server timezone when the column has no timezone. The default remains `"local"` in 1.x and preserves the existing host-local conversion. This setting does not change `datetime64`-dtype NumPy and Pandas columns. Use `naive_datetime_binding` to control naive `datetime` query parameters. See [#938](https://github.com/ClickHouse/clickhouse-connect/issues/938).
9+
10+
### Behavior Changes
11+
12+
- Naive `datetime` query parameters now bind as wall time instead of being interpreted in the client host timezone. Previously a naive value passed through `astimezone` for server-side `{name:DateTime}` parameters and `DT64Param` values, so the same query could match different rows depending on the timezone of the machine running it. Only workloads that bind naive datetime parameters with a non-UTC host timezone or a non-UTC target timezone are affected. Environments where both the host and the bind target are UTC see no change, and client-side `%` parameters against a UTC server were already sent verbatim. Two changes are observable. First, on a non-UTC host with a UTC target, server-side parameters and `DT64Param` values no longer shift, which corrects silently wrong results. Second, when the bind target is a non-UTC timezone, a naive value now means wall time in that timezone instead of the instant implied by the client local timezone, which can change matched rows for code that relied on the old conversion. A related consequence is that inserting a naive datetime and then filtering with the same naive value no longer matches on a non-UTC host, because the insert path still interprets naive values as host local time. [#938](https://github.com/ClickHouse/clickhouse-connect/issues/938) tracks unifying insert semantics. Timezone-aware datetimes are unchanged and still convert to the target bind timezone. Set `common.set_setting("naive_datetime_binding", "legacy")` to restore the previous behavior exactly. To make a naive value represent a specific instant under either mode, attach the intended `tzinfo` before binding.
13+
514
### Bug Fixes
615

16+
- DB API module now provides the PEP 249 type constructors `Binary`, `Date`, `Time`, `Timestamp`, `DateFromTicks`, `TimeFromTicks`, and `TimestampFromTicks`. SQLAlchemy `LargeBinary` inserts no longer raise `AttributeError`. Addresses the Binary constructor failure in [#919](https://github.com/ClickHouse/clickhouse-connect/issues/919).
17+
- Fractional `DateTime64` values before the Unix epoch now serialize with the correct second. The serializer truncated negative timestamps toward zero before adding the fractional component, which shifted affected values forward by one second. This affected Python `datetime` values and accepted ISO strings in both naive datetime insert modes. See [#938](https://github.com/ClickHouse/clickhouse-connect/issues/938).
718
- Parsing a nested `Variant`, `Tuple`, `Nested`, or typed `JSON` column type whose element is an `Enum` with an escaped single quote in a value name no longer corrupts the escape sequence and fails while re-parsing the element type. Closes [#878](https://github.com/ClickHouse/clickhouse-connect/issues/878).
819
- `None` nested inside an `Array` or `Tuple`, or inside a `Map` when `dict_parameter_format="map"`, now renders as the SQL `NULL` keyword instead of the `\N` sentinel used for top-level values. Top-level scalar `None` binds are unchanged. Closes [#879](https://github.com/ClickHouse/clickhouse-connect/issues/879).
920
- Inserting empty bytes `b""` into a non-nullable `FixedString(N)` column now zero-pads to N bytes instead of raising `DataError`, matching the existing string and nullable-bytes write paths. Closes [#880](https://github.com/ClickHouse/clickhouse-connect/issues/880).
1021
- Per-query and client settings that are not present in `system.settings` for the current user (including custom settings declared `CHANGEABLE_IN_READONLY` on a role) are now forwarded to ClickHouse instead of raising `ProgrammingError: Setting ... is unknown or readonly`. The client cannot discover those settings without extra privileges, so the server is treated as authoritative. Setting `invalid_setting_action` to `drop` still drops them, so a single settings dict stays portable across server versions. Known readonly settings still honor `invalid_setting_action`, and reserved HTTP request parameter names such as `query`, `user`, `default_format`, and the `param_` bound-parameter namespace still raise a client-side `ProgrammingError` because they are not settings. Closes [#530](https://github.com/ClickHouse/clickhouse-connect/issues/530).
1122
- SQLAlchemy reflection and metadata queries now force internal `String` decoding, so `set_default_formats("String", "bytes")` no longer turns reflected database, table, or column names into `bytes`. The Alembic startup current database lookup uses the same internal format. Alembic version table queries do not use the internal override and remain affected by a global `String` bytes format. Closes [#920](https://github.com/ClickHouse/clickhouse-connect/issues/920).
23+
- Removing a block comment for query type detection no longer joins the tokens around it. The server lexer treats a comment as a token separator, but `remove_sql_comments` replaced it with nothing, so `SELECT/*c*/number FROM numbers(9)` became the single token `SELECTnumber`, stopped looking like a `SELECT`, and the client side `query_limit` was silently dropped, while `SELECT number FROM numbers(9)/*c*/LIMIT 1` became `numbers(9)LIMIT 1`, hid the real `LIMIT`, and the client appended a second one that the server rejected with `Code: 62`. A removed block comment now leaves a single space behind, and the trailing `LIMIT 0` check that routes a query to the columns only metadata probe accepts any whitespace between `LIMIT` and `0` instead of exactly one space, so `LIMIT /*c*/0` keeps reaching that probe. A `--` line comment is unchanged, its terminating newline was already kept. Closes [#928](https://github.com/ClickHouse/clickhouse-connect/issues/928).
1224
- The native streaming response buffer again detects mid-stream server exceptions proactively. Its in-band exception scan built the markers as `__exception__<tag>` and `<tag>__exception__`, but the server separates `__exception__` from the tag with a CRLF on both markers (`__exception__\r\n<tag>` ... `<tag>\r\n__exception__`), so the scan never matched and the exception block was only recovered by the last-chunk fallback in `NativeTransform.parse_response`. When the block spanned a transport-chunk boundary that fallback saw just a fragment and surfaced a truncated or garbled error instead of the real ClickHouse exception. Both the pure Python and compiled Cython buffers are corrected. Closes [#915](https://github.com/ClickHouse/clickhouse-connect/issues/915).
1325
- DB-API `Cursor.description` now reports the result type's top-level nullability instead of hardcoding `null_ok=True`, including the implicit null values supported by `Variant`, `Dynamic`, and `SimpleAggregateFunction` over a nullable element type. Existing `type_code` values are unchanged, and types whose nullability is unknown report `None`. The empty-result metadata probe also recognizes leading ClickHouse comments, including nested block comments, and is best effort, so a failed probe leaves `description` empty instead of raising after the original query succeeded. Closes [#902](https://github.com/ClickHouse/clickhouse-connect/issues/902), [#907](https://github.com/ClickHouse/clickhouse-connect/issues/907), and [#909](https://github.com/ClickHouse/clickhouse-connect/issues/909).
26+
- Compound values stored in JSON shared data, such as arrays of objects, heterogeneous arrays, and nested arrays, are now decoded to Python objects instead of being returned as raw bytes. `Date`, `DateTime`, and `DateTime64` values in shared data, both as scalars and inside arrays, now decode as well. Closes [#897](https://github.com/ClickHouse/clickhouse-connect/issues/897).
27+
- `AsyncClient` no longer tears down the aiohttp response from the parser's executor thread when a query fails mid-stream. The synchronous cleanup cancelled the producer task and closed the response directly, which raced with the event loop handling the server's connection abort and could surface an `AttributeError` from asyncio's SSL shutdown on TLS connections instead of the real `StreamFailureError`. Cleanup is now scheduled onto the event loop with `call_soon_threadsafe`.
1428

1529
## 1.6.0, 2026-07-23
1630

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,13 @@ because Apache Superset currently requires `sqlalchemy>=1.4,<2`.
4444
Supported features include:
4545
- Basic query execution via SQLAlchemy Core
4646
- `SELECT` queries with `JOIN`s (including ClickHouse-specific strictness, `USING`, and `GLOBAL` modifiers),
47-
`ARRAY JOIN` (single and multi-column), `FINAL`, and `SAMPLE`
47+
`ARRAY JOIN` (single and multi-column), `FINAL`, `SAMPLE`, and materialized CTEs
4848
- `VALUES` table function syntax
4949
- Lightweight `DELETE` statements
5050
- **Alembic** schema migrations (autogenerate, upgrade/downgrade, ClickHouse engine support)
5151

52-
A small number of features require SQLAlchemy 2.x: `Values.cte()` and certain literal-rendering behaviors.
52+
A small number of features require SQLAlchemy 2.x. `Values.cte()` specifically requires SQLAlchemy 2.0.42 or later.
53+
Certain literal-rendering behaviors also require SQLAlchemy 2.x.
5354
All other dialect features, including those used by Superset, work on both 1.4 and 2.x.
5455

5556
Basic ORM usage works for insert-heavy, read-focused workloads: declarative model definitions, `CREATE TABLE`,

clickhouse_connect/cc_sqlalchemy/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from clickhouse_connect.cc_sqlalchemy.datatypes.base import schema_types
77
from clickhouse_connect.cc_sqlalchemy.ddl import tableengine as engines
88
from clickhouse_connect.cc_sqlalchemy.ddl.dictionary import Dictionary
9-
from clickhouse_connect.cc_sqlalchemy.sql import ClickHouseSelect, final, sample, select
9+
from clickhouse_connect.cc_sqlalchemy.sql import ClickHouseSelect, cte, final, sample, select
1010
from clickhouse_connect.cc_sqlalchemy.sql.clauses import ArrayJoin, ClickHouseJoin, Lambda, array_join, ch_join
1111
from clickhouse_connect.dbapi.cursor import Cursor
1212

@@ -28,6 +28,7 @@
2828
"ch_join",
2929
"ClickHouseJoin",
3030
"Lambda",
31+
"cte",
3132
"final",
3233
"sample",
3334
"select",

clickhouse_connect/cc_sqlalchemy/sql/__init__.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from typing import Any, cast
22

33
from sqlalchemy import Table, and_
4-
from sqlalchemy.sql.selectable import FromClause, Select
4+
from sqlalchemy.sql.selectable import CTE, FromClause, HasCTE, Select
55

6+
from clickhouse_connect import driver_name
67
from clickhouse_connect.cc_sqlalchemy.sql.clauses import ArrayJoin, LimitByClause, PreWhereClause
78
from clickhouse_connect.cc_sqlalchemy.sql.clauses import array_join as _array_join_fromclause
89
from clickhouse_connect.cc_sqlalchemy.sql.clauses import ch_join as _ch_join_fromclause
@@ -12,6 +13,10 @@
1213
# compiled-statement cache keys when FINAL/SAMPLE/PREWHERE/LIMIT BY are applied.
1314
_CH_MODIFIER_DIALECT = "_ch_modifier"
1415

16+
# SQLAlchemy renders CTE prefixes between the name and the body, which is exactly where
17+
# ClickHouse expects the materialization keyword: WITH <name> AS MATERIALIZED (...).
18+
_MATERIALIZED_KEYWORD = "MATERIALIZED"
19+
1520

1621
def full_table(table_name: str, schema: str | None = None) -> str:
1722
if table_name.startswith("(") or not schema:
@@ -158,6 +163,47 @@ def limit_by(select_stmt: Select, by_clauses: Any, limit: int, offset: int | Non
158163
return new_stmt
159164

160165

166+
def _validate_cte_options(recursive: bool, materialized: bool) -> None:
167+
if recursive and materialized:
168+
raise ValueError("materialized CTEs cannot be recursive")
169+
170+
171+
def _apply_materialized(new_cte: CTE, materialized: bool) -> CTE:
172+
if not materialized:
173+
return new_cte
174+
# SQLAlchemy renders CTE prefixes between the name and the body, which is exactly
175+
# where ClickHouse expects the keyword. Scoping it to this dialect keeps a statement
176+
# shared with another backend compiling unchanged there.
177+
return new_cte.prefix_with(_MATERIALIZED_KEYWORD, dialect=driver_name)
178+
179+
180+
def cte(
181+
statement: HasCTE,
182+
name: str | None = None,
183+
recursive: bool = False,
184+
nesting: bool = False,
185+
materialized: bool = False,
186+
) -> CTE:
187+
"""Standard SQLAlchemy `cte()` plus `materialized=True` for `WITH <name> AS MATERIALIZED (...)`.
188+
189+
A materialized CTE body is computed once instead of being inlined at every reference.
190+
Requires ClickHouse 26.3 or later. The server only honors the keyword when the
191+
analyzer and the `enable_materialized_cte` setting are enabled for the query:
192+
193+
stmt = select(...).execution_options(
194+
settings={"enable_materialized_cte": 1, "enable_analyzer": 1}
195+
)
196+
197+
Use this with the standard `sqlalchemy.select`. Statements built with
198+
`cc_sqlalchemy.select` have the same options on their own `.cte()` method.
199+
Raises `ValueError` when `recursive` and `materialized` are both true.
200+
"""
201+
if not isinstance(statement, HasCTE):
202+
raise TypeError(f"cte() expects a SQLAlchemy statement that supports CTEs. Got {type(statement).__name__}")
203+
_validate_cte_options(recursive, materialized)
204+
return _apply_materialized(statement.cte(name=name, recursive=recursive, nesting=nesting), materialized)
205+
206+
161207
def _select_ch_join(
162208
self: Select,
163209
right: Any,
@@ -278,6 +324,20 @@ def left_array_join(self, *cols: Any, alias: Any = None) -> "ClickHouseSelect":
278324
def prewhere(self, whereclause: Any) -> "ClickHouseSelect":
279325
return cast("ClickHouseSelect", prewhere(self, whereclause))
280326

327+
def cte(
328+
self,
329+
name: str | None = None,
330+
recursive: bool = False,
331+
nesting: bool = False,
332+
materialized: bool = False,
333+
) -> CTE:
334+
"""Standard `Select.cte()` plus `materialized=True` for `AS MATERIALIZED`.
335+
336+
See :func:`cte` for the server requirements.
337+
"""
338+
_validate_cte_options(recursive, materialized)
339+
return _apply_materialized(super().cte(name=name, recursive=recursive, nesting=nesting), materialized)
340+
281341
def limit_by(self, by_clauses: Any, limit: int, offset: int | None = None) -> "ClickHouseSelect":
282342
return cast("ClickHouseSelect", limit_by(self, by_clauses, limit, offset))
283343

clickhouse_connect/common.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ def _init_common(name: str, options: Sequence[Any], default: Any) -> None:
7272
_init_common("autogenerate_query_id", (True, False), True)
7373
_init_common("dict_parameter_format", ("json", "map"), "json")
7474
_init_common("invalid_setting_action", ("send", "drop", "error"), "error")
75+
_init_common("naive_datetime_binding", ("wall", "legacy"), "wall")
76+
_init_common("naive_datetime_insert", ("local", "server"), "local")
7577
_init_common("max_connection_age", (), 10 * 60) # Max time in seconds to keep reusing a database TCP connection
7678
_init_common("product_name", (), "") # Product name used as part of client identification for ClickHouse query_log
7779
_init_common("readonly", (0, 1), 0) # Implied "read_only" ClickHouse settings for versions prior to 19.17

0 commit comments

Comments
 (0)