Files
fastapi-best-architecture/backend/utils/serializers.py
T
Wu Clan 2b56168ad0 Refactor foreign keys and relationships to pure logic (#901)
* Refactor foreign keys and relationships to pure logic

* Revert of some changes

* More revert

* Update the user paginate

* Update user create and update

* Update dept select and delete

* Rename the join query functions

* Update select_join_serialize doc and README

* Fix typo in README

* Update the user delete

* Update the user social

* Update the dict plugin crud

* Update the dict plugin version

* Bump dependencies and pre-commits

* Update the code generator plugin crud

* Update the menu crud

* Update the role crud

* Update the data scope and rule crud

* Restore get_paginated to get_select

* Update the code generator plugin version

* Add the py version in pre-commit

* Remove the plugin include parameter config

* Add more cache cleaning TODO

* Rename get_with_relation to get_join

* Add the user cache clear

* Fix known compatibility issues

* Update the version number to 1.11.0

* Fix lint

* Optimize select_join_serialize logic

* Delete cache cleanup comments

* Update the oauth2 plugin version

* Fix user-role table cleanup when user update
2025-11-12 13:06:26 +08:00

377 lines
15 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.
from collections import defaultdict, namedtuple
from collections.abc import Sequence
from decimal import Decimal
from typing import Any, TypeVar
from fastapi.encoders import decimal_encoder
from msgspec import json
from sqlalchemy import Row, RowMapping
from sqlalchemy.orm import ColumnProperty, SynonymProperty, class_mapper
from starlette.responses import JSONResponse
RowData = Row[Any] | RowMapping | Any
R = TypeVar('R', bound=RowData)
class MsgSpecJSONResponse(JSONResponse):
"""
使用高性能的 msgspec 库将数据序列化为 JSON 的响应类
"""
def render(self, content: Any) -> bytes:
return json.encode(content)
def select_columns_serialize(row: R) -> dict[str, Any]:
"""
序列化 SQLAlchemy 查询表的列,不包含关联列
:param row: SQLAlchemy 查询结果行
:return:
"""
result = {}
for column in row.__table__.columns.keys():
value = getattr(row, column)
if isinstance(value, Decimal):
value = decimal_encoder(value)
result[column] = value
return result
def select_list_serialize(row: Sequence[R]) -> list[dict[str, Any]]:
"""
序列化 SQLAlchemy 查询列表
:param row: SQLAlchemy 查询结果列表
:return:
"""
return [select_columns_serialize(item) for item in row]
def select_as_dict(row: R, *, use_alias: bool = False) -> dict[str, Any]:
"""
将 SQLAlchemy 查询结果转换为字典,可以包含关联数据
:param row: SQLAlchemy 查询结果行
:param use_alias: 是否使用别名作为列名
:return:
"""
if not use_alias:
result = row.__dict__
if '_sa_instance_state' in result:
del result['_sa_instance_state']
else:
result = {}
mapper = class_mapper(row.__class__) # type: ignore
for prop in mapper.iterate_properties:
if isinstance(prop, (ColumnProperty, SynonymProperty)):
key = prop.key
result[key] = getattr(row, key)
return result
def select_join_serialize( # noqa: C901
row: R | Sequence[R],
relationships: list[str] | None = None,
*,
return_as_dict: bool = False,
) -> dict[str, Any] | list[dict[str, Any]] | tuple[Any, ...] | list[tuple[Any, ...]] | None:
"""
将 SQLAlchemy 连接查询结果序列化为字典或支持属性访问的 namedtuple
扁平序列化:``relationships=None``
| 将所有查询结果平铺到同一层级,不进行嵌套处理
输出:Result(name='Alice', dept=Dept(...))
嵌套序列化:``relationships=['User-m2o-Dept', 'User-m2m-Role:permissions', 'Role-m2m-Menu']``
| 根据指定的关系类型将数据嵌套组织,支持层级结构
| row = select(User, Dept, Role).join(...).all()
输出:Result(name='Alice', dept=Dept(...), permissions=[Role(..., menus=[Menu(...)])])
:param row: SQLAlchemy 查询结果
:param relationships: 表之间的虚拟关系
source_model_class-type-target_model_class[:custom_name], type: o2m/m2o/o2o/m2m
- o2m (一对多): 目标模型类名会自动添加's'变为复数形式 (如: dept->depts)
- m2o (多对一): 目标模型类名保持单数形式 (如: user->user)
- o2o (一对一): 目标模型类名保持单数形式 (如: profile->profile)
- m2m (多对多): 目标模型类名会自动添加's'变为复数形式 (如: role->roles)
- 自定义名称: 可以通过在关系字符串末尾添加 ':custom_name' 来指定自定义的目标字段名
例如: 'User-m2m-Role:permissions' 会将角色数据放在 'permissions' 字段而不是默认的 'roles'
:param return_as_dict: False 返回 namedtupleTrue 返回 dict
:return:
"""
def get_relation_key(model_name: str, rel_type: str, custom_field: str | None = None) -> str:
"""获取关系键名"""
return custom_field or (model_name if rel_type in ('o2o', 'm2o') else f'{model_name}s')
def parse_relationships(relationship_list: list[str]) -> tuple[dict, dict, dict]:
"""解析关系定义"""
if not relationship_list:
return {}, {}, {}
parsed_relation_graph = defaultdict(dict)
parsed_reverse_relation = {}
parsed_custom_names = {}
for rel_str in relationship_list:
parts = rel_str.split(':', 1)
rel_part = parts[0].strip()
field_custom_name = parts[1].strip() if len(parts) > 1 else None
rel_info = rel_part.split('-')
if len(rel_info) != 3:
continue
source_model, rel_type, target_model = (info.lower() for info in rel_info)
if rel_type not in ('o2m', 'm2o', 'o2o', 'm2m'):
continue
parsed_relation_graph[source_model][target_model] = rel_type
parsed_reverse_relation[target_model] = source_model
if field_custom_name:
parsed_custom_names[source_model, target_model] = field_custom_name
return parsed_relation_graph, parsed_reverse_relation, parsed_custom_names
def get_model_columns(model_obj: Any) -> list[str]:
"""获取模型列名"""
mapper = class_mapper(type(model_obj))
return [
prop.key
for prop in mapper.iterate_properties
if isinstance(prop, (ColumnProperty, SynonymProperty)) and hasattr(model_obj, prop.key)
]
def get_unique_objects(objs: list[Any], key_attr: str = 'id') -> list[Any]:
"""根据键属性去重对象列表"""
seen = set()
unique = []
for item in objs:
item_id = getattr(item, key_attr, None)
if item_id is not None and item_id not in seen:
seen.add(item_id)
unique.append(item)
return unique
if not row:
return None
rows_list = [row] if not isinstance(row, list) else row
if not rows_list:
return None
# 获取主对象信息
first_row = rows_list[0]
main_obj = first_row[0] if hasattr(first_row, '__getitem__') and first_row else first_row
if main_obj is None:
return None
main_obj_name = type(main_obj).__name__.lower()
main_columns = get_model_columns(main_obj)
# 解析关系
relation_graph, reverse_relation, custom_names = parse_relationships(relationships or [])
has_relationships = bool(relation_graph)
# 预处理所有模型类型和列信息
model_info = {}
cls_idxs = {}
for preprocess_row in rows_list:
preprocess_row_items = preprocess_row if hasattr(preprocess_row, '__getitem__') else (preprocess_row,)
for idx, row_obj in enumerate(preprocess_row_items):
if row_obj is None:
continue
obj_class_name = type(row_obj).__name__.lower()
if obj_class_name not in model_info:
model_info[obj_class_name] = get_model_columns(row_obj)
if obj_class_name not in cls_idxs:
cls_idxs[obj_class_name] = idx
# 数据收集和分组
main_data = {}
grouped_data = defaultdict(lambda: defaultdict(list))
for data_row in rows_list:
data_row_items = data_row if hasattr(data_row, '__getitem__') else (data_row,)
if not data_row_items or data_row_items[0] is None:
continue
main_obj = data_row_items[0]
main_id = getattr(main_obj, 'id', None) or id(main_obj)
if main_id not in main_data:
main_data[main_id] = main_obj
# 收集子对象
for child_obj in data_row_items[1:]:
if child_obj is None:
continue
child_class_name = type(child_obj).__name__.lower()
grouped_data[main_id][child_class_name].append(child_obj)
if not main_data:
return None
# 预生成 namedtuple 类型
namedtuple_cache = {}
if not return_as_dict:
for cls_name, columns in model_info.items():
if columns:
# 为嵌套关系预计算完整字段列表
full_columns = columns.copy()
if has_relationships:
for target_class, relation_type in relation_graph.get(cls_name, {}).items():
field_name = custom_names.get((cls_name, target_class))
rel_key = get_relation_key(target_class, relation_type, field_name)
full_columns.append(rel_key)
full_columns = sorted(set(full_columns)) # 去重并排序
namedtuple_cache[cls_name] = namedtuple(cls_name.capitalize(), full_columns or columns) # noqa: PYI024
def build_flat_result(build_main_id: int, build_main_obj: Any) -> dict[str, Any]: # noqa: C901
"""构建扁平化结果"""
flat_result = {col: getattr(build_main_obj, col, None) for col in main_columns}
for class_name in sorted(grouped_data[build_main_id]):
if class_name == main_obj_name:
continue
flat_objs = get_unique_objects(grouped_data[build_main_id][class_name])
cls_columns = model_info.get(class_name, [])
if not flat_objs:
flat_result[class_name] = []
elif len(flat_objs) == 1:
obj_data = {col: getattr(flat_objs[0], col, None) for col in cls_columns}
# 确保 namedtuple 所需的所有字段都存在
if not return_as_dict and class_name in namedtuple_cache:
nt_fields = getattr(namedtuple_cache[class_name], '_fields', [])
for field in nt_fields:
if field not in obj_data:
obj_data[field] = None
flat_result[class_name] = obj_data if return_as_dict else namedtuple_cache[class_name](**obj_data)
else:
if return_as_dict:
flat_result[class_name] = [
{col: getattr(flat_obj, col, None) for col in cls_columns} for flat_obj in flat_objs
]
else:
nested_result_list = []
for nested_obj in flat_objs:
obj_data = {col: getattr(nested_obj, col, None) for col in cls_columns}
# 确保 namedtuple 所需的所有字段都存在
if class_name in namedtuple_cache:
nt_fields = getattr(namedtuple_cache[class_name], '_fields', [])
for field in nt_fields:
if field not in obj_data:
obj_data[field] = None
nested_result_list.append(namedtuple_cache[class_name](**obj_data))
flat_result[class_name] = nested_result_list
return flat_result
def build_nested_result(nested_main_id: int, nested_main_obj: Any) -> dict[str, Any]: # noqa: C901
"""构建嵌套化结果"""
nested_result = {col: getattr(nested_main_obj, col, None) for col in main_columns}
# 构建关系层级数据结构
hierarchy = defaultdict(lambda: defaultdict(list))
for iter_row in rows_list:
iter_row_items = iter_row if hasattr(iter_row, '__getitem__') else (iter_row,)
if not iter_row_items or iter_row_items[0] is None:
continue
iter_main_id = getattr(iter_row_items[0], 'id', None) or id(iter_row_items[0])
if iter_main_id != nested_main_id:
continue
for _i, related_obj in enumerate(iter_row_items[1:], 1):
if related_obj is None:
continue
related_class_name = type(related_obj).__name__.lower()
if related_class_name in reverse_relation:
parent_cls = reverse_relation[related_class_name]
parent_idx = cls_idxs.get(parent_cls, 0)
if parent_idx < len(iter_row_items):
parent_obj = iter_row_items[parent_idx]
if parent_obj is not None:
parent_obj_id = getattr(parent_obj, 'id', None)
if parent_obj_id is not None:
hierarchy[related_class_name][parent_obj_id].append(related_obj)
def build_recursive(current_cls_name: str, current_parent_id: int) -> list:
"""递归构建嵌套数据"""
recursive_objs = get_unique_objects(hierarchy[current_cls_name].get(current_parent_id, []))
if not recursive_objs:
return []
recursive_result = []
for nested_obj in recursive_objs:
# 基础数据
obj_data = {col: getattr(nested_obj, col, None) for col in model_info[current_cls_name]}
# 处理子关系
for child_cls, child_rel_type in relation_graph.get(current_cls_name, {}).items():
child_parent_id = getattr(nested_obj, 'id', None)
if child_parent_id is None:
continue
child_list = build_recursive(child_cls, child_parent_id)
child_key = get_relation_key(
child_cls, child_rel_type, custom_names.get((current_cls_name, child_cls))
)
if child_rel_type in ('m2o', 'o2o'):
obj_data[child_key] = child_list[0] if child_list else None
else:
obj_data[child_key] = child_list
if not return_as_dict and current_cls_name in namedtuple_cache:
nt_fields = getattr(namedtuple_cache[current_cls_name], '_fields', [])
for field in nt_fields:
if field not in obj_data:
obj_data[field] = None
recursive_result.append(obj_data if return_as_dict else namedtuple_cache[current_cls_name](**obj_data))
return recursive_result
# 构建顶级关系
for top_cls_name, top_rel_type in relation_graph.get(main_obj_name, {}).items():
instances = build_recursive(top_cls_name, nested_main_id)
key = get_relation_key(top_cls_name, top_rel_type, custom_names.get((main_obj_name, top_cls_name)))
if top_rel_type in ('m2o', 'o2o'):
nested_result[key] = instances[0] if instances else None
else:
nested_result[key] = instances
return nested_result
# 构建最终结果
final_result_list = []
for current_main_id in sorted(main_data.keys()):
current_main_obj = main_data[current_main_id]
if has_relationships:
final_result_data = build_nested_result(current_main_id, current_main_obj)
else:
final_result_data = build_flat_result(current_main_id, current_main_obj)
if not return_as_dict:
all_fields = list(final_result_data.keys())
result_type = namedtuple('Result', all_fields) # noqa: PYI024
final_result_list.append(result_type(**final_result_data))
else:
final_result_list.append(final_result_data)
return final_result_list[0] if len(final_result_list) == 1 else final_result_list