From 42741f4d98c751386cd5c4ca865348258973e830 Mon Sep 17 00:00:00 2001 From: TheNoxium Date: Tue, 28 Oct 2025 14:30:27 +0500 Subject: [PATCH] fix: delete ps node CASCADE --- ...dd_cascade_delete_to_node_link_foreign_.py | 52 +++++++++++++++++++ api/api/db/logic/ps_node.py | 39 ++++---------- api/api/endpoints/ps_node.py | 4 +- api/api/error/error_model/error_types.py | 5 ++ 4 files changed, 70 insertions(+), 30 deletions(-) create mode 100644 api/api/db/alembic/versions/80840e78631e_add_cascade_delete_to_node_link_foreign_.py diff --git a/api/api/db/alembic/versions/80840e78631e_add_cascade_delete_to_node_link_foreign_.py b/api/api/db/alembic/versions/80840e78631e_add_cascade_delete_to_node_link_foreign_.py new file mode 100644 index 0000000..935900f --- /dev/null +++ b/api/api/db/alembic/versions/80840e78631e_add_cascade_delete_to_node_link_foreign_.py @@ -0,0 +1,52 @@ +"""add_cascade_delete_to_node_link_foreign_keys + +Revision ID: 80840e78631e +Revises: cc3b95f1f99d +Create Date: 2025-10-26 18:47:24.004327 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '80840e78631e' +down_revision: Union[str, None] = 'cc3b95f1f99d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Drop existing foreign key constraints + # Note: These constraint names are MySQL auto-generated names + # If they differ, check with: SHOW CREATE TABLE node_link; + op.drop_constraint('node_link_ibfk_2', 'node_link', type_='foreignkey') # next_node_id + op.drop_constraint('node_link_ibfk_3', 'node_link', type_='foreignkey') # node_id + + # Add new foreign key constraints with CASCADE + op.create_foreign_key( + 'fk_node_link_next_node_id_cascade', + 'node_link', 'ps_node', + ['next_node_id'], ['id'], + ondelete='CASCADE' + ) + op.create_foreign_key( + 'fk_node_link_node_id_cascade', + 'node_link', 'ps_node', + ['node_id'], ['id'], + ondelete='CASCADE' + ) + + +def downgrade() -> None: + """Downgrade schema.""" + # Drop CASCADE foreign key constraints + op.drop_constraint('fk_node_link_next_node_id_cascade', 'node_link', type_='foreignkey') + op.drop_constraint('fk_node_link_node_id_cascade', 'node_link', type_='foreignkey') + + # Restore original foreign key constraints without CASCADE + op.create_foreign_key('node_link_ibfk_2', 'node_link', 'ps_node', ['next_node_id'], ['id']) + op.create_foreign_key('node_link_ibfk_3', 'node_link', 'ps_node', ['node_id'], ['id']) diff --git a/api/api/db/logic/ps_node.py b/api/api/db/logic/ps_node.py index 1b38b72..3ef2e8e 100644 --- a/api/api/db/logic/ps_node.py +++ b/api/api/db/logic/ps_node.py @@ -2,7 +2,7 @@ from typing import Optional, List from datetime import datetime, timezone -from sqlalchemy import insert, select, desc, and_, or_, delete, update +from sqlalchemy import insert, select, desc, and_, delete, update from sqlalchemy.ext.asyncio import AsyncConnection from orm.tables.process import ps_node_table, node_link_table, process_schema_table @@ -89,13 +89,15 @@ async def check_node_connection(connection: AsyncConnection, node_id: int, next_ return result.mappings().first() is not None -async def get_all_child_nodes_with_depth(connection: AsyncConnection, node_id: int) -> List[tuple[Ps_Node, int]]: +async def get_nodes_for_deletion_ordered(connection: AsyncConnection, node_id: int) -> List[int]: """ - Рекурсивно находит ВСЕ дочерние узлы с их уровнем вложенности. + Рекурсивно находит ВСЕ дочерние узлы и возвращает их ID в правильном порядке: + от самых глубоких к корневым. """ all_child_nodes = [] visited_nodes = set() + # тут надо будет поработать с зацикливанием на вышестояющую ноду, сейчас вышестоящая нода если она уже была учтена, не будет занесена в спиок на удаление. async def find_children_with_depth(current_node_id: int, current_depth: int): if current_node_id in visited_nodes: return @@ -117,28 +119,19 @@ async def get_all_child_nodes_with_depth(connection: AsyncConnection, node_id: i await find_children_with_depth(node.id, current_depth + 1) await find_children_with_depth(node_id, 0) - return all_child_nodes + all_child_nodes.sort(key=lambda x: x[1], reverse=True) -async def get_nodes_for_deletion_ordered(connection: AsyncConnection, node_id: int) -> List[int]: - """ - Возвращает список ID узлов для удаления в правильном порядке: - от самых последних к первым. - """ - child_nodes_with_depth = await get_all_child_nodes_with_depth(connection, node_id) - - child_nodes_with_depth.sort(key=lambda x: x[1], reverse=True) - - ordered_node_ids = [node.id for node, depth in child_nodes_with_depth] + ordered_node_ids = [node.id for node, depth in all_child_nodes] ordered_node_ids.append(node_id) return ordered_node_ids -async def delete_ps_node_by_id_completely(connection: AsyncConnection, node_id: int) -> tuple[bool, str]: +async def delete_ps_node_by_id_CASCADE(connection: AsyncConnection, node_id: int) -> tuple[bool, str]: """ - Полностью удаляет узел из базы данных по ID. + Полностью удаляет узел из базы данных по ID - ON DELETE CASCADE. """ try: node_query = select(ps_node_table).where(ps_node_table.c.id == node_id) @@ -150,12 +143,6 @@ async def delete_ps_node_by_id_completely(connection: AsyncConnection, node_id: ps_id = node_data["ps_id"] - await connection.execute( - delete(node_link_table).where( - or_(node_link_table.c.node_id == node_id, node_link_table.c.next_node_id == node_id) - ) - ) - await remove_node_from_process_schema_settings(connection, ps_id, node_id) result = await connection.execute(delete(ps_node_table).where(ps_node_table.c.id == node_id)) @@ -172,23 +159,19 @@ async def delete_ps_node_by_id_completely(connection: AsyncConnection, node_id: return False, str(e) -async def delete_ps_nodes_sequentially_with_error_handling( - connection: AsyncConnection, node_ids: List[int] -) -> List[int]: +async def delete_ps_nodes_delete_handler(connection: AsyncConnection, node_ids: List[int]) -> List[int]: """ Поочередно удаляет узлы из базы данных. Возвращает список успешно удаленных ID узлов. - Выбрасывает исключение при первой ошибке. """ successfully_deleted = [] for node_id in node_ids: - success, error_message = await delete_ps_node_by_id_completely(connection, node_id) + success, error_message = await delete_ps_node_by_id_CASCADE(connection, node_id) if success: successfully_deleted.append(node_id) else: raise Exception(f"Failed to delete node {node_id}: {error_message}") - return successfully_deleted diff --git a/api/api/endpoints/ps_node.py b/api/api/endpoints/ps_node.py index 787497e..bfe0944 100644 --- a/api/api/endpoints/ps_node.py +++ b/api/api/endpoints/ps_node.py @@ -17,7 +17,7 @@ from api.db.logic.ps_node import ( get_ps_node_by_id, check_node_connection, get_nodes_for_deletion_ordered, - delete_ps_nodes_sequentially_with_error_handling, + delete_ps_nodes_delete_handler, ) from api.db.logic.node_link import get_last_link_name_by_node_id, create_node_link_schema @@ -98,7 +98,7 @@ async def delete_ps_node_endpoint( ordered_node_ids = await get_nodes_for_deletion_ordered(connection, ps_node_delete_data.next_node_id) try: - deleted_node_ids = await delete_ps_nodes_sequentially_with_error_handling(connection, ordered_node_ids) + deleted_node_ids = await delete_ps_nodes_delete_handler(connection, ordered_node_ids) except Exception as e: raise create_server_error( message="Failed to delete nodes", diff --git a/api/api/error/error_model/error_types.py b/api/api/error/error_model/error_types.py index 92c9bb2..ee32422 100644 --- a/api/api/error/error_model/error_types.py +++ b/api/api/error/error_model/error_types.py @@ -22,6 +22,7 @@ class BaseError(BaseModel): """ Базовая модель ошибки. """ + error_type: ErrorType message: str details: Optional[Dict[str, Any]] = None @@ -31,6 +32,7 @@ class ServerError(BaseError): """ Критические серверные ошибки (БД, соединения и прочие неприятности). """ + error_type: ErrorType = ErrorType.SERVER @@ -38,6 +40,7 @@ class AccessError(BaseError): """ Ошибки доступа (несоответствие тенантности, ролям доступа). """ + error_type: ErrorType = ErrorType.ACCESS @@ -45,6 +48,7 @@ class OperationError(BaseError): """ Ошибки операции (несоответствие прохождению верификации, ошибки в датасете). """ + error_type: ErrorType = ErrorType.OPERATION @@ -52,5 +56,6 @@ class ValidationError(BaseError): """ Ошибки валидации (несоответствие первичной валидации). """ + error_type: ErrorType = ErrorType.VALIDATION field_errors: Optional[Dict[str, str]] = None