mirror of
https://github.com/hanzo-ds/connect.git
synced 2026-10-30 20:31:24 +00:00
441 lines
18 KiB
Python
441 lines
18 KiB
Python
import re
|
|
|
|
from sqlalchemy.exc import CompileError
|
|
from sqlalchemy.sql import elements, sqltypes
|
|
from sqlalchemy.sql.compiler import SQLCompiler
|
|
from sqlalchemy.util import memoized_property
|
|
|
|
from clickhouse_connect.cc_sqlalchemy.datatypes.base import ChSqlaType
|
|
from clickhouse_connect.cc_sqlalchemy.sql import format_table
|
|
|
|
# The driver's external_bind_re only recognizes \w+ placeholder names.
|
|
_bind_name_re = re.compile(r"\w+\Z")
|
|
|
|
|
|
def _find_outermost_marker(text, markers):
|
|
"""Earliest index in `text` where any of `markers` appears at paren depth 0, skipping
|
|
string literals (single-quoted) and backtick-quoted identifiers. -1 if no match.
|
|
Used to splice PREWHERE into a SELECT body without matching subquery clauses.
|
|
"""
|
|
depth = 0
|
|
i = 0
|
|
n = len(text)
|
|
while i < n:
|
|
c = text[i]
|
|
if c == "'" or c == "`":
|
|
quote = c
|
|
i += 1
|
|
while i < n:
|
|
if text[i] == "\\" and i + 1 < n:
|
|
i += 2
|
|
continue
|
|
if text[i] == quote:
|
|
i += 1
|
|
break
|
|
i += 1
|
|
continue
|
|
if c == "(":
|
|
depth += 1
|
|
elif c == ")":
|
|
depth -= 1
|
|
elif depth == 0:
|
|
for marker in markers:
|
|
if text.startswith(marker, i):
|
|
return i
|
|
i += 1
|
|
return -1
|
|
|
|
|
|
def _ch_type_name(sqla_type):
|
|
"""Map a SQLAlchemy type to a ClickHouse type name, or None if unmapped."""
|
|
if isinstance(sqla_type, ChSqlaType):
|
|
return sqla_type.name
|
|
# Order matters so we need to check subtypes before parent types
|
|
if isinstance(sqla_type, sqltypes.SmallInteger):
|
|
return "Int16"
|
|
if isinstance(sqla_type, sqltypes.BigInteger):
|
|
return "Int64"
|
|
if isinstance(sqla_type, sqltypes.Integer):
|
|
return "Int32"
|
|
if isinstance(sqla_type, sqltypes.Float):
|
|
return "Float64"
|
|
if isinstance(sqla_type, sqltypes.Numeric):
|
|
p = sqla_type.precision or 18
|
|
s = sqla_type.scale or 0
|
|
return f"Decimal({p}, {s})"
|
|
if isinstance(sqla_type, sqltypes.Boolean):
|
|
return "Bool"
|
|
if isinstance(sqla_type, sqltypes.DateTime):
|
|
return "DateTime"
|
|
if isinstance(sqla_type, sqltypes.Date):
|
|
return "Date"
|
|
if isinstance(sqla_type, sqltypes.String):
|
|
return "String"
|
|
return None
|
|
|
|
|
|
def _resolve_ch_type_name(sqla_type):
|
|
"""ClickHouse type name for a VALUES column, falling back to String."""
|
|
return _ch_type_name(sqla_type) or "String"
|
|
|
|
|
|
def _resolve_ch_bind_type(sqla_type):
|
|
"""ClickHouse type name for a server-side bind, raising on unmapped types."""
|
|
if isinstance(sqla_type, sqltypes.TupleType):
|
|
return f"Tuple({', '.join(_resolve_ch_bind_type(t) for t in sqla_type.types)})"
|
|
name = _ch_type_name(sqla_type)
|
|
if name is None:
|
|
raise CompileError(f"server_side_params needs an explicit type for every bind; none resolved for {sqla_type!r}.")
|
|
return name
|
|
|
|
|
|
class ChStatementCompiler(SQLCompiler):
|
|
# SQLAlchemy 1.4 does not pass bindparam_type to bindparam_string, so stash it here.
|
|
_ch_bind_type = None
|
|
|
|
@property
|
|
def _server_side_params(self):
|
|
return getattr(self.dialect, "server_side_params", False)
|
|
|
|
@property
|
|
def _ch_array_binds(self):
|
|
return self.__dict__.setdefault("_ch_array_bind_names", set())
|
|
|
|
@memoized_property
|
|
def _bind_processors(self):
|
|
"""Bind processors with array-bound params dropped; the driver formats those."""
|
|
processors = SQLCompiler._bind_processors.fget(self)
|
|
if self._ch_array_binds:
|
|
return {k: v for k, v in processors.items() if k not in self._ch_array_binds}
|
|
return processors
|
|
|
|
def _ch_check_bind_name(self, name):
|
|
if not _bind_name_re.match(name):
|
|
raise CompileError(
|
|
f"server_side_params cannot bind parameter {name!r}: ClickHouse server-side "
|
|
"parameter names must match [A-Za-z0-9_]. Rename the bind parameter."
|
|
)
|
|
|
|
def visit_bindparam(self, bindparam, **kw):
|
|
if not self._server_side_params:
|
|
return super().visit_bindparam(bindparam, **kw)
|
|
if bindparam.expanding and not kw.get("literal_binds") and not bindparam.literal_execute:
|
|
return self._ch_expanding_bindparam(bindparam, **kw)
|
|
prev = self._ch_bind_type
|
|
self._ch_bind_type = bindparam.type
|
|
try:
|
|
return super().visit_bindparam(bindparam, **kw)
|
|
finally:
|
|
self._ch_bind_type = prev
|
|
|
|
def _ch_has_bind_processor(self, sqla_type):
|
|
if isinstance(sqla_type, sqltypes.TupleType):
|
|
return any(self._ch_has_bind_processor(t) for t in sqla_type.types)
|
|
return sqla_type._cached_bind_processor(self.dialect) is not None
|
|
|
|
def _ch_expanding_bindparam(self, bindparam, **kw):
|
|
"""Render an IN-family bind as a single {name:Array(Type)} placeholder."""
|
|
# Array binds skip per-element processors, so a type that needs one can't be bound.
|
|
if self._ch_has_bind_processor(bindparam.type):
|
|
raise CompileError(f"server_side_params cannot bind an IN list of {bindparam.type!r}: it needs a bind processor.")
|
|
# Drop the param from post-compile expansion so its list reaches the driver intact.
|
|
super().visit_bindparam(bindparam, **kw)
|
|
self.post_compile_params = self.post_compile_params.difference([bindparam])
|
|
name = self._truncate_bindparam(bindparam)
|
|
actual = self.escaped_bind_names.get(name, name) if self.escaped_bind_names else name
|
|
self._ch_check_bind_name(actual)
|
|
self._ch_array_binds.add(actual)
|
|
return "{" + actual + ":Array(" + _resolve_ch_bind_type(bindparam.type) + ")}"
|
|
|
|
def bindparam_string(self, name, **kw):
|
|
base = super().bindparam_string(name, **kw)
|
|
if not self._server_side_params or kw.get("post_compile") or kw.get("expanding"):
|
|
return base
|
|
actual = self.escaped_bind_names.get(name, name) if self.escaped_bind_names else name
|
|
self._ch_check_bind_name(actual)
|
|
ch_type = kw.get("bindparam_type") or self._ch_bind_type
|
|
if ch_type is None:
|
|
raise CompileError(f"server_side_params requires a typed bind parameter, but none was available for {name!r}.")
|
|
return "{" + actual + ":" + _resolve_ch_bind_type(ch_type) + "}"
|
|
|
|
def _raise_on_escape(self, binary, operator_name: str):
|
|
if binary.modifiers.get("escape") is not None:
|
|
raise CompileError(f"ClickHouse does not support the ESCAPE clause on {operator_name}")
|
|
|
|
def visit_delete(self, delete_stmt, visiting_cte=None, **kw):
|
|
table = delete_stmt.table
|
|
text = f"DELETE FROM {format_table(table)}"
|
|
|
|
if delete_stmt.whereclause is not None:
|
|
self._in_delete_where = True
|
|
try:
|
|
text += " WHERE " + self.process(delete_stmt.whereclause, **kw)
|
|
finally:
|
|
self._in_delete_where = False
|
|
else:
|
|
raise CompileError("ClickHouse DELETE statements require a WHERE clause. To delete all rows, use 'TRUNCATE TABLE' instead.")
|
|
|
|
return text
|
|
|
|
def visit_values(self, element, asfrom=False, from_linter=None, visiting_cte=None, **kw):
|
|
"""Compile a VALUES clause using ClickHouse's VALUES table function syntax.
|
|
|
|
ClickHouse requires the column structure as the first argument:
|
|
VALUES('col1 Type1, col2 Type2', (row1_val1, row1_val2), ...)
|
|
|
|
This differs from standard SQL which places column names after the alias:
|
|
(VALUES (row1), (row2)) AS name (col1, col2)
|
|
|
|
Compatible with both SQLAlchemy 1.4 and 2.x.
|
|
"""
|
|
if getattr(element, "_independent_ctes", None):
|
|
self._dispatch_independent_ctes(element, kw)
|
|
|
|
structure = ", ".join(f"{col.name} {_resolve_ch_type_name(col.type)}" for col in element.columns)
|
|
|
|
kw.setdefault("literal_binds", element.literal_binds)
|
|
tuples = ", ".join(
|
|
self.process(
|
|
elements.Tuple(types=element._column_types, *elem).self_group(), # noqa: B026
|
|
**kw,
|
|
)
|
|
for chunk in element._data
|
|
for elem in chunk
|
|
)
|
|
|
|
structure_literal = self.render_literal_value(structure, sqltypes.String())
|
|
v = f"VALUES({structure_literal}, {tuples})"
|
|
|
|
# SA 2.x has _unnamed; SA 1.4 uses name=None for unnamed values
|
|
is_unnamed = getattr(element, "_unnamed", element.name is None)
|
|
if is_unnamed:
|
|
name = None
|
|
elif isinstance(element.name, elements._truncated_label):
|
|
name = self._truncated_identifier("values", element.name)
|
|
else:
|
|
name = element.name
|
|
|
|
lateral = "LATERAL " if element._is_lateral else ""
|
|
|
|
if asfrom:
|
|
if from_linter:
|
|
# SA 2.x has _de_clone(); SA 1.4 doesn't
|
|
key = element._de_clone() if hasattr(element, "_de_clone") else element
|
|
from_linter.froms[key] = name if name is not None else "(unnamed VALUES element)"
|
|
|
|
if visiting_cte is not None and visiting_cte.element is element:
|
|
if element._is_lateral:
|
|
raise CompileError("Can't use a LATERAL VALUES expression inside of a CTE")
|
|
v = f"SELECT * FROM {v}"
|
|
elif name:
|
|
kw["include_table"] = False
|
|
v = f"{lateral}{v}{self.get_render_as_alias_suffix(self.preparer.quote(name))}"
|
|
else:
|
|
v = f"{lateral}{v}"
|
|
|
|
return v
|
|
|
|
def visit_join(self, join, **kw):
|
|
left = self.process(join.left, **kw)
|
|
right = self.process(join.right, **kw)
|
|
onclause = join.onclause
|
|
|
|
is_cross = getattr(join, "_is_cross", False) or onclause is None
|
|
if getattr(join, "full", False):
|
|
join_type = "FULL OUTER JOIN"
|
|
elif is_cross:
|
|
join_type = "CROSS JOIN"
|
|
elif join.isouter:
|
|
join_type = "LEFT OUTER JOIN"
|
|
else:
|
|
join_type = "INNER JOIN"
|
|
|
|
# ClickHouse modifiers: [GLOBAL] [ALL|ANY|ASOF] <join_type>
|
|
distribution = getattr(join, "distribution", None)
|
|
strictness = getattr(join, "strictness", None)
|
|
parts = []
|
|
if distribution:
|
|
parts.append(distribution)
|
|
if strictness:
|
|
parts.append(strictness)
|
|
parts.append(join_type)
|
|
join_kw = " ".join(parts)
|
|
|
|
text = f"{left} {join_kw} {right}"
|
|
|
|
using_columns = getattr(join, "using_columns", None)
|
|
if using_columns:
|
|
# Process the onclause so the from-linter registers the
|
|
# table relationship, but render USING syntax instead.
|
|
if onclause is not None:
|
|
self.process(onclause, **kw)
|
|
quoted = ", ".join(self.preparer.quote(col) for col in using_columns)
|
|
text += f" USING ({quoted})"
|
|
elif not is_cross and onclause is not None:
|
|
text += " ON " + self.process(onclause, **kw)
|
|
|
|
return text
|
|
|
|
def visit_column(self, column, add_to_result_map=None, include_table=True, result_map_targets=(), ambiguous_table_name_map=None, **kw):
|
|
if getattr(self, "_in_delete_where", False):
|
|
return self.preparer.quote(column.name)
|
|
|
|
return super().visit_column(
|
|
column,
|
|
add_to_result_map=add_to_result_map,
|
|
include_table=include_table,
|
|
result_map_targets=result_map_targets,
|
|
**kw,
|
|
)
|
|
|
|
# Abstract methods required by SQLCompiler
|
|
def delete_extra_from_clause(self, delete_stmt, from_table, extra_froms, from_hints, **kw):
|
|
raise NotImplementedError("ClickHouse doesn't support DELETE with extra FROM clause")
|
|
|
|
def update_from_clause(self, update_stmt, from_table, extra_froms, from_hints, **kw):
|
|
raise NotImplementedError("ClickHouse doesn't support UPDATE with FROM clause")
|
|
|
|
def visit_empty_set_expr(self, element_types, **kw):
|
|
return "SELECT 1 WHERE 1=0"
|
|
|
|
def visit_sequence(self, sequence, **kw):
|
|
raise NotImplementedError("ClickHouse doesn't support sequences")
|
|
|
|
def group_by_clause(self, select, **kw):
|
|
"""Render GROUP BY using label aliases instead of full expressions."""
|
|
kw["_ch_group_by"] = True
|
|
return super().group_by_clause(select, **kw)
|
|
|
|
def visit_label(
|
|
self,
|
|
label,
|
|
within_columns_clause=False,
|
|
render_label_as_label=None,
|
|
**kw,
|
|
):
|
|
ch_group_by = kw.pop("_ch_group_by", False)
|
|
if ch_group_by and not within_columns_clause and render_label_as_label is None:
|
|
if isinstance(label.name, elements._truncated_label):
|
|
labelname = self._truncated_identifier("colident", label.name)
|
|
else:
|
|
labelname = label.name
|
|
return self.preparer.format_label(label, labelname)
|
|
return super().visit_label(
|
|
label,
|
|
within_columns_clause=within_columns_clause,
|
|
render_label_as_label=render_label_as_label,
|
|
**kw,
|
|
)
|
|
|
|
def _ch_modifier_attr(self, select, compile_state, attr, default):
|
|
"""Read a CH modifier attribute."""
|
|
val = getattr(select, attr, None)
|
|
if val is not None:
|
|
return val
|
|
if compile_state is not None:
|
|
orig = getattr(compile_state, "select_statement", None)
|
|
if orig is not None and orig is not select:
|
|
return getattr(orig, attr, default)
|
|
return default
|
|
|
|
def _compose_select_body(self, text, select, compile_state, inner_columns, froms, byfrom, toplevel, kwargs):
|
|
ch_final = self._ch_modifier_attr(select, compile_state, "_ch_final", set())
|
|
ch_sample = self._ch_modifier_attr(select, compile_state, "_ch_sample", {})
|
|
ch_prewhere = self._ch_modifier_attr(select, compile_state, "_ch_prewhere", None)
|
|
ch_limit_by = self._ch_modifier_attr(select, compile_state, "_ch_limit_by", None)
|
|
|
|
prev_lb = getattr(self, "_ch_active_limit_by", None)
|
|
self._ch_active_limit_by = ch_limit_by
|
|
|
|
try:
|
|
if ch_final or ch_sample:
|
|
mods = {}
|
|
for target in ch_final | set(ch_sample):
|
|
parts = []
|
|
if target in ch_final:
|
|
parts.append("FINAL")
|
|
if target in ch_sample:
|
|
parts.append(f"SAMPLE {ch_sample[target]}")
|
|
mods[target] = " ".join(parts)
|
|
|
|
prev = getattr(self, "_ch_from_modifiers", None)
|
|
self._ch_from_modifiers = mods
|
|
try:
|
|
result = super()._compose_select_body(text, select, compile_state, inner_columns, froms, byfrom, toplevel, kwargs)
|
|
finally:
|
|
self._ch_from_modifiers = prev
|
|
else:
|
|
result = super()._compose_select_body(text, select, compile_state, inner_columns, froms, byfrom, toplevel, kwargs)
|
|
finally:
|
|
self._ch_active_limit_by = prev_lb
|
|
|
|
if ch_prewhere is not None:
|
|
prewhere_text = self.process(ch_prewhere.whereclause, **kwargs)
|
|
prewhere_segment = f" \nPREWHERE {prewhere_text}"
|
|
markers = (" \nWHERE ", " GROUP BY ", " \nHAVING ", " ORDER BY ", "\n LIMIT ")
|
|
insert_at = _find_outermost_marker(result, markers)
|
|
if insert_at == -1:
|
|
result = result + prewhere_segment
|
|
else:
|
|
result = result[:insert_at] + prewhere_segment + result[insert_at:]
|
|
|
|
# LIMIT BY: SA calls limit_clause() only when there's a regular LIMIT/OFFSET.
|
|
# Without one, it's never called, so append the LIMIT BY here instead.
|
|
if ch_limit_by is not None and not select._has_row_limiting_clause:
|
|
result += self._render_ch_limit_by(ch_limit_by, kwargs)
|
|
|
|
return result
|
|
|
|
def _render_ch_limit_by(self, ch_limit_by, kw):
|
|
by_text = ", ".join(self.process(col, **kw) for col in ch_limit_by.by_clauses)
|
|
offset_prefix = f"{ch_limit_by.offset}, " if ch_limit_by.offset is not None else ""
|
|
return f"\n LIMIT {offset_prefix}{ch_limit_by.limit} BY {by_text}"
|
|
|
|
def limit_clause(self, select, **kw):
|
|
text = ""
|
|
ch_limit_by = getattr(select, "_ch_limit_by", None)
|
|
if ch_limit_by is None:
|
|
ch_limit_by = getattr(self, "_ch_active_limit_by", None)
|
|
if ch_limit_by is not None:
|
|
text += self._render_ch_limit_by(ch_limit_by, kw)
|
|
text += super().limit_clause(select, **kw)
|
|
return text
|
|
|
|
def visit_table(self, table, asfrom=False, iscrud=False, ashint=False, fromhints=None, enclosing_alias=None, **kwargs):
|
|
result = super().visit_table(
|
|
table, asfrom=asfrom, iscrud=iscrud, ashint=ashint, fromhints=fromhints, enclosing_alias=enclosing_alias, **kwargs
|
|
)
|
|
if asfrom and enclosing_alias is None:
|
|
mods = getattr(self, "_ch_from_modifiers", None)
|
|
if mods and table in mods:
|
|
result += " " + mods[table]
|
|
return result
|
|
|
|
def visit_alias(self, alias, asfrom=False, **kwargs):
|
|
result = super().visit_alias(alias, asfrom=asfrom, **kwargs)
|
|
if asfrom:
|
|
mods = getattr(self, "_ch_from_modifiers", None)
|
|
if mods and alias in mods:
|
|
result += " " + mods[alias]
|
|
return result
|
|
|
|
def visit_like_op_binary(self, binary, operator, **kw):
|
|
self._raise_on_escape(binary, "LIKE")
|
|
return super().visit_like_op_binary(binary, operator, **kw)
|
|
|
|
def visit_not_like_op_binary(self, binary, operator, **kw):
|
|
self._raise_on_escape(binary, "LIKE")
|
|
return super().visit_not_like_op_binary(binary, operator, **kw)
|
|
|
|
def visit_ilike_op_binary(self, binary, operator, **kw):
|
|
self._raise_on_escape(binary, "ILIKE")
|
|
left = self.process(binary.left, **kw)
|
|
right = self.process(binary.right, **kw)
|
|
return f"{left} ILIKE {right}"
|
|
|
|
def visit_not_ilike_op_binary(self, binary, operator, **kw):
|
|
self._raise_on_escape(binary, "ILIKE")
|
|
left = self.process(binary.left, **kw)
|
|
right = self.process(binary.right, **kw)
|
|
return f"{left} NOT ILIKE {right}"
|