Files
AgentLab/backend/tests/test_sql_binding.py
T
ThearchyHelios 68ea3a7f47 口径账本内核第一步:sql_semantics 加参数绑定与等价证明(bind_template / match_template)
为什么:口径账本要认出「这次执行的 SQL 是不是某一版口径的取数」。只比 normalize_sql 的文本会错给信任(status = 'paid'
和 'refunded' 文本相同),拿模板原文直接比 explain_sql_diff 又永远不等价(参数不同)。做法照 next-round 02 做法 1:

- 绑定只是启发:模板里的 {{ input.<字段> }}(可带 trim / compact)按位置换成哨兵,依次在 optimizer、light、parsed 三级
  规范形式上抹掉字面量的值比结构,结构一样就按位置配字面量(半截字面量按正则取,写死的常量必须相等,同一槽位取同一个值);
  三级都配不上时退回「同一列、同一运算符」的启发式配对(只给说明和预填)。
- 等价只由按绑定 render_template(运行时同一个渲染器)渲染出的口径 SQL 交给 explain_sql_diff 判;有槽位既没取到值又没有
  缺省值时不证明(渲染出 EXISTS (SELECT  FROM …) 这种口径实际不会执行的 SQL,sqlglot 照样能解析)。
- 不可比照实给原因:引用 vars. / nodes. / file_text、参数写在名字里、别的过滤器。
- 等价时按位置给列映射(外层别名不计、列先后计,SELECT * 按表结构展开)。

测试:新增 test_sql_binding.py(认得出、不错给信任、不可比、列映射);test_sql_equivalence.py 加「口径模板」一节,语料里
每一对把 B 的头两个字面量换成槽位当模板拿 A 比,判了等价的在虚构库上执行结果必须一致、explain_sql_diff 也必须判等价。
去掉「槽位没值不证明」那一道回跑,这一节抓到 11 对执行报错。
2026-10-16 13:39:15 +08:00

274 lines
15 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.
"""口径模板的参数绑定与等价证明(data/sql_semantics.py 的 bind_template / match_template,next-round 02 做法 1)。
绑定只是启发:按位置配出参数之后,等价与否只由 explain_sql_diff(执行的 SQL, 按绑定渲染的口径 SQL) 判。这里守三件事:
- 认得出:槽位在引号里、裸写、在半截字面量里、带 trim / compact,同一个槽位出现几处;别名不同、条件换序、BETWEEN 改写;
- 不错给信任:写死的码值不同('paid' 对 'refunded')、IN 列表不同、少一个条件、同一个槽位取到两个值,一律不等价;
- 说得清:不可比的(vars.、nodes.、file_text、参数写在名字里、别的过滤器)照实给原因;等价时按位置给列映射。
执行比对的守门(判等价的在虚构库上结果必须一样)在 tests/test_sql_equivalence.py 的「口径模板」一节。库和表结构用
tests/fixtures/sql_pairs.py 的虚构店铺库。
"""
from __future__ import annotations
import pytest
from app.data.sql_semantics import bind_template, explain_sql_diff, match_template, render_bound
from tests.fixtures import sql_pairs
SCHEMA = sql_pairs.schema_cache()
WEEKLY = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' "
"AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'")
def _sound(m, sql: str) -> None:
"""判了等价的,按绑定渲染的口径 SQL 交给 explain_sql_diff 一定也判等价(结论只能来自它)。"""
if m.equivalent:
assert m.rendered is not None
assert explain_sql_diff(sql, m.rendered, "sqlite").equivalent or \
explain_sql_diff(sql, m.rendered, "sqlite", schema=SCHEMA).equivalent
# --------------------------------------------------------------------------
# 认得出
# --------------------------------------------------------------------------
@pytest.mark.parametrize("schema", [None, SCHEMA], ids=["bare", "schema"])
def test_quoted_slots_bind_and_prove(schema):
sql = ("select sum(o.amount) as 销售额 from orders o where o.paid_at >= '2026-09-01' "
"and o.status = 'paid' and o.paid_at < '2026-10-01'")
m = match_template(WEEKLY, None, sql, "sqlite", schema=schema)
assert m.equivalent and m.comparable
assert m.binding == {"start": "2026-09-01", "end": "2026-10-01"}
assert m.unbound == ()
assert m.level in ("optimizer", "light", "parsed", "heuristic")
# 外层别名不计、按位置对应
assert m.columns is not None and [(c["ours"], c["caliber"]) for c in m.columns] == [("销售额", "gmv")]
_sound(m, sql)
def test_bind_template_gives_the_binding_without_proving():
sql = "SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND paid_at >= '2026-08-01' " \
"AND paid_at < '2026-09-01'"
b = bind_template(WEEKLY, None, sql, "sqlite")
assert b.comparable and b.binding == {"start": "2026-08-01", "end": "2026-09-01"} and b.level == "optimizer"
assert [s.context for s in b.slots] == ["quoted", "quoted"]
def test_bare_slot_binds_a_number():
template = "SELECT COUNT(*) AS n FROM orders WHERE amount > {{ input.min }}"
m = match_template(template, [{"name": "min", "type": "number"}], "SELECT count(*) AS n FROM orders "
"WHERE amount > 100", "sqlite")
assert m.equivalent and m.binding == {"min": 100}
def test_half_literal_slot_binds_the_inner_part():
template = "SELECT COUNT(*) AS n FROM orders WHERE created_at LIKE '{{ input.month }}-%' AND amount > {{ input.min }}"
sql = "SELECT COUNT(*) AS n FROM orders WHERE amount > 10 AND created_at LIKE '2026-09-%'"
m = match_template(template, None, sql, "sqlite")
assert m.equivalent and m.binding == {"month": "2026-09", "min": 10}
_sound(m, sql)
def test_slot_with_trim_and_compact_filters():
template = ("SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input.region | trim }}' "
"AND amount >= {{ input.floor | compact }}")
sql = "SELECT COUNT(*) AS n FROM orders WHERE region = 'east' AND amount >= 12.5"
m = match_template(template, None, sql, "sqlite")
assert m.equivalent and m.binding == {"region": "east", "floor": 12.5}
def test_same_slot_in_several_places_takes_one_value():
template = ("SELECT COUNT(*) AS n FROM orders WHERE created_at >= '{{ input.day }}' "
"AND paid_at >= '{{ input.day }}'")
same = "SELECT COUNT(*) AS n FROM orders WHERE created_at >= '2026-09-01' AND paid_at >= '2026-09-01'"
m = match_template(template, None, same, "sqlite")
assert m.equivalent and m.binding == {"day": "2026-09-01"}
# 两处取到不同的值:没有哪一组参数渲染得出它,不等价
different = "SELECT COUNT(*) AS n FROM orders WHERE created_at >= '2026-09-01' AND paid_at >= '2026-09-02'"
m = match_template(template, None, different, "sqlite")
assert not m.equivalent
_sound(m, different)
@pytest.mark.parametrize("sql", [
# 换别名、换表别名
"SELECT SUM(x.amount) AS total FROM orders AS x WHERE x.status = 'paid' AND x.paid_at >= '2026-09-01' "
"AND x.paid_at < '2026-10-01'",
# 条件换序
"SELECT SUM(amount) AS gmv FROM orders WHERE paid_at < '2026-10-01' AND paid_at >= '2026-09-01' "
"AND status = 'paid'",
# 关键字大小写、空白
"select sum(amount) gmv\nfrom orders\nwhere status='paid' and paid_at>='2026-09-01' and paid_at<'2026-10-01'",
], ids=["alias", "order", "spacing"])
def test_writing_differences_are_recognized(sql):
m = match_template(WEEKLY, None, sql, "sqlite", schema=SCHEMA)
assert m.equivalent, m.diff.as_dict() if m.diff else m
assert m.binding == {"start": "2026-09-01", "end": "2026-10-01"}
_sound(m, sql)
def test_between_rewrite_is_recognized():
template = "SELECT COUNT(*) AS n FROM orders WHERE amount BETWEEN {{ input.low }} AND {{ input.high }}"
sql = "SELECT COUNT(*) AS n FROM orders WHERE amount >= 10 AND amount <= 100"
m = match_template(template, None, sql, "sqlite", schema=SCHEMA)
assert m.equivalent and m.binding == {"low": 10, "high": 100}
_sound(m, sql)
def test_conditions_sorted_differently_still_bind_by_heuristic():
"""规范化按字面量的值排 AND 的各项,哨兵和真实的值排出来的先后不同时结构那几级配不上,退回同列同运算符的配对;
等价照旧只由 explain_sql_diff 判。"""
template = "SELECT SUM(amount) AS s FROM orders WHERE region = '{{ input.region }}' AND status = 'paid'"
sql = "SELECT SUM(amount) AS s FROM orders WHERE status = 'paid' AND region = 'west'"
m = match_template(template, None, sql, "sqlite")
assert m.equivalent and m.binding == {"region": "west"}
_sound(m, sql)
def test_template_without_slots_is_compared_as_is():
template = "SELECT COUNT(*) AS n FROM orders WHERE status = 'paid'"
m = match_template(template, None, "select count(*) as n from orders where status = 'paid'", "sqlite")
assert m.equivalent and m.binding == {} and m.unbound == ()
def test_unbound_slot_falls_back_to_the_default():
"""槽位只在注释里:取不到值,按入口字段的缺省值渲染(同入口节点)。渲染出来等价的照样等价。"""
template = "SELECT COUNT(*) AS n FROM orders -- 门店 {{ input.store }}\nWHERE status = 'paid'"
m = match_template(template, [{"name": "store", "default": "s1"}], "SELECT COUNT(*) AS n FROM orders "
"WHERE status = 'paid'", "sqlite")
assert m.equivalent and m.unbound == ("store",)
def test_render_bound_uses_the_runtime_renderer_and_defaults():
rendered = render_bound("SELECT * FROM orders WHERE region = '{{ input.r }}' AND amount > {{ input.m }}",
{"r": "east"}, [{"name": "m", "default": 5}])
assert rendered == "SELECT * FROM orders WHERE region = 'east' AND amount > 5"
# --------------------------------------------------------------------------
# 不错给信任
# --------------------------------------------------------------------------
def test_hard_coded_code_values_differ():
"""status = 'paid' 和 'refunded' 的 normalize_sql 文本相同,绑定之后照样不等价:口径里写死的常量不是参数。"""
sql = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'refunded' AND paid_at >= '2026-09-01' "
"AND paid_at < '2026-10-01'")
m = match_template(WEEKLY, None, sql, "sqlite", schema=SCHEMA)
assert not m.equivalent and m.diff is not None and m.diff.items
_sound(m, sql)
def test_in_list_differences_are_not_equivalent():
template = "SELECT COUNT(*) AS n FROM orders WHERE status IN ('paid', 'done') AND region = '{{ input.r }}'"
sql = "SELECT COUNT(*) AS n FROM orders WHERE status IN ('paid', 'void') AND region = 'east'"
m = match_template(template, None, sql, "sqlite", schema=SCHEMA)
assert not m.equivalent
# 槽位在 IN 列表里:取到的值照样只能让渲染结果和执行的一样才算
template = "SELECT COUNT(*) AS n FROM orders WHERE status IN ('paid', '{{ input.s }}')"
assert match_template(template, None, "SELECT COUNT(*) AS n FROM orders WHERE status IN ('done', 'paid')",
"sqlite").equivalent
assert not match_template(template, None, "SELECT COUNT(*) AS n FROM orders WHERE status IN ('done', 'void')",
"sqlite").equivalent
def test_missing_condition_says_where_it_differs():
"""少一个条件:不等价,差在哪写成「口径只保留 … 的行,这次没有这个条件」。参数仍按启发配出来(给对账预填)。"""
sql = "SELECT SUM(amount) AS gmv FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-10-01'"
m = match_template(WEEKLY, None, sql, "sqlite", schema=SCHEMA)
assert not m.equivalent
assert m.binding == {"start": "2026-09-01", "end": "2026-10-01"} and m.level == "heuristic"
texts = [d.text for d in m.diff.items]
assert any("口径只保留" in t and "这次没有这个条件" in t for t in texts), texts
def test_different_time_column_is_not_equivalent():
sql = "SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND created_at >= '2026-09-01' " \
"AND created_at < '2026-10-01'"
m = match_template(WEEKLY, None, sql, "sqlite", schema=SCHEMA)
assert not m.equivalent and any(d.aspect == "time" for d in m.diff.items)
def test_a_different_query_shape_is_not_equivalent():
m = match_template(WEEKLY, None, "SELECT COUNT(*) FROM orders", "sqlite")
assert not m.equivalent and m.comparable
def test_unparseable_executed_sql_is_not_equivalent():
m = match_template(WEEKLY, None, "SELEC broken", "sqlite")
assert not m.equivalent and m.diff is not None and m.diff.items[0].aspect == "parse"
# --------------------------------------------------------------------------
# 不可比:照实给原因
# --------------------------------------------------------------------------
@pytest.mark.parametrize("template, word", [
("SELECT * FROM orders WHERE id = {{ vars.id }}", "运行时"),
("SELECT * FROM orders WHERE id = {{ nodes.q.rows }}", "运行时"),
("SELECT * FROM orders WHERE note = '{{ file_text(input.附件) }}'", "附件"),
("SELECT * FROM \"{{ input.table }}\"", "表名或列名"),
("SELECT * FROM orders WHERE region = '{{ input.r | upper }}'", "过滤器"),
])
def test_incomparable_templates_say_why(template, word):
m = match_template(template, None, "SELECT * FROM orders WHERE id = 1", "sqlite")
assert not m.equivalent and not m.comparable and m.diff is None
assert word in (m.reason or "")
b = bind_template(template, None, "SELECT * FROM orders WHERE id = 1", "sqlite")
assert not b.comparable and word in (b.reason or "")
# --------------------------------------------------------------------------
# 列映射
# --------------------------------------------------------------------------
def test_columns_map_by_position_with_different_aliases():
template = "SELECT region AS r, SUM(amount) AS gmv FROM orders WHERE status = '{{ input.s }}' GROUP BY region"
sql = "SELECT region AS 区域, SUM(amount) AS 销售额 FROM orders WHERE status = 'paid' GROUP BY region"
m = match_template(template, None, sql, "sqlite", columns=["区域", "销售额"])
assert m.equivalent
assert [(c["index"], c["ours"], c["caliber"]) for c in m.columns] == [(0, "区域", "r"), (1, "销售额", "gmv")]
def test_select_star_expands_by_the_schema():
template = "SELECT * FROM stores WHERE region = '{{ input.r }}'"
sql = "SELECT id, name, city, region, owner_id FROM stores WHERE region = 'east'"
m = match_template(template, None, sql, "sqlite", schema=SCHEMA)
assert m.equivalent
assert [c["caliber"] for c in m.columns] == ["id", "name", "city", "region", "owner_id"]
# 不给表结构认不出 * 有哪几列:等价照样判,列映射不给
m = match_template(template, None, "SELECT * FROM stores WHERE region = 'east'", "sqlite")
assert m.equivalent and m.columns is None
def test_reordered_columns_are_not_equivalent():
"""选择列的先后计:换了先后判不等价(列映射靠的就是这一点)。"""
template = "SELECT region, SUM(amount) AS gmv FROM orders WHERE status = '{{ input.s }}' GROUP BY region"
sql = "SELECT SUM(amount) AS gmv, region FROM orders WHERE status = 'paid' GROUP BY region"
assert not match_template(template, None, sql, "sqlite", schema=SCHEMA).equivalent
def test_deadline_stops_trying_more_bindings():
import time
sql = "SELECT SUM(amount) AS gmv FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-10-01'"
m = match_template(WEEKLY, None, sql, "sqlite", deadline=time.monotonic() - 1)
assert not m.equivalent and m.diff is not None
def test_a_slot_canonicalized_away_without_a_default_does_not_prove():
"""EXISTS 里的 SELECT 列表规范化时写成 SELECT 1:裸写在那里的槽位配不上值。没有缺省值时渲染出来是 SELECT FROM …
(口径实际不会执行的 SQL,sqlglot 照样解析得了),不能拿它证明等价;有缺省值的按缺省值渲染、照常证明。"""
template = ("SELECT COUNT(*) AS n FROM orders o WHERE EXISTS (SELECT {{ input.one }} FROM refunds r "
"WHERE r.order_id = o.id)")
# 这次写的是 SELECT *:只有规范化的两级对得上,那两级里 EXISTS 的选择列表已经换成了 SELECT 1,配不出槽位的值
sql = "SELECT COUNT(*) AS n FROM orders o WHERE EXISTS (SELECT * FROM refunds r WHERE r.order_id = o.id)"
m = match_template(template, None, sql, "sqlite")
assert not m.equivalent and m.diff is not None and m.diff.fallback
m = match_template(template, [{"name": "one", "default": 1}], sql, "sqlite")
assert m.equivalent