diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py index 1723aea3..36a8254e 100644 --- a/backend/app/data/catalog.py +++ b/backend/app/data/catalog.py @@ -1388,15 +1388,16 @@ _DATE_WORDS = frozenset({"date", "time", "day", "dt", "ts", "timestamp", "dateti _TEXT_TYPES = ("CHAR", "TEXT", "STRING", "CLOB") -def is_date_column(name: str, type_name: str | None) -> bool: +def is_date_column(name: str, type_name: str | None, *, enum_values: Any = None) -> bool: """日期类列:类型是日期、时间戳(DATE、DATETIME、TIMESTAMP……);或者类型是文字(或者不知道类型)、名字像日期 - (SQLite 的日期常存成文字)。只有时刻的 TIME、整数存的时间戳不算:取值不是日期。 + (SQLite 的日期常存成文字)。只有时刻的 TIME、整数存的时间戳不算:取值不是日期。有取值列表的(MySQL 的 ENUM / + SET、探查记下 enum_values 的 PostgreSQL 枚举)不算:PG 的枚举编译出来是类型名本身,update_reason 里也有「DATE」。 数据剖析(表里只有一个日期类列时提议它作业务日期)和 SQL 检查(按业务日期以外的时间列统计)共用这一个判断。 以前两边各写一套:剖析认的「唯一日期列」,SQL 检查可能认为还有别的时间列,反过来也一样,两边对不上。 """ t = _type_words(type_name) - if t.startswith(("ENUM", "SET")) or "INTERVAL" in t: + if enum_values or _MYSQL_ENUM.match(t) or "INTERVAL" in t: return False if "DATE" in t or "TIMESTAMP" in t: return True @@ -1442,6 +1443,18 @@ def _type_words(type_name: str | None) -> str: return _QUOTED_IN_TYPE.sub("''", (type_name or "").upper()).strip() +#: MySQL 的 ENUM / SET 编译出来的写法:ENUM('a','b')、SET('x','y'),只认带括号的。PostgreSQL 的枚举编译出来是类型名本身, +#: setup_status、enum_kind 这种以 set、enum 开头的名字不能按前缀认成 MySQL 的 SET / ENUM——PG 的枚举一律靠探查记下的 +#: enum_values 认(column_family) +_MYSQL_ENUM = re.compile(r"^(ENUM|SET)\s*\(") + + +def _mysql_enum_kind(type_name: str | None) -> str | None: + """类型写法是 MySQL 的 ENUM(…) / SET(…) 时返回 "ENUM" / "SET",否则 None。""" + m = _MYSQL_ENUM.match(_type_words(type_name)) + return m.group(1) if m else None + + def _type_family(type_name: str | None) -> str | None: """列类型的大类:数字、文字、时间;认不出来返回 None(不据此排除)。 @@ -1451,7 +1464,7 @@ def _type_family(type_name: str | None) -> str | None: t = _type_words(type_name) if not t: return None - if t.startswith(("ENUM", "SET")): + if _MYSQL_ENUM.match(t): return "text" if "INTERVAL" in t: return None diff --git a/backend/app/data/catalog_profile.py b/backend/app/data/catalog_profile.py index b484ab54..17b59a39 100644 --- a/backend/app/data/catalog_profile.py +++ b/backend/app/data/catalog_profile.py @@ -1293,8 +1293,9 @@ INVALID_DATES = "零日期 0000-00-00、0001-01-01、9999-12-31 这类占位日 def _is_enum(type_name: str | None, enum_values: Any = None) -> bool: - """取值列表有限的列:MySQL 的 ENUM / SET(类型写法以它们开头),或者探查记了取值列表(PostgreSQL 的枚举)。""" - return bool(enum_values) or catalog._type_words(type_name).startswith(("ENUM", "SET")) + """取值列表有限的列:MySQL 的 ENUM(…) / SET(…),或者探查记了取值列表(PostgreSQL 的枚举:类型写法只是类型名, + setup_status 这种以 set 开头的名字不算 SET,见 catalog._MYSQL_ENUM)。""" + return bool(enum_values) or catalog._mysql_enum_kind(type_name) is not None def _code_type_ok(type_name: str | None, enum_values: Any = None) -> bool: @@ -1407,7 +1408,7 @@ async def _check_codes(cx: _TableCx, table: str, column: str, out: _TableOutcome spread = "、".join(f"{k}({_share(n, rows)})" for k, n in values) extra: list[str] = [] # ENUM 的取值列表(SET 的是成员,取值是成员的组合,不算):这次没出现的也是可能的取值 - is_set = catalog._type_words(col_meta.get("type")).startswith("SET") + is_set = catalog._mysql_enum_kind(col_meta.get("type")) == "SET" declared = [] if is_set else [str(v) for v in col_meta.get("enum_values") or []] unseen = [v for v in declared if v not in counts] if unseen: @@ -1424,7 +1425,7 @@ async def _check_codes(cx: _TableCx, table: str, column: str, out: _TableOutcome def _is_date_column(col: dict[str, Any]) -> bool: """日期类列(catalog.is_date_column:和 SQL 检查认时间列是同一个判断)。""" - return catalog.is_date_column(str(col.get("name") or ""), col.get("type")) + return catalog.is_date_column(str(col.get("name") or ""), col.get("type"), enum_values=col.get("enum_values")) async def _check_dates(cx: _TableCx, table: str, columns: list[str], total: int, out: _TableOutcome, diff --git a/backend/app/data/introspect.py b/backend/app/data/introspect.py index 30fbfda2..7d7f478b 100644 --- a/backend/app/data/introspect.py +++ b/backend/app/data/introspect.py @@ -165,15 +165,28 @@ def _unique_groups(constraints: list[dict[str, Any]], indexes: list[dict[str, An return groups +def _storage_type(col_type: Any) -> Any: + """PostgreSQL 的域(DOMAIN)换成它底下的类型(域套域的一路往下找);别的类型原样返回。""" + from sqlalchemy.dialects.postgresql import DOMAIN + + seen = 0 + while isinstance(col_type, DOMAIN) and getattr(col_type, "data_type", None) is not None and seen < 8: + col_type, seen = col_type.data_type, seen + 1 + return col_type + + def type_text(col_type: Any, dialect: Any) -> str: """反射到的列类型 → 存进表结构的写法:按这个数据源的方言编译的完整写法(VARCHAR2(20 CHAR)、NUMBER(10, 0)、 - ENUM('paid','refund')、TIMESTAMP WITH TIME ZONE),编译不了的退回 str()。 + ENUM('paid','refund')、TIMESTAMP WITH TIME ZONE),编译不了的退回 str()。PostgreSQL 的域(DOMAIN)记它底下的类型: + 域编译出来只是域名(settlement_amount),看不出是数字还是文字,名字里的词(set、UPDATE 里的 DATE)还会让类型大类、 + 日期列、码值候选认错;模型照着写 SQL 也要知道它存的是什么。 以前存的是 str(类型),即 SQLAlchemy 的通用写法:Oracle 的 VARCHAR2、NUMBER 丢了长度和精度,MySQL 的 ENUM 丢了 取值列表,PostgreSQL 的 TIMESTAMP 丢了时区。类型写不全,表结构前后比对就看不出类型变了,ENUM 状态列也进不了 码值候选。这一改会让新探查的表结构快照哈希变化(提示词里的类型写法变长),旧运行封存的快照不受影响。""" if col_type is None: return "" + col_type = _storage_type(col_type) try: compiled = str(col_type.compile(dialect=dialect)) except Exception: # noqa: BLE001 - 编译不了(NullType 等)就用通用写法,不让一列拖垮整次探查 @@ -186,10 +199,12 @@ def type_text(col_type: Any, dialect: Any) -> str: def _enum_values(col_type: Any) -> list[str] | None: - """ENUM(MySQL、PostgreSQL 的枚举,都是 sqltypes.Enum 的子类)的取值列表,MySQL SET 的成员列表;别的类型 None。""" + """ENUM(MySQL、PostgreSQL 的枚举,都是 sqltypes.Enum 的子类)的取值列表,MySQL SET 的成员列表;别的类型 None。 + 套在域(DOMAIN)里的枚举也算(同 type_text)。""" from sqlalchemy.dialects.mysql import SET from sqlalchemy.types import Enum + col_type = _storage_type(col_type) if isinstance(col_type, Enum): values = list(col_type.enums or []) elif isinstance(col_type, SET): diff --git a/backend/tests/test_profile_dialect_units.py b/backend/tests/test_profile_dialect_units.py index ff04b0c7..d2bc226d 100644 --- a/backend/tests/test_profile_dialect_units.py +++ b/backend/tests/test_profile_dialect_units.py @@ -15,7 +15,7 @@ import datetime as _dt from typing import Any import pytest -from sqlalchemy import VARCHAR +from sqlalchemy import VARCHAR, Date, Numeric from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite from sqlalchemy.sql.elements import quoted_name @@ -64,6 +64,56 @@ def test_enum_values_never_make_a_date_column(): assert not catalog.is_date_column("gap", "INTERVAL DAY(2) TO SECOND(6)") +# PostgreSQL 的枚举、域编译出来是类型名本身:名字以 set、enum 开头不能被当成 MySQL 的 SET / ENUM,PG 的枚举一律靠 +# 探查记下的 enum_values 认 +@pytest.mark.parametrize("type_name, family", [ + ("setup_status", None), + ("settlement_amount", None), + ("enum_kind", None), + ("SET ('a','b')", "text"), + ("enum('paid')", "text"), +]) +def test_only_mysql_enum_and_set_spellings_are_enum(type_name, family): + assert catalog._type_family(type_name) == family + assert catalog_profile._is_enum(type_name) is (family == "text") + + +def test_postgres_type_names_starting_with_set_or_enum(): + # 带取值列表的 PG 枚举:是码值候选、是文字,但不是 MySQL 的 SET + assert catalog_profile._is_enum("setup_status", ["draft", "done"]) + assert catalog.column_family({"name": "stage", "type": "setup_status", "enum_values": ["draft"]}) == "text" + # 名字以 set 开头的数值域:不是文字、不是码值候选 + assert not catalog_profile._code_type_ok("settlement_amount") + # 名字以 set 开头的日期域:照样是日期列 + assert catalog.is_date_column("settled_on", "settlement_date") + # 枚举的类型名里碰巧有 DATE(UPDATE 里的):有取值列表就不是日期列 + assert not catalog.is_date_column("reason", "update_reason", enum_values=["manual", "sync"]) + assert not catalog_profile._is_date_column({"name": "reason", "type": "update_reason", + "enum_values": ["manual", "sync"]}) + + +def test_postgres_enum_named_like_set_keeps_its_unseen_values(): + """PG 枚举 setup_status 不是 MySQL 的 SET:列定义里这次没出现的取值也写进码值(含义待填)。""" + settings = ProfileSettings(enabled=True) + source = type("S", (), {"id": "s1", "name": "shop", "kind": "postgresql", "options": {}})() + tables = {"jobs": {"columns": [{"name": "stage", "type": "setup_status", + "enum_values": ["draft", "running", "done"]}]}} + job = catalog_profile.ProfileContext(source=source, settings=settings, dialect=SqlDialect("postgresql"), + runner=None, tables=tables, masked=set()) + job.sizes["jobs"] = TableSize(10, "count") + result = catalog_profile.TableResult(table="jobs") + cx = catalog_profile._TableCx(job, "jobs", result, catalog_profile.TableHooks()) + + async def run(sql: str, **_kw: Any) -> QueryResult: + return QueryResult(columns=["v", "n"], rows=[["draft", 6], ["done", 4]], row_count=2, truncated=False, + elapsed_ms=1, sql=sql) + cx.run = run # type: ignore[method-assign] + out = catalog_profile._TableOutcome() + asyncio.run(catalog_profile._check_codes(cx, "jobs", "stage", out, result)) + [codes] = out.codes + assert codes.declared == ["running"] and "列定义里另有 1 个取值" in codes.note + + @pytest.mark.parametrize("col, ok", [ ({"type": "VARCHAR2(20 CHAR)"}, True), ({"type": "VARCHAR2(200 CHAR)"}, False), @@ -100,6 +150,9 @@ def test_enum_columns_are_code_candidates_whatever_their_name(): (oracle.dialect(), VARCHAR(10), "VARCHAR2(10 CHAR)"), (oracle.dialect(), oracle.NUMBER(10, 0), "NUMBER(10, 0)"), (postgresql.dialect(), postgresql.ENUM("a", "b", name="order_status"), "order_status"), + # PG 的域编译出来只是域名,名字里的词(set、UPDATE 里的 DATE)会让它被认错:记它底下的类型 + (postgresql.dialect(), postgresql.DOMAIN("settlement_amount", Numeric(12, 2)), "NUMERIC(12, 2)"), + (postgresql.dialect(), postgresql.DOMAIN("update_day", postgresql.DOMAIN("biz_day", Date())), "DATE"), (sqlite.dialect(), sqlite.DATETIME(), "DATETIME"), ]) def test_reflected_type_is_compiled_for_the_dialect(dialect, col_type, text): @@ -125,6 +178,13 @@ def test_column_entry_records_enum_values_and_oracle_quoted_names(): assert members["enum_values"] == ["x", "y"] and members["type"] == "SET('x','y')" +def test_domain_over_an_enum_keeps_the_enum_values(): + status = postgresql.ENUM("draft", "done", name="setup_status") + entry = introspect.column_entry({"name": "stage", "type": postgresql.DOMAIN("stage_kind", status)}, + postgresql.dialect()) + assert entry["type"] == "setup_status" and entry["enum_values"] == ["draft", "done"] + + def test_index_first_columns_are_recorded(): indexes = [{"column_names": ["paid_at", "id"]}, {"column_names": [None]}, {"column_names": ["store_id"]}, {"column_names": ["paid_at"]}]