init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.common.response import ResponseSchema, StreamResponse, SuccessResponse
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema, PaginationQueryParam
|
||||
from app.core.dependencies import AuthPermission, db_getter
|
||||
from app.core.logger import logger
|
||||
from app.core.router_class import OperationLogRoute
|
||||
from app.utils.common_util import bytes2file_response
|
||||
|
||||
from .schema import GenCreateTableSqlBody, GenDBTableSchema, GenSyncPreviewSchema, GenTableOutSchema, GenTableQueryParam, GenTableSchema
|
||||
from .service import GenTableService
|
||||
|
||||
GenRouter = APIRouter(route_class=OperationLogRoute, prefix="/gencode", tags=["代码生成"])
|
||||
|
||||
|
||||
@GenRouter.get("/list", summary="查询代码生成业务表列表", response_model=ResponseSchema[list[GenTableOutSchema]])
|
||||
async def gen_table_list_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[GenTableQueryParam, Query()],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
order_by = [{"created_time": "desc"}]
|
||||
if page.order_by:
|
||||
order_by = page.order_by
|
||||
result_dict = await GenTableService(auth, db).get_gen_table_page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
order_by=order_by,
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="获取代码生成业务表列表成功")
|
||||
|
||||
|
||||
@GenRouter.get("/db/list", summary="查询数据库表列表", response_model=ResponseSchema[PageResultSchema[GenDBTableSchema]])
|
||||
async def get_gen_db_table_list_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:dblist:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[GenTableQueryParam, Query()],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await GenTableService(auth, db).get_gen_db_table_page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="获取数据库表列表成功")
|
||||
|
||||
|
||||
@GenRouter.post("/import", summary="导入表结构", response_model=ResponseSchema[bool])
|
||||
async def import_gen_table_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:import"]))],
|
||||
table_names: Annotated[list[str], Body(description="表名列表")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
svc = GenTableService(auth, db)
|
||||
add_gen_table_list = await svc.get_gen_db_table_list_by_name(table_names)
|
||||
result = await svc.import_gen_table(add_gen_table_list)
|
||||
return SuccessResponse(msg="导入表结构成功", data=result)
|
||||
|
||||
|
||||
@GenRouter.get("/detail/{table_id}", summary="获取业务表详细信息", response_model=ResponseSchema[GenTableOutSchema])
|
||||
async def gen_table_detail_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:query"]))],
|
||||
table_id: Annotated[int, Path(description="业务表ID")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
result = await GenTableService(auth, db).get_gen_table_detail(table_id)
|
||||
return SuccessResponse(data=result, msg="获取业务表详细信息成功")
|
||||
|
||||
|
||||
@GenRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建表结构", response_model=ResponseSchema[bool])
|
||||
async def create_table_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:create"]))],
|
||||
body: Annotated[GenCreateTableSqlBody, Body(description="创建表结构参数")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
result = await GenTableService(auth, db).create_table(body.sql)
|
||||
return SuccessResponse(msg="创建表结构成功", data=result)
|
||||
|
||||
|
||||
@GenRouter.put("/update/{table_id}", summary="编辑业务表信息", response_model=ResponseSchema[GenTableOutSchema])
|
||||
async def update_gen_table_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:update"]))],
|
||||
table_id: Annotated[int, Path(description="业务表ID")],
|
||||
data: Annotated[GenTableSchema, Body(description="业务表信息")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await GenTableService(auth, db).update_gen_table(data, table_id)
|
||||
return SuccessResponse(data=result_dict, msg="编辑业务表信息成功")
|
||||
|
||||
|
||||
@GenRouter.delete("/delete", summary="删除业务表信息", response_model=ResponseSchema[None])
|
||||
async def delete_gen_table_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="业务表ID列表")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
result = await GenTableService(auth, db).delete_gen_table(ids)
|
||||
return SuccessResponse(msg="删除业务表信息成功", data=result)
|
||||
|
||||
|
||||
@GenRouter.patch("/batch/output", summary="批量生成代码")
|
||||
async def batch_gen_code_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:operate"]))],
|
||||
table_names: Annotated[list[str], Body(description="表名列表")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> StreamResponse:
|
||||
batch_gen_code_result, failed_tables = await GenTableService(auth, db).batch_gen_code(table_names)
|
||||
headers = {"Content-Disposition": "attachment; filename=code.zip"}
|
||||
if failed_tables:
|
||||
logger.warning(f"批量生成代码部分失败,跳过表: {failed_tables}")
|
||||
headers["X-Skipped-Tables"] = ",".join(failed_tables)
|
||||
return StreamResponse(
|
||||
data=bytes2file_response(batch_gen_code_result),
|
||||
media_type="application/zip",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@GenRouter.post("/output/{table_name}", summary="生成代码到指定路径", response_model=ResponseSchema[bool])
|
||||
async def gen_code_local_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:code"]))],
|
||||
table_name: Annotated[str, Path(description="表名")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
result = await GenTableService(auth, db).generate_code(table_name)
|
||||
return SuccessResponse(msg="生成代码到指定路径成功", data=result)
|
||||
|
||||
|
||||
@GenRouter.get("/preview/{table_id}", summary="预览代码", response_model=ResponseSchema[GenTableOutSchema])
|
||||
async def preview_code_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:gencode:query"]))],
|
||||
table_id: Annotated[int, Path(description="业务表ID")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
result = await GenTableService(auth, db).preview_code(table_id)
|
||||
return SuccessResponse(data=result, msg="预览代码成功")
|
||||
|
||||
|
||||
@GenRouter.post("/sync_db/{table_name}", summary="同步数据库", response_model=ResponseSchema[None])
|
||||
async def sync_db_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:db:sync"]))],
|
||||
table_name: Annotated[str, Path(description="表名")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
result = await GenTableService(auth, db).sync_db(table_name)
|
||||
return SuccessResponse(msg="同步数据库成功", data=result)
|
||||
|
||||
|
||||
@GenRouter.get("/sync_db/preview/{table_name}", summary="同步数据库差异预览", response_model=ResponseSchema[GenSyncPreviewSchema])
|
||||
async def sync_db_preview_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_generator:db:sync"]))],
|
||||
table_name: Annotated[str, Path(description="表名")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
result = await GenTableService(auth, db).sync_db_preview(table_name)
|
||||
return SuccessResponse(msg="获取同步差异预览成功", data=result)
|
||||
@@ -0,0 +1,594 @@
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Inspector, inspect, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config.setting import settings
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
from app.core.database import engine
|
||||
from app.core.logger import logger
|
||||
from app.utils.common_util import search_to_dict
|
||||
|
||||
from .model import GenTableColumnModel, GenTableModel
|
||||
from .schema import (
|
||||
GenDBTableSchema,
|
||||
GenTableColumnOutSchema,
|
||||
GenTableColumnSchema,
|
||||
GenTableQueryParam,
|
||||
GenTableSchema,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
|
||||
|
||||
class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
|
||||
"""代码生成业务表模块数据库操作层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
"""初始化CRUD操作层
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 认证信息模型
|
||||
- db (AsyncSession): 数据库会话
|
||||
"""
|
||||
super().__init__(model=GenTableModel, auth=auth, db=db)
|
||||
|
||||
async def get_gen_table_by_id(self, table_id: int, preload: list | None = None) -> GenTableModel | None:
|
||||
"""根据业务表ID获取需要生成的业务表信息。
|
||||
|
||||
参数:
|
||||
- table_id (int): 业务表ID。
|
||||
- preload (list | None): 预加载关系,未提供时使用模型默认项
|
||||
|
||||
返回:
|
||||
- GenTableModel | None: 业务表信息对象。
|
||||
"""
|
||||
return await self.get(id=table_id, preload=preload)
|
||||
|
||||
async def get_gen_table_by_name(self, table_name: str, preload: list | None = None) -> GenTableModel | None:
|
||||
"""根据业务表名称获取需要生成的业务表信息。
|
||||
|
||||
参数:
|
||||
- table_name (str): 业务表名称。
|
||||
- preload (list | None): 预加载关系,未提供时使用模型默认项
|
||||
|
||||
返回:
|
||||
- GenTableModel | None: 业务表信息对象。
|
||||
"""
|
||||
return await self.get(table_name=table_name, preload=preload)
|
||||
|
||||
async def get_gen_table_all(self, preload: list | None = None) -> Sequence[GenTableModel]:
|
||||
"""获取所有业务表信息。
|
||||
|
||||
参数:
|
||||
- preload (list | None): 预加载关系,未提供时使用模型默认项
|
||||
|
||||
返回:
|
||||
- Sequence[GenTableModel]: 所有业务表信息列表。
|
||||
"""
|
||||
return await self.get_list(preload=preload)
|
||||
|
||||
async def get_gen_table_list(
|
||||
self,
|
||||
search: GenTableQueryParam | None = None,
|
||||
preload: list | None = None,
|
||||
) -> Sequence[GenTableModel]:
|
||||
"""根据查询参数获取代码生成业务表列表信息。
|
||||
|
||||
参数:
|
||||
- search (GenTableQueryParam | None): 查询参数对象。
|
||||
- preload (list | None): 预加载关系,未提供时使用模型默认项
|
||||
|
||||
返回:
|
||||
- Sequence[GenTableModel]: 业务表列表信息。
|
||||
"""
|
||||
return await self.get_list(
|
||||
search=search_to_dict(search, {}),
|
||||
order_by=[{"created_time": "desc"}],
|
||||
preload=preload,
|
||||
)
|
||||
|
||||
async def add_gen_table(self, add_model: GenTableSchema) -> GenTableModel:
|
||||
"""新增业务表信息。
|
||||
|
||||
参数:
|
||||
- add_model (GenTableSchema): 新增业务表信息模型。
|
||||
|
||||
返回:
|
||||
- GenTableModel: 新增的业务表信息对象。
|
||||
"""
|
||||
return await self.create(data=add_model)
|
||||
|
||||
async def edit_gen_table(self, table_id: int, edit_model: GenTableSchema) -> GenTableModel:
|
||||
"""修改业务表信息。
|
||||
|
||||
参数:
|
||||
- table_id (int): 业务表ID。
|
||||
- edit_model (GenTableSchema): 修改业务表信息模型。
|
||||
|
||||
返回:
|
||||
- GenTableSchema: 修改后的业务表信息模型。
|
||||
"""
|
||||
# 排除嵌套对象字段,避免SQLAlchemy尝试直接将字典设置到模型实例上
|
||||
return await self.update(
|
||||
id=table_id,
|
||||
data=GenTableSchema(**edit_model.model_dump(exclude_unset=True, exclude={"columns"})),
|
||||
)
|
||||
|
||||
async def delete_gen_table(self, ids: list[int]) -> None:
|
||||
"""删除业务表信息。除了系统表。
|
||||
|
||||
参数:
|
||||
- ids (list[int]): 业务表ID列表。
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
await self.delete(ids=ids)
|
||||
|
||||
async def get_db_table_list(self, search: GenTableQueryParam | None = None) -> list[dict]:
|
||||
"""根据查询参数获取数据库表列表信息。
|
||||
|
||||
参数:
|
||||
- search (GenTableQueryParam | None): 查询参数对象。
|
||||
|
||||
返回:
|
||||
- list[dict]: 数据库表列表信息(已转为可序列化字典)。
|
||||
"""
|
||||
database_name = settings.DATABASE_NAME
|
||||
database_type = settings.DATABASE_TYPE
|
||||
|
||||
inspector: Inspector = inspect(engine)
|
||||
table_names = inspector.get_table_names()
|
||||
|
||||
dict_data = []
|
||||
for table_name in table_names:
|
||||
try:
|
||||
table_comment = inspector.get_table_comment(table_name)
|
||||
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else table_comment
|
||||
table_comment = comment or ""
|
||||
except Exception as e:
|
||||
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
|
||||
table_comment = ""
|
||||
|
||||
# 统一处理 search 为 None 的情况,避免重复判断
|
||||
if search:
|
||||
# 表名过滤:忽略大小写,支持模糊匹配
|
||||
if search.table_name and search.table_name[1] and search.table_name[1].lower() not in table_name.lower():
|
||||
continue
|
||||
# 表注释过滤:忽略大小写,支持模糊匹配;table_comment 为 None 时视为空字符串
|
||||
if search.table_comment and search.table_comment[1] and search.table_comment[1] not in table_comment:
|
||||
continue
|
||||
|
||||
table_info = {
|
||||
"database_name": database_name,
|
||||
"table_name": table_name,
|
||||
"table_type": database_type,
|
||||
"table_comment": table_comment,
|
||||
}
|
||||
|
||||
dict_data.append(GenDBTableSchema(**table_info).model_dump())
|
||||
|
||||
return dict_data
|
||||
|
||||
async def get_db_table_page(
|
||||
self,
|
||||
search: GenTableQueryParam | None,
|
||||
offset: int,
|
||||
limit: int,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""数据库侧分页获取物理表列表(用于导入表弹窗)。
|
||||
|
||||
说明:
|
||||
- 旧实现使用 SQLAlchemy Inspector 全量遍历再内存分页,表多时非常慢。
|
||||
- 这里按方言走系统表(MySQL information_schema / Postgres pg_catalog)进行分页与过滤。
|
||||
- 若方言不支持,则回退到旧的全量遍历。
|
||||
|
||||
参数:
|
||||
- search (GenTableQueryParam | None): 表名/注释过滤条件。
|
||||
- offset (int): 偏移量。
|
||||
- limit (int): 每页条数。
|
||||
|
||||
返回:
|
||||
- tuple[list[dict], int]: 当前页表信息列表与总条数。
|
||||
"""
|
||||
database_name = settings.DATABASE_NAME
|
||||
db_type = (settings.DATABASE_TYPE or "").lower()
|
||||
|
||||
# 解析 like 关键字(GenTableQueryParam 把字段包装成 ("like", value))
|
||||
name_kw = None
|
||||
comment_kw = None
|
||||
if search:
|
||||
try:
|
||||
if search.table_name and search.table_name[1]:
|
||||
name_kw = str(search.table_name[1]).strip()
|
||||
if search.table_comment and search.table_comment[1]:
|
||||
comment_kw = str(search.table_comment[1]).strip()
|
||||
except Exception:
|
||||
# 兜底:参数结构异常时忽略过滤
|
||||
name_kw = None
|
||||
comment_kw = None
|
||||
|
||||
# MySQL / MariaDB
|
||||
if db_type in {"mysql", "mariadb"}:
|
||||
where_sql = "WHERE table_schema = :db AND table_type = 'BASE TABLE'"
|
||||
params: dict = {"db": database_name, "offset": offset, "limit": limit}
|
||||
if name_kw:
|
||||
where_sql += " AND table_name LIKE :name_kw"
|
||||
params["name_kw"] = f"%{name_kw}%"
|
||||
if comment_kw:
|
||||
where_sql += " AND table_comment LIKE :comment_kw"
|
||||
params["comment_kw"] = f"%{comment_kw}%"
|
||||
|
||||
count_sql = text(f"SELECT COUNT(1) AS cnt FROM information_schema.tables {where_sql}")
|
||||
rows_sql = text(f"SELECT table_name, table_comment FROM information_schema.tables {where_sql} ORDER BY table_name ASC LIMIT :limit OFFSET :offset")
|
||||
total_res = await self.db.execute(count_sql, params)
|
||||
total = int(total_res.scalar() or 0)
|
||||
res = await self.db.execute(rows_sql, params)
|
||||
items: list[dict] = []
|
||||
for r in res.fetchall():
|
||||
# r may be Row/tuple depending on driver
|
||||
table_name = r[0]
|
||||
table_comment = r[1] or ""
|
||||
items.append(
|
||||
GenDBTableSchema(
|
||||
database_name=database_name,
|
||||
table_name=table_name,
|
||||
table_type=settings.DATABASE_TYPE,
|
||||
table_comment=table_comment,
|
||||
).model_dump(),
|
||||
)
|
||||
return items, total
|
||||
|
||||
# PostgreSQL
|
||||
if db_type in {"postgresql", "postgres"}:
|
||||
# pg_description 需要通过 objsubid=0 获取 table comment
|
||||
where_sql = "WHERE n.nspname NOT IN ('pg_catalog','information_schema') AND c.relkind = 'r'"
|
||||
params = {"offset": offset, "limit": limit}
|
||||
if name_kw:
|
||||
where_sql += " AND c.relname ILIKE :name_kw"
|
||||
params["name_kw"] = f"%{name_kw}%"
|
||||
if comment_kw:
|
||||
where_sql += " AND COALESCE(d.description,'') ILIKE :comment_kw"
|
||||
params["comment_kw"] = f"%{comment_kw}%"
|
||||
|
||||
base_from = "FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_catalog.pg_description d ON d.objoid = c.oid AND d.objsubid = 0 "
|
||||
count_sql = text(f"SELECT COUNT(1) AS cnt {base_from} {where_sql}")
|
||||
rows_sql = text(f"SELECT c.relname AS table_name, COALESCE(d.description,'') AS table_comment {base_from} {where_sql} ORDER BY c.relname ASC LIMIT :limit OFFSET :offset")
|
||||
total_res = await self.db.execute(count_sql, params)
|
||||
total = int(total_res.scalar() or 0)
|
||||
res = await self.db.execute(rows_sql, params)
|
||||
items = []
|
||||
for r in res.fetchall():
|
||||
table_name = r[0]
|
||||
table_comment = r[1] or ""
|
||||
items.append(
|
||||
GenDBTableSchema(
|
||||
database_name=database_name,
|
||||
table_name=table_name,
|
||||
table_type=settings.DATABASE_TYPE,
|
||||
table_comment=table_comment,
|
||||
).model_dump(),
|
||||
)
|
||||
return items, total
|
||||
|
||||
# Fallback:回退旧逻辑(全量遍历再分页由上层处理)
|
||||
all_items = await self.get_db_table_list(search)
|
||||
total = len(all_items)
|
||||
return all_items[offset : offset + limit], total
|
||||
|
||||
async def get_db_table_list_by_names(self, table_names: list[str]) -> list[GenDBTableSchema]:
|
||||
"""根据业务表名称列表获取数据库表信息。
|
||||
|
||||
参数:
|
||||
- table_names (list[str]): 业务表名称列表。
|
||||
|
||||
返回:
|
||||
- list[GenDBTableSchema]: 数据库表信息对象列表。
|
||||
"""
|
||||
if not table_names:
|
||||
return []
|
||||
|
||||
database_name = settings.DATABASE_NAME
|
||||
database_type = settings.DATABASE_TYPE
|
||||
|
||||
inspector: Inspector = inspect(engine)
|
||||
all_table_names = set(inspector.get_table_names())
|
||||
|
||||
results = []
|
||||
for table_name in table_names:
|
||||
if table_name not in all_table_names:
|
||||
continue
|
||||
try:
|
||||
table_comment = inspector.get_table_comment(table_name)
|
||||
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
|
||||
comment = ""
|
||||
|
||||
results.append(
|
||||
GenDBTableSchema(
|
||||
database_name=database_name,
|
||||
table_name=table_name,
|
||||
table_type=database_type,
|
||||
table_comment=comment or "",
|
||||
),
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
async def check_table_exists(self, table_name: str) -> bool:
|
||||
"""检查数据库中是否已存在指定表名的表。
|
||||
|
||||
参数:
|
||||
- table_name (str): 要检查的表名。
|
||||
|
||||
返回:
|
||||
- bool: 如果表存在返回True,否则返回False。
|
||||
"""
|
||||
inspector: Inspector = inspect(engine)
|
||||
return inspector.has_table(table_name)
|
||||
|
||||
async def get_db_table_comment(self, table_name: str) -> str:
|
||||
"""获取数据库中指定表的注释(用于主子表场景下从库中加载子表元信息)。
|
||||
|
||||
参数:
|
||||
- table_name (str): 物理表名。
|
||||
|
||||
返回:
|
||||
- str: 表注释;表不存在或失败时为空字符串。
|
||||
"""
|
||||
inspector: Inspector = inspect(engine)
|
||||
if not inspector.has_table(table_name):
|
||||
return ""
|
||||
try:
|
||||
table_comment = inspector.get_table_comment(table_name)
|
||||
comment = table_comment.get("text", "") if isinstance(table_comment, dict) else (table_comment or "")
|
||||
return comment or ""
|
||||
except Exception as e:
|
||||
logger.warning(f"获取表 {table_name} 的注释失败: {e}")
|
||||
return ""
|
||||
|
||||
async def execute_sql(self, sql: str) -> bool:
|
||||
"""执行SQL语句。
|
||||
|
||||
参数:
|
||||
- sql (str): 要执行的SQL语句。
|
||||
|
||||
返回:
|
||||
- bool: 是否执行成功。
|
||||
"""
|
||||
try:
|
||||
# 执行SQL但不手动提交事务,由框架管理事务生命周期
|
||||
await self.db.execute(text(sql))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"执行SQL时发生错误: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, GenTableColumnSchema]):
|
||||
"""代码生成业务表字段模块数据库操作层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
"""初始化CRUD操作层
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 认证信息模型
|
||||
- db (AsyncSession): 数据库会话
|
||||
"""
|
||||
super().__init__(model=GenTableColumnModel, auth=auth, db=db)
|
||||
|
||||
@staticmethod
|
||||
def _sync_get_table_columns(database_type: str, table_name: str) -> list[dict]:
|
||||
"""同步函数:获取数据库表的列信息
|
||||
|
||||
参数:
|
||||
- database_type: 数据库类型
|
||||
- table_name: 表名
|
||||
|
||||
返回:
|
||||
- list: 列信息列表
|
||||
"""
|
||||
# 使用SQLAlchemy Inspector获取表列信息
|
||||
inspector: Inspector = inspect(engine)
|
||||
|
||||
# 获取列信息
|
||||
columns = inspector.get_columns(table_name)
|
||||
|
||||
# 获取主键信息
|
||||
try:
|
||||
pk_constraint = inspector.get_pk_constraint(table_name)
|
||||
primary_keys = set(pk_constraint.get("constrained_columns", [])) if pk_constraint else set()
|
||||
except Exception:
|
||||
primary_keys = set()
|
||||
|
||||
# 获取唯一约束信息
|
||||
unique_columns = set()
|
||||
unique_constraints = inspector.get_unique_constraints(table_name)
|
||||
for constraint in unique_constraints:
|
||||
unique_columns.update(constraint.get("column_names", []))
|
||||
|
||||
# 处理列信息
|
||||
columns_list = []
|
||||
for idx, column in enumerate(columns):
|
||||
# 获取列的基本信息
|
||||
column_name = column["name"]
|
||||
column_type = str(column["type"])
|
||||
is_nullable = column.get("nullable", True)
|
||||
column_default = column.get("default", None)
|
||||
# 获取列注释(如果有的话)
|
||||
column_comment = column.get("comment", "")
|
||||
# 判断是否为主键
|
||||
is_pk = column_name in primary_keys
|
||||
# 判断是否为唯一约束
|
||||
is_unique = column_name in unique_columns
|
||||
# 判断是否为自增列(基于数据库类型和列类型)
|
||||
is_increment = column.get("autoincrement", False) in (True, "auto")
|
||||
# 获取列长度(如果适用)
|
||||
col_len = getattr(column["type"], "length", None)
|
||||
column_length = str(col_len) if col_len is not None else ""
|
||||
|
||||
# 构造列信息字典
|
||||
column_info = {
|
||||
"column_name": column_name,
|
||||
"column_comment": column_comment or "",
|
||||
"column_type": column_type,
|
||||
"column_length": column_length or "",
|
||||
"column_default": str(column_default) if column_default is not None else "",
|
||||
"sort": idx + 1, # 序号从1开始
|
||||
"is_pk": bool(is_pk),
|
||||
"is_increment": bool(is_increment),
|
||||
"is_nullable": bool(is_nullable),
|
||||
"is_unique": bool(is_unique),
|
||||
}
|
||||
|
||||
columns_list.append(column_info)
|
||||
|
||||
return columns_list
|
||||
|
||||
async def get_gen_table_column_by_id(self, id: int, preload: list | None = None) -> GenTableColumnModel | None:
|
||||
"""根据业务表字段ID获取业务表字段信息。
|
||||
|
||||
参数:
|
||||
- id (int): 业务表字段ID。
|
||||
- preload (list | None): 预加载关系,未提供时使用模型默认项
|
||||
|
||||
返回:
|
||||
- GenTableColumnModel | None: 业务表字段信息对象。
|
||||
"""
|
||||
return await self.get(id=id, preload=preload)
|
||||
|
||||
async def get_gen_table_column_list_by_table_id(self, table_id: int, preload: list | None = None) -> GenTableColumnModel | None:
|
||||
"""根据业务表ID获取业务表字段列表信息。
|
||||
|
||||
参数:
|
||||
- table_id (int): 业务表ID。
|
||||
- preload (list | None): 预加载关系,未提供时使用模型默认项
|
||||
|
||||
返回:
|
||||
- GenTableColumnModel | None: 业务表字段列表信息对象。
|
||||
"""
|
||||
return await self.get(table_id=table_id, preload=preload)
|
||||
|
||||
async def list_gen_table_column_crud_by_table_id(
|
||||
self,
|
||||
table_id: int,
|
||||
order_by: list | None = None,
|
||||
preload: list | None = None,
|
||||
) -> Sequence[GenTableColumnModel]:
|
||||
"""根据业务表ID查询业务表字段列表。
|
||||
|
||||
参数:
|
||||
- table_id (int): 业务表ID。
|
||||
- order_by (list | None): 排序字段列表,每个元素为{"field": "字段名", "order": "asc" | "desc"}。
|
||||
- preload (list | None): 预加载关系,未提供时使用模型默认项
|
||||
|
||||
返回:
|
||||
- Sequence[GenTableColumnModel]: 业务表字段列表信息对象序列。
|
||||
"""
|
||||
return await self.get_list(search={"table_id": table_id}, order_by=order_by, preload=preload)
|
||||
|
||||
async def get_gen_db_table_columns_by_name(self, table_name: str | None) -> list[GenTableColumnOutSchema]:
|
||||
"""根据业务表名称获取业务表字段列表信息。
|
||||
|
||||
参数:
|
||||
- table_name (str | None): 业务表名称。
|
||||
|
||||
返回:
|
||||
- list[GenTableColumnOutSchema]: 业务表字段列表信息对象。
|
||||
"""
|
||||
# 检查表名是否为空
|
||||
if not table_name:
|
||||
raise ValueError("数据表名称不能为空")
|
||||
|
||||
try:
|
||||
# 在线程池中执行同步 inspect 操作,避免阻塞事件循环
|
||||
columns_info = await asyncio.to_thread(
|
||||
GenTableColumnCRUD._sync_get_table_columns,
|
||||
settings.DATABASE_TYPE,
|
||||
table_name,
|
||||
)
|
||||
|
||||
# 转换为GenTableColumnOutSchema对象列表
|
||||
columns_list = [GenTableColumnOutSchema(**column_info) for column_info in columns_info]
|
||||
|
||||
return columns_list
|
||||
except Exception as e:
|
||||
logger.error(f"获取表{table_name}的字段列表时出错: {e!s}")
|
||||
# 确保即使出错也返回空列表而不是None
|
||||
raise
|
||||
|
||||
async def list_gen_table_column_crud(
|
||||
self,
|
||||
search: dict | None = None,
|
||||
order_by: list | None = None,
|
||||
preload: list | None = None,
|
||||
) -> Sequence[GenTableColumnModel]:
|
||||
"""根据业务表字段查询业务表字段列表。
|
||||
|
||||
参数:
|
||||
- search (dict | None): 查询参数,例如{"table_id": 1}。
|
||||
- order_by (list | None): 排序字段列表,每个元素为{"field": "字段名", "order": "asc" | "desc"}。
|
||||
- preload (list | None): 预加载关系,未提供时使用模型默认项
|
||||
|
||||
返回:
|
||||
- Sequence[GenTableColumnModel]: 业务表字段列表信息对象序列。
|
||||
"""
|
||||
return await self.get_list(search=search, order_by=order_by, preload=preload)
|
||||
|
||||
async def create_gen_table_column_crud(self, data: GenTableColumnSchema) -> GenTableColumnModel | None:
|
||||
"""创建业务表字段。
|
||||
|
||||
参数:
|
||||
- data (GenTableColumnSchema): 业务表字段模型。
|
||||
|
||||
返回:
|
||||
- GenTableColumnModel | None: 业务表字段列表信息对象。
|
||||
"""
|
||||
return await self.create(data=data)
|
||||
|
||||
async def update_gen_table_column_crud(self, id: int, data: GenTableColumnSchema) -> GenTableColumnModel | None:
|
||||
"""更新业务表字段。
|
||||
|
||||
参数:
|
||||
- id (int): 业务表字段ID。
|
||||
- data (GenTableColumnSchema): 业务表字段模型。
|
||||
|
||||
返回:
|
||||
- GenTableColumnModel | None: 业务表字段列表信息对象。
|
||||
"""
|
||||
return await self.update(id=id, data=data)
|
||||
|
||||
async def delete_gen_table_column_by_table_id_crud(self, table_ids: list[int]) -> None:
|
||||
"""根据业务表ID批量删除业务表字段。
|
||||
|
||||
参数:
|
||||
- table_ids (list[int]): 业务表ID列表。
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
# 先查询出这些表ID对应的所有字段ID
|
||||
query = select(GenTableColumnModel.id).where(GenTableColumnModel.table_id.in_(table_ids))
|
||||
result = await self.db.execute(query)
|
||||
column_ids = [row[0] for row in result.fetchall()]
|
||||
|
||||
# 如果有字段ID,则删除这些字段
|
||||
if column_ids:
|
||||
await self.delete(ids=column_ids)
|
||||
|
||||
async def delete_gen_table_column_by_column_id_crud(self, column_ids: list[int]) -> None:
|
||||
"""根据业务表字段ID批量删除业务表字段。
|
||||
|
||||
参数:
|
||||
- column_ids (list[int]): 业务表字段ID列表。
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
return await self.delete(ids=column_ids)
|
||||
@@ -0,0 +1,309 @@
|
||||
import re
|
||||
|
||||
from app.common.constant import GenConstant
|
||||
from app.config.setting import settings
|
||||
from app.utils.string_util import StringUtil
|
||||
|
||||
from .schema import (
|
||||
GenTableColumnSchema,
|
||||
GenTableOutSchema,
|
||||
GenTableSchema,
|
||||
)
|
||||
|
||||
|
||||
class GenUtils:
|
||||
"""代码生成器工具类"""
|
||||
|
||||
@classmethod
|
||||
def init_table(cls, gen_table: GenTableSchema) -> None:
|
||||
"""初始化表信息
|
||||
|
||||
参数:
|
||||
- gen_table (GenTableSchema): 业务表对象。
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
gen_table.class_name = cls.convert_class_name(gen_table.table_name or "")
|
||||
# 导入时给出"可用默认值",减少用户点开后看到空表单:
|
||||
# - module_name:从表名推导(去掉 gen_/tb_ 前缀)
|
||||
# - package_name:默认 module_{module_name}(仍可在前端改)
|
||||
if gen_table.business_name is None:
|
||||
gen_table.business_name = gen_table.table_name
|
||||
if gen_table.module_name is None or not str(gen_table.module_name).strip():
|
||||
tn = (gen_table.table_name or "").strip().lower()
|
||||
if tn.startswith("gen_"):
|
||||
tn = tn[4:]
|
||||
elif tn.startswith("tb_"):
|
||||
tn = tn[3:]
|
||||
tn = re.sub(r"[^a-z0-9_]+", "_", tn)
|
||||
tn = re.sub(r"_+", "_", tn).strip("_") or "module"
|
||||
gen_table.module_name = tn
|
||||
|
||||
if gen_table.package_name is None or not str(gen_table.package_name).strip():
|
||||
mn = (gen_table.module_name or "").strip()
|
||||
if mn:
|
||||
gen_table.package_name = mn if mn.startswith("module_") else f"module_{mn}"
|
||||
|
||||
fn = re.sub(r"(?:表|测试)", "", gen_table.table_comment or "")
|
||||
fn = (fn or "").strip()
|
||||
if not fn:
|
||||
# 表注释为空时:用表名兜底,至少不为空
|
||||
fn = (gen_table.table_name or "").strip()
|
||||
gen_table.function_name = fn
|
||||
|
||||
@classmethod
|
||||
def init_column_field(cls, column: GenTableColumnSchema, table: GenTableOutSchema) -> None:
|
||||
"""初始化列属性字段
|
||||
|
||||
参数:
|
||||
- column (GenTableColumnSchema): 业务表字段对象。
|
||||
- table (GenTableOutSchema): 业务表对象。
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
data_type = cls.get_db_type(column.column_type or "")
|
||||
column_name = column.column_name or ""
|
||||
if table.id is None:
|
||||
raise ValueError("业务表ID不能为空")
|
||||
column.table_id = table.id
|
||||
column.python_field = StringUtil.to_lower_camel_case(column_name)
|
||||
|
||||
# 特殊处理几何类型,根据数据库类型选择不同的映射
|
||||
if data_type in [
|
||||
"point",
|
||||
"line",
|
||||
"linestring",
|
||||
"polygon",
|
||||
"multipoint",
|
||||
"multilinestring",
|
||||
"multipolygon",
|
||||
"geometrycollection",
|
||||
"geometry",
|
||||
]:
|
||||
if settings.DATABASE_TYPE == "mysql":
|
||||
column.python_type = "bytes"
|
||||
elif settings.DATABASE_TYPE == "postgres":
|
||||
column.python_type = "list"
|
||||
else:
|
||||
# 只有当python_type为None时才设置默认类型
|
||||
column.python_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_PYTHON, data_type)
|
||||
|
||||
if column.column_length is None:
|
||||
column.column_length = ""
|
||||
|
||||
if column.column_default is None:
|
||||
column.column_default = ""
|
||||
|
||||
if column.html_type is None:
|
||||
# 先按"字段名语义"推断(优先级高于通用字符串规则)
|
||||
lower_name = column_name.lower()
|
||||
if lower_name.endswith("status"):
|
||||
column.html_type = GenConstant.HTML_RADIO
|
||||
elif lower_name.endswith("type") or lower_name.endswith("sex"):
|
||||
column.html_type = GenConstant.HTML_SELECT
|
||||
elif lower_name.endswith("image"):
|
||||
column.html_type = GenConstant.HTML_IMAGE_UPLOAD
|
||||
elif lower_name.endswith("file"):
|
||||
column.html_type = GenConstant.HTML_FILE_UPLOAD
|
||||
elif lower_name.endswith("content"):
|
||||
column.html_type = GenConstant.HTML_EDITOR
|
||||
# 再按"数据类型"推断
|
||||
elif cls.arrays_contains(GenConstant.COLUMNTYPE_TIME, data_type):
|
||||
column.html_type = GenConstant.HTML_DATETIME
|
||||
elif cls.arrays_contains(GenConstant.COLUMNTYPE_NUMBER, data_type):
|
||||
column.html_type = GenConstant.HTML_INPUT
|
||||
elif cls.arrays_contains(GenConstant.COLUMNTYPE_STR, data_type) or cls.arrays_contains(GenConstant.COLUMNTYPE_TEXT, data_type):
|
||||
# 字符串长度超过500设置为文本域
|
||||
column_length = cls.get_column_length(column.column_type or "")
|
||||
column.html_type = GenConstant.HTML_TEXTAREA if column_length >= 500 or cls.arrays_contains(GenConstant.COLUMNTYPE_TEXT, data_type) else GenConstant.HTML_INPUT
|
||||
else:
|
||||
column.html_type = GenConstant.HTML_INPUT
|
||||
|
||||
# 默认新增字段:非主键且不在"新增不展示"黑名单中
|
||||
# 说明:schema 默认值可能为 True/False;仅当调用方未显式配置时才做推断
|
||||
if column.is_insert is None:
|
||||
column.is_insert = bool((not column.is_pk) and (not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_ADD_SHOW, column_name)))
|
||||
|
||||
# 默认编辑字段:非主键且不在"不编辑"黑名单中
|
||||
if column.is_edit is None:
|
||||
column.is_edit = bool((not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_EDIT, column_name)) and (not column.is_pk))
|
||||
|
||||
# 默认列表字段:非主键且不在"不列表显示"黑名单中
|
||||
if column.is_list is None:
|
||||
column.is_list = bool((not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_LIST, column_name)) and (not column.is_pk))
|
||||
|
||||
# 默认查询字段:非主键且不在"不查询"黑名单中
|
||||
if column.is_query is None:
|
||||
column.is_query = bool((not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_QUERY, column_name)) and (not column.is_pk))
|
||||
|
||||
# 查询类型:仅当开启查询且 query_type 未显式配置时推断
|
||||
if column.is_query:
|
||||
if column.query_type is None:
|
||||
if column_name.lower().endswith("name") or data_type in ["varchar", "char", "text"]:
|
||||
column.query_type = GenConstant.QUERY_LIKE
|
||||
else:
|
||||
column.query_type = GenConstant.QUERY_EQ
|
||||
else:
|
||||
column.query_type = None
|
||||
|
||||
# 主键强约束:无论默认推断/历史配置如何,主键列不应出现在新增/编辑/列表/查询
|
||||
if bool(column.is_pk):
|
||||
column.is_insert = False
|
||||
column.is_edit = False
|
||||
column.is_list = False
|
||||
column.is_query = False
|
||||
column.query_type = None
|
||||
|
||||
@classmethod
|
||||
def arrays_contains(cls, arr: list, target_value: str) -> bool:
|
||||
"""检查目标值是否在数组中
|
||||
|
||||
注意:从根本上解决问题,现在确保传入的参数都是正确的类型:
|
||||
- arr 是列表类型,且在GenConstant中定义
|
||||
- target_value 不会是None
|
||||
|
||||
参数:
|
||||
- arr: 数组类型
|
||||
- target_value: 目标值
|
||||
|
||||
返回:
|
||||
- bool: 如果目标值在数组中,返回True;否则返回False
|
||||
"""
|
||||
# 从根本上解决问题,不再需要复杂的防御性检查
|
||||
# 因为现在我们确保传入的arr是GenConstant中定义的列表常量
|
||||
# 并且target_value在调用前已经被处理过不会是None
|
||||
|
||||
# 移除 COLLATE 子句和 UNSIGNED 标记(不区分大小写)
|
||||
target_str = str(target_value)
|
||||
|
||||
# 移除 COLLATE 子句
|
||||
collate_pattern = re.compile(r"\s+COLLATE\s+", re.IGNORECASE)
|
||||
if collate_pattern.search(target_str):
|
||||
target_str = collate_pattern.split(target_str)[0].strip()
|
||||
|
||||
# 移除 UNSIGNED 标记
|
||||
unsigned_pattern = re.compile(r"\s+UNSIGNED", re.IGNORECASE)
|
||||
if unsigned_pattern.search(target_str):
|
||||
target_str = unsigned_pattern.sub("", target_str).strip()
|
||||
|
||||
# 转换为小写进行比较
|
||||
target_str = target_str.lower()
|
||||
|
||||
# 对于包含括号的类型(如TINYINT(1)),需要特殊处理
|
||||
# 先获取基本类型名称(不含括号)用于比较
|
||||
target_base_type = target_str.split("(")[0] if "(" in target_str else target_str
|
||||
|
||||
for item in arr:
|
||||
item_str = str(item).lower()
|
||||
item_base_type = item_str.split("(")[0] if "(" in item_str else item_str
|
||||
if target_base_type == item_base_type:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def convert_class_name(cls, table_name: str) -> str:
|
||||
"""表名转换成 Python 类名
|
||||
|
||||
参数:
|
||||
- table_name (str): 业务表名。
|
||||
|
||||
返回:
|
||||
- str: Python 类名。
|
||||
"""
|
||||
return StringUtil.convert_to_camel_case(table_name)
|
||||
|
||||
@classmethod
|
||||
def replace_first(cls, input_string: str, search_list: list[str]) -> str:
|
||||
"""批量替换前缀
|
||||
|
||||
参数:
|
||||
- input_string (str): 需要被替换的字符串。
|
||||
- search_list (list[str]): 可替换的字符串列表。
|
||||
|
||||
返回:
|
||||
- str: 替换后的字符串。
|
||||
"""
|
||||
for search_string in search_list:
|
||||
if input_string.startswith(search_string):
|
||||
return input_string.replace(search_string, "", 1)
|
||||
return input_string
|
||||
|
||||
@classmethod
|
||||
def get_db_type(cls, column_type: str) -> str:
|
||||
"""获取数据库类型字段
|
||||
|
||||
参数:
|
||||
- column_type (str): 字段类型。
|
||||
|
||||
返回:
|
||||
- str: 数据库类型。
|
||||
"""
|
||||
# 移除 COLLATE 子句(处理带引号和不带引号的情况,不区分大小写)
|
||||
collate_pattern = re.compile(r"\s+COLLATE\s+", re.IGNORECASE)
|
||||
if collate_pattern.search(column_type):
|
||||
column_type = collate_pattern.split(column_type)[0].strip()
|
||||
|
||||
# 移除 UNSIGNED 标记(不区分大小写)
|
||||
unsigned_pattern = re.compile(r"\s+UNSIGNED", re.IGNORECASE)
|
||||
if unsigned_pattern.search(column_type):
|
||||
column_type = unsigned_pattern.sub("", column_type).strip()
|
||||
|
||||
# 特殊处理tinyint(1),映射为boolean
|
||||
if column_type.lower().startswith("tinyint(1)"):
|
||||
return "boolean"
|
||||
|
||||
# 处理PostgreSQL数组类型(如 integer[], text[] 或 ARRAY[INTEGER])
|
||||
if "[]" in column_type or column_type.upper().startswith("ARRAY["):
|
||||
return "array"
|
||||
|
||||
# 提取基本类型:
|
||||
# - 去掉括号参数:varchar(64) -> varchar
|
||||
# - 去掉空格后的修饰:timestamp without time zone -> timestamp
|
||||
# - 统一小写
|
||||
base = column_type.split("(", 1)[0].strip()
|
||||
if not base:
|
||||
return ""
|
||||
base = base.split(None, 1)[0].strip()
|
||||
return base.lower()
|
||||
|
||||
@classmethod
|
||||
def get_column_length(cls, column_type: str) -> int:
|
||||
"""获取字段长度
|
||||
|
||||
参数:
|
||||
- column_type (str): 字段类型,例如 'varchar(255)' 或 'decimal(10,2)'
|
||||
|
||||
返回:
|
||||
- int: 字段长度(优先取第一个长度值,无法解析时返回0)。
|
||||
"""
|
||||
if not column_type:
|
||||
return 0
|
||||
if "(" not in column_type or ")" not in column_type:
|
||||
return 0
|
||||
|
||||
# 形如 varchar(255) / decimal(10,2) / numeric(20, 0)
|
||||
inner = column_type.split("(", 1)[1].split(")", 1)[0].strip()
|
||||
if not inner:
|
||||
return 0
|
||||
|
||||
first = inner.split(",", 1)[0].strip()
|
||||
try:
|
||||
return int(first)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def split_column_type(cls, column_type: str) -> list[str]:
|
||||
"""拆分列类型
|
||||
|
||||
参数:
|
||||
- column_type (str): 字段类型。
|
||||
|
||||
返回:
|
||||
- list[str]: 拆分结果。
|
||||
"""
|
||||
if "(" in column_type and ")" in column_type:
|
||||
return column_type.split("(")[1].split(")")[0].split(",")
|
||||
return []
|
||||
@@ -0,0 +1,760 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, Template
|
||||
|
||||
from app.common.constant import GenConstant
|
||||
from app.config.path_conf import TEMPLATE_DIR
|
||||
from app.config.setting import settings
|
||||
from app.utils.common_util import CamelCaseUtil, SnakeCaseUtil, compute_menu_route_first_segment
|
||||
from app.utils.string_util import StringUtil
|
||||
|
||||
from .gen_util import GenUtils
|
||||
from .schema import (
|
||||
GenTableColumnOutSchema,
|
||||
GenTableOutSchema,
|
||||
)
|
||||
|
||||
|
||||
class Jinja2TemplateUtil:
|
||||
"""模板处理工具类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def normalize_db_column_type_for_mapping(cls, column_type: str | None) -> str:
|
||||
"""与 ``GenUtils.get_db_type`` 一致地去掉 COLLATE / UNSIGNED,便于与 ``DB_TO_SQLALCHEMY`` 键匹配。
|
||||
|
||||
参数:
|
||||
- column_type (str | None): 原始列类型字符串。
|
||||
|
||||
返回:
|
||||
- str: 规范化后的类型片段;空输入返回空字符串。
|
||||
"""
|
||||
ct = (column_type or "").strip()
|
||||
if not ct:
|
||||
return ""
|
||||
collate_pattern = re.compile(r"\s+COLLATE\s+", re.IGNORECASE)
|
||||
if collate_pattern.search(ct):
|
||||
ct = collate_pattern.split(ct)[0].strip()
|
||||
unsigned_pattern = re.compile(r"\s+UNSIGNED", re.IGNORECASE)
|
||||
if unsigned_pattern.search(ct):
|
||||
ct = unsigned_pattern.sub("", ct).strip()
|
||||
return ct
|
||||
|
||||
# 项目路径
|
||||
FRONTEND_PROJECT_PATH = "frontend/web"
|
||||
BACKEND_PROJECT_PATH = "backend"
|
||||
|
||||
# 环境对象
|
||||
_env = None
|
||||
|
||||
@classmethod
|
||||
def get_env(cls):
|
||||
"""获取模板环境对象。
|
||||
|
||||
参数:
|
||||
- 无
|
||||
|
||||
返回:
|
||||
- Environment: Jinja2 环境对象。
|
||||
"""
|
||||
try:
|
||||
if cls._env is None:
|
||||
cls._env = Environment(
|
||||
loader=FileSystemLoader(TEMPLATE_DIR),
|
||||
autoescape=False, # 自动转义HTML
|
||||
trim_blocks=True, # 删除多余的空行
|
||||
lstrip_blocks=True, # 删除行首空格
|
||||
keep_trailing_newline=True, # 保留行尾换行符
|
||||
enable_async=True, # 开启异步支持
|
||||
)
|
||||
cls._env.filters.update(
|
||||
{
|
||||
"camel_to_snake": SnakeCaseUtil.camel_to_snake,
|
||||
"snake_to_camel": CamelCaseUtil.snake_to_camel,
|
||||
"get_sqlalchemy_type": cls.get_sqlalchemy_type,
|
||||
"python_to_ts_type": cls.python_type_to_ts_type,
|
||||
},
|
||||
)
|
||||
return cls._env
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"初始化Jinja2模板引擎失败: {e}")
|
||||
|
||||
@classmethod
|
||||
def get_template(cls, template_path: str) -> Template:
|
||||
"""获取模板。
|
||||
|
||||
参数:
|
||||
- template_path (str): 模板路径。
|
||||
|
||||
返回:
|
||||
- Template: Jinja2 模板对象。
|
||||
|
||||
异常:
|
||||
- TemplateNotFound: 模板未找到时抛出。
|
||||
"""
|
||||
return cls.get_env().get_template(template_path)
|
||||
|
||||
@classmethod
|
||||
def business_name_to_slug(cls, business_name: str | None) -> str:
|
||||
"""业务路径可含斜杠(如 ``demo/subdir``)用于目录与路由前缀;
|
||||
Python 函数/方法名仅使用最后一段并规范为合法 snake_case 片段。
|
||||
|
||||
参数:
|
||||
- business_name (str | None): 业务路径或名称。
|
||||
|
||||
返回:
|
||||
- str: 用于 Python 标识的 slug,默认 entity。
|
||||
"""
|
||||
s = (business_name or "").strip().strip("/")
|
||||
if not s:
|
||||
return "entity"
|
||||
if "/" in s:
|
||||
s = s.split("/")[-1]
|
||||
s = re.sub(r"[^a-zA-Z0-9_]", "_", s)
|
||||
if not s:
|
||||
return "entity"
|
||||
if s[0].isdigit():
|
||||
s = "_" + s
|
||||
return s
|
||||
|
||||
@classmethod
|
||||
def business_name_to_path(cls, business_name: str | None) -> str:
|
||||
"""把 business_name 规范为可用于目录/路由的多段路径(保留 `/`)。
|
||||
|
||||
约定:`business_name` 允许 `a/b/c` 表示多级菜单目录。
|
||||
- 目录/路由:使用完整多段
|
||||
- 文件名/route_name:使用最后一段 slug(见 `business_name_to_slug`)
|
||||
|
||||
参数:
|
||||
- business_name (str | None): 业务路径或名称。
|
||||
|
||||
返回:
|
||||
- str: 多段路径字符串(小写 slug),默认 entity。
|
||||
"""
|
||||
s = (business_name or "").strip().strip("/")
|
||||
if not s:
|
||||
return "entity"
|
||||
# 每段都做一次轻度规范(与 schema 的 slug 规则一致:a-z0-9_)
|
||||
segs = []
|
||||
for raw in [p for p in s.split("/") if p]:
|
||||
seg = re.sub(r"[^a-zA-Z0-9_]", "_", raw).lower()
|
||||
seg = re.sub(r"_+", "_", seg).strip("_") or "entity"
|
||||
if seg[0].isdigit():
|
||||
seg = "_" + seg
|
||||
segs.append(seg)
|
||||
return "/".join(segs) if segs else "entity"
|
||||
|
||||
@classmethod
|
||||
def prepare_context(cls, gen_table: GenTableOutSchema) -> dict[str, Any]:
|
||||
"""准备模板变量。
|
||||
|
||||
参数:
|
||||
- gen_table (GenTableOutSchema): 生成表的配置信息。
|
||||
|
||||
返回:
|
||||
- Dict[str, Any]: 模板上下文字典。
|
||||
"""
|
||||
# 处理options为None的情况
|
||||
# if not gen_table.options:
|
||||
# raise ValueError('请先完善生成配置信息')
|
||||
class_name = gen_table.class_name or ""
|
||||
package_name = (gen_table.package_name or "").strip()
|
||||
module_name = (gen_table.module_name or "").strip()
|
||||
business_name = (gen_table.business_name or "").strip()
|
||||
function_name = gen_table.function_name or ""
|
||||
|
||||
# 生成规则(对齐 module_example/demo):
|
||||
# - 分系统根:package_name = module_xxx
|
||||
# - 目录固定为:module_xxx / module_name(不再额外使用业务名作为目录层级)
|
||||
# - 权限前缀固定为:module_xxx:module_name(操作在模板里再拼 :query/:create...)
|
||||
business_path = cls.business_name_to_path(business_name)
|
||||
business_name_slug = cls.business_name_to_slug(business_name)
|
||||
permission_prefix = ":".join([s for s in [package_name, module_name] if s])
|
||||
api_route_prefix = cls.get_api_route_prefix(package_name)
|
||||
|
||||
_cols = gen_table.columns or []
|
||||
table_column_names = frozenset(c.column_name for c in _cols if getattr(c, "column_name", None))
|
||||
has_dict_column = any(getattr(c, "dict_type", None) for c in _cols)
|
||||
has_image_column = any(getattr(c, "html_type", None) == "imageUpload" for c in _cols)
|
||||
|
||||
sub_class_name = ""
|
||||
sub_model_class_name = ""
|
||||
sub_rel_list_name = ""
|
||||
parent_rel_name = ""
|
||||
if gen_table.sub and gen_table.sub_table:
|
||||
st = gen_table.sub_table
|
||||
scn = (st.class_name or GenUtils.convert_class_name(gen_table.sub_table_name or "")).strip()
|
||||
sub_class_name = scn
|
||||
sub_model_class_name = f"{scn}Model"
|
||||
sub_rel_list_name = f"{SnakeCaseUtil.camel_to_snake(scn)}_list"
|
||||
parent_rel_name = SnakeCaseUtil.camel_to_snake(gen_table.class_name or "")
|
||||
|
||||
context = {
|
||||
"table_name": gen_table.table_name or "",
|
||||
"table_comment": gen_table.table_comment or "",
|
||||
"function_name": function_name if StringUtil.is_not_empty(function_name) else "【请填写功能名称】",
|
||||
"class_name": class_name,
|
||||
"module_name": module_name,
|
||||
"business_name": business_name,
|
||||
"business_path": business_path,
|
||||
"business_file": business_name_slug,
|
||||
"business_name_slug": business_name_slug,
|
||||
"base_package": cls.get_package_prefix(package_name),
|
||||
"package_name": package_name,
|
||||
"menu_route_first_segment": cls.get_menu_route_first_segment(gen_table),
|
||||
"datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"pk_column": gen_table.pk_column,
|
||||
"model_import_list": cls.get_model_import_list(gen_table),
|
||||
"schema_import_list": cls.get_schema_import_list(gen_table),
|
||||
"permission_prefix": permission_prefix,
|
||||
"api_route_prefix": api_route_prefix,
|
||||
"columns": gen_table.columns or [],
|
||||
"table_column_names": table_column_names,
|
||||
"table": gen_table,
|
||||
"dicts": cls.get_dicts(gen_table),
|
||||
"db_type": settings.DATABASE_TYPE,
|
||||
"column_not_add_show": GenConstant.COLUMNNAME_NOT_ADD_SHOW,
|
||||
"column_not_edit_show": GenConstant.COLUMNNAME_NOT_EDIT_SHOW,
|
||||
"parent_menu_id": int(gen_table.parent_menu_id) if gen_table.parent_menu_id else None,
|
||||
"is_sub_entity": False,
|
||||
"sub_class_name": sub_class_name,
|
||||
"sub_model_class_name": sub_model_class_name,
|
||||
"sub_module_name": (gen_table.sub_table.module_name if gen_table.sub and gen_table.sub_table else ""),
|
||||
"sub_rel_list_name": sub_rel_list_name,
|
||||
"parent_rel_name": parent_rel_name,
|
||||
"parent_list_rel_name": "",
|
||||
"parent_table_name": "",
|
||||
"parent_model_class_name": "",
|
||||
# 数据表实际主键列名(用于生成前端行键等;ModelMixin 仍默认带 id 字段)
|
||||
"pk_column_name": (gen_table.pk_column.column_name if gen_table.pk_column else None) or "id",
|
||||
"parent_pk_column_name": (gen_table.pk_column.column_name if gen_table.pk_column else None) or "id",
|
||||
"sub_table_fk_name": "",
|
||||
"has_dict_column": has_dict_column,
|
||||
"has_image_column": has_image_column,
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
@classmethod
|
||||
def get_menu_route_first_segment(cls, gen_table: GenTableOutSchema) -> str:
|
||||
"""前端页面路由首段(与写入菜单 ``route_path`` 第一段一致):始终为 ``module_xxx``。"""
|
||||
pid = int(gen_table.parent_menu_id) if gen_table.parent_menu_id is not None else None
|
||||
return compute_menu_route_first_segment(
|
||||
pid,
|
||||
gen_table.package_name or "",
|
||||
gen_table.module_name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def prepare_sub_render_context(cls, parent: GenTableOutSchema, sub: GenTableOutSchema) -> dict[str, Any]:
|
||||
"""子表业务代码渲染上下文(与主表同模块、独立业务目录)。
|
||||
|
||||
参数:
|
||||
- parent (GenTableOutSchema): 主表配置。
|
||||
- sub (GenTableOutSchema): 子表配置。
|
||||
|
||||
返回:
|
||||
- dict[str, Any]: 子表模板上下文字典。
|
||||
"""
|
||||
ctx = cls.prepare_context(sub)
|
||||
scn = (sub.class_name or GenUtils.convert_class_name(sub.table_name or "")).strip()
|
||||
ctx["is_sub_entity"] = True
|
||||
ctx["parent_class_name"] = parent.class_name or ""
|
||||
ctx["parent_model_class_name"] = f"{parent.class_name}Model"
|
||||
ctx["parent_table_name"] = parent.table_name or ""
|
||||
ctx["parent_pk_column_name"] = (parent.pk_column.column_name if parent.pk_column else None) or "id"
|
||||
ctx["parent_rel_name"] = SnakeCaseUtil.camel_to_snake(parent.class_name or "parent")
|
||||
ctx["parent_list_rel_name"] = f"{SnakeCaseUtil.camel_to_snake(scn)}_list"
|
||||
ctx["sub_table_fk_name"] = (parent.sub_table_fk_name or "").strip()
|
||||
ctx["model_import_list"] = cls.get_model_import_list(sub, is_sub_entity=True)
|
||||
ctx["schema_import_list"] = cls.get_schema_import_list(sub)
|
||||
return ctx
|
||||
|
||||
@classmethod
|
||||
def get_template_list(cls):
|
||||
"""获取主表模板列表。
|
||||
|
||||
参数:
|
||||
- 无
|
||||
返回:
|
||||
- List[str]: 模板路径列表。
|
||||
"""
|
||||
templates = [
|
||||
"python/controller.py.jinja2",
|
||||
"python/service.py.jinja2",
|
||||
"python/crud.py.jinja2",
|
||||
"python/schema.py.jinja2",
|
||||
"python/model.py.jinja2",
|
||||
"python/__init__.py.jinja2",
|
||||
"ts/api.ts.jinja2",
|
||||
"vue/index.vue.jinja2",
|
||||
]
|
||||
return templates
|
||||
|
||||
@classmethod
|
||||
def get_sub_table_template_list(cls):
|
||||
"""获取子表模板列表(仅 model / schema / __init__,不含 controller/service/crud/vue/api)。
|
||||
|
||||
参数:
|
||||
- 无
|
||||
|
||||
返回:
|
||||
- List[str]: 子表模板路径列表。
|
||||
"""
|
||||
return [
|
||||
"python/model.py.jinja2",
|
||||
"python/schema.py.jinja2",
|
||||
"python/__init__.py.jinja2",
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_file_name(cls, template: str, gen_table: GenTableOutSchema):
|
||||
"""根据模板生成文件名。
|
||||
|
||||
参数:
|
||||
- template (str): 模板路径字符串。
|
||||
- gen_table (GenTableOutSchema): 生成表的配置信息。
|
||||
|
||||
返回:
|
||||
- str: 模板生成的文件名。
|
||||
|
||||
异常:
|
||||
- ValueError: 当无法生成有效文件名时抛出。
|
||||
"""
|
||||
package_name = (gen_table.package_name or "").strip()
|
||||
module_name = (gen_table.module_name or "").strip()
|
||||
|
||||
if not package_name:
|
||||
raise ValueError(f"无法为模板 {template} 生成文件名:包名未设置")
|
||||
if not module_name:
|
||||
raise ValueError(f"无法为模板 {template} 生成文件名:模块名未设置")
|
||||
|
||||
# 目录固定为:module_xxx/{module_name}
|
||||
backend_base = f"{cls.BACKEND_PROJECT_PATH}/app/plugin/{package_name}"
|
||||
frontend_view_base = f"{cls.FRONTEND_PROJECT_PATH}/src/views/{package_name}"
|
||||
frontend_api_base = f"{cls.FRONTEND_PROJECT_PATH}/src/api/{package_name}"
|
||||
|
||||
backend_dir = f"{backend_base}/{module_name}"
|
||||
view_dir = f"{frontend_view_base}/{module_name}"
|
||||
api_path = f"{frontend_api_base}/{module_name}.ts"
|
||||
|
||||
template_mapping = {
|
||||
"controller.py.jinja2": f"{backend_dir}/controller.py",
|
||||
"service.py.jinja2": f"{backend_dir}/service.py",
|
||||
"crud.py.jinja2": f"{backend_dir}/crud.py",
|
||||
"schema.py.jinja2": f"{backend_dir}/schema.py",
|
||||
"model.py.jinja2": f"{backend_dir}/model.py",
|
||||
"__init__.py.jinja2": f"{backend_dir}/__init__.py",
|
||||
"api.ts.jinja2": api_path,
|
||||
"index.vue.jinja2": f"{view_dir}/index.vue",
|
||||
}
|
||||
|
||||
# 查找匹配的模板路径
|
||||
for key, path in template_mapping.items():
|
||||
if key in template:
|
||||
return path
|
||||
|
||||
# 遍历完所有映射都没找到匹配项,才抛出异常
|
||||
raise ValueError(f"未找到模板 '{template}' 的路径映射")
|
||||
|
||||
@classmethod
|
||||
def get_package_prefix(cls, package_name: str) -> str:
|
||||
"""获取包前缀。
|
||||
|
||||
参数:
|
||||
- package_name (str): 包名。
|
||||
|
||||
返回:
|
||||
- str: 包前缀。
|
||||
"""
|
||||
# 修复:当包名中不存在'.'时,直接返回原包名
|
||||
return package_name[: package_name.rfind(".")] if "." in package_name else package_name
|
||||
|
||||
@classmethod
|
||||
def get_schema_import_list(cls, gen_table: GenTableOutSchema):
|
||||
"""获取 schema 模板所需的 Python 导入语句集合。
|
||||
|
||||
参数:
|
||||
- gen_table (GenTableOutSchema): 生成表配置(含主子表列)。
|
||||
|
||||
返回:
|
||||
- set[str]: 导入语句字符串集合。
|
||||
"""
|
||||
columns = gen_table.columns or []
|
||||
import_list = set()
|
||||
has_datetime_import = False
|
||||
has_date_import = False
|
||||
has_time_import = False
|
||||
|
||||
for column in columns:
|
||||
# 处理datetime类型的导入
|
||||
if column.python_type and column.python_type in GenConstant.TYPE_DATE:
|
||||
if column.python_type == "datetime":
|
||||
has_datetime_import = True
|
||||
elif column.python_type == "date":
|
||||
has_date_import = True
|
||||
elif column.python_type == "time":
|
||||
has_time_import = True
|
||||
elif column.python_type == GenConstant.TYPE_DECIMAL:
|
||||
import_list.add("from decimal import Decimal")
|
||||
|
||||
if gen_table.sub and gen_table.sub_table and gen_table.sub_table.columns:
|
||||
sub_columns = gen_table.sub_table.columns or []
|
||||
for sub_column in sub_columns:
|
||||
# 处理datetime类型的导入
|
||||
if sub_column.python_type and sub_column.python_type in GenConstant.TYPE_DATE:
|
||||
if sub_column.python_type == "datetime":
|
||||
has_datetime_import = True
|
||||
elif sub_column.python_type == "date":
|
||||
has_date_import = True
|
||||
elif sub_column.python_type == "time":
|
||||
has_time_import = True
|
||||
elif sub_column.python_type == GenConstant.TYPE_DECIMAL:
|
||||
import_list.add("from decimal import Decimal")
|
||||
|
||||
# 添加datetime导入
|
||||
if has_datetime_import:
|
||||
import_list.add("from datetime import datetime")
|
||||
if has_date_import:
|
||||
import_list.add("from datetime import date")
|
||||
if has_time_import:
|
||||
import_list.add("from datetime import time")
|
||||
|
||||
return import_list
|
||||
|
||||
@classmethod
|
||||
def get_model_import_list(cls, gen_table: GenTableOutSchema, *, is_sub_entity: bool = False) -> list[str]:
|
||||
"""获取 model 模板所需的 Python 导入语句列表(含合并后的 sqlalchemy 导入)。
|
||||
|
||||
参数:
|
||||
- gen_table (GenTableOutSchema): 生成表配置。
|
||||
- is_sub_entity (bool): 是否为子表独立生成(含外键与 relationship)。
|
||||
|
||||
返回:
|
||||
- list[str]: 导入语句列表。
|
||||
"""
|
||||
columns = gen_table.columns or []
|
||||
import_list = set()
|
||||
has_datetime_import = False
|
||||
has_date_import = False
|
||||
has_time_import = False
|
||||
|
||||
# 基类 ModelMixin/UserMixin 已定义的列,无需导入 SQLAlchemy 类型
|
||||
_BASE_MODEL_COLUMNS = {
|
||||
"id",
|
||||
"uuid",
|
||||
"created_time",
|
||||
"updated_time",
|
||||
"created_id",
|
||||
"updated_id",
|
||||
"is_deleted",
|
||||
"deleted_time",
|
||||
"deleted_id",
|
||||
}
|
||||
|
||||
for column in columns:
|
||||
if column.column_name in _BASE_MODEL_COLUMNS:
|
||||
continue
|
||||
if column.column_type:
|
||||
data_type = cls.get_db_type(column.column_type)
|
||||
if data_type in GenConstant.COLUMNTYPE_GEOMETRY:
|
||||
import_list.add("from geoalchemy2 import Geometry")
|
||||
import_list.add(f"from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, data_type)}")
|
||||
# 处理datetime类型的导入
|
||||
if column.python_type and column.python_type in GenConstant.TYPE_DATE:
|
||||
if column.python_type == "datetime":
|
||||
has_datetime_import = True
|
||||
elif column.python_type == "date":
|
||||
has_date_import = True
|
||||
elif column.python_type == "time":
|
||||
has_time_import = True
|
||||
# 处理Decimal类型的导入
|
||||
elif column.python_type == GenConstant.TYPE_DECIMAL:
|
||||
import_list.add("from decimal import Decimal")
|
||||
if gen_table.sub or is_sub_entity:
|
||||
import_list.add("from sqlalchemy import ForeignKey")
|
||||
if gen_table.sub and not is_sub_entity and gen_table.sub_table and gen_table.sub_table.columns:
|
||||
sub_columns = gen_table.sub_table.columns or []
|
||||
for sub_column in sub_columns:
|
||||
if sub_column.column_type:
|
||||
data_type = cls.get_db_type(sub_column.column_type)
|
||||
import_list.add(f"from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, data_type)}")
|
||||
# 处理datetime类型的导入
|
||||
if sub_column.python_type and sub_column.python_type in GenConstant.TYPE_DATE:
|
||||
if sub_column.python_type == "datetime":
|
||||
has_datetime_import = True
|
||||
elif sub_column.python_type == "date":
|
||||
has_date_import = True
|
||||
elif sub_column.python_type == "time":
|
||||
has_time_import = True
|
||||
# 处理Decimal类型的导入
|
||||
elif sub_column.python_type == GenConstant.TYPE_DECIMAL:
|
||||
import_list.add("from decimal import Decimal")
|
||||
|
||||
# 添加datetime导入
|
||||
if has_datetime_import:
|
||||
import_list.add("from datetime import datetime")
|
||||
if has_date_import:
|
||||
import_list.add("from datetime import date")
|
||||
if has_time_import:
|
||||
import_list.add("from datetime import time")
|
||||
|
||||
merged = cls.merge_same_imports(list(import_list), "from sqlalchemy import")
|
||||
if gen_table.sub or is_sub_entity:
|
||||
merged.append("from sqlalchemy.orm import relationship")
|
||||
return merged
|
||||
|
||||
@classmethod
|
||||
def get_db_type(cls, column_type: str) -> str:
|
||||
"""获取数据库字段类型。
|
||||
|
||||
参数:
|
||||
- column_type (str): 字段类型字符串。
|
||||
|
||||
返回:
|
||||
- str: 数据库类型(去除长度等修饰)。
|
||||
"""
|
||||
# 移除 COLLATE 子句(处理带引号和不带引号的情况,不区分大小写)
|
||||
collate_pattern = re.compile(r"\s+COLLATE\s+", re.IGNORECASE)
|
||||
if collate_pattern.search(column_type):
|
||||
column_type = collate_pattern.split(column_type)[0].strip()
|
||||
|
||||
# 移除 UNSIGNED 标记(不区分大小写)
|
||||
unsigned_pattern = re.compile(r"\s+UNSIGNED", re.IGNORECASE)
|
||||
if unsigned_pattern.search(column_type):
|
||||
column_type = unsigned_pattern.sub("", column_type).strip()
|
||||
|
||||
# 处理PostgreSQL数组类型(如 integer[], text[])
|
||||
if "[]" in column_type:
|
||||
return "array"
|
||||
|
||||
# 提取基本类型
|
||||
if "(" in column_type:
|
||||
return column_type.split("(")[0]
|
||||
return column_type
|
||||
|
||||
@classmethod
|
||||
def merge_same_imports(cls, imports: list[str], import_start: str) -> list[str]:
|
||||
"""合并相同的导入语句。
|
||||
|
||||
参数:
|
||||
- imports (list[str]): 导入语句列表。
|
||||
- import_start (str): 导入语句的起始字符串。
|
||||
|
||||
返回:
|
||||
- list[str]: 合并后的导入语句列表。
|
||||
"""
|
||||
merged_imports = []
|
||||
imports_ = []
|
||||
for import_stmt in imports:
|
||||
if import_stmt.startswith(import_start):
|
||||
imported_items = import_stmt.split("import")[1].strip()
|
||||
imports_.extend(imported_items.split(", "))
|
||||
else:
|
||||
merged_imports.append(import_stmt)
|
||||
|
||||
if imports_:
|
||||
# 去重并过滤空字符串,然后用逗号连接
|
||||
unique_imports = [item for item in imports_ if item]
|
||||
if len(unique_imports) > 0:
|
||||
merged_datetime_import = f"{import_start} {', '.join(unique_imports)}"
|
||||
merged_imports.append(merged_datetime_import)
|
||||
|
||||
return merged_imports
|
||||
|
||||
@classmethod
|
||||
def get_dicts(cls, gen_table: GenTableOutSchema):
|
||||
"""获取字典列表。
|
||||
|
||||
参数:
|
||||
- gen_table (GenTableOutSchema): 生成表的配置信息。
|
||||
|
||||
返回:
|
||||
- str: 以逗号分隔的字典类型字符串。
|
||||
"""
|
||||
columns = gen_table.columns or []
|
||||
dicts = set()
|
||||
cls.add_dicts(dicts, columns)
|
||||
# 处理sub_table为None的情况
|
||||
if gen_table.sub_table is not None:
|
||||
# 处理sub_table.columns为None的情况
|
||||
sub_columns = gen_table.sub_table.columns or []
|
||||
cls.add_dicts(dicts, sub_columns)
|
||||
return ", ".join(dicts)
|
||||
|
||||
@classmethod
|
||||
def add_dicts(cls, dicts: set[str], columns: list[GenTableColumnOutSchema]) -> None:
|
||||
"""添加字典类型到集合。
|
||||
|
||||
参数:
|
||||
- dicts (set[str]): 字典类型集合。
|
||||
- columns (list[GenTableColumnOutSchema]): 字段列表。
|
||||
|
||||
返回:
|
||||
- set[str]: 更新后的字典类型集合。
|
||||
"""
|
||||
for column in columns:
|
||||
super_column = column.super_column if column.super_column is not None else "0"
|
||||
dict_type = column.dict_type or ""
|
||||
html_type = column.html_type or ""
|
||||
|
||||
if (
|
||||
not super_column
|
||||
and StringUtil.is_not_empty(dict_type)
|
||||
and StringUtil.equals_any_ignore_case(
|
||||
html_type,
|
||||
[
|
||||
GenConstant.HTML_SELECT,
|
||||
GenConstant.HTML_RADIO,
|
||||
GenConstant.HTML_CHECKBOX,
|
||||
],
|
||||
)
|
||||
):
|
||||
dicts.add(f"'{dict_type}'")
|
||||
|
||||
@classmethod
|
||||
def get_permission_prefix(cls, module_name: str | None, business_name: str | None) -> str:
|
||||
"""获取权限前缀。
|
||||
|
||||
参数:
|
||||
- module_name (str | None): 模块名。
|
||||
- business_name (str | None): 业务名。
|
||||
|
||||
返回:
|
||||
- str: 权限前缀字符串。
|
||||
"""
|
||||
mn = (module_name or "").strip()
|
||||
bn = (business_name or "").strip().replace("/", ":")
|
||||
if not bn:
|
||||
return mn
|
||||
return f"{mn}:{bn}"
|
||||
|
||||
@classmethod
|
||||
def python_type_to_ts_type(cls, python_type: str | None) -> str:
|
||||
"""将列上的 Python 类型(`get_db_type` + `DB_TO_PYTHON` 映射结果)转为前端 TS 类型片段。
|
||||
|
||||
与 JSON 序列化习惯一致:Decimal、日期时间多为字符串;dict/list 用宽松类型。
|
||||
|
||||
参数:
|
||||
- python_type (str | None): Python 类型名。
|
||||
|
||||
返回:
|
||||
- str: 前端 TypeScript 类型片段。
|
||||
"""
|
||||
if not python_type or not str(python_type).strip():
|
||||
return "string"
|
||||
p = str(python_type).strip()
|
||||
mapping: dict[str, str] = {
|
||||
"int": "number",
|
||||
"float": "number",
|
||||
"bool": "boolean",
|
||||
"Decimal": "string",
|
||||
"date": "string",
|
||||
"time": "string",
|
||||
"datetime": "string",
|
||||
"timedelta": "string",
|
||||
"dict": "Record<string, unknown>",
|
||||
"list": "unknown[]",
|
||||
"bytes": "string",
|
||||
"str": "string",
|
||||
}
|
||||
return mapping.get(p, "string")
|
||||
|
||||
@classmethod
|
||||
def get_api_route_prefix(cls, module_name: str | None) -> str:
|
||||
"""获取前端 API 路径首段,与 `discover` 中插件路由前缀一致(`module_xxx` → `xxx`)。
|
||||
|
||||
参数:
|
||||
- module_name (str | None): 模块名,如 ``module_example``。
|
||||
|
||||
返回:
|
||||
- str: 路由前缀,如 ``example``。
|
||||
"""
|
||||
if not module_name:
|
||||
return ""
|
||||
if module_name.startswith("module_"):
|
||||
return module_name[7:]
|
||||
return module_name
|
||||
|
||||
@classmethod
|
||||
def get_sqlalchemy_type(cls, column: Any) -> str:
|
||||
"""获取 SQLAlchemy 类型。
|
||||
|
||||
参数:
|
||||
- column (Any): 列对象或列类型字符串。
|
||||
|
||||
返回:
|
||||
- str: SQLAlchemy 类型字符串。
|
||||
"""
|
||||
# 获取column_type和column_length
|
||||
column_type = column
|
||||
column_length = None
|
||||
|
||||
# 检查是否是对象
|
||||
if hasattr(column, "column_type"):
|
||||
column_type = column.column_type or ""
|
||||
column_length = column.column_length or None
|
||||
|
||||
column_type = cls.normalize_db_column_type_for_mapping(column_type)
|
||||
|
||||
# MySQL:仅 tinyint(1) 映射为 Boolean;其余 tinyint 走 SmallInteger(见 GenConstant.DB_TO_SQLALCHEMY)
|
||||
ct_norm = (column_type or "").strip()
|
||||
if settings.DATABASE_TYPE != "postgres" and ct_norm:
|
||||
ct_lower = ct_norm.lower()
|
||||
if ct_lower.startswith("tinyint(1)"):
|
||||
return "Boolean"
|
||||
|
||||
# 首先尝试匹配完整类型(包括括号)
|
||||
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, column_type)
|
||||
|
||||
# 特殊处理PostgreSQL类型
|
||||
if settings.DATABASE_TYPE == "postgres":
|
||||
if column_type.upper() == "BOOLEAN":
|
||||
return "Boolean"
|
||||
if column_type.upper() == "REAL" or column_type.upper() == "DOUBLE PRECISION":
|
||||
return "Float"
|
||||
if column_type.upper() == "TIMESTAMP":
|
||||
return "DateTime"
|
||||
if column_type.upper() == "JSONB":
|
||||
return "JSONB"
|
||||
if column_type.upper() == "UUID":
|
||||
return "Uuid"
|
||||
if column_type.upper() == "BYTEA":
|
||||
return "LargeBinary"
|
||||
|
||||
# get_mapping_value_by_key_ignore_case 未命中时返回 "",须与 None 同样视为未匹配
|
||||
if not sqlalchemy_type and "(" in column_type:
|
||||
# 如果没有匹配到,再尝试剥离括号
|
||||
column_type_list = column_type.split("(")
|
||||
col_type = column_type_list[0]
|
||||
# 将 'character' 映射为 'char' 以匹配常量定义
|
||||
if col_type.lower() == "character":
|
||||
col_type = "char"
|
||||
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, col_type)
|
||||
# 如果是字符串类型且包含括号参数,保持原参数
|
||||
if sqlalchemy_type in ["String", "CHAR"] or sqlalchemy_type in ["Numeric", "DECIMAL"]:
|
||||
sqlalchemy_type += "(" + column_type_list[1]
|
||||
elif not sqlalchemy_type:
|
||||
# 处理没有括号的类型
|
||||
col_type = column_type
|
||||
# 将 'character' 映射为 'char' 以匹配常量定义
|
||||
if col_type.lower() == "character":
|
||||
col_type = "char"
|
||||
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, col_type)
|
||||
# 如果是字符串类型且没有指定长度,使用column_length或默认255
|
||||
if sqlalchemy_type in ["String", "CHAR"]:
|
||||
length = column_length if column_length and column_length.isdigit() else "255"
|
||||
sqlalchemy_type += f"({length})"
|
||||
# 对于已经匹配到的类型,如果是字符串类型且column有长度信息,添加长度
|
||||
elif sqlalchemy_type in ["String", "CHAR"] and "(" not in sqlalchemy_type:
|
||||
# 检查column_length是否有效
|
||||
length = column_length if column_length and column_length.isdigit() else "255"
|
||||
sqlalchemy_type += f"({length})"
|
||||
|
||||
# 如果没有找到匹配的类型,使用String(column_length)或String(255)作为默认类型
|
||||
if not sqlalchemy_type:
|
||||
length = column_length if column_length and column_length.isdigit() else "255"
|
||||
sqlalchemy_type = f"String({length})"
|
||||
return sqlalchemy_type
|
||||
@@ -0,0 +1,88 @@
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
from sqlalchemy.sql import expression
|
||||
|
||||
from app.config.setting import settings
|
||||
from app.core.base_model import ModelMixin, UserMixin
|
||||
from app.utils.common_util import SqlalchemyUtil
|
||||
|
||||
|
||||
class GenTableModel(ModelMixin, UserMixin):
|
||||
"""代码生成表
|
||||
"""
|
||||
|
||||
__tablename__: str = "gen_table"
|
||||
__table_args__: dict[str, str] = {"comment": "代码生成表"}
|
||||
|
||||
table_name: Mapped[str] = mapped_column(String(200), nullable=False, default="", index=True, comment="表名称")
|
||||
table_comment: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="表描述")
|
||||
class_name: Mapped[str] = mapped_column(String(100), nullable=False, default="", comment="实体类名称")
|
||||
package_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="生成包路径")
|
||||
module_name: Mapped[str | None] = mapped_column(String(30), nullable=True, comment="生成模块名")
|
||||
business_name: Mapped[str | None] = mapped_column(String(30), nullable=True, comment="生成业务名")
|
||||
function_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="生成功能名")
|
||||
sub_table_name: Mapped[str | None] = mapped_column(String(64), nullable=True, server_default=SqlalchemyUtil.get_server_default_null(settings.DATABASE_TYPE), comment="关联子表的表名")
|
||||
sub_table_fk_name: Mapped[str | None] = mapped_column(String(64), nullable=True, server_default=SqlalchemyUtil.get_server_default_null(settings.DATABASE_TYPE), comment="子表关联的外键名")
|
||||
parent_menu_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="父菜单ID")
|
||||
columns: Mapped[list["GenTableColumnModel"]] = relationship(order_by="GenTableColumnModel.sort", back_populates="table", cascade="all, delete-orphan")
|
||||
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)")
|
||||
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
|
||||
|
||||
@validates("table_name")
|
||||
def validate_table_name(self, key: str, table_name: str) -> str:
|
||||
"""验证表名非空并去首尾空格。"""
|
||||
if not table_name or not table_name.strip():
|
||||
raise ValueError("表名称不能为空")
|
||||
return table_name.strip()
|
||||
|
||||
@validates("class_name")
|
||||
def validate_class_name(self, key: str, class_name: str) -> str:
|
||||
"""验证实体类名非空并去首尾空格。"""
|
||||
if not class_name or not class_name.strip():
|
||||
raise ValueError("实体类名称不能为空")
|
||||
return class_name.strip()
|
||||
|
||||
|
||||
class GenTableColumnModel(ModelMixin, UserMixin):
|
||||
"""代码生成表字段"""
|
||||
|
||||
__tablename__: str = "gen_table_column"
|
||||
__table_args__: dict[str, str] = {"comment": "代码生成表字段"}
|
||||
|
||||
column_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True, comment="列名称")
|
||||
column_comment: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="列描述")
|
||||
column_type: Mapped[str] = mapped_column(String(100), nullable=False, comment="列类型")
|
||||
column_length: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="列长度")
|
||||
column_default: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="列默认值")
|
||||
is_pk: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=expression.false(), comment="是否主键")
|
||||
is_increment: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=expression.false(), comment="是否自增")
|
||||
is_nullable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=expression.true(), comment="是否允许为空")
|
||||
is_unique: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=expression.false(), comment="是否唯一")
|
||||
python_type: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="Python类型")
|
||||
python_field: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="Python字段名")
|
||||
is_insert: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=expression.true(), comment="是否为新增字段")
|
||||
is_edit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=expression.true(), comment="是否编辑字段")
|
||||
is_list: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=expression.true(), comment="是否列表字段")
|
||||
is_query: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=expression.false(), comment="是否查询字段")
|
||||
query_type: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None, comment="查询方式")
|
||||
html_type: Mapped[str | None] = mapped_column(String(100), nullable=True, default="input", comment="前端显示类型")
|
||||
dict_type: Mapped[str | None] = mapped_column(String(200), nullable=True, default="", comment="前端对应字典类型")
|
||||
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序")
|
||||
table_id: Mapped[int] = mapped_column(Integer, ForeignKey("gen_table.id", ondelete="CASCADE"), nullable=False, index=True, comment="归属表编号")
|
||||
table: Mapped["GenTableModel"] = relationship(back_populates="columns")
|
||||
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)")
|
||||
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
|
||||
|
||||
@validates("column_name")
|
||||
def validate_column_name(self, key: str, column_name: str) -> str:
|
||||
"""验证列名非空并去首尾空格。"""
|
||||
if not column_name or not column_name.strip():
|
||||
raise ValueError("列名称不能为空")
|
||||
return column_name.strip()
|
||||
|
||||
@validates("column_type")
|
||||
def validate_column_type(self, key: str, column_type: str) -> str:
|
||||
"""验证列类型非空并去首尾空格。"""
|
||||
if not column_type or not column_type.strip():
|
||||
raise ValueError("列类型不能为空")
|
||||
return column_type.strip()
|
||||
@@ -0,0 +1,292 @@
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
|
||||
|
||||
|
||||
class GenDBTableSchema(BaseModel):
|
||||
"""数据库中的表信息(跨方言统一结构)。
|
||||
- 供“导入表结构”与“同步结构”环节使用。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
database_name: str | None = Field(default=None, description="数据库名称")
|
||||
table_name: str | None = Field(default=None, description="表名称")
|
||||
table_type: str | None = Field(default=None, description="表类型")
|
||||
table_comment: str | None = Field(default=None, description="表描述")
|
||||
|
||||
|
||||
class GenCreateTableSqlBody(BaseModel):
|
||||
"""从代码生成页提交的建表 SQL(JSON 对象,便于与前端 axios 一致)。"""
|
||||
|
||||
sql: str = Field(..., description="CREATE TABLE 等 DDL,可多条语句")
|
||||
|
||||
|
||||
class GenTableColumnSchema(BaseModel):
|
||||
"""代码生成业务表字段创建模型(原始字段+生成配置)。
|
||||
- 从根本上解决问题:所有字段都设置了合理的默认值,避免None值问题
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
table_id: int = Field(default=0, description="归属表编号")
|
||||
column_name: str = Field(default="", description="列名称")
|
||||
column_comment: str | None = Field(default="", description="列描述")
|
||||
column_type: str = Field(default="varchar(255)", description="列类型")
|
||||
column_length: str | None = Field(default="", description="列长度")
|
||||
column_default: str | None = Field(default="", description="列默认值")
|
||||
is_pk: bool = Field(default=False, description="是否主键(True是 False否)")
|
||||
is_increment: bool = Field(default=False, description="是否自增(True是 False否)")
|
||||
is_nullable: bool = Field(default=True, description="是否允许为空(True是 False否)")
|
||||
is_unique: bool = Field(default=False, description="是否唯一(True是 False否)")
|
||||
python_type: str = Field(default="str", description="python类型")
|
||||
python_field: str = Field(default="", description="python字段名")
|
||||
# 这些开关若默认 True,会导致导入/同步时无法触发自动推断(如主键 id 误入新增/编辑/列表/查询)
|
||||
# 约定:None 表示“未配置”,由 GenUtils.init_column_field 推断填充
|
||||
is_insert: bool | None = Field(default=None, description="是否为新增字段(True是 False否)")
|
||||
is_edit: bool | None = Field(default=None, description="是否编辑字段(True是 False否)")
|
||||
is_list: bool | None = Field(default=None, description="是否列表字段(True是 False否)")
|
||||
is_query: bool | None = Field(default=None, description="是否查询字段(True是 False否)")
|
||||
query_type: str | None = Field(default=None, description="查询方式(等于、不等于、大于、小于、范围)")
|
||||
# html_type 若给默认值会导致导入/同步时无法触发自动推断(全部变成 input)
|
||||
# 约定:None 表示“未配置”,由 GenUtils.init_column_field 推断填充
|
||||
html_type: str | None = Field(
|
||||
default=None,
|
||||
description="显示类型(文本框、文本域、下拉框、复选框、单选框、日期控件)",
|
||||
)
|
||||
dict_type: str | None = Field(default="", description="字典类型")
|
||||
sort: int = Field(default=0, description="排序")
|
||||
|
||||
|
||||
class GenTableColumnOutSchema(GenTableColumnSchema, BaseSchema):
|
||||
"""业务表字段输出模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
super_column: str | None = Field(default="0", description="是否为基类字段(1是 0否)")
|
||||
|
||||
|
||||
class GenTableSchema(BaseModel):
|
||||
"""代码生成业务表更新模型(扩展聚合字段)。
|
||||
- 聚合:`columns`字段包含字段列表;`pk_column`主键字段;子表结构`sub_table`。
|
||||
"""
|
||||
|
||||
"""代码生成业务表基础模型(创建/更新共享字段)。
|
||||
- 说明:`params`为前端结构体,后端持久化为`options`的JSON。
|
||||
"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
table_name: str = Field(..., description="表名称")
|
||||
table_comment: str | None = Field(default=None, description="表描述")
|
||||
class_name: str | None = Field(default=None, description="实体类名称")
|
||||
package_name: str | None = Field(default=None, description="生成包路径")
|
||||
module_name: str | None = Field(default=None, description="生成模块名")
|
||||
business_name: str | None = Field(
|
||||
default=None,
|
||||
description=("功能子目录/路由段;导入时默认表名;同 module_name 下多表须不同。可含斜杠表示嵌套,参考 module_example:demo、demo/subdir、gen_demo。"),
|
||||
)
|
||||
function_name: str | None = Field(default=None, description="生成功能名")
|
||||
sub_table_name: str | None = Field(default=None, description="关联子表的表名")
|
||||
sub_table_fk_name: str | None = Field(default=None, description="子表关联的外键名")
|
||||
parent_menu_id: int | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"写入本地须选目录类型。有值:侧栏 上级/短包名/功能/按钮,页面路由 /包名/业务名。"
|
||||
"留空:侧栏 module_包名/功能/按钮,页面路由 /module_包名/业务名(与 plugin 一致);"
|
||||
"后端 HTTP 接口仍为 /短名(module_xxx→/xxx)。"
|
||||
),
|
||||
)
|
||||
description: str | None = Field(default=None, max_length=255, description="描述")
|
||||
|
||||
columns: list["GenTableColumnOutSchema"] | None = Field(default=None, description="表列信息")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_slug_segment(v: str, *, allow_slash: bool = False) -> str:
|
||||
"""将输入规范成工程约定的路径片段:
|
||||
- 小写
|
||||
- 只保留 a-z 0-9 _
|
||||
- 连续 _ 合并
|
||||
- 首字符不能是数字(则前置 _)
|
||||
- allow_slash=True 时允许多段 path(每段分别规范)
|
||||
"""
|
||||
raw = (v or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if allow_slash:
|
||||
segs = [s for s in raw.strip("/").split("/") if s.strip()]
|
||||
norm = [GenTableSchema._normalize_slug_segment(s, allow_slash=False) for s in segs]
|
||||
norm = [s for s in norm if s]
|
||||
return "/".join(norm)
|
||||
s = raw.lower()
|
||||
s = re.sub(r"[^a-z0-9_]+", "_", s)
|
||||
s = re.sub(r"_+", "_", s).strip("_")
|
||||
if not s:
|
||||
return ""
|
||||
if s[0].isdigit():
|
||||
s = "_" + s
|
||||
return s
|
||||
|
||||
@field_validator("table_name")
|
||||
@classmethod
|
||||
def table_name_update(cls, v: str) -> str:
|
||||
"""校验并规范化表名称。
|
||||
|
||||
参数:
|
||||
- v (str): 原始表名。
|
||||
|
||||
返回:
|
||||
- str: 去空白后的表名。
|
||||
|
||||
异常:
|
||||
- ValueError: 表名为空时抛出。
|
||||
"""
|
||||
if not v:
|
||||
raise ValueError("表名称不能为空")
|
||||
return v.strip()
|
||||
|
||||
@field_validator(
|
||||
"table_comment",
|
||||
"class_name",
|
||||
"function_name",
|
||||
"description",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def strip_optional_text_fields(cls, v: str | None) -> str | None:
|
||||
"""文本类字段统一去首尾空格;空串视为 None。
|
||||
|
||||
参数:
|
||||
- v (str | None): 原始值。
|
||||
|
||||
返回:
|
||||
- str | None: 非空字符串或 None。
|
||||
"""
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
return s if s else None
|
||||
|
||||
@field_validator("package_name", mode="before")
|
||||
@classmethod
|
||||
def normalize_package_name(cls, v: str | None) -> str | None:
|
||||
"""包名规范:必须是 module_xxx 形态(工程约定)。
|
||||
|
||||
参数:
|
||||
- v (str | None): 原始包名。
|
||||
|
||||
返回:
|
||||
- str | None: 规范化后的包名或 None。
|
||||
"""
|
||||
if v is None:
|
||||
return None
|
||||
s = cls._normalize_slug_segment(str(v), allow_slash=False)
|
||||
if not s:
|
||||
return None
|
||||
return s if s.startswith("module_") else f"module_{s}"
|
||||
|
||||
@field_validator("module_name", mode="before")
|
||||
@classmethod
|
||||
def normalize_module_name(cls, v: str | None) -> str | None:
|
||||
"""模块名规范:不带 module_ 前缀;统一按 slug 规范。
|
||||
|
||||
参数:
|
||||
- v (str | None): 原始模块名。
|
||||
|
||||
返回:
|
||||
- str | None: 规范化后的模块名或 None。
|
||||
"""
|
||||
if v is None:
|
||||
return None
|
||||
s = cls._normalize_slug_segment(str(v), allow_slash=False)
|
||||
if not s:
|
||||
return None
|
||||
return s.removeprefix("module_")
|
||||
|
||||
@field_validator("business_name", mode="before")
|
||||
@classmethod
|
||||
def normalize_business_name(cls, v: str | None) -> str | None:
|
||||
"""业务名允许多段(如 demo/subdir);统一按 slug 规范。
|
||||
|
||||
参数:
|
||||
- v (str | None): 原始业务名。
|
||||
|
||||
返回:
|
||||
- str | None: 规范化后的业务名或 None。
|
||||
"""
|
||||
if v is None:
|
||||
return None
|
||||
s = cls._normalize_slug_segment(str(v), allow_slash=True)
|
||||
return s if s else None
|
||||
|
||||
@field_validator("sub_table_name", "sub_table_fk_name", mode="before")
|
||||
@classmethod
|
||||
def strip_optional_sub_fields(cls, v: str | None) -> str | None:
|
||||
"""主子表字段去首尾空格,空串视为未填。
|
||||
|
||||
参数:
|
||||
- v (str | None): 原始值。
|
||||
|
||||
返回:
|
||||
- str | None: 非空字符串或 None。
|
||||
"""
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
return s if s else None
|
||||
|
||||
|
||||
class GenTableOutSchema(GenTableSchema, BaseSchema, UserBySchema):
|
||||
"""业务表输出模型(面向控制器/前端)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
pk_column: GenTableColumnOutSchema | None = Field(default=None, description="主键信息")
|
||||
# 子表同样需要携带 columns/pk_column 等输出字段,使用 OutSchema 便于模板与类型检查
|
||||
sub_table: "GenTableOutSchema | None" = Field(default=None, description="子表信息")
|
||||
sub: bool | None = Field(default=None, description="是否为子表")
|
||||
master_sub_hint: str | None = Field(
|
||||
default=None,
|
||||
description="主子表配置说明或异常提示(仅接口输出,不落库)",
|
||||
)
|
||||
|
||||
|
||||
class GenSyncColumnChange(BaseModel):
|
||||
"""同步差异:单个字段的变化项(用于预览,不落库)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
column_name: str = Field(..., description="列名")
|
||||
change_fields: list[str] = Field(default_factory=list, description="变化字段名列表")
|
||||
before: dict = Field(default_factory=dict, description="同步前(当前 gen 配置)摘要")
|
||||
after: dict = Field(default_factory=dict, description="同步后(来自 DB)摘要")
|
||||
|
||||
|
||||
class GenSyncPreviewSchema(BaseModel):
|
||||
"""同步数据库前的差异预览(主表 + 可选子表)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
table_name: str = Field(..., description="表名")
|
||||
added: list[str] = Field(default_factory=list, description="新增列(DB有、gen无)")
|
||||
removed: list[str] = Field(default_factory=list, description="删除列(gen有、DB无)")
|
||||
changed: list[GenSyncColumnChange] = Field(default_factory=list, description="变更列(同名但属性变化)")
|
||||
unchanged: int = Field(default=0, description="未变化列数(同名且关键属性一致)")
|
||||
|
||||
sub_table_name: str | None = Field(default=None, description="子表表名")
|
||||
sub: "GenSyncPreviewSchema | None" = Field(default=None, description="子表差异(若配置了主子表)")
|
||||
|
||||
|
||||
class GenTableQueryParam(BaseQueryParam, UserByQueryParam):
|
||||
"""代码生成业务表查询参数
|
||||
- 支持按`table_name`、`table_comment`进行模糊检索(由CRUD层实现like)。
|
||||
- 空值将被忽略,不参与过滤。
|
||||
"""
|
||||
|
||||
table_name: str | None = Field(None, description="表名称", json_schema_extra={"q": "like"})
|
||||
table_comment: str | None = Field(None, description="表注释", json_schema_extra={"q": "like"})
|
||||
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)", json_schema_extra={"q": "eq"})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user