Files
test/app/service/advanced_service.py
T
2026-09-21 19:03:31 +08:00

681 lines
28 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.
"""通用高级筛选规则引擎(需求 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