代码提交

This commit is contained in:
xwt
2026-09-21 19:03:31 +08:00
parent 34d656492c
commit 97986d9a1a
217 changed files with 12042 additions and 0 deletions
+680
View File
@@ -0,0 +1,680 @@
"""通用高级筛选规则引擎(需求 2.7.1)。
设计要点
--------------------------------------------------------------------------
1. **白名单**:调用方只能传字段名,不能传 SQL 片段。字段名先在
``MODEL_REGISTRY`` 里查表,查不到直接报错并把可用字段回给调用方。
这样即便把接口暴露出去也不存在注入问题——安全边界在建 SQL 之前就关掉了。
2. **类型强转**:每个字段带类型(int/float/str/date/gender/status),
`"25"` 和 `25` 都能比较,`"男"` 自动转成 1,避免"字符串比数字"这种静默错误。
3. **嵌套组合**:规则树递归解析,AND/OR 任意嵌套,带深度与节点数上限防炸。
4. **可解释**:返回里带上规则解析说明和实际执行的 SQL(literal_binds),
前端能直接把"你提交的条件翻译成了什么"展示给人看。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any, Callable
from sqlalchemy import Select, and_, distinct, func, not_, or_, select
from sqlalchemy.orm import Session
from sqlalchemy.sql import ColumnElement
from app.core.exceptions import RuleError
from app.core.utils import age_expression, date_diff_days_expr, parse_date
from app.model import (
Advisor,
Clazz,
Employment,
Score,
Student,
StudentStatus,
Teacher,
class_teachers,
)
from app.model.constants import CLASS_STATUS_TEXT, STUDENT_STATUS_TEXT, gender_to_code
from app.schema.advanced_schema import AggregationParams, FilterRule, QueryRequest
MAX_DEPTH = 6
MAX_NODES = 80
# ==================================================================== 元数据
@dataclass(frozen=True)
class FieldSpec:
column: Any
type: str
label: str
joins: tuple[str, ...] = ()
sortable: bool = True
@dataclass(frozen=True)
class JoinSpec:
target: Any
onclause: Any
outer: bool = True
@dataclass
class ModelSpec:
key: str
label: str
entity: type
fields: dict[str, FieldSpec]
joins: dict[str, JoinSpec] = field(default_factory=dict)
default_order: str = "id"
GENDER_TYPE = "gender"
STATUS_TYPE = "status"
CLASS_STATUS_TYPE = "class_status"
STUDENT_FIELDS: dict[str, FieldSpec] = {
"id": FieldSpec(Student.id, "int", "学生ID"),
"stu_no": FieldSpec(Student.stu_no, "str", "学号"),
"name": FieldSpec(Student.name, "str", "姓名"),
"gender": FieldSpec(Student.gender, GENDER_TYPE, "性别"),
"age": FieldSpec(age_expression(Student.birth_date), "int", "年龄(实时计算)"),
"birth_date": FieldSpec(Student.birth_date, "date", "出生日期"),
"native_place": FieldSpec(Student.native_place, "str", "籍贯"),
"graduate_school": FieldSpec(Student.graduate_school, "str", "毕业院校"),
"major": FieldSpec(Student.major, "str", "专业"),
"education": FieldSpec(Student.education, "str", "学历"),
"enroll_date": FieldSpec(Student.enroll_date, "date", "入学时间"),
"graduate_date": FieldSpec(Student.graduate_date, "date", "毕业时间"),
"phone": FieldSpec(Student.phone, "str", "联系电话"),
"status": FieldSpec(Student.status, STATUS_TYPE, "状态"),
"remark": FieldSpec(Student.remark, "str", "备注"),
# ---- 跨表字段:靠 JOIN 拿 ----
"class_id": FieldSpec(Clazz.id, "int", "班级ID", joins=("clazz",)),
"class_no": FieldSpec(Clazz.class_no, "str", "班级编号", joins=("clazz",)),
"class_name": FieldSpec(Clazz.name, "str", "班级名称", joins=("clazz",)),
"advisor_name": FieldSpec(Advisor.name, "str", "顾问姓名", joins=("advisor",)),
"company": FieldSpec(Employment.company, "str", "就业公司", joins=("employment",)),
"salary": FieldSpec(Employment.salary, "float", "就业薪资", joins=("employment",)),
"position": FieldSpec(Employment.position, "str", "就业岗位", joins=("employment",)),
"offer_date": FieldSpec(Employment.offer_date, "date", "offer 下发时间", joins=("employment",)),
"open_date": FieldSpec(Employment.open_date, "date", "就业开放时间", joins=("employment",)),
"duration_days": FieldSpec(
date_diff_days_expr(Employment.offer_date, Employment.open_date),
"int",
"就业时长(天)",
joins=("employment",),
),
}
CLASS_FIELDS: dict[str, FieldSpec] = {
"id": FieldSpec(Clazz.id, "int", "班级ID"),
"class_no": FieldSpec(Clazz.class_no, "str", "班级编号"),
"name": FieldSpec(Clazz.name, "str", "班级名称"),
"direction": FieldSpec(Clazz.direction, "str", "班级方向"),
"open_date": FieldSpec(Clazz.open_date, "date", "开课时间"),
"close_date": FieldSpec(Clazz.close_date, "date", "结课时间"),
"classroom": FieldSpec(Clazz.classroom, "str", "教室"),
"capacity": FieldSpec(Clazz.capacity, "int", "计划人数"),
"status": FieldSpec(Clazz.status, CLASS_STATUS_TYPE, "班级状态"),
"head_teacher_name": FieldSpec(Teacher.name, "str", "班主任", joins=("head_teacher",)),
"advisor_name": FieldSpec(Advisor.name, "str", "带班顾问", joins=("advisor",)),
}
TEACHER_FIELDS: dict[str, FieldSpec] = {
"id": FieldSpec(Teacher.id, "int", "老师ID"),
"teacher_no": FieldSpec(Teacher.teacher_no, "str", "工号"),
"name": FieldSpec(Teacher.name, "str", "姓名"),
"gender": FieldSpec(Teacher.gender, GENDER_TYPE, "性别"),
"phone": FieldSpec(Teacher.phone, "str", "手机号"),
"email": FieldSpec(Teacher.email, "str", "邮箱"),
"title": FieldSpec(Teacher.title, "str", "职称"),
"subject": FieldSpec(Teacher.subject, "str", "授课方向"),
"hire_date": FieldSpec(Teacher.hire_date, "date", "入职时间"),
}
ADVISOR_FIELDS: dict[str, FieldSpec] = {
"id": FieldSpec(Advisor.id, "int", "顾问ID"),
"advisor_no": FieldSpec(Advisor.advisor_no, "str", "顾问编号"),
"name": FieldSpec(Advisor.name, "str", "姓名"),
"gender": FieldSpec(Advisor.gender, GENDER_TYPE, "性别"),
"phone": FieldSpec(Advisor.phone, "str", "电话"),
"email": FieldSpec(Advisor.email, "str", "邮箱"),
"dept": FieldSpec(Advisor.dept, "str", "部门"),
}
SCORE_FIELDS: dict[str, FieldSpec] = {
"id": FieldSpec(Score.id, "int", "成绩ID"),
"stu_id": FieldSpec(Score.stu_id, "int", "学生ID"),
"exam_seq": FieldSpec(Score.exam_seq, "int", "考核序次"),
"exam_name": FieldSpec(Score.exam_name, "str", "考核名称"),
"exam_date": FieldSpec(Score.exam_date, "date", "考核日期"),
"score": FieldSpec(Score.score, "float", "成绩"),
"flag": FieldSpec(Score.flag, "int", "预警标记"),
"student_name": FieldSpec(Student.name, "str", "学生姓名", joins=("student",)),
"stu_no": FieldSpec(Student.stu_no, "str", "学号", joins=("student",)),
"class_name": FieldSpec(Clazz.name, "str", "班级名称", joins=("student", "clazz")),
}
EMPLOYMENT_FIELDS: dict[str, FieldSpec] = {
"id": FieldSpec(Employment.id, "int", "就业ID"),
"stu_id": FieldSpec(Employment.stu_id, "int", "学生ID"),
"open_date": FieldSpec(Employment.open_date, "date", "就业开放时间"),
"offer_date": FieldSpec(Employment.offer_date, "date", "offer 下发时间"),
"company": FieldSpec(Employment.company, "str", "就业公司"),
"salary": FieldSpec(Employment.salary, "float", "就业薪资"),
"position": FieldSpec(Employment.position, "str", "岗位"),
"city": FieldSpec(Employment.city, "str", "城市"),
"duration_days": FieldSpec(
date_diff_days_expr(Employment.offer_date, Employment.open_date), "int", "就业时长(天)"
),
"student_name": FieldSpec(Student.name, "str", "学生姓名", joins=("student",)),
"stu_no": FieldSpec(Student.stu_no, "str", "学号", joins=("student",)),
"gender": FieldSpec(Student.gender, GENDER_TYPE, "性别", joins=("student",)),
"status": FieldSpec(Student.status, STATUS_TYPE, "学生状态", joins=("student",)),
"class_name": FieldSpec(Clazz.name, "str", "班级名称", joins=("clazz",)),
"class_no": FieldSpec(Clazz.class_no, "str", "班级编号", joins=("clazz",)),
}
MODEL_REGISTRY: dict[str, ModelSpec] = {
"student": ModelSpec(
key="student",
label="学生",
entity=Student,
fields=STUDENT_FIELDS,
joins={
"clazz": JoinSpec(Clazz, Clazz.id == Student.class_id),
"advisor": JoinSpec(Advisor, Advisor.id == Student.advisor_id),
"employment": JoinSpec(
Employment,
and_(Employment.stu_id == Student.id, Employment.is_del == 0),
),
},
default_order="id",
),
"class": ModelSpec(
key="class",
label="班级",
entity=Clazz,
fields=CLASS_FIELDS,
joins={
"head_teacher": JoinSpec(Teacher, Teacher.id == Clazz.head_teacher_id),
"advisor": JoinSpec(Advisor, Advisor.id == Clazz.advisor_id),
},
default_order="id",
),
"teacher": ModelSpec(key="teacher", label="老师", entity=Teacher, fields=TEACHER_FIELDS),
"advisor": ModelSpec(key="advisor", label="顾问", entity=Advisor, fields=ADVISOR_FIELDS),
"score": ModelSpec(
key="score",
label="成绩",
entity=Score,
fields=SCORE_FIELDS,
joins={
"student": JoinSpec(Student, and_(Student.id == Score.stu_id, Student.is_del == 0)),
"clazz": JoinSpec(Clazz, Clazz.id == Student.class_id),
},
default_order="id",
),
"employment": ModelSpec(
key="employment",
label="就业",
entity=Employment,
fields=EMPLOYMENT_FIELDS,
joins={
"student": JoinSpec(Student, and_(Student.id == Employment.stu_id, Student.is_del == 0)),
"clazz": JoinSpec(Clazz, Clazz.id == Employment.class_id),
},
default_order="id",
),
}
MODEL_ALIASES = {
"students": "student", "clazz": "class", "classes": "class", "cls": "class",
"teacher": "teacher", "teachers": "teacher", "advisors": "advisor",
"scores": "score", "employments": "employment", "employment": "employment",
}
# ==================================================================== 类型转换
def _to_int(value: Any, label: str) -> Any:
if isinstance(value, list):
return [_to_int(v, label) for v in value]
try:
return int(float(value))
except (TypeError, ValueError) as exc:
raise RuleError(f"字段「{label}」需要整数,收到 {value!r}") from exc
def _to_float(value: Any, label: str) -> Any:
if isinstance(value, list):
return [_to_float(v, label) for v in value]
try:
return float(value)
except (TypeError, ValueError) as exc:
raise RuleError(f"字段「{label}」需要数字,收到 {value!r}") from exc
def _to_str(value: Any, label: str) -> Any:
if isinstance(value, list):
return [str(v) for v in value]
return str(value)
def _to_date(value: Any, label: str) -> Any:
if isinstance(value, list):
return [_to_date(v, label) for v in value]
return parse_date(value, label)
def _to_gender(value: Any, label: str) -> Any:
if isinstance(value, list):
return [_to_gender(v, label) for v in value]
code = gender_to_code(value)
if code is None:
raise RuleError(f"字段「{label}」只接受 男/女 或 1/2,收到 {value!r}")
return code
def _to_status(value: Any, label: str) -> Any:
if isinstance(value, list):
return [_to_status(v, label) for v in value]
if isinstance(value, int) or (isinstance(value, str) and value.strip().isdigit()):
return int(value)
for code, text in STUDENT_STATUS_TEXT.items():
if str(value).strip() == text:
return int(code)
raise RuleError(f"字段「{label}」状态只接受 在读/进入就业/已就业 或 1/2/3,收到 {value!r}")
def _to_class_status(value: Any, label: str) -> Any:
if isinstance(value, list):
return [_to_class_status(v, label) for v in value]
if isinstance(value, int) or (isinstance(value, str) and value.strip().isdigit()):
return int(value)
for code, text in CLASS_STATUS_TEXT.items():
if str(value).strip() == text:
return int(code)
raise RuleError(f"字段「{label}」状态只接受 在读/已结课/已解散 或 1/2/3,收到 {value!r}")
CASTERS: dict[str, Callable[[Any, str], Any]] = {
"int": _to_int,
"float": _to_float,
"str": _to_str,
"date": _to_date,
GENDER_TYPE: _to_gender,
STATUS_TYPE: _to_status,
CLASS_STATUS_TYPE: _to_class_status,
}
OPERATORS = {
"=", "==", "!=", "<>", ">", ">=", "<", "<=",
"like", "not_like", "in", "not_in", "between", "not_between",
"is_null", "not_null",
}
NULL_OPS = {"is_null", "not_null"}
# ==================================================================== 引擎
class AdvancedQueryService:
# ---------------------------------------------------------------- 解析
@classmethod
def get_spec(cls, model_key: str | None) -> ModelSpec:
key = (model_key or "student").strip().lower()
key = MODEL_ALIASES.get(key, key)
spec = MODEL_REGISTRY.get(key)
if spec is None:
raise RuleError(
f"不支持的数据模型「{model_key}」,可选:{'、'.join(MODEL_REGISTRY.keys())}"
)
return spec
@classmethod
def build_condition(cls, rule: FilterRule, spec: ModelSpec, depth: int, counter: list[int]) -> ColumnElement | None:
counter[0] += 1
if counter[0] > MAX_NODES:
raise RuleError(f"规则节点过多(上限 {MAX_NODES}),请拆成多次查询")
if depth > MAX_DEPTH:
raise RuleError(f"规则嵌套过深(上限 {MAX_DEPTH} 层)")
if rule.sub_rules:
conditions = [
c for c in (cls.build_condition(sub, spec, depth + 1, counter) for sub in rule.sub_rules) if c is not None
]
if not conditions:
return None
return or_(*conditions) if str(rule.logic).upper() == "OR" else and_(*conditions)
assert rule.field is not None
field_key = rule.field.strip()
field_spec = spec.fields.get(field_key)
if field_spec is None:
similar = [k for k in spec.fields if field_key.lower() in k.lower()]
hint = f",你是不是想用:{'、'.join(similar[:5])}" if similar else ""
raise RuleError(
f"模型「{spec.label}」没有可筛选字段「{rule.field}」{hint}。"
f"可用字段:{'、'.join(spec.fields.keys())}"
)
op = (rule.operator or "=").strip().lower()
if op not in OPERATORS:
raise RuleError(f"不支持的操作符「{op}」,可选:{'、'.join(sorted(OPERATORS))}")
column = field_spec.column
caster = CASTERS.get(field_spec.type, _to_str)
# ---- 空值判定 ----
if op in NULL_OPS:
return column.is_(None) if op == "is_null" else column.is_not(None)
if rule.value is None:
raise RuleError(f"字段「{field_key}」的 {op} 操作缺少 value")
# ---- 集合 / 区间 ----
if op in ("in", "not_in"):
values = rule.value if isinstance(rule.value, list) else [rule.value]
if not values:
raise RuleError(f"字段「{field_key}」的 {op} 需要一个非空数组")
converted = [caster(v, field_spec.label) for v in values]
return column.in_(converted) if op == "in" else column.not_in(converted)
if op in ("between", "not_between"):
if not isinstance(rule.value, list) or len(rule.value) != 2:
raise RuleError(f"字段「{field_key}」的 {op} 需要 [最小值, 最大值] 两个元素")
low = caster(rule.value[0], field_spec.label)
high = caster(rule.value[1], field_spec.label)
if low > high:
low, high = high, low # 顺序反了自动纠正,不让人白等一次报错
expr = column.between(low, high)
return not_(expr) if op == "not_between" else expr
value = caster(rule.value, field_spec.label)
if op in ("like", "not_like"):
pattern = f"%{value}%"
return column.like(pattern) if op == "like" else column.not_like(pattern)
if op in ("=", "=="):
return column == value
if op in ("!=", "<>"):
return column != value
if op == ">":
return column > value
if op == ">=":
return column >= value
if op == "<":
return column < value
if op == "<=":
return column <= value
raise RuleError(f"操作符「{op}」暂未实现")
# ---------------------------------------------------------------- 组装
@classmethod
def _collect_joins(cls, spec: ModelSpec, names: list[str], used_joins: set[str]) -> None:
for name in names:
field_spec = spec.fields.get(name)
if field_spec:
used_joins.update(field_spec.joins)
@classmethod
def _rule_field_names(cls, rules: list[FilterRule]) -> list[str]:
names: list[str] = []
for rule in rules:
if rule.sub_rules:
names.extend(cls._rule_field_names(rule.sub_rules))
elif rule.field:
names.append(rule.field.strip())
return names
@classmethod
def build_stmt(cls, request: QueryRequest) -> tuple[Select, ModelSpec, list[str]]:
"""返回 (可执行语句, 模型元信息, 实际会输出的字段名)。"""
spec = cls.get_spec(request.model)
output_fields = request.fields or list(spec.fields.keys())
unknown = [f for f in output_fields if f not in spec.fields]
if unknown:
raise RuleError(
f"模型「{spec.label}」没有字段 {unknown}。可用字段:{'、'.join(spec.fields.keys())}"
)
if request.order_by and request.order_by not in spec.fields:
raise RuleError(
f"排序字段「{request.order_by}」不在可用字段内:{'、'.join(spec.fields.keys())}"
)
columns = [spec.fields[name].column.label(name) for name in output_fields]
stmt = select(*columns).select_from(spec.entity)
used_joins: set[str] = set()
cls._collect_joins(spec, output_fields, used_joins)
cls._collect_joins(spec, cls._rule_field_names(request.rules), used_joins)
if request.order_by:
cls._collect_joins(spec, [request.order_by], used_joins)
# 需要 join 时才 join,不需要就不加,省掉无谓的扫描
for name in sorted(used_joins, key=lambda n: list(spec.joins).index(n) if n in spec.joins else 99):
join_spec = spec.joins.get(name)
if join_spec is None:
continue
stmt = (
stmt.outerjoin(join_spec.target, join_spec.onclause)
if join_spec.outer
else stmt.join(join_spec.target, join_spec.onclause)
)
stmt = stmt.where(spec.entity.is_del == 0)
counter = [0]
conditions = [
c for c in (cls.build_condition(rule, spec, 1, counter) for rule in request.rules) if c is not None
]
if conditions:
stmt = stmt.where(and_(*conditions))
order_name = request.order_by or spec.default_order
order_col = spec.fields[order_name].column
stmt = stmt.order_by(order_col.desc() if request.order.value == "desc" else order_col.asc())
return stmt, spec, output_fields
@classmethod
def execute(cls, db: Session, request: QueryRequest) -> dict:
from app.core.utils import page_count
stmt, spec, output_fields = cls.build_stmt(request)
count_stmt = select(func.count()).select_from(stmt.order_by(None).subquery())
total = int(db.scalar(count_stmt) or 0)
page = max(request.page, 1)
page_size = min(max(request.page_size, 1), 200)
rows = db.execute(stmt.limit(page_size).offset((page - 1) * page_size)).all()
items = []
for row in rows:
record = dict(row._mapping)
for key, value in list(record.items()):
if isinstance(value, datetime):
record[key] = value.date().isoformat()
elif isinstance(value, date):
record[key] = value.isoformat()
elif hasattr(value, "as_tuple"): # Decimal
record[key] = float(value)
items.append(record)
explain = None
if request.with_explain:
try:
explain = str(
stmt.compile(db.bind, compile_kwargs={"literal_binds": True})
).replace("\n", " ")
except Exception: # noqa: BLE001
explain = None
return {
"model": spec.key,
"model_label": spec.label,
"total": total,
"page": page,
"page_size": page_size,
"pages": page_count(total, page_size),
"fields": output_fields,
"items": items,
"explain": explain,
}
# ---------------------------------------------------------------- 聚合
@classmethod
def aggregate(cls, db: Session, params: AggregationParams) -> dict:
spec = cls.get_spec(params.model)
used_joins: set[str] = set()
cls._collect_joins(spec, params.group_by, used_joins)
cls._collect_joins(spec, cls._rule_field_names(params.rules), used_joins)
select_columns: list[Any] = []
columns: list[str] = []
for name in params.group_by:
field_spec = spec.fields.get(name)
if field_spec is None:
raise RuleError(f"分组字段「{name}」不在模型「{spec.label}」中")
select_columns.append(field_spec.column.label(name))
columns.append(name)
metric_exprs: dict[str, Any] = {}
for metric in params.metrics:
func_name = metric.func
if func_name == "count" and not metric.field:
expr = func.count()
alias = metric.alias or "count"
else:
if not metric.field or metric.field not in spec.fields:
raise RuleError(
f"聚合字段「{metric.field}」不在模型「{spec.label}」中。"
f"可用字段:{'、'.join(spec.fields.keys())}"
)
column = spec.fields[metric.field].column
cls._collect_joins(spec, [metric.field], used_joins)
alias = metric.alias or f"{func_name}_{metric.field}"
if func_name == "count":
expr = func.count(column)
elif func_name == "count_distinct":
expr = func.count(distinct(column))
else:
expr = getattr(func, func_name)(column)
if alias in columns or alias in metric_exprs:
alias = f"{alias}_{len(metric_exprs) + 1}"
metric_exprs[alias] = expr
select_columns.append(expr.label(alias))
columns.append(alias)
stmt = select(*select_columns).select_from(spec.entity)
for name in sorted(used_joins, key=lambda n: list(spec.joins).index(n) if n in spec.joins else 99):
join_spec = spec.joins.get(name)
if join_spec is None:
continue
stmt = (
stmt.outerjoin(join_spec.target, join_spec.onclause)
if join_spec.outer
else stmt.join(join_spec.target, join_spec.onclause)
)
stmt = stmt.where(spec.entity.is_del == 0)
counter = [0]
conditions = [
c for c in (cls.build_condition(rule, spec, 1, counter) for rule in params.rules) if c is not None
]
if conditions:
stmt = stmt.where(and_(*conditions))
if params.group_by:
stmt = stmt.group_by(*select_columns[: len(params.group_by)])
# HAVING 里的字段名优先按聚合别名解析(如 having: score_avg > 80)
for rule in params.having:
if rule.sub_rules:
raise RuleError("HAVING 暂不支持嵌套分组,请拆成多条规则")
alias = (rule.field or "").strip()
if alias in metric_exprs:
condition = cls._build_metric_condition(rule, metric_exprs[alias])
stmt = stmt.having(condition)
elif alias in spec.fields:
condition = cls.build_condition(rule, spec, 1, counter)
if condition is not None:
stmt = stmt.having(condition)
else:
raise RuleError(
f"HAVING 字段「{alias}」既不是聚合别名也不是模型字段。"
f"可用聚合别名:{'、'.join(metric_exprs.keys())}"
)
order_name = params.order_by or (columns[-1] if columns else None)
if order_name:
match = None
if order_name in metric_exprs:
match = metric_exprs[order_name]
elif order_name in spec.fields:
match = spec.fields[order_name].column if order_name in params.group_by else None
if match is not None:
stmt = stmt.order_by(match.desc() if params.order.value == "desc" else match.asc())
stmt = stmt.limit(params.limit)
rows = db.execute(stmt).all()
result_rows = []
for row in rows:
record = dict(row._mapping)
for key, value in list(record.items()):
if hasattr(value, "as_tuple"):
record[key] = float(value)
elif isinstance(value, (date, datetime)):
record[key] = value.isoformat()
result_rows.append(record)
sql = None
try:
sql = str(stmt.compile(db.bind, compile_kwargs={"literal_binds": True})).replace("\n", " ")
except Exception: # noqa: BLE001
sql = None
return {"columns": columns, "rows": result_rows, "total": len(result_rows), "sql": sql}
@staticmethod
def _build_metric_condition(rule: FilterRule, expr: Any) -> ColumnElement:
op = (rule.operator or "=").strip().lower()
try:
value = float(rule.value)
except (TypeError, ValueError) as exc:
raise RuleError(f"HAVING 的值需要是数字,收到 {rule.value!r}") from exc
mapping = {
"=": expr == value, "==": expr == value, "!=": expr != value, "<>": expr != value,
">": expr > value, ">=": expr >= value, "<": expr < value, "<=": expr <= value,
}
if op not in mapping:
raise RuleError(f"HAVING 不支持操作符「{op}」")
return mapping[op]
# ---------------------------------------------------------------- 元信息
@classmethod
def meta(cls) -> list[dict]:
result = []
for spec in MODEL_REGISTRY.values():
result.append(
{
"model": spec.key,
"label": spec.label,
"fields": [
{
"field": name,
"type": f.type,
"desc": f.label,
"sortable": f.sortable,
"filterable": True,
}
for name, f in spec.fields.items()
],
"relations": list(spec.joins.keys()),
}
)
return result