From 2eddd8236abf99efc707fac41dcebedc9e8cd041 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 09:18:10 +0800 Subject: [PATCH] =?UTF-8?q?SQL=20=E5=B7=AE=E5=BC=82=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E6=94=B6=E5=B0=BE=EF=BC=9A=E5=89=8D=E7=BC=80=E7=9A=84=20NOT=20?= =?UTF-8?q?=E5=90=8E=E9=9D=A2=E5=90=8C=E4=B8=80=E6=AE=B5=E9=87=8C=E6=97=A2?= =?UTF-8?q?=E6=9C=89=E8=B0=93=E8=AF=8D=E3=80=81=E5=8F=88=E6=9C=89=E7=AE=97?= =?UTF-8?q?=E6=9C=AF=20/=20=E6=8B=BC=E6=8E=A5=20/=20=E6=8C=89=E4=BD=8D?= =?UTF-8?q?=E8=BF=90=E7=AE=97=E6=97=B6=EF=BC=88NOT=20status=20IN=20(?= =?UTF-8?q?=E2=80=A6)=20*=20amount=EF=BC=89=EF=BC=8CSQLite=20=E9=87=8C=20N?= =?UTF-8?q?OT=20=E6=9C=80=E6=9D=BE=E3=80=81sqlglot=20=E8=AF=BB=E6=88=90=20?= =?UTF-8?q?(NOT=20=E2=80=A6)=20*=20amount=EF=BC=8C=E4=B8=8D=E5=9C=A8?= =?UTF-8?q?=E8=AF=AD=E6=B3=95=E6=A0=91=E4=B8=8A=E5=88=A4=E7=AD=89=E4=BB=B7?= =?UTF-8?q?=E3=80=81=E5=8F=AA=E7=BB=99=E5=85=9C=E5=BA=95=EF=BC=9B48=20?= =?UTF-8?q?=E7=A7=8D=E7=BB=84=E5=90=88=E4=B8=A4=E7=A7=8D=E6=96=B9=E8=A8=80?= =?UTF-8?q?=E9=83=BD=E4=B8=8D=E5=86=8D=E5=88=A4=E7=AD=89=E4=BB=B7=EF=BC=8C?= =?UTF-8?q?=E5=B8=B8=E8=A7=81=E7=9A=84=20NOT=20=E2=80=A6=20IN=E3=80=81NOT?= =?UTF-8?q?=20=E2=80=A6=20=3D=20=E7=85=A7=E6=A0=B7=E8=AE=A4=E5=BE=97?= =?UTF-8?q?=E5=87=BA=E7=AD=89=E4=BB=B7=E6=94=B9=E5=86=99=E3=80=82=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E8=AF=B4=E6=98=8E=E7=85=A7=E5=AE=9E=E5=88=97=E5=87=BA?= =?UTF-8?q?=E4=BB=8D=E5=81=9A=E4=B8=8D=E5=88=B0=E7=9A=84=E5=87=A0=E7=B1=BB?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=87=86=E7=A1=AE=E5=BA=A6=E9=97=AE=E9=A2=98?= =?UTF-8?q?=EF=BC=88Unix=20=E7=A7=92=E7=9A=84=E6=97=B6=E9=97=B4=E5=88=97?= =?UTF-8?q?=E3=80=81=E5=B8=A6=20time=20=E7=9A=84=E6=97=B6=E9=95=BF?= =?UTF-8?q?=E5=88=97=E3=80=81=E8=A2=AB=E5=87=BD=E6=95=B0=E5=AE=9A=E6=AD=BB?= =?UTF-8?q?=E7=9A=84=E5=88=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/data/sql_semantics.py | 19 ++++++++++--- backend/tests/test_sql_semantics.py | 41 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/backend/app/data/sql_semantics.py b/backend/app/data/sql_semantics.py index f9d229c3..13f99203 100644 --- a/backend/app/data/sql_semantics.py +++ b/backend/app/data/sql_semantics.py @@ -174,7 +174,11 @@ WHERE / GROUP BY 里用列别名的),而且只说四类: COUNT(*) > 0 THEN 1 END 这类恒为一个值的表达式不算聚合,认作看不见(少说),可 MAX(x) 旁边并排的那一项恒为一个值时 仍按看得见说;表结构里没写外键、没写键的,按「可能对不上、可能重复」说(库里实际有约束时这句话可能不起作用);没给 表结构时按主键分组的 MAX(x) 对 MIN(x)、AVG(DISTINCT x) 对 AVG(x) 照说(那时不知道键);「可能是时间」只认列名里的 - 词和像日期的常量,一边按 id 的单边范围、按批次号这类筛出来的其实是一段时间时,另一边仍会被说成「没有按时间筛选」。 + 词和像日期的常量,一边按 id 的单边范围、按批次号这类筛出来的其实是一段时间时,另一边仍会被说成「没有按时间筛选」; + 存成 Unix 秒的时间列(created >= 1788220800)认不出是时间,会说成有效行条件;列名带 time 的时长列(wait_time < + 3600)会被说成「按它筛时间」;被 CURRENT_DATE、DATE('now', …)、CAST(…)、上下界相同的 BETWEEN 定死的列不算定死, + MAX(stat_date) 对 MIN(stat_date) 这类仍会照说。这几类只影响说明的准确度,不会判错等价;双盲复算写数据目录时另有一道 + 门槛(业务日期只在两边的列都确实是日期 / 时间类型时才写,有效行条件只收排除无效记录的写法),不会因此写进错的事实。 - 收窄换来的代价:一边的聚合参数里用到某一列(SUM(amount - discount)),另一边多一个这一列上的条件(WHERE discount > 0)时,这个条件不再说成「另一边没有这个条件」(认不出参数里的运算是不是在筛);列名带 day / month / period 这类 词、常量像日期的条件(delivery_days < 3、period_type = 'monthly')不再说成有效行过滤;另一边有任何上下界(amount @@ -2747,7 +2751,9 @@ def _misread(tokens: list[Any], text: str, read: str | None) -> str | None: 同一段里又有 ::(PostgreSQL 里 x COLLATE "C"::text 的 :: 套在整个 COLLATE 上,sqlglot 读成套在排序规则的名字上); - 不知道是哪种库时:块注释里再写 /*(PostgreSQL 嵌套、SQLite / MySQL 不嵌套,见 _COMMENTS_AS_SQLGLOT)、后面紧跟着 字的 --(MySQL 里 amount--1 是 amount - (-1),别的库是注释)、#(MySQL 里是注释,PostgreSQL 里是按位异或)、双引号 - 括起来的名字(MySQL 默认的 sql_mode 里 "status" 是字符串,别的库和 sqlglot 的通用读法里是列名)。 + 括起来的名字(MySQL 默认的 sql_mode 里 "status" 是字符串,别的库和 sqlglot 的通用读法里是列名); + - 前缀的 NOT 后面同一段里既有谓词、又有算术 / 拼接 / 按位运算(NOT status IN (…) * amount、NOT paid_at IS NULL * amount): + SQLite 里 NOT 最松,sqlglot 的 NOT 只取等值那一级。宁可多拦(NOT a = 1 + 2 其实读得对),只给兜底不会说错。 一段条件到 AND、OR、逗号、子句关键字为止(BETWEEN 自己的 AND 不算;IS [NOT] DISTINCT FROM 的 FROM 不算)。""" if read not in _PLAIN_BACKSLASH and any( @@ -2771,11 +2777,15 @@ def _misread(tokens: list[Any], text: str, read: str | None) -> str | None: frames: list[dict[str, Any]] = [{}] def fresh(frame: dict[str, Any]) -> None: - frame.update(compare=0, between=0, bitwise=set(), arithmetic=False, collated=False) + frame.update(compare=0, between=0, bitwise=set(), arithmetic=False, collated=False, negated=False) def done(frame: dict[str, Any]) -> str | None: if frame["compare"] >= 2: return "比较运算和 IS / IN / LIKE / BETWEEN 没加括号地混用" + if frame["negated"] and frame["compare"] and (frame["arithmetic"] or frame["bitwise"]): + # 前缀的 NOT 后面同一段里既有谓词、又有算术 / 拼接 / 按位运算(NOT status IN (…) * amount):SQLite 里 NOT 最松、 + # * 比 IN 紧,是 NOT ((status IN (…)) * amount);sqlglot 的 NOT 只取等值那一级,读成 (NOT status IN (…)) * amount + return "前缀的 NOT 和谓词、算术运算没加括号地混用" if frame["bitwise"] and (len(frame["bitwise"]) >= 2 or frame["arithmetic"]): return "按位运算、COLLATE 和别的运算没加括号地混用" return None @@ -2849,6 +2859,9 @@ def _misread(tokens: list[Any], text: str, read: str | None) -> str | None: continue i = j continue + elif kind == "NOT" and not after_operand and not (nxt is not None and nxt.token_type.name == "NULL"): + # 前缀的 NOT(前面不是操作数):x NOT IN / NOT LIKE / NOT BETWEEN 跟在操作数后面,不算;IS NOT 在 IS 那一支整个算过 + frame["negated"] = True elif kind == "NOT" and nxt is not None and nxt.token_type.name == "NULL": # SQLite 的后缀 x NOT NULL(前面是 IS 的已经整个算过了) frame["compare"] += 1 diff --git a/backend/tests/test_sql_semantics.py b/backend/tests/test_sql_semantics.py index 1d274fbf..2a5740b9 100644 --- a/backend/tests/test_sql_semantics.py +++ b/backend/tests/test_sql_semantics.py @@ -1619,3 +1619,44 @@ def test_time_columns_on_different_tables_are_written_with_their_tables(): "b": [{"table": "users", "column": "created_at"}]} [item] = explain_sql_diff(b, plain, "sqlite").items assert (item.aspect, item.text) == ("time", "A 按 users.created_at 筛时间,B 没有按时间筛选") + + +# ---------------------------------------------------------------- 前缀的 NOT 和谓词、算术混用(解析保真守卫) + +_NOT_PREDICATES = ["status IN ('void', 'refunded')", "status NOT IN ('void')", "paid_at IS NULL", "paid_at IS NOT NULL", + "user_id IN (SELECT user_id FROM refunds)", "paid_at NOT NULL"] +_NOT_OPERATORS = ["*", "+", "-", "/", "%", "||", "&", "|"] + + +@pytest.mark.parametrize("predicate", _NOT_PREDICATES) +@pytest.mark.parametrize("operator", _NOT_OPERATORS) +@pytest.mark.parametrize("dialect", ["sqlite", None]) +def test_a_prefix_not_mixed_with_a_predicate_and_arithmetic_is_never_equivalent(predicate, operator, dialect): + """SQLite 里 NOT 最松:NOT status IN (…) * amount 是 NOT ((status IN (…)) * amount);sqlglot 的 NOT 只取等值那一级, + 读成 (NOT status IN (…)) * amount,和加了括号的写法成了同一棵树。这类写法不在语法树上判等价,只给兜底。""" + a = f"SELECT id, NOT {predicate} {operator} amount FROM orders" + b = f"SELECT id, (NOT {predicate}) {operator} amount FROM orders" + diff = explain_sql_diff(a, b, dialect) + assert not diff.equivalent and diff.fallback, (a, diff) + + +@pytest.mark.parametrize(("a", "b"), [ + ("SELECT user_id, SUM(NOT status IN ('void', 'refunded') * amount) AS net FROM orders GROUP BY user_id", + "SELECT user_id, SUM((NOT status IN ('void', 'refunded')) * amount) AS net FROM orders GROUP BY user_id"), + ("SELECT user_id, SUM(NOT paid_at IS NULL * amount) AS paid FROM orders GROUP BY user_id", + "SELECT user_id, SUM((NOT paid_at IS NULL) * amount) AS paid FROM orders GROUP BY user_id"), +]) +def test_the_reported_prefix_not_pairs_are_not_equivalent(a, b): + for dialect in ("sqlite", None): + assert not explain_sql_diff(a, b, dialect).equivalent + + +@pytest.mark.parametrize(("a", "b"), [ + ("SELECT COUNT(*) FROM orders WHERE NOT status IN ('void') AND amount > 0", + "SELECT COUNT(*) FROM orders o WHERE NOT o.status IN ('void') AND o.amount > 0"), + ("SELECT SUM(amount) FROM orders WHERE NOT status = 'void'", + "SELECT SUM(o.amount) FROM orders AS o WHERE NOT o.status = 'void'"), +]) +def test_ordinary_prefix_not_is_still_recognised_as_equivalent(a, b): + """守卫只拦同一段里 NOT、谓词、算术三样都有的;常见的 NOT … IN、NOT … = 照样认得出等价改写。""" + assert explain_sql_diff(a, b, "sqlite").equivalent