mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:21:59 +00:00
ENUM / SET 只认 MySQL 带括号的写法;PostgreSQL 的域记底下的类型
复核发现(C-enum-prefix-match):类型写法改成按方言编译以后,PostgreSQL 的枚举、域编译出来就是类型名本身,
而 _type_family、is_date_column、_is_enum 和码值检查里的 is_set 都按前缀 startswith(("ENUM", "SET")) 认:
- 叫 setup_status 的 PG 枚举被当成 MySQL 的 SET,列定义里这次没出现的取值不写进码值;
- 叫 settlement_amount 的数值域被认成文字,又被当成码值候选。
改法:
- catalog._MYSQL_ENUM 只认 ENUM( / SET((_mysql_enum_kind),四处都改用它;PG 的枚举一律靠探查记下的
enum_values 认(column_family、_is_enum 本来就看它)。
- is_date_column 加 enum_values 参数:有取值列表的不算日期列。PG 枚举的类型名里碰巧带 DATE(update_reason)
以前会被认成日期列;剖析这边(catalog_profile._is_date_column)已经传上。sqlcheck 里那一处归 W1-a,没动,
不传时行为和原来一样。
- 探查(introspect.type_text、_enum_values)遇到 PG 的域(DOMAIN)记它底下的类型(域套域一路往下找):域名
看不出存的是数字还是文字,名字里的词还会让类型大类、日期列、码值候选认错,模型照着写 SQL 也要知道底下是
NUMERIC(12, 2) 还是 DATE。套在域里的枚举照样记下取值列表。升级前存的是 str(类型) 即「DOMAIN」,同样认不出。
测试:test_profile_dialect_units 加以 set、enum 开头的 PG 类型名、带取值列表的 PG 枚举码值、DOMAIN 编译成底下
类型的单测;改动前 8 条失败。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]}]
|
||||
|
||||
Reference in New Issue
Block a user