规则 DSL 编译成只读 SQL,并用 fixture 验证

This commit is contained in:
ThearchyHelios
2026-11-03 20:50:06 +08:00
parent 6e7f2a5ce8
commit e1d63e7389
3 changed files with 209 additions and 0 deletions
+21
View File
@@ -286,6 +286,27 @@ async def caliber_descriptor(workflow_id: str, version: int, node_id: str,
return made
class RuleCompileIn(BaseModel):
"""规则 DSL 编译:表 + 结构化条件 → 只读 SQL;fixtures 可选,用内存 SQLite 跑一遍。"""
rule: dict[str, Any]
table: str
fixtures: list[dict[str, Any]] = Field(default_factory=list)
@router.post("/rules/compile")
async def compile_rule_endpoint(payload: RuleCompileIn) -> dict[str, Any]:
"""把结构化规则编译成 SQL,并(给了 fixtures 时)用样例行验证。只生成 SELECT,不碰业务库。"""
from app.data.rule_compile import RuleInvalid, compile_rule, run_fixtures
try:
sql = compile_rule(payload.rule, payload.table)
checks = run_fixtures(payload.rule, payload.table, payload.fixtures)
except RuleInvalid as e:
raise HTTPException(422, str(e)) from None
return {"sql": sql, "fixtures": checks}
class TrialIn(BaseModel):
"""试跑只收入口字段的值;没写的按口径里的缺省值补。"""
+130
View File
@@ -0,0 +1,130 @@
"""规则 DSL 的编译与 fixture 校验:结构化条件 → 一条只读 SQL,发布前用样例行跑一遍。
和口径卡里的 `kind=rule` 不是同一层:口径卡里的规则在单元格上确定性求值(不需要查库);这里给作者和发布检查用,
把「表 + 条件 → then / else」编译成 SQL,再用 fixture 验证条件写对了。编译只生成 SELECT,不执行任何写操作。
规则形状:
{
"inputs": {"status": "status", "amount": "amount"}, # 名字 → 表里的列
"all": [{"input": "status", "op": "eq", "value": "paid"},
{"input": "amount", "op": "gte", "value": 50}],
"then": 1, "else": 0
}
"""
from __future__ import annotations
import re
import sqlite3
from typing import Any, Iterable, Mapping
OPS = ("eq", "ne", "gt", "gte", "lt", "lte", "in", "not_in")
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
class RuleInvalid(ValueError):
"""规则本身写错了:message 给人看。"""
def _ident(value: Any, what: str) -> str:
text = str(value or "").strip()
if not _IDENT.match(text):
raise RuleInvalid(f"{what}「{value}」不是合法的标识符(字母或下划线开头,只含字母、数字、下划线)")
return text
def _literal(exp: Any, value: Any) -> Any:
if isinstance(value, bool):
return exp.Boolean(this=value)
if isinstance(value, (int, float)):
return exp.Literal.number(value)
if value is None:
return exp.Null()
return exp.Literal.string(str(value))
def _condition(exp: Any, cond: Mapping[str, Any], inputs: Mapping[str, str]) -> Any:
name = str(cond.get("input") or "")
if name not in inputs:
raise RuleInvalid(f"条件指向了没声明的输入「{name}」")
column = exp.column(_ident(inputs[name], "列名"))
op = str(cond.get("op") or "")
if op not in OPS:
raise RuleInvalid(f"条件运算符只能是 {'、'.join(OPS)},当前为「{op}」")
if op in ("in", "not_in"):
values = cond.get("values")
if not isinstance(values, list) or not values:
raise RuleInvalid("in / not_in 需要非空的 values 列表")
node = exp.In(this=column, expressions=[_literal(exp, v) for v in values])
return node if op == "in" else exp.Not(this=node)
if "value" not in cond:
raise RuleInvalid("条件需要 value")
right = _literal(exp, cond["value"])
return {
"eq": exp.EQ, "ne": exp.NEQ, "gt": exp.GT, "gte": exp.GTE, "lt": exp.LT, "lte": exp.LTE,
}[op](this=column, expression=right)
def compile_rule(rule: Mapping[str, Any], table: str, *, dialect: str | None = None) -> str:
"""结构化规则 → `SELECT CASE WHEN … THEN … ELSE … END AS value FROM <table>`。"""
try:
import sqlglot
from sqlglot import exp
except ImportError as e: # pragma: no cover - 依赖缺失时给一句人话
raise RuleInvalid("没有安装 sqlglot,无法编译规则") from e
table_name = _ident(table, "表名")
if not isinstance(rule, Mapping):
raise RuleInvalid("规则需要是一个对象")
inputs = rule.get("inputs")
if not isinstance(inputs, Mapping) or not inputs:
raise RuleInvalid("规则需要 inputs:名字 → 表里的列")
columns = {str(k): _ident(v, "列名") for k, v in inputs.items()}
conditions = rule.get("all")
if not isinstance(conditions, list) or not conditions:
raise RuleInvalid("规则需要 all:至少一条条件")
where = None
for cond in conditions:
node = _condition(exp, cond, columns)
where = node if where is None else exp.And(this=where, expression=node)
case = exp.Case(
ifs=[exp.If(this=where, true=_literal(exp, rule.get("then", 1)))],
default=_literal(exp, rule.get("else", 0)),
)
query = exp.select(exp.alias_(case, "value")).from_(exp.table_(table_name))
return query.sql(dialect=dialect or "sqlite")
def run_fixtures(rule: Mapping[str, Any], table: str, fixtures: Iterable[Mapping[str, Any]], *,
dialect: str = "sqlite") -> list[dict[str, Any]]:
"""用一条只读 SQL 在内存 SQLite 上跑 fixture:每项 {rows, expected},返回 [{ok, expected, actual}]。
fixture 的 rows 是列名 → 值;表结构按第一行推(整数 / 实数 / 文本)。空 fixture 返回空列表。
"""
items = [dict(f) for f in fixtures]
if not items:
return []
sql = compile_rule(rule, table, dialect="sqlite" if dialect != "sqlite" else dialect)
db = sqlite3.connect(":memory:")
try:
columns: dict[str, str] = {}
for item in items:
for row in item.get("rows") or []:
for name, value in row.items():
columns.setdefault(str(name), "INTEGER" if isinstance(value, int) and not isinstance(value, bool)
else "REAL" if isinstance(value, float) else "TEXT")
if not columns:
raise RuleInvalid("fixture 需要至少一行 rows")
db.execute(f"CREATE TABLE {table} (" + ", ".join(f"{name} {kind}" for name, kind in columns.items()) + ")")
out: list[dict[str, Any]] = []
for item in items:
db.execute(f"DELETE FROM {table}")
for row in item.get("rows") or []:
db.execute(f"INSERT INTO {table} ({', '.join(row)}) VALUES ({', '.join('?' for _ in row)})",
list(row.values()))
actual = db.execute(sql).fetchone()[0]
out.append({"ok": actual == item.get("expected"), "expected": item.get("expected"), "actual": actual})
return out
finally:
db.close()
__all__ = ["OPS", "RuleInvalid", "compile_rule", "run_fixtures"]
+58
View File
@@ -0,0 +1,58 @@
"""规则 DSL 编译(app/data/rule_compile.py、POST /api/ledger/rules/compile):结构化条件 → 只读 SQL,fixture 验证。"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from app.data.rule_compile import RuleInvalid, compile_rule, run_fixtures
from app.main import app
RULE = {"inputs": {"status": "status", "amount": "amount"},
"all": [{"input": "status", "op": "eq", "value": "paid"},
{"input": "amount", "op": "gte", "value": 50}],
"then": 1, "else": 0}
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def test_compile_rule_is_a_select_with_a_case():
sql = compile_rule(RULE, "orders")
assert sql.lower().startswith("select") and "case when" in sql.lower()
assert "from orders" in sql.lower()
assert "insert" not in sql.lower() and "update" not in sql.lower() and "delete" not in sql.lower()
def test_fixtures_run_against_the_compiled_sql():
checks = run_fixtures(RULE, "orders", [
{"rows": [{"status": "paid", "amount": 100}], "expected": 1},
{"rows": [{"status": "paid", "amount": 10}], "expected": 0},
{"rows": [{"status": "open", "amount": 100}], "expected": 0},
])
assert [c["ok"] for c in checks] == [True, True, True]
@pytest.mark.parametrize("rule", [
{"inputs": {}, "all": [{"input": "x", "op": "eq", "value": 1}]},
{"inputs": {"x": "x"}, "all": [{"input": "x", "op": "like", "value": 1}]},
{"inputs": {"x": "x"}, "all": [{"input": "y", "op": "eq", "value": 1}]},
])
def test_bad_rules_are_refused(rule):
with pytest.raises(RuleInvalid):
compile_rule(rule, "orders")
async def test_the_compile_endpoint_returns_sql_and_fixture_results(client):
res = await client.post("/api/ledger/rules/compile", json={
"rule": RULE, "table": "orders",
"fixtures": [{"rows": [{"status": "paid", "amount": 100}], "expected": 1},
{"rows": [{"status": "paid", "amount": 10}], "expected": 0}],
})
assert res.status_code == 200, res.text
body = res.json()
assert body["sql"].lower().startswith("select") and [c["ok"] for c in body["fixtures"]] == [True, True]
bad = await client.post("/api/ledger/rules/compile", json={"rule": {"inputs": {}}, "table": "orders"})
assert bad.status_code == 422