Files
d3i-szct/paste/db/basemodel.py
T
zwf 4729698049 Squashed 'paste-framework/' content from commit 34e8684
git-subtree-dir: paste-framework
git-subtree-split: 34e8684c4bc3cebbe177509f42ab4ef5b5425a7a
2026-06-02 19:09:22 +08:00

630 lines
23 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.
"""
数据模型基础类,继承于数据表。集成了模型的基础功能,如数据验证、错误消息、数据影射、对象比较等功能。
"""
import datetime
from decimal import Decimal, ROUND_HALF_UP
from typing import Union, Any, Optional, Callable
import pandas as pd
from sqlalchemy import Column, text, desc
from paste.db import baseadapter
from paste.db.basetable import BaseTable
from paste.util import udict, ustr
from paste.util.pagination import Pagination
from paste.util.snow_id import IdWorker
LOCAL_DATE_FORMAT = baseadapter.LOCAL_DATE_FORMAT
LOCAL_TIME_FORMAT = baseadapter.LOCAL_TIME_FORMAT
LOCAL_DATETIME_FORMAT = baseadapter.LOCAL_DATETIME_FORMAT
class BaseModel(BaseTable):
"""
数据模型基类。集成了验证辅助功能。
"""
__abstract__ = True
@classmethod
def new_id(cls, datacenter_id: int = 1, worker_id: int = 1, sequence: int = 0) -> int:
"""
生成新的 Snow ID 对象,并生成 ID 值。
:param datacenter_id: 数据中心(机器区域)ID
:param worker_id: 机器ID
:param sequence: 起始序号
:return: 新的 Snow ID 值
"""
return IdWorker.get_id_worker(datacenter_id, worker_id, sequence).get_id()
@classmethod
def now(cls):
"""
取得当前时间的格式化字符串。
:return: 当前时间格式化字符串
"""
return datetime.datetime.now().strftime(LOCAL_DATETIME_FORMAT)
@classmethod
def is_len(cls, v: str, length: int):
"""
检测字符串长度的函数,例如检测是否是18位。主要用于数据校验。
:param v: 待检测的值
:param length: 目标长度
:return: 相同返回 True,否则返回 False
"""
v = f"{v}"
return not cls.is_empty_or_none(v) and len(v) == length
@classmethod
def is_in_range(cls, v: Union[int, float], v_min: Union[int, float], v_max: Union[int, float]):
"""
返回检测数值范围的函数,检测是处于最大最小值范围内。主要用于数据校验。
:param v: 待检测的值
:param v_min: 最小值(包含)
:param v_max: 最大值(包含)
:return: 在范围内返回 True,否则返回 False
"""
return not cls.is_empty_or_none(v) and v_min <= v <= v_max
@classmethod
def is_in_items(cls, v: Union[int, float], items: list = None):
"""
返回检测数值是否在列表中。主要用于数据校验。
:param v: 待检测的值
:param items: 所有项目
:return: 在列表内返回 True,否则返回 False
"""
return items is not None and v in items
@classmethod
def is_empty_or_none(cls, v: Any):
"""
检查是 None 或 空字符串。
:param v: 待检查的内容
:return: 为 None 或 Nan 或 '' 时返回 True,否则返回 False
"""
return v is None or pd.isna(v) or f"{v}" == ''
@classmethod
def not_empty_or_none(cls, v: Any):
"""
与 isEmptyOrNone 函数功能相反。
"""
return not cls.is_empty_or_none(v)
@classmethod
def is_digit(cls, v: str):
"""
检查字符串是否是整数。
:param v: 带检查内容
:return: 是整数返回 True,否则返回 False
"""
v = f"{v}"
return v.isdigit()
@classmethod
def is_decimal(cls, v: str):
"""
检查是否是浮点数,若为整数,也返回 True。
:param v: 待检查内容
:return: 浮点数或整数返回 True,否则返回 False
"""
v = f"{v}"
is_decimal = True
vs = v.replace(',', '').split('.')
for _v in vs:
is_decimal = is_decimal and cls.is_digit(_v)
return is_decimal
@classmethod
def is_datetime(cls, v: str):
"""
检查是否是日期时间格式。
:param v: 待检查内容
:return: 日期时间返回 True,否则返回 False
"""
try:
datetime.datetime.strptime(v, LOCAL_DATETIME_FORMAT)
except (ValueError, Exception):
return False
return True
@classmethod
def is_date(cls, v: str):
"""
检查是否是日期格式。
:param v: 待检查内容
:return: 日期返回 True,否则返回 False
"""
try:
datetime.datetime.strptime(v, LOCAL_DATE_FORMAT)
except (ValueError, Exception):
return False
return True
@classmethod
def is_time(cls, v: str):
"""
检查是否是时间格式。
:param v: 待检查内容
:return: 时间返回 True,否则返回 False
"""
try:
datetime.datetime.strptime(v, LOCAL_TIME_FORMAT)
except (ValueError, Exception):
return False
return True
@classmethod
def error_empty_msg(cls, column: Union[Column, Any]):
"""
空字符串错误。主要用于数据校验错误。
:return: 以字段备注为主的错误消息
"""
return '%s必须包含内容.' % cls.label(column=column)
@classmethod
def error_date_msg(cls, column: Union[Column, Any]):
"""
日期格式错误。主要用于数据校验错误。
:return: 以字段备注为主的错误消息
"""
return '%s必须是日期.' % cls.label(column=column)
@classmethod
def error_datetime_msg(cls, column: Union[Column, Any]):
"""
日期时间格式错误。主要用于数据校验错误。
:return: 以字段备注为主的错误消息
"""
return '%s必须是日期时间.' % cls.label(column=column)
@classmethod
def error_time_msg(cls, column: Union[Column, Any]):
"""
时间格式错误。主要用于数据校验错误。
:return: 以字段备注为主的错误消息
"""
return '%s必须是时间.' % cls.label(column=column)
@classmethod
def error_decimal_msg(cls, column: Union[Column, Any]):
"""
非浮点或双精度类型错误。主要用于数据校验错误。
:return: 以字段备注为主的错误消息
"""
return '%s必须是浮点或双进度类型.' % cls.label(column=column)
@classmethod
def error_format_msg(cls, column: Union[Column, Any]):
"""
格式错误。主要用于数据校验错误。
:return: 以字段备注为主的错误消息
"""
return '%s格式错误.' % cls.label(column=column)
@classmethod
def error_int_msg(cls, column: Union[Column, Any]):
"""
非整数类型错误。主要用于数据校验错误。
:return: 以字段备注为主的错误消息
"""
return '%s必须是整数.' % cls.label(column=column)
@classmethod
def error_len_msg(cls, column: Union[Column, Any], length: int):
"""
长度错误消息。主要用于数据校验错误。
:return: 以字段备注为主的错误消息
"""
return '%s必须是%d位.' % (cls.label(column=column), length)
@classmethod
def error_in_range_msg(cls, column: Union[Column, Any],
v_min: Union[int, float], v_max: Union[int, float]):
"""
范围错误。主要用于数据值校验错误。
:return: 以字段备注为主的错误消息
"""
return '%s必须在:[%s%s] 范围内.' % (cls.label(column=column), f"{v_min}", f"{v_max}")
@classmethod
def error_in_items_msg(cls, column: Union[Column, Any], items: list = None):
"""
范围错误。主要用于数据项校验错误。
:return: 以字段备注为主的错误消息
"""
if items is None:
return '%s超出范围.' % cls.label(column=column)
else:
return '%s必须在:[%s] 范围内.' % (cls.label(column=column), ','.join(items))
@classmethod
def error_str_msg(cls, column: Union[Column, Any]):
"""
非字符串类型错误。主要用于数据校验错误。
:return: 以字段备注为主的错误消息
"""
return '%s必须是字符串' % cls.label(column=column)
field_validators: dict[Column, tuple] = {}
"""
字段验证器配置。
规则为:字段名 -> 验证配置
验证配置为一个 tuple 数据,各元素说明如下::
第 0 项:验证方法与消息方法,类型为 method 或 tuple,若仅有验证方法,则直接是方法名即可,若两者皆有,则为 tuple。
第 1 项:是否跳过 None 值,类型为 bool。
第 2~n 项,验证方法或消息方法的参数,注意验证方法与消息方法除第一项参数外的其他参数必须一致。
"""
@classmethod
def validate_fields(cls, row: dict) -> list[dict[str, str]]:
"""
结合 field_validators 的配置,对字段执行验证。
若发现错误,则记录在 _errors 中并返回。
:param row: 待验证数据。
:return: 验证得到的错误描述。
"""
_errors: list[dict[str: str]] = []
for _column, _validator in cls.field_validators.items():
# 消息函数
_message_func = None
# 验证函数,是否跳过空值
_verify_func, _skip_null = _validator[:2]
if isinstance(_verify_func, tuple):
_verify_func, _message_func = _verify_func
_value = udict.get_with_default(row, _column.key, None)
if _value is None and _skip_null:
continue
_args = _validator[2:]
_vfy_args = (_value,) + _args
_err_args = (_column,) + _args
assert isinstance(_verify_func, Callable), '验证器配置错误.'
if not _verify_func(*_vfy_args):
if isinstance(_message_func, Callable):
_errors.append({_column.key: _message_func(*_err_args)})
else:
_errors.append({_column.key: f'{_column.key} 字段数据错误.'})
return _errors
@classmethod
def validate_dict(cls, row: dict, row_list: list[dict], err_list: list[dict]):
"""
验证字典数据。仅将结果加入对应的列表,不改变原有数据。
:param row: 待验证的行数据对象
:param row_list: 用于存放验证成功模型的列表
:param err_list: 用于存放错误消息的列表
"""
try:
row_list.append(row)
except TypeError:
err_list.append(row)
@classmethod
def validate_dict_list(cls, row_list: list[dict]) -> tuple[list[dict], list[dict]]:
"""
验证字典列表数据,首先清除历史模型列表和错误消息。
:param row_list: 待验证的字典数组
:return: 数据模型列表和错误消息列表
"""
_row_list: list[dict] = []
_err_list: list[dict] = []
for row in row_list:
cls.validate_dict(row=row, row_list=_row_list, err_list=_err_list)
return _row_list, _err_list
@classmethod
def mapping_data_struct(cls, source: Optional[dict], mapping: Optional[dict]):
"""
将源数据字典中的数据,按照映射关系字典的方式转换为新的字典对象。
下面是一个递归映射关系字典的样本::
dict_key_mapping = {
'devUseNo': 'dev_use_no',
'mainId': 'id',
'mainCycle': lambda dict_obj: MAIN_CYCLE_LABELS.get(dict_obj['main_cycle'], ''),
'mainCycleCode': 'main_cycle',
'fileList': {
'__name__': 'main_files',
'__mapping__': {
'fileName': 'file_name',
'filePath': 'file_url',
},
},
'mainDetailList': {
'__name__': 'main_items',
'__mapping__': {
'id': 'id',
'itemId': 'item_id',
'itemName': 'item_name',
'itemRequest': 'item_request',
'itemResult': 'item_result',
'remarks': 'remarks',
'itemFileList': {
'__name__': 'main_item_files',
'__mapping__': {
'fileName': 'file_name',
'filePath': 'file_url',
},
},
},
},
}
映射关系字典遵循:{`目标属性`: `源属性`} 的结构,对`源属性`,允许有以下几种类型::
1、为字符串时,表示从源数据字典中直接读取。
2、为函数或 lambda 表达式时,执行函数,并将源数据字典以参数形式传给该函数。
3、为字典时,表示有子对象数据,此时需要配置 __name__ 属性和 __mapping__ 属性。
4、非以上情况的,直接使用该内容作为目标字典属性的数据。
:param source: 源数据字典
:param mapping: 映射关系字典
:return: 转换后的字典
"""
if source is None or mapping is None:
return None
target = {}
for _tar_attr, _src_attr in mapping.items():
if isinstance(_src_attr, str):
#
# 直接处理 key 映射关系
# 注意,对于需要强制设置为字符串的,不能直接使用字符串,会被误认为是 key 映射关系,应当使用无参数 lambda 表达式。
#
target[_tar_attr] = source.get(_src_attr, None)
elif isinstance(_src_attr, Callable):
#
# 处理函数或 lambda 表达式
#
target[_tar_attr] = _src_attr(source)
elif isinstance(_src_attr, dict):
if '__name__' in _src_attr and '__mapping__' in _src_attr:
#
# 包含名称映射的,表示新的映射关系,递归处理
# 这里仅处理类型为 dict 和 list 的数据
#
# 取出内部源数据字典和映射关系
_sd = source.get(_src_attr.get('__name__'), None)
_mp = _src_attr.get('__mapping__', None)
if isinstance(_sd, dict):
#
# 直接递归映射
#
target[_tar_attr] = cls.mapping_data_struct(_sd, _mp)
elif isinstance(_sd, list):
#
# 遍历后递归映射
#
_t_list = []
for _sd_item in _sd:
_t_list.append(cls.mapping_data_struct(_sd_item, _mp))
target[_tar_attr] = _t_list
else:
#
# 非 dictlist 的,直接设置
#
target[_tar_attr] = _sd
else:
#
# 无映射关系的,直接设置
#
target[_tar_attr] = _src_attr
else:
#
# 非 strfunctiondict 的,直接设置
#
target[_tar_attr] = _src_attr
return target
@classmethod
def transform(cls, rows: list[dict], mapping: dict):
"""
将源数据 rows 字典中的数据,按照映射关系字典 mapping 的方式转换为新的字典对象。
下面是一个递归映射关系字典的样本::
dict_key_mapping = {
'devUseNo': 'dev_use_no',
'mainId': 'id',
'mainCycle': lambda dict_obj: MAIN_CYCLE_LABELS.get(dict_obj['main_cycle'], ''),
'mainCycleCode': 'main_cycle',
'fileList': {
'__name__': 'main_files',
'__mapping__': {
'fileName': 'file_name',
'filePath': 'file_url',
},
},
'mainDetailList': {
'__name__': 'main_items',
'__mapping__': {
'id': 'id',
'itemId': 'item_id',
'itemName': 'item_name',
'itemRequest': 'item_request',
'itemResult': 'item_result',
'remarks': 'remarks',
'itemFileList': {
'__name__': 'main_item_files',
'__mapping__': {
'fileName': 'file_name',
'filePath': 'file_url',
},
},
},
},
}
映射关系字典遵循:{`目标属性`: `源属性`} 的结构,对`源属性`,允许有以下几种类型::
1、为字符串时,表示从源数据字典中直接读取。
2、为函数或 lambda 表达式时,执行函数,并将源数据字典以参数形式传给该函数。
3、为字典时,表示有子对象数据,此时需要配置 __name__ 属性和 __mapping__ 属性。
4、非以上情况的,直接使用该内容作为目标字典属性的数据。
:param rows: 源数据字典列表
:param mapping: 映射关系字典
:return: 转换结果
"""
_dict_list: list[dict] = []
for _r in rows:
_tar_dict = cls.mapping_data_struct(_r, mapping)
_dict_list.append(_tar_dict)
return _dict_list
@classmethod
def convert(cls, dataframe: pd.DataFrame, mapping: dict):
"""
将源数据框架 dataframe 中的数据,按照映射关系字典 mapping 的方式转换为新的 dataframe 对象。
下面是一个递归映关系射字典的样本::
dict_key_mapping = {
'devUseNo': 'dev_use_no',
'mainId': 'id',
'mainCycle': lambda dict_obj: MAIN_CYCLE_LABELS.get(dict_obj['main_cycle'], ''),
'mainCycleCode': 'main_cycle',
}
映射关系字典遵循:{`目标属性`: `源属性`} 的结构,对`源属性`,允许有以下几种类型::
1、为字符串时,表示从源数据字典中直接读取。
2、为函数或 lambda 表达式时,执行函数,并将源数据字典以参数形式传给该函数。
3、非以上情况的,直接使用该内容作为目标字典属性的数据。
注意:与字典映射转换不同,:class:`pd.DataFrame` 映射转换不支持多层递归转换。
:param dataframe: 源数据 dataframe
:param mapping: 映射关系字典
:return: 转换结果
"""
_tar_df = pd.DataFrame()
for _tar_attr, _src_attr in mapping.items():
if isinstance(_src_attr, str):
_tar_df[_tar_attr] = dataframe[_src_attr]
elif isinstance(_src_attr, Callable):
_tar_df[_tar_attr] = dataframe.apply(_src_attr, axis=1)
else:
_tar_df[_tar_attr] = _tar_attr
return _tar_df
@classmethod
def is_equal(cls, data_dict: dict, data_model: 'BaseModel', skip_kes: list[str] = None, decimals: str = '0.00'):
"""
判断 data_dict 中的值是否都与 equ_model 中的对应值相等。一般而言若相等,则表明无需更新数据模型,否则就需要更新。
:param data_dict: 数据字典,用于遍历比对的数据,也是用于更新的数据
:param data_model: 数据模型
:param skip_kes: 允许跳过,不做比较的字段
:param decimals: 浮点数保留的小数位,默认 2 位
:return: 是否相等,各字段是否相等的对应关系字典
"""
is_equal = True
equal_dict: dict = {}
if skip_kes is None:
skip_kes = []
for _key, _new_val in data_dict.items():
if _key in skip_kes:
continue
if _new_val is None:
# 跳过新值中的 None
continue
if _key not in data_model.__dict__:
# 跳过不存在的属性
continue
_old_val = data_model.__dict__.get(_key, None)
if isinstance(_old_val, (Decimal, float)):
_old_val = Decimal(f"{_old_val}").quantize(Decimal(decimals), rounding=ROUND_HALF_UP)
_new_val = Decimal(f"{_new_val}").quantize(Decimal(decimals), rounding=ROUND_HALF_UP)
elif isinstance(_old_val, int):
_new_val = int(_new_val)
elif isinstance(_old_val, datetime.datetime):
_old_val = _old_val.strftime(LOCAL_DATETIME_FORMAT)
_datetime = ustr.to_datetime(_new_val, [LOCAL_DATETIME_FORMAT, LOCAL_DATE_FORMAT])
_new_val = _datetime.strftime(LOCAL_DATETIME_FORMAT) if _datetime is not None else f"{_new_val}"
elif isinstance(_old_val, datetime.date):
_old_val = _old_val.strftime(LOCAL_DATE_FORMAT)
_date = ustr.to_datetime(_new_val, [LOCAL_DATE_FORMAT, LOCAL_DATETIME_FORMAT])
_new_val = _date.strftime(LOCAL_DATE_FORMAT) if _date is not None else f"{_new_val}"
else:
_old_val = f"{_old_val}" if _old_val is not None else ''
if isinstance(_new_val, float):
_new_val = int(_new_val)
_new_val = f"{_new_val}"
_isFieldEqual = _new_val == _old_val
is_equal = is_equal and _isFieldEqual
equal_dict[_key] = _isFieldEqual
return is_equal, equal_dict
@classmethod
async def page_info(cls, *where_clause, page_size: int = 20):
"""
分页参数。
:return: 页数, 数据行数
"""
_row_count = await cls.async_row_count(*where_clause)
_pagination = Pagination(row_count=_row_count)
return _pagination.pages(page_size=page_size), _row_count
@classmethod
def sort_clauses(cls, sort_d: dict):
"""
按照参数 sort_d 中的定义,组织排序表达式。参数 sortd_d 应该具有如下结构::
{
'field_name1': 'asc',
'field_name2': 'desc',
}
:param sort_d 排序参数
"""
_sort_clause = []
for _fn, _st in sort_d.items():
if _st in ('', 'asc', 'ascend'):
_sort_clause.append(text(_fn))
if _st in ('desc', 'descend'):
_sort_clause.append(desc(text(_fn)))
return _sort_clause