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