Files
Joe SpadolaandGitHub 7ce2499f68 Integrate Alembic support & SQLAlchemy improvements into main (#758)
* sqa dict and alembic work

* sqa dialect improvements

* import shim

* engine shim

* implement alembic support

* clickhouse dictionary support

* compat shim

* add alembic to test reqs

* integration/unit tests

* update readme

* update changelog

* fix to support sqa 1.4 and 2.x

* update readme

* add alembic worked example

* consolidate duplicate code

* add sql parse logic file

* cloud quirk reflection fixes

* cc_sqalchemy ergonomics

* chcsqa prewhere, limit by, lambda chainables

* preserve ARRAY JOIN labels, fix Tuple adapt

* wording edits, name fixes

* linting

* linting, use format

* simplify docstrings

* update migration guide

* update migration guide

* fix alembic enum render for autogen migrations

* fix alembic enum render for nested container types

* 1.1.0a1 release prep

* preserve CH select modifiers when compiling from ORM-mapped attributes

* 1.1.0a2 release prep

* address pr comments
2026-05-22 14:26:46 -07:00

172 lines
6.6 KiB
Python

from sqlalchemy import Table, and_
from sqlalchemy.sql.selectable import FromClause, Select
from clickhouse_connect.cc_sqlalchemy.sql.clauses import ArrayJoin, LimitByClause, PreWhereClause
from clickhouse_connect.cc_sqlalchemy.sql.clauses import array_join as _array_join_fromclause
from clickhouse_connect.driver.binding import quote_identifier
# Non-rendering statement-hint dialect tag. Used only to force distinct
# compiled-statement cache keys when FINAL/SAMPLE/PREWHERE/LIMIT BY are applied.
_CH_MODIFIER_DIALECT = "_ch_modifier"
def full_table(table_name: str, schema: str | None = None) -> str:
if table_name.startswith("(") or "." in table_name or not schema:
return quote_identifier(table_name)
return f"{quote_identifier(schema)}.{quote_identifier(table_name)}"
def format_table(table: Table):
return full_table(table.name, table.schema)
def _resolve_target(select_stmt: Select, table: FromClause | None, method_name: str) -> FromClause:
if not isinstance(select_stmt, Select):
raise TypeError(f"{method_name}() expects a SQLAlchemy Select instance")
target = table
if target is None:
froms = select_stmt.get_final_froms()
if not froms:
raise ValueError(f"{method_name}() requires a table to apply the {method_name.upper()} modifier.")
if len(froms) > 1:
raise ValueError(f"{method_name}() is ambiguous for statements with multiple FROM clauses. Specify the table explicitly.")
target = froms[0]
# FINAL/SAMPLE apply to the underlying table, not to an ArrayJoin wrapper.
while isinstance(target, ArrayJoin):
target = target.left
if not isinstance(target, FromClause):
raise TypeError("table must be a SQLAlchemy FromClause when provided")
return target
def _target_cache_key(target: FromClause) -> str:
if hasattr(target, "fullname"):
return target.fullname
return target.name
def final(select_stmt: Select, table: FromClause | None = None) -> Select:
"""Apply the ClickHouse FINAL modifier. For ReplacingMergeTree-family engines."""
target = _resolve_target(select_stmt, table, "final")
ch_final = getattr(select_stmt, "_ch_final", set())
if target in ch_final:
return select_stmt
hint_key = _target_cache_key(target)
new_stmt = select_stmt.with_statement_hint(f"FINAL:{hint_key}", dialect_name=_CH_MODIFIER_DIALECT)
new_stmt._ch_final = ch_final | {target}
return new_stmt
def _select_final(self: Select, table: FromClause | None = None) -> Select:
return final(self, table=table)
def sample(select_stmt: Select, sample_value: str | int | float, table: FromClause | None = None) -> Select:
"""Apply the ClickHouse SAMPLE modifier. sample_value may be a float (fraction), int (row count), or string expression like '1/10 OFFSET 1/2'."""
target = _resolve_target(select_stmt, table, "sample")
hint_key = _target_cache_key(target)
new_stmt = select_stmt.with_statement_hint(f"SAMPLE:{hint_key}:{sample_value}", dialect_name=_CH_MODIFIER_DIALECT)
ch_sample = dict(getattr(select_stmt, "_ch_sample", {}))
ch_sample[target] = sample_value
new_stmt._ch_sample = ch_sample
return new_stmt
def _select_sample(self: Select, sample_value: str | int | float, table: FromClause | None = None) -> Select:
return sample(self, sample_value=sample_value, table=table)
def _apply_array_join(select_stmt: Select, cols, alias, is_left: bool) -> Select:
if not isinstance(select_stmt, Select):
raise TypeError("array_join() expects a SQLAlchemy Select instance")
if not cols:
raise ValueError("array_join() requires at least one array column")
froms = select_stmt.get_final_froms()
if not froms:
raise ValueError("array_join() requires the Select to have a FROM clause to wrap.")
if len(froms) > 1:
raise ValueError(
"array_join() is ambiguous for statements with multiple FROM clauses. "
"Use the module-level array_join(left, array_column, ...) with select_from() instead."
)
target = froms[0]
columns = list(cols)
if len(columns) == 1:
array_column = columns[0]
alias_arg = alias
else:
array_column = columns
if alias is None:
alias_arg = None
elif isinstance(alias, (list, tuple)):
alias_arg = list(alias)
else:
raise ValueError("alias must be a list/tuple matching the number of columns when multiple columns are provided")
aj = _array_join_fromclause(target, array_column, alias=alias_arg, is_left=is_left)
return select_stmt.select_from(aj)
def _select_array_join(self: Select, *cols, alias=None) -> Select:
return _apply_array_join(self, cols, alias, is_left=False)
def _select_left_array_join(self: Select, *cols, alias=None) -> Select:
return _apply_array_join(self, cols, alias, is_left=True)
def prewhere(select_stmt, whereclause):
"""Apply ClickHouse PREWHERE. Multiple calls compose with AND."""
if not isinstance(select_stmt, Select):
raise TypeError("prewhere() expects a SQLAlchemy Select instance")
existing = getattr(select_stmt, "_ch_prewhere", None)
combined = and_(existing.whereclause, whereclause) if existing is not None else whereclause
# Hint key is str(combined) (structural, with bind placeholders) rather
# than id() so equivalent statements share a compiled-statement cache entry.
new_stmt = select_stmt.with_statement_hint(f"PREWHERE:{str(combined)}", dialect_name=_CH_MODIFIER_DIALECT)
new_stmt._ch_prewhere = PreWhereClause(combined)
return new_stmt
def limit_by(select_stmt, by_clauses, limit, offset=None):
"""Apply ClickHouse LIMIT BY (top-N per group). Renders `LIMIT [offset,] limit BY by_clauses`."""
if not isinstance(select_stmt, Select):
raise TypeError("limit_by() expects a SQLAlchemy Select instance")
by_tuple = tuple(by_clauses)
if not by_tuple:
raise ValueError("limit_by() requires at least one by_clause")
by_key = ",".join(str(c) for c in by_tuple)
new_stmt = select_stmt.with_statement_hint(f"LIMIT_BY:{limit}:{offset}:{by_key}", dialect_name=_CH_MODIFIER_DIALECT)
new_stmt._ch_limit_by = LimitByClause(by_tuple, limit, offset)
return new_stmt
def _select_prewhere(self, whereclause):
return prewhere(self, whereclause)
def _select_limit_by(self, by_clauses, limit, offset=None):
return limit_by(self, by_clauses, limit, offset)
Select.sample = _select_sample
Select.final = _select_final
Select.array_join = _select_array_join
Select.left_array_join = _select_left_array_join
Select.prewhere = _select_prewhere
Select.limit_by = _select_limit_by