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