Files
cd902b864e Implement SQLAlchemy reflection for the cc_sqlalchemy dialect (#766)
* Implement SQLAlchemy reflection for the cc_sqlalchemy dialect

The cc_sqlalchemy dialect did not support SQLAlchemy reflection
(MetaData.reflect / Inspector multi-table reflection), which broke
sqlacodegen and any tool that calls `dialect.get_columns()` directly.
This change fills in the missing dialect methods so reflection works
end-to-end against a ClickHouse server.

Changes:

1. dialect.py: add `ClickHouseDialect.get_columns()`. Previously only
   `ChInspector.get_columns()` existed, but SQLAlchemy's reflection
   path goes through `Dialect.get_multi_columns` -> `Dialect.get_columns`
   and never touches `Inspector.get_columns` on its own. Without a
   dialect implementation, `MetaData.reflect()` raised
   `NotImplementedError` from the SQLAlchemy base class.

   `get_pk_constraint()` / `get_primary_keys()` now return the
   actual primary key columns derived from
   `system.columns.is_in_primary_key` (which mirrors MergeTree's
   ORDER BY / PRIMARY KEY) instead of empty lists. This lets
   sqlacodegen generate declarative classes instead of bare
   `Table(...)` definitions for any MergeTree table.

2. inspector.py: promote `get_columns` and `get_pk_constraint` to
   module-level functions so the dialect can call the same logic.
   `ChInspector.reflect_table()` now applies the PK constraint to
   reflected columns (it was building columns with no PK info, so
   even direct `Table('asset', md, autoload_with=engine)` reflection
   lost the primary key).

3. datatypes/sqltypes.py: replace `python_type = None` on UDT-based
   types with concrete Python types. SQLAlchemy's contract for
   `TypeEngine.python_type` is that it either returns a class or
   raises `NotImplementedError`; returning `None` makes any consumer
   that does `python_type.__module__` / `__name__` crash with
   `AttributeError: 'NoneType' object has no attribute '__module__'`
   (sqlacodegen, and anything else that walks python_type for
   annotations or metadata).

   - UUID            -> uuid.UUID
   - IPv4 / IPv6     -> ipaddress.IPv4Address / IPv6Address
   - Nothing         -> type(None)
   - Point           -> tuple
   - Ring / Polygon  -> list
   - LineString etc. -> list
   - JSON            -> dict
   - Nested          -> list
   - (Simple)AggregateFunction -> str

4. datatypes/sqltypes.py: `Array` now subclasses
   `sqlalchemy.types.ARRAY` (alongside `ChSqlaType`) and exposes
   `item_type` as a regular instance attribute plus `dimensions = 1`.
   Two effects:

   - `isinstance(col.type, sqlalchemy.types.ARRAY)` now matches CH
     arrays, which lets sqlacodegen render `Mapped[list[T]]`
     annotations for single-dim arrays without special-casing.
   - `item_type` is mutable so sqlacodegen's `fix_column_types`
     adaptation pass (which reassigns `new_coltype.item_type`) works.

   `dimensions = 1` reflects CH's type system: every Array is
   one-dimensional and nested arrays (`Array(Array(String))`) are
   represented via the inner item type, not via a dimension count.

Tests:

- tests/integration_tests/test_sqlalchemy/test_reflect.py:
  `test_metadata_reflect_and_primary_keys` exercises the
  `Dialect.get_columns` reflection path via `MetaData.reflect()`
  and asserts composite primary key reflection from a MergeTree
  ORDER BY clause, both via `MetaData.reflect()` and via direct
  `Table(autoload_with=...)`.

End-to-end effect: `MetaData.reflect()` and
`sqlacodegen <clickhousedb+connect://...>` now produce a complete,
importable Python module with declarative ORM classes, composite
primary keys, and typed `Mapped[...]` annotations against a real
ClickHouse schema. No changes to the runtime client paths.

* Address review: drop PK reflection, set Array.as_tuple

Per review on #766, the dialect no longer reflects a primary key.
ClickHouse PRIMARY KEY / ORDER BY is a sparse index, not a uniqueness
guarantee, so get_primary_keys / get_pk_constraint return empty results
and the identity key is left for application code to declare. Removes the
is_in_primary_key query and the PK-application path in reflect_table.

Also set Array.as_tuple = False. Array bypasses ARRAY.__init__ to allow
nested arrays, but as_tuple has no class-level default and ARRAY.hashable
reads it, so select(arr).unique() raised AttributeError before.

* Add test for user-declared primary key surviving reflection

---------

Signed-off-by: Joe Spadola <[email protected]>
Co-authored-by: Joe Spadola <[email protected]>
2026-06-05 10:59:55 -07:00

191 lines
8.0 KiB
Python

import ast
import re
from collections.abc import Collection
from typing import Any
import sqlalchemy.schema as sa_schema
from sqlalchemy import String, bindparam, text
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.exc import NoResultFound
from clickhouse_connect.cc_sqlalchemy.datatypes.base import sqla_type_from_name
from clickhouse_connect.cc_sqlalchemy.ddl.tableengine import build_engine
from clickhouse_connect.cc_sqlalchemy.sql import full_table
from clickhouse_connect.cc_sqlalchemy.sql.sqlparse import (
extract_parenthesized_block,
find_top_level_clause,
split_top_level,
)
def _database_name(connection, schema: str | None) -> str:
if schema:
return schema
return connection.execute(text("SELECT currentDatabase()")).scalar()
def get_table_metadata(connection, table_name, schema=None):
database = _database_name(connection, schema)
result_set = connection.execute(
text("SELECT engine, engine_full, comment FROM system.tables WHERE database = :database AND name = :table_name").bindparams(
bindparam("database", type_=String()), bindparam("table_name", type_=String())
),
{"database": database, "table_name": table_name},
)
row = next(result_set, None)
if not row:
raise NoResultFound(f"Table {database}.{table_name} does not exist")
return row
def get_engine(connection, table_name, schema=None):
row = get_table_metadata(connection, table_name, schema)
return build_engine(row.engine_full)
def get_dictionary_create_sql(connection, table_name: str, schema: str | None = None) -> str:
create_sql = connection.execute(text(f"SHOW CREATE DICTIONARY {full_table(table_name, schema)}")).scalar()
return create_sql or ""
def _parse_dictionary_column(definition: str) -> dict[str, Any]:
match = re.match(r"^`(?P<name>[^`]+)`\s+(?P<rest>.+)$", definition, flags=re.DOTALL)
if not match:
match = re.match(r"^(?P<name>\S+)\s+(?P<rest>.+)$", definition, flags=re.DOTALL)
if not match:
raise ValueError(f"Could not parse dictionary column definition: {definition}")
name = match.group("name")
remainder = match.group("rest").strip()
type_index, _ = find_top_level_clause(
remainder,
(" DEFAULT ", " MATERIALIZED ", " ALIAS ", " TTL ", " COMMENT ", " CODEC("),
)
type_name = remainder[:type_index].strip() if type_index != -1 else remainder
sqla_type = sqla_type_from_name(type_name.replace("\n", " "))
column = {
"name": name,
"type": sqla_type,
"nullable": sqla_type.nullable,
"autoincrement": False,
}
comment_index, comment_clause = find_top_level_clause(remainder, (" COMMENT ",))
if comment_clause:
comment_sql = remainder[comment_index + len(comment_clause) :].strip()
column["comment"] = ast.literal_eval(comment_sql)
remainder = remainder[:comment_index].rstrip()
default_index, default_clause = find_top_level_clause(remainder, (" DEFAULT ", " MATERIALIZED ", " ALIAS "))
if default_clause:
default_sql = remainder[default_index + len(default_clause) :].strip()
if default_clause == " DEFAULT ":
column["server_default"] = text(default_sql)
elif default_clause == " MATERIALIZED ":
column["clickhouse_materialized"] = text(default_sql)
elif default_clause == " ALIAS ":
column["clickhouse_alias"] = text(default_sql)
return column
def get_dictionary_columns(connection, table_name: str, schema: str | None = None) -> list[dict[str, Any]]:
create_sql = get_dictionary_create_sql(connection, table_name, schema)
if not create_sql:
return []
start = create_sql.find("(")
if start == -1:
return []
column_block, _ = extract_parenthesized_block(create_sql, start)
return [_parse_dictionary_column(column_sql) for column_sql in split_top_level(column_block)]
def get_dictionary_metadata(connection, table_name: str, schema: str | None = None) -> dict[str, Any]:
create_sql = get_dictionary_create_sql(connection, table_name, schema)
if not create_sql:
return {}
metadata: dict[str, Any] = {"clickhouse_table_type": "dictionary"}
for line in (line.strip() for line in create_sql.splitlines()):
if not line:
continue
if line.startswith("PRIMARY KEY "):
metadata["clickhouse_dictionary_primary_key"] = line[len("PRIMARY KEY ") :]
elif line.startswith("SOURCE(") and line.endswith(")"):
metadata["clickhouse_dictionary_source"] = line[len("SOURCE(") : -1]
elif line.startswith("LIFETIME(") and line.endswith(")"):
metadata["clickhouse_dictionary_lifetime"] = line[len("LIFETIME(") : -1]
elif line.startswith("LAYOUT(") and line.endswith(")"):
metadata["clickhouse_dictionary_layout"] = line[len("LAYOUT(") : -1]
elif line.startswith("COMMENT "):
metadata["comment"] = ast.literal_eval(line[len("COMMENT ") :])
return metadata
def get_columns(connection, table_name: str, schema: str | None = None) -> list[dict[str, Any]]:
table_metadata = get_table_metadata(connection, table_name, schema)
if table_metadata.engine == "Dictionary":
return get_dictionary_columns(connection, table_name, schema)
table_id = full_table(table_name, schema)
result_set = connection.execute(text(f"DESCRIBE TABLE {table_id}"))
if not result_set:
raise NoResultFound(f"Table {table_id} does not exist")
columns = []
for row in result_set:
sqla_type = sqla_type_from_name(row.type.replace("\n", ""))
col = {
"name": row.name,
"type": sqla_type,
"nullable": sqla_type.nullable,
"autoincrement": False,
"comment": row.comment or None,
"clickhouse_codec": row.codec_expression or None,
"clickhouse_ttl": text(row.ttl_expression) if row.ttl_expression else None,
}
if row.default_type == "DEFAULT" and row.default_expression:
col["server_default"] = text(row.default_expression)
elif row.default_type == "MATERIALIZED" and row.default_expression:
col["clickhouse_materialized"] = text(row.default_expression)
elif row.default_type == "ALIAS" and row.default_expression:
col["clickhouse_alias"] = text(row.default_expression)
columns.append(col)
return columns
class ChInspector(Inspector):
def reflect_table(
self,
table,
*_args,
include_columns: Collection[str] | None = None,
exclude_columns: Collection[str] = (),
**_kwargs,
):
schema = table.schema
table_metadata = get_table_metadata(self.bind, table.name, schema)
if table_metadata.engine == "Dictionary":
reflected_columns = get_dictionary_columns(self.bind, table.name, schema)
else:
reflected_columns = self.get_columns(table.name, schema)
for col in reflected_columns:
name = col.pop("name")
if (include_columns and name not in include_columns) or (exclude_columns and name in exclude_columns):
continue
col_type = col.pop("type")
col_args = {key: value for key, value in col.items() if value is not None}
table.append_column(sa_schema.Column(name, col_type, **col_args))
if table_metadata.engine == "Dictionary":
dictionary_metadata = get_dictionary_metadata(self.bind, table.name, schema)
table.comment = dictionary_metadata.pop("comment", None)
for key, value in dictionary_metadata.items():
table.kwargs[key] = value
return
table.engine = build_engine(table_metadata.engine_full)
table.comment = table_metadata.comment or None
if table.engine is not None:
table.kwargs["clickhouse_engine"] = table.engine
def get_columns(self, table_name, schema=None, **_kwargs):
return get_columns(self.bind, table_name, schema)