Files
AgentLab/backend/tests/test_profile_dialect_golden.py
T
ThearchyHelios 7f949cce92 取值观察:业务日期有未来值时不再误报量跌、缺天;「今天」统一按服务端所在时区
验收复现:业务日期最晚到明天、今天只写了几行,记出一条 volume_drop。原因有两层:
- 比较器 value_drift.daily() 取 last = max(days),只在 last >= today 时退一天;max 落在明天时退到的是今天这个
  还没写完的日子。改成最近一个整天 = min(有数据的最后一天, 昨天)。
- 按日行数的查询 day_volume_sql 只有下界,未来的日子也进了窗口。加上界:数到观察当天(含),第二天起不算
  (< 第二天,时间列上当天的时分秒照样算进来,文字存的日期按字符串比同样成立)。签名改成必须传 until。

缺天同样的问题也修了:今天以后的空日子不算缺天;以前那次观察只看到它自己那天(data.until)为止,免得它
窗口里未来的空日子被当成「以前就有过缺天」,挡住这一次真缺的天。断档遇到未来值时离今天是负数,只会报不出、
不会误报,观察本身照实记最新业务日期(目录里也照实给人看),不截未来值,写进了模块说明。

「今天」:原来窗口用 UTC 日期、比较器用 now.date()(也是 UTC),业务日期是本地日期,东八区凌晨 0–8 点会差
一天。统一成服务端所在时区(catalog_profile_observe.local_today,和待办汇总 notify.digest_at 同一个口径),
写进模块说明。

测试:test_value_drift 两条(未来值不算最近整天、未来空日子不算缺天且历史按 until 截);test_catalog_observe
两条(SQLite 真跑剖析任务,明天的行不进窗口、不记量跌;local_today 按 TZ 取本地日期)。去掉修复回跑都挂。
金样(tests/fixtures/dialect/*.sql)的 day_volume 一行随上界更新;scripts/dialect_it.py 加了一条「只数到观察
当天」的真库核对。
2026-10-17 07:59:19 +08:00

182 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""剖析 SQL 的方言金样(下一轮 03 细案 C2):没有真库时挡住方言写法的回退。
三层,各挡各的:
1. **金样**:每种方言(mysql、mariadb、postgresql、oracle、sqlite)× 每类查询(统计信息、有上限的计数、取不同键值、
对键值、唯一性、取值分布、日期最小最大、时间窗口里的取值分布、按日行数、块采样、最新业务日期),完整 SQL 钉在
tests/fixtures/dialect/<方言>.sql,人审过一次;以后拼法有任何改动都会出 diff。要更新金样:
``UPDATE_DIALECT_GOLDEN=1 pytest tests/test_profile_dialect_golden.py``,再逐行看 git diff。
2. **sqlglot 逐条解析**:按方言读一遍(mariadb 按 mysql 读),挡住语法写坏了(少括号、关键字拼错)。
3. **IN 两边类型族一致**:用 sqlglot 的 qualify + annotate_types,配上表结构里的类型,核对 IN 列表的字面量和被指向
列是同一类型族(PostgreSQL 上 varchar 父列配整数子列时,字面量必须带引号)。
挡不住的照实写:sqlglot 不知道真库的统计信息是不是过期、零日期驱动怎么返回、Oracle 小写对象是否存在、排序规则
怎么比——这些要靠 scripts/dialect-it.sh 在本机容器里跑真库(不进 CI)。
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
import sqlglot
from sqlglot import exp
from sqlglot.optimizer.annotate_types import annotate_types
from sqlglot.optimizer.qualify import qualify
from app.data import catalog, guard
from app.data.catalog_profile import IN_CHUNK, SqlDialect, key_literals
GOLDEN = Path(__file__).parent / "fixtures" / "dialect"
DIALECTS = ("mysql", "mariadb", "postgresql", "oracle", "sqlite")
#: sqlglot 的方言名
READ = {"mysql": "mysql", "mariadb": "mysql", "postgresql": "postgres", "oracle": "oracle", "sqlite": "sqlite"}
#: 夹具表结构:一张带 schema 的订单表、一张门店表(编码是文字),Oracle 的一张小写带引号的表和一个小写带引号的列
META = {
"orders": {"schema": "sales", "columns": [
{"name": "id", "type": "INTEGER"}, {"name": "store_code", "type": "INTEGER"},
{"name": "status", "type": "CHAR(2)"}, {"name": "paid_at", "type": "TIMESTAMP"},
{"name": "biz_day", "type": "VARCHAR(10)"}, {"name": "level", "type": "INTEGER"}],
"primary_key": ["id"]},
"stores": {"schema": None, "columns": [{"name": "code", "type": "VARCHAR(10)"}], "primary_key": ["code"]},
"visits": {"schema": None, "quote_name": True, "columns": [
{"name": "id", "type": "INTEGER"}, {"name": "level", "type": "INTEGER", "quote_name": True}],
"primary_key": ["id"]},
}
def _queries(kind: str) -> list[tuple[str, str | None]]:
"""一种方言的全部剖析、观察查询:(名字, SQL)。没有这种查询(SQLite 没有统计信息、MySQL 没有块采样)的 SQL 为 None。"""
d = SqlDialect(kind)
orders, stores, visits = d.table(META["orders"], "orders"), d.table(META["stores"], "stores"), \
d.table(META["visits"], "visits")
status, code = d.column(META["orders"], "status"), d.column(META["stores"], "code")
level = d.column(META["visits"], "level")
keys = key_literals([12, 7], "number", parent="text", child_type="INTEGER").literals
out: list[tuple[str, str | None]] = [
("stats", d.stats_sql(META["orders"], "orders")),
("stats_quoted", d.stats_sql(META["visits"], "visits")),
("bounded", d.bounded_count_sql(orders, 100_001)),
("distinct", d.distinct_sample_sql(orders, d.column(META["orders"], "store_code"), 2000, scan_cap=None)),
("distinct_capped", d.distinct_sample_sql(orders, d.column(META["orders"], "store_code"), 2000,
scan_cap=100_000)),
("match", d.match_count_sql(stores, code, keys)),
("unique", d.unique_check_sql(visits, level)),
("value_counts", d.value_counts_sql(orders, status, 21, scan_cap=None)),
("value_counts_capped", d.value_counts_sql(orders, status, 21, scan_cap=100_000)),
("min_max", d.min_max_sql(orders, [(d.column(META["orders"], "paid_at"), False),
(d.column(META["orders"], "biz_day"), True)])),
("date_window", d.window_value_counts_sql(orders, status, d.column(META["orders"], "paid_at"),
"2026-08-27", 21)),
("day_volume", d.day_volume_sql(orders, d.column(META["orders"], "paid_at"), "2026-08-27", "2026-10-01")),
("sample", d.sample_value_counts_sql(orders, status, 1, 21)),
# 第 3 波:取值观察的最新业务日期(文字存的日期按字符串比)
("max_date", d.max_date_sql(orders, d.column(META["orders"], "paid_at"))),
("max_date_text", d.max_date_sql(orders, d.column(META["orders"], "biz_day"), text=True)),
]
return out
def _render(kind: str) -> str:
lines = [f"-- 剖析与取值观察的 SQL 金样:{kind}(tests/test_profile_dialect_golden.py 生成,改动要人审)"]
for name, sql in _queries(kind):
lines.append(f"-- {name}")
lines.append(f"{sql};" if sql is not None else "-- (这种方言没有这类查询)")
return "\n".join(lines) + "\n"
@pytest.mark.parametrize("kind", DIALECTS)
def test_profile_sql_matches_the_golden_file(kind):
path = GOLDEN / f"{kind}.sql"
text = _render(kind)
if os.environ.get("UPDATE_DIALECT_GOLDEN") == "1":
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
assert path.read_text(encoding="utf-8") == text, f"{kind} 的剖析 SQL 和金样不一致:看 diff,确认无误后重新生成金样"
@pytest.mark.parametrize("kind", DIALECTS)
def test_every_profile_query_parses_and_passes_the_guard(kind):
for name, sql in _queries(kind):
if sql is None:
continue
parsed = sqlglot.parse(sql, read=READ[kind], error_level=sqlglot.ErrorLevel.RAISE)
assert len(parsed) == 1 and parsed[0] is not None, (kind, name)
assert guard.check(sql, readonly=True, dialect=kind) == sql, (kind, name)
@pytest.mark.parametrize("kind", ["postgresql", "oracle", "mysql"])
def test_observation_queries_use_the_right_day_and_sample_functions(kind):
"""按天截断和块采样各家写法不同,解析成语法树再看用的是哪个函数,不只比字符串。"""
sql = dict(_queries(kind))
day = sqlglot.parse_one(sql["day_volume"], read=READ[kind])
if kind == "oracle":
assert any(isinstance(n, (exp.DateTrunc, exp.TimestampTrunc, exp.Anonymous)) or n.key == "trunc"
for n in day.walk()), sql["day_volume"]
assert "TRUNC(paid_at)" in sql["day_volume"]
elif kind == "postgresql":
assert any(isinstance(n, exp.Cast) for n in day.walk())
else:
assert "DATE(paid_at)" in sql["day_volume"]
if kind == "mysql":
assert sql["sample"] is None
else:
sampled = sqlglot.parse_one(sql["sample"], read=READ[kind])
assert any(isinstance(n, exp.TableSample) for n in sampled.walk()), sql["sample"]
# ---------------------------------------------------------------- IN 两边的类型族
_FAMILY_OF = [(exp.DataType.TEXT_TYPES, "text"), (exp.DataType.NUMERIC_TYPES, "number"),
(exp.DataType.TEMPORAL_TYPES, "time")]
def _sg_family(t: exp.DataType | None) -> str | None:
if t is None:
return None
for group, name in _FAMILY_OF:
if t.this in group:
return name
return None
def _in_families(sql: str, kind: str, schema: dict[str, dict[str, str]]) -> list[tuple[str | None, set[str | None]]]:
"""sql 里每个 IN:(被比较的列的类型族, 字面量的类型族集合),按 annotate_types 配上表结构的类型推出来。"""
tree = qualify(sqlglot.parse_one(sql, read=READ[kind]), schema=schema, dialect=READ[kind],
validate_qualify_columns=False)
tree = annotate_types(tree, schema=schema, dialect=READ[kind])
return [(_sg_family(node.this.type), {_sg_family(x.type) for x in node.expressions})
for node in tree.find_all(exp.In)]
@pytest.mark.parametrize("kind", DIALECTS)
@pytest.mark.parametrize("parent_type, child_values, child_type", [
("VARCHAR(10)", [12, 7, 30], "INTEGER"), # 父列文字、子列数:字面量要带引号
("INTEGER", ["12", "0007", "A1"], "VARCHAR(10)"), # 父列数、子列文字:只收整数写法,写成数字
("VARCHAR(10)", ["A ", "BC"], "CHAR(2)"), # 定长子列:去掉补齐的空格
("INTEGER", [1, 2], "INTEGER"),
])
def test_in_list_literals_have_the_parent_columns_family(kind, parent_type, child_values, child_type):
d = SqlDialect(kind)
parent = {"name": "code", "type": parent_type}
literals = key_literals(child_values, None, parent=catalog.column_family(parent), child_type=child_type).literals
assert literals
sql = d.match_count_sql("stores", d.quote("code"), literals[:IN_CHUNK])
found = _in_families(sql, kind, {"stores": {"code": parent_type}})
assert found, sql
for column_family, literal_families in found:
assert literal_families == {column_family}, (kind, sql, column_family, literal_families)
def test_the_old_way_would_mismatch_on_postgres():
"""对照:以前按子表取回的值写字面量,varchar 父列配整数子列在 sqlglot 推出的类型上就两边不同——PostgreSQL 真库上
这是「operator does not exist: character varying = integer」。"""
from app.data.catalog_profile import sql_literal
old = [lit for v in (12, 7) if (lit := sql_literal(v, "number"))]
sql = SqlDialect("postgresql").match_count_sql("stores", "code", old)
[(column_family, literal_families)] = _in_families(sql, "postgresql", {"stores": {"code": "VARCHAR(10)"}})
assert column_family == "text" and literal_families == {"number"}