mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 16:32:00 +00:00
516 lines
27 KiB
Python
516 lines
27 KiB
Python
"""基于数据目录的 SQL 检查(data/sqlcheck.py):七条规则的正例、反例,级别随依据的状态变化,以及解析不了的写法。
|
|
|
|
用合成的景区业务库(tests/fixtures/catalog/scenic.py)和它的一份目录(scenic_notes.py)。检查只做有把握的判断:
|
|
解析不了、遇到不支持的写法、列对不上表,一律跳过不报——它的 error 会把图打回去改、挡住受管级别的发布、让出具降档,
|
|
误报的代价比漏报大。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.data.engine import engines
|
|
from app.data.introspect import introspect
|
|
from app.data.sqlcheck import CHECK_CODES, SqlChecker, check_sql, dialect_of
|
|
from tests.fixtures.catalog import scenic_notes
|
|
from tests.fixtures.catalog.scenic_notes import ORDER_ITEMS_TO_ORDERS
|
|
|
|
_CACHE: dict[str, dict] = {}
|
|
|
|
|
|
@pytest.fixture
|
|
async def cache(scenic_db):
|
|
"""景区库的表结构缓存(真实探查一次:带外键和唯一约束,关系现推要用)。每个测试进程只探查一次。"""
|
|
if scenic_db not in _CACHE:
|
|
source = SimpleNamespace(id="sqlcheck-scenic", name="scenic", kind="sqlite", database=scenic_db, host=None,
|
|
port=None, username=None, password=None, options={}, readonly=True, description="",
|
|
schema_cache={}, origin="manual")
|
|
try:
|
|
_CACHE[scenic_db] = await introspect(source)
|
|
finally:
|
|
await engines.invalidate(source.id)
|
|
return _CACHE[scenic_db]
|
|
|
|
|
|
def run(cache, sql, status="confirmed", override=None, kind="sqlite"):
|
|
checker = SqlChecker(kind=kind, schema_cache=cache, notes=scenic_notes.notes(status, override),
|
|
source_name="scenic")
|
|
return checker.check(sql)
|
|
|
|
|
|
def found(cache, sql, status="confirmed", override=None, kind="sqlite"):
|
|
"""[(code, level, table, column)],按检查给出的顺序。"""
|
|
return [(c.code, c.level, c.table, c.column) for c in run(cache, sql, status, override, kind)]
|
|
|
|
|
|
def codes(cache, sql, status="confirmed", override=None):
|
|
return {c.code for c in run(cache, sql, status, override)}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 结果的形状
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_result_shape_and_wording(cache):
|
|
sql = ("SELECT o.channel_id, SUM(o.total_amount) AS amt FROM orders o "
|
|
"JOIN order_items i ON i.order_id = o.id WHERE o.status = 1 GROUP BY o.channel_id")
|
|
[check] = run(cache, sql)
|
|
out = check.as_dict()
|
|
assert set(out) == {"code", "level", "message", "for_model", "table", "column", "relation_id", "sql_excerpt"}
|
|
assert out["code"] == "fanout_sum" and out["level"] == "error"
|
|
assert out["table"] == "orders" and out["column"] == "total_amount"
|
|
assert out["relation_id"] == ORDER_ITEMS_TO_ORDERS
|
|
assert "SUM(o.total_amount)" in out["sql_excerpt"]
|
|
assert out["message"].startswith("「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算。")
|
|
# 给模型的写 SQL 里的真名和改法;给人看的不出现工具名和对模型的指令
|
|
assert "orders" in out["for_model"] and "order_items" in out["for_model"]
|
|
assert "db_schema__" not in out["message"] and "db_" not in out["message"]
|
|
# 没有的可选字段不出现
|
|
single = run(cache, "SELECT AVG(discount_rate) FROM member_levels")[0].as_dict()
|
|
assert "relation_id" not in single and single["column"] == "discount_rate"
|
|
assert set(CHECK_CODES) == {"fanout_sum", "stock_summed", "join_unconfirmed", "ratio_aggregated",
|
|
"missing_valid_filter", "unknown_code", "wrong_date_column"}
|
|
|
|
|
|
def test_dialects():
|
|
assert dialect_of("sqlite") == "sqlite"
|
|
assert dialect_of("postgres") == dialect_of("postgresql") == "postgres"
|
|
assert dialect_of("mysql") == dialect_of("mariadb") == "mysql"
|
|
assert dialect_of("oracle") == "oracle"
|
|
assert dialect_of("mssql") is None and dialect_of(None) is None
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# fanout_sum:沿一对多关联之后对「一」那一侧的度量列求和、计数
|
|
# --------------------------------------------------------------------------
|
|
|
|
FANOUT = [
|
|
"SELECT SUM(o.total_amount) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1",
|
|
# 先写明细、后连订单:订单照样随明细重复
|
|
"SELECT SUM(o.total_amount) FROM order_items i JOIN orders o ON o.id = i.order_id WHERE o.status = 1",
|
|
# 包一层 COALESCE、CASE 里只用订单的列:还是订单的数
|
|
"SELECT SUM(COALESCE(o.total_amount, 0)) FROM orders o LEFT JOIN order_items i ON o.id = i.order_id "
|
|
"WHERE o.status = 1",
|
|
"SELECT SUM(CASE WHEN o.status = 1 THEN o.total_amount END) FROM orders o JOIN order_items i "
|
|
"ON i.order_id = o.id AND i.qty > 0",
|
|
# 逗号连接、条件写在 WHERE 里
|
|
"SELECT SUM(o.total_amount) FROM orders o, order_items i WHERE i.order_id = o.id AND o.status = 1",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("sql", FANOUT)
|
|
async def test_fanout_sum_positive(cache, sql):
|
|
assert ("fanout_sum", "error", "orders", "total_amount") in found(cache, sql)
|
|
|
|
|
|
async def test_fanout_count_of_the_one_side_key(cache):
|
|
sql = "SELECT COUNT(o.id) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1"
|
|
[check] = run(cache, sql)
|
|
assert (check.code, check.level, check.table, check.column) == ("fanout_sum", "error", "orders", "id")
|
|
assert "计数会重复计算" in check.message
|
|
|
|
|
|
async def test_fanout_through_two_hops_with_an_inferred_foreign_key(cache):
|
|
"""支付记录没写目录:订单 → 支付记录的关系按外键现推(verified),明细经订单连支付记录,明细的金额照样重复。"""
|
|
sql = ("SELECT SUM(i.amount) FROM order_items i JOIN orders o ON o.id = i.order_id "
|
|
"JOIN payments p ON p.order_id = o.id WHERE o.status = 1")
|
|
[check] = [c for c in run(cache, sql) if c.code == "fanout_sum"]
|
|
assert (check.level, check.table, check.column) == ("error", "order_items", "amount")
|
|
assert "「订单」关联「payments」是一对多,「订单明细」的每一行会随之重复" in check.message
|
|
assert "去掉与「payments」的关联" in check.message
|
|
|
|
|
|
FANOUT_NEGATIVE = [
|
|
# 对「多」那一侧求和:本来就在明细粒度上
|
|
"SELECT SUM(i.amount) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1",
|
|
"SELECT SUM(i.amount) FROM order_items i JOIN orders o ON o.id = i.order_id WHERE o.status = 1",
|
|
# 不关联
|
|
"SELECT SUM(o.total_amount) FROM orders o WHERE o.status = 1",
|
|
# 去重计数、去重求和:写的人已经在处理重复
|
|
"SELECT COUNT(DISTINCT o.id) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1",
|
|
# 两边的列一起算:按明细逐行算出来的数
|
|
"SELECT SUM(i.qty * o.total_amount) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1",
|
|
# 按明细的主键分组:每组只有一行明细,订单不重复
|
|
"SELECT i.id, SUM(o.total_amount) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1 "
|
|
"GROUP BY i.id",
|
|
# 明细先在子查询里按订单汇总再连:一对一
|
|
"SELECT SUM(o.total_amount), SUM(x.n) FROM orders o JOIN (SELECT order_id, COUNT(*) AS n FROM order_items "
|
|
"GROUP BY order_id) x ON x.order_id = o.id WHERE o.status = 1",
|
|
# 计数所有行:说不清是要数订单还是数明细
|
|
"SELECT COUNT(*) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1",
|
|
# 窗口函数:不是分组聚合
|
|
"SELECT o.id, SUM(o.total_amount) OVER (PARTITION BY o.channel_id) FROM orders o "
|
|
"JOIN order_items i ON i.order_id = o.id WHERE o.status = 1",
|
|
# 反连接:只留没有明细的订单,每笔订单至多一行
|
|
"SELECT SUM(o.total_amount) FROM orders o LEFT JOIN order_items i ON i.order_id = o.id "
|
|
"WHERE i.id IS NULL AND o.status = 1",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("sql", FANOUT_NEGATIVE)
|
|
async def test_fanout_sum_negative(cache, sql):
|
|
assert "fanout_sum" not in codes(cache, sql)
|
|
|
|
|
|
async def test_fanout_level_follows_the_status_of_its_basis(cache):
|
|
sql = FANOUT[0]
|
|
assert ("fanout_sum", "error", "orders", "total_amount") in found(cache, sql, "verified")
|
|
assert ("fanout_sum", "info", "orders", "total_amount") in found(cache, sql, "proposed")
|
|
# 关系确认了、度量类型只是推断:依据里有推断就降为 info
|
|
assert ("fanout_sum", "info", "orders", "total_amount") in found(
|
|
cache, sql, override={("orders", "columns.total_amount.measure"): "proposed"})
|
|
# 关系被驳回:不知道是一对多,不报重复计算;关联本身对不上目录,单独提示
|
|
rejected = found(cache, sql, override={("order_items", f"relations.{ORDER_ITEMS_TO_ORDERS}"): "rejected"})
|
|
assert not [f for f in rejected if f[0] == "fanout_sum"]
|
|
assert ("join_unconfirmed", "warning", "order_items", None) in rejected
|
|
# 度量类型被驳回:不知道订单金额能不能加,不报
|
|
assert "fanout_sum" not in codes(cache, sql, override={("orders", "columns.total_amount.measure"): "rejected"})
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# stock_summed:存量跨时间求和
|
|
# --------------------------------------------------------------------------
|
|
|
|
STOCK = [
|
|
"SELECT SUM(on_hand) FROM inventory_snapshots",
|
|
'SELECT "storeID", SUM(on_hand) FROM inventory_snapshots GROUP BY "storeID"',
|
|
"SELECT SUM(s.on_hand) FROM inventory_snapshots s WHERE s.snapshot_date BETWEEN '2026-09-01' AND '2026-09-30'",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("sql", STOCK)
|
|
async def test_stock_summed_positive(cache, sql):
|
|
assert ("stock_summed", "error", "inventory_snapshots", "on_hand") in found(cache, sql)
|
|
|
|
|
|
STOCK_NEGATIVE = [
|
|
"SELECT snapshot_date, SUM(on_hand) FROM inventory_snapshots GROUP BY snapshot_date",
|
|
"SELECT SUM(on_hand) FROM inventory_snapshots WHERE snapshot_date = '2026-09-30'",
|
|
"SELECT substr(snapshot_date, 1, 7) AS m, SUM(on_hand) FROM inventory_snapshots GROUP BY substr(snapshot_date, 1, 7)",
|
|
"SELECT AVG(on_hand) FROM inventory_snapshots",
|
|
"SELECT MAX(on_hand) FROM inventory_snapshots",
|
|
"SELECT snapshot_date, SUM(on_hand) OVER (PARTITION BY snapshot_date) FROM inventory_snapshots",
|
|
# 经日期维度表分组:维度表的日期和快照日期相等
|
|
"SELECT d.biz_date, SUM(s.on_hand) FROM inventory_snapshots s JOIN dim_dates d ON d.biz_date = s.snapshot_date "
|
|
"GROUP BY d.biz_date",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("sql", STOCK_NEGATIVE)
|
|
async def test_stock_summed_negative(cache, sql):
|
|
assert "stock_summed" not in codes(cache, sql)
|
|
|
|
|
|
async def test_stock_level_follows_status(cache):
|
|
sql = STOCK[0]
|
|
assert ("stock_summed", "error", "inventory_snapshots", "on_hand") in found(cache, sql, "verified")
|
|
assert ("stock_summed", "info", "inventory_snapshots", "on_hand") in found(cache, sql, "proposed")
|
|
assert "stock_summed" not in codes(cache, sql, "rejected")
|
|
# 业务日期被驳回:不知道按哪一列算时间,不报
|
|
assert "stock_summed" not in codes(cache, sql, override={("inventory_snapshots", "business_date"): "rejected"})
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# join_unconfirmed:关联条件对不上目录里的关系
|
|
# --------------------------------------------------------------------------
|
|
|
|
async def test_join_unconfirmed_positive(cache):
|
|
# 列连错了:订单的主键对明细的主键
|
|
wrong = found(cache, "SELECT SUM(i.amount) FROM orders o JOIN order_items i ON i.id = o.id WHERE o.status = 1")
|
|
assert ("join_unconfirmed", "warning", "order_items", None) in wrong
|
|
[check] = [c for c in run(cache, "SELECT o.id FROM orders o JOIN members m ON m.id = o.channel_id "
|
|
"WHERE o.status = 1") if c.code == "join_unconfirmed"]
|
|
assert check.level == "warning" and "o.channel_id" in check.sql_excerpt
|
|
assert check.table == "members" and "「订单」与「members」的关联条件" in check.message
|
|
# 只对上命名推断出来的关系(入园记录.member_id → 会员):提示一句,级别 info
|
|
proposed = found(cache, "SELECT m.name FROM visits v JOIN members m ON m.id = v.member_id WHERE v.status = 1")
|
|
assert ("join_unconfirmed", "info", "members", None) in proposed
|
|
|
|
|
|
JOIN_NEGATIVE = [
|
|
"SELECT SUM(i.amount) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1",
|
|
# 多写的过滤条件不影响对关系
|
|
"SELECT SUM(i.amount) FROM orders o JOIN order_items i ON i.order_id = o.id AND i.qty > 0 WHERE o.status = 1",
|
|
# 没写目录的表之间按外键现推(verified)
|
|
"SELECT SUM(p.paid_amount) FROM orders o JOIN payments p ON p.order_id = o.id WHERE o.status = 1",
|
|
# 两张表都没写目录:没有可对照的目录,不判
|
|
"SELECT COUNT(*) FROM tour_groups t JOIN agencies a ON a.channel_id = t.agency_id",
|
|
# 子查询、CTE 当一边:不知道它的粒度,不判
|
|
"WITH x AS (SELECT order_id, SUM(amount) AS a FROM order_items GROUP BY order_id) "
|
|
"SELECT SUM(x.a) FROM orders o JOIN x ON x.order_id = o.id WHERE o.status = 1",
|
|
# 自己连自己:目录不记这种关系
|
|
"SELECT c.name FROM categories c JOIN categories p ON p.id = c.\"parentId\"",
|
|
# 没有等值条件
|
|
"SELECT COUNT(*) FROM orders o JOIN order_items i ON i.amount > o.total_amount WHERE o.status = 1",
|
|
# OR 连起来的条件:说不清按哪条连
|
|
"SELECT COUNT(*) FROM orders o JOIN order_items i ON i.order_id = o.id OR i.id = o.id WHERE o.status = 1",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("sql", JOIN_NEGATIVE)
|
|
async def test_join_unconfirmed_negative(cache, sql):
|
|
assert "join_unconfirmed" not in codes(cache, sql)
|
|
|
|
|
|
async def test_sibling_join_through_a_shared_parent_is_only_a_note(cache):
|
|
"""两张表各自按 member_id 指向会员(都是外键),彼此按 member_id 相连:目录里没有这两张表之间的关系,
|
|
但这是经共同父表推得出来的连法,不是「连错了列」。只提示一句:关联后行数可能成倍增加。"""
|
|
sql = "SELECT COUNT(*) FROM orders o JOIN member_tag_links t ON t.member_id = o.member_id WHERE o.status = 1"
|
|
[check] = [c for c in run(cache, sql) if c.code == "join_unconfirmed"]
|
|
assert check.level == "info"
|
|
assert "会员" in check.message or "members" in check.message
|
|
assert "成倍" in check.message
|
|
|
|
|
|
async def test_join_level_follows_status(cache):
|
|
sql = "SELECT SUM(i.amount) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1"
|
|
assert "join_unconfirmed" not in codes(cache, sql, "confirmed")
|
|
assert "join_unconfirmed" not in codes(cache, sql, "verified")
|
|
# 结果记在 JOIN 引入的那张表上
|
|
assert ("join_unconfirmed", "info", "order_items", None) in found(cache, sql, "proposed")
|
|
assert ("join_unconfirmed", "warning", "order_items", None) in found(cache, sql, "rejected")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# ratio_aggregated:对比率求和、求平均
|
|
# --------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("sql", [
|
|
"SELECT AVG(discount_rate) FROM member_levels",
|
|
"SELECT SUM(discount_rate) FROM member_levels",
|
|
"SELECT ROUND(AVG(l.discount_rate), 2) FROM member_levels l",
|
|
])
|
|
async def test_ratio_aggregated_positive(cache, sql):
|
|
assert found(cache, sql) == [("ratio_aggregated", "warning", "member_levels", "discount_rate")]
|
|
|
|
|
|
@pytest.mark.parametrize("sql", [
|
|
"SELECT level_name, discount_rate FROM member_levels",
|
|
"SELECT MAX(discount_rate) FROM member_levels",
|
|
# 加权:比率乘上数量再加,是正确的算法
|
|
"SELECT SUM(m.id * l.discount_rate) FROM members m JOIN member_levels l ON l.id = m.member_level_id",
|
|
])
|
|
async def test_ratio_aggregated_negative(cache, sql):
|
|
assert "ratio_aggregated" not in codes(cache, sql)
|
|
|
|
|
|
async def test_ratio_level_follows_status(cache):
|
|
sql = "SELECT AVG(discount_rate) FROM member_levels"
|
|
assert found(cache, sql, "verified") == [("ratio_aggregated", "warning", "member_levels", "discount_rate")]
|
|
assert found(cache, sql, "proposed") == [("ratio_aggregated", "info", "member_levels", "discount_rate")]
|
|
assert found(cache, sql, "rejected") == []
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# missing_valid_filter:没按有效记录条件筛
|
|
# --------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("sql", [
|
|
"SELECT COUNT(*) FROM visits",
|
|
"SELECT SUM(visitor_count) FROM visits WHERE park_id = 1",
|
|
"SELECT v.park_id, SUM(v.visitor_count) FROM visits v GROUP BY v.park_id",
|
|
])
|
|
async def test_missing_valid_filter_positive(cache, sql):
|
|
[check] = run(cache, sql)
|
|
assert (check.code, check.level, check.table) == ("missing_valid_filter", "warning", "visits")
|
|
assert "有效记录条件「status = 1」" in check.message
|
|
|
|
|
|
@pytest.mark.parametrize("sql", [
|
|
"SELECT COUNT(*) FROM visits WHERE status = 1",
|
|
"SELECT COUNT(*) FROM visits v WHERE v.status IN (0, 1)",
|
|
# 按状态分组:写的人就是要看每种状态
|
|
"SELECT status, COUNT(*) FROM visits GROUP BY status",
|
|
# 条件写在聚合里
|
|
"SELECT SUM(CASE WHEN status = 1 THEN visitor_count ELSE 0 END) FROM visits",
|
|
# CTE 里筛过
|
|
"WITH ok AS (SELECT * FROM visits WHERE status = 1) SELECT COUNT(*) FROM ok",
|
|
# 子查询原样透出、外层再筛
|
|
"SELECT COUNT(*) FROM (SELECT * FROM visits) x WHERE x.status = 1",
|
|
])
|
|
async def test_missing_valid_filter_negative(cache, sql):
|
|
assert "missing_valid_filter" not in codes(cache, sql)
|
|
|
|
|
|
async def test_valid_filter_level_follows_status(cache):
|
|
sql = "SELECT COUNT(*) FROM visits"
|
|
assert found(cache, sql, "verified") == [("missing_valid_filter", "warning", "visits", None)]
|
|
assert found(cache, sql, "proposed") == [("missing_valid_filter", "info", "visits", None)]
|
|
assert found(cache, sql, "rejected") == []
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# unknown_code:写了码值表里没有的值
|
|
# --------------------------------------------------------------------------
|
|
|
|
async def test_unknown_code_positive(cache):
|
|
[check] = run(cache, "SELECT COUNT(*) FROM visits WHERE status = 3")
|
|
assert (check.code, check.level, check.table, check.column) == ("unknown_code", "warning", "visits", "status")
|
|
assert "「3」" in check.message and "1=有效、0=作废" in check.message
|
|
[listed] = run(cache, "SELECT COUNT(*) FROM visits WHERE status IN (1, 5, 7)")
|
|
assert "「5」、「7」" in listed.message and "「1」" not in listed.message
|
|
assert found(cache, "SELECT COUNT(*) FROM channels WHERE channel_type = '线上'") == [
|
|
("unknown_code", "warning", "channels", "channel_type")]
|
|
|
|
|
|
@pytest.mark.parametrize("sql", [
|
|
"SELECT COUNT(*) FROM visits WHERE status = 1",
|
|
"SELECT COUNT(*) FROM visits WHERE status = '1'",
|
|
"SELECT COUNT(*) FROM visits WHERE 0 = status OR status = 1",
|
|
"SELECT COUNT(*) FROM visits WHERE status IN (0, 1)",
|
|
"SELECT COUNT(*) FROM visits WHERE status = 1.0",
|
|
"SELECT COUNT(*) FROM channels WHERE channel_type = '分销'",
|
|
# 模板和参数:值要到运行时才知道
|
|
"SELECT COUNT(*) FROM visits WHERE status = '{{ input.status }}'",
|
|
"SELECT COUNT(*) FROM visits WHERE status = {{ input.status }}",
|
|
"SELECT COUNT(*) FROM visits WHERE status = ?",
|
|
"SELECT COUNT(*) FROM visits WHERE status = :status",
|
|
])
|
|
async def test_unknown_code_negative(cache, sql):
|
|
assert "unknown_code" not in codes(cache, sql)
|
|
|
|
|
|
async def test_unknown_code_level_follows_status(cache):
|
|
sql = "SELECT COUNT(*) FROM channels WHERE channel_type = '线上'"
|
|
assert found(cache, sql, "verified") == [("unknown_code", "warning", "channels", "channel_type")]
|
|
assert found(cache, sql, "proposed") == [("unknown_code", "info", "channels", "channel_type")]
|
|
assert found(cache, sql, "rejected") == []
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# wrong_date_column:按另一个时间列分组、筛选
|
|
# --------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("sql", [
|
|
"SELECT substr(entered_at, 1, 10) AS d, SUM(fee) FROM parking_records GROUP BY substr(entered_at, 1, 10)",
|
|
"SELECT SUM(p.fee) FROM parking_records p WHERE p.entered_at >= '2026-09-01'",
|
|
])
|
|
async def test_wrong_date_column_positive(cache, sql):
|
|
[check] = run(cache, sql)
|
|
assert (check.code, check.level, check.table, check.column) == (
|
|
"wrong_date_column", "warning", "parking_records", "entered_at")
|
|
assert "业务日期按「出场时间」计" in check.message and "「入场时间」" in check.message
|
|
|
|
|
|
@pytest.mark.parametrize("sql", [
|
|
"SELECT substr(exited_at, 1, 10) AS d, SUM(fee) FROM parking_records GROUP BY substr(exited_at, 1, 10)",
|
|
"SELECT SUM(fee) FROM parking_records WHERE exited_at >= '2026-09-01' AND entered_at IS NOT NULL",
|
|
# 两个时间都用上了:写的人知道两者的区别
|
|
"SELECT SUM(fee) FROM parking_records WHERE exited_at >= '2026-09-01' AND entered_at < '2026-09-30'",
|
|
"SELECT entered_at, exited_at, fee FROM parking_records",
|
|
])
|
|
async def test_wrong_date_column_negative(cache, sql):
|
|
assert "wrong_date_column" not in codes(cache, sql)
|
|
|
|
|
|
async def test_wrong_date_level_follows_status(cache):
|
|
sql = "SELECT SUM(p.fee) FROM parking_records p WHERE p.entered_at >= '2026-09-01'"
|
|
assert found(cache, sql, "verified") == [("wrong_date_column", "warning", "parking_records", "entered_at")]
|
|
assert found(cache, sql, "proposed") == [("wrong_date_column", "info", "parking_records", "entered_at")]
|
|
assert found(cache, sql, "rejected") == []
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 复杂写法:照样分析得出,或者跳过;不报错、不误报
|
|
# --------------------------------------------------------------------------
|
|
|
|
CLEAN_COMPLEX = [
|
|
# CTE + 窗口函数 + 子查询
|
|
"""WITH daily AS (
|
|
SELECT substr(o.ordered_at, 1, 10) AS d, SUM(i.amount) AS amt
|
|
FROM orders o JOIN order_items i ON i.order_id = o.id
|
|
WHERE o.status = 1 GROUP BY substr(o.ordered_at, 1, 10))
|
|
SELECT d, amt, SUM(amt) OVER (ORDER BY d) AS running,
|
|
(SELECT COUNT(*) FROM visits v WHERE v.status = 1) AS visits
|
|
FROM daily ORDER BY d""",
|
|
# UNION
|
|
"SELECT 'web' AS ch, SUM(total_amount) FROM orders WHERE status = 1 AND channel_id = 1 "
|
|
"UNION ALL SELECT 'box', SUM(total_amount) FROM orders WHERE status = 1 AND channel_id = 3",
|
|
# 相关子查询、EXISTS
|
|
"SELECT o.id FROM orders o WHERE o.status = 1 AND EXISTS (SELECT 1 FROM order_items i WHERE i.order_id = o.id)",
|
|
# 引号括起来的驼峰标识符
|
|
'SELECT t.tag_name, COUNT(*) FROM member_tag_links l JOIN "memberTags" t ON t.id = l."memberTagId" '
|
|
"GROUP BY t.tag_name",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("sql", CLEAN_COMPLEX)
|
|
async def test_complex_but_clean_sql_reports_nothing(cache, sql):
|
|
assert run(cache, sql) == []
|
|
|
|
|
|
async def test_problems_inside_cte_and_union_are_found(cache):
|
|
cte = ("WITH t AS (SELECT o.channel_id, SUM(o.total_amount) AS amt FROM orders o "
|
|
"JOIN order_items i ON i.order_id = o.id WHERE o.status = 1 GROUP BY o.channel_id) SELECT * FROM t")
|
|
assert ("fanout_sum", "error", "orders", "total_amount") in found(cache, cte)
|
|
union = ("SELECT SUM(on_hand) FROM inventory_snapshots UNION ALL "
|
|
"SELECT SUM(on_hand) FROM inventory_snapshots WHERE snapshot_date = '2026-09-30'")
|
|
assert found(cache, union) == [("stock_summed", "error", "inventory_snapshots", "on_hand")]
|
|
|
|
|
|
@pytest.mark.parametrize("sql", [
|
|
"SELECT FROM WHERE",
|
|
"SELEC total_amount FROM orders",
|
|
"SELECT SUM(o.total_amount FROM orders o JOIN order_items i ON i.order_id = o.id",
|
|
"SELECT 1; SELECT SUM(on_hand) FROM inventory_snapshots",
|
|
"DELETE FROM visits",
|
|
"",
|
|
" ",
|
|
# 方言特性:SQLite 读不了 Oracle 的 q 引号
|
|
"SELECT SUM(on_hand) FROM inventory_snapshots WHERE q'[x]' = 'x'",
|
|
# 模板里的控制语句:拼出来是什么样说不清
|
|
"SELECT SUM(on_hand) FROM inventory_snapshots {% if x %}WHERE snapshot_date = '2026-09-30'{% endif %}",
|
|
# 结构里没有的表、别名对不上的列
|
|
"SELECT SUM(x.on_hand) FROM inventory_snapshots s",
|
|
"SELECT SUM(on_hand) FROM not_a_table",
|
|
])
|
|
async def test_unparseable_or_unsupported_sql_is_skipped(cache, sql):
|
|
assert run(cache, sql) == []
|
|
|
|
|
|
async def test_other_dialects(cache):
|
|
pg = "SELECT s.on_hand::numeric FROM inventory_snapshots s WHERE s.snapshot_date = $1"
|
|
assert run(cache, pg, kind="postgres") == []
|
|
assert found(cache, "SELECT SUM(s.on_hand)::numeric FROM inventory_snapshots s", kind="postgres") == [
|
|
("stock_summed", "error", "inventory_snapshots", "on_hand")]
|
|
mysql = "SELECT `v`.`status`, COUNT(*) FROM `visits` `v` WHERE `v`.`status` = 3 GROUP BY `v`.`status`"
|
|
assert found(cache, mysql, kind="mysql") == [("unknown_code", "warning", "visits", "status")]
|
|
# 能读就照常分析,读不了就跳过:只要求不抛
|
|
assert isinstance(run(cache, "SELECT SUM(on_hand) FROM inventory_snapshots WHERE q'[it''s]' = 'x'",
|
|
kind="oracle"), list)
|
|
assert run(cache, "SELECT SUM(on_hand) FROM inventory_snapshots", kind="mssql") == []
|
|
|
|
|
|
async def test_no_catalog_means_no_checks(cache):
|
|
sql = "SELECT SUM(o.total_amount) FROM orders o JOIN order_items i ON i.id = o.id"
|
|
assert SqlChecker(kind="sqlite", schema_cache=cache, notes={}).check(sql) == []
|
|
assert check_sql(sql, kind="sqlite", schema_cache=cache, notes=None) == []
|
|
# 没探查过结构:对不上表,一样不判
|
|
assert check_sql(sql, kind="sqlite", schema_cache={}, notes=scenic_notes.notes()) == []
|
|
|
|
|
|
async def test_duplicates_are_folded_and_errors_come_first(cache):
|
|
sql = ("SELECT SUM(o.total_amount), SUM(o.total_amount) * 2, AVG(l.discount_rate) "
|
|
"FROM orders o JOIN order_items i ON i.order_id = o.id JOIN members m ON m.id = o.member_id "
|
|
"JOIN member_levels l ON l.id = m.member_level_id WHERE o.status = 1")
|
|
out = found(cache, sql)
|
|
assert [f for f in out if f[0] == "fanout_sum"] == [("fanout_sum", "error", "orders", "total_amount")]
|
|
levels = [f[1] for f in out]
|
|
assert levels == sorted(levels, key=["error", "warning", "info"].index)
|
|
|
|
|
|
async def test_checker_builds_relation_graph_lazily_and_reuses_it(cache, monkeypatch):
|
|
from app.data import catalog
|
|
|
|
calls = []
|
|
real = catalog.relation_graph
|
|
monkeypatch.setattr(catalog, "relation_graph", lambda *a, **k: calls.append(1) or real(*a, **k))
|
|
checker = SqlChecker(kind="sqlite", schema_cache=cache, notes=scenic_notes.notes())
|
|
checker.check("SELECT AVG(discount_rate) FROM member_levels")
|
|
assert calls == [] # 单表查询用不到关系图
|
|
checker.check(FANOUT[0])
|
|
checker.check(FANOUT[1])
|
|
assert calls == [1]
|