feat: delete ps node #20

Merged
ivan.dev merged 6 commits from VORKOUT-29 into master 2025-11-05 14:28:51 +05:00
10 changed files with 433 additions and 24 deletions
Showing only changes of commit 42741f4d98 - Show all commits

View File

@@ -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'])

View File

@@ -2,7 +2,7 @@ from typing import Optional, List
from datetime import datetime, timezone 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 sqlalchemy.ext.asyncio import AsyncConnection
from orm.tables.process import ps_node_table, node_link_table, process_schema_table 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 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 = [] all_child_nodes = []
visited_nodes = set() visited_nodes = set()
# тут надо будет поработать с зацикливанием на вышестояющую ноду, сейчас вышестоящая нода если она уже была учтена, не будет занесена в спиок на удаление.
async def find_children_with_depth(current_node_id: int, current_depth: int): async def find_children_with_depth(current_node_id: int, current_depth: int):
if current_node_id in visited_nodes: if current_node_id in visited_nodes:
return 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, current_depth + 1)
await find_children_with_depth(node_id, 0) await find_children_with_depth(node_id, 0)
return all_child_nodes
all_child_nodes.sort(key=lambda x: x[1], reverse=True)
ivan.dev marked this conversation as resolved Outdated

Там FK у node_link на node_id (т.е. нужно удалять node_link раньше node_id, но позже next_node_id), есть у меня сомнение, что такое упорядочивание здесь реализовано.

Тут можно было после размышлений поступить проще - удалять Узлы прямым запросом через алхимию с JOIN'ами по связям, тогда можно не париться с упорядочиванием.

Второй вариант - сделать ON DELETE CASCADE в определении FK на node_link'е.

Там FK у node_link на node_id (т.е. нужно удалять node_link раньше node_id, но позже next_node_id), есть у меня сомнение, что такое упорядочивание здесь реализовано. Тут можно было после размышлений поступить проще - удалять Узлы прямым запросом через алхимию с JOIN'ами по связям, тогда можно не париться с упорядочиванием. Второй вариант - сделать ON DELETE CASCADE в определении FK на node_link'е.

Ещё момент. Циклы у нас могут быть, когда замыкающая цикл нода ссылается куда-то "наверх".

Это решается маркировкой уровня "иерархии" у нод и последующей проверкой перехода по уровням (вниз - норм, а если вверх - не трогаем ноду, на которую ссылаются), пока можно не реализовывать, т.к. до циклов надо реально ещё дожить, но момент нужно держать в уме.

Ещё момент. Циклы у нас могут быть, когда замыкающая цикл нода ссылается куда-то "наверх". Это решается маркировкой уровня "иерархии" у нод и последующей проверкой перехода по уровням (вниз - норм, а если вверх - не трогаем ноду, на которую ссылаются), пока можно не реализовывать, т.к. до циклов надо реально ещё дожить, но момент нужно держать в уме.
async def get_nodes_for_deletion_ordered(connection: AsyncConnection, node_id: int) -> List[int]: ordered_node_ids = [node.id for node, depth in all_child_nodes]
"""
Возвращает список 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.append(node_id) ordered_node_ids.append(node_id)
return ordered_node_ids 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: try:
node_query = select(ps_node_table).where(ps_node_table.c.id == node_id) 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"] 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) 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)) 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) return False, str(e)
async def delete_ps_nodes_sequentially_with_error_handling( async def delete_ps_nodes_delete_handler(connection: AsyncConnection, node_ids: List[int]) -> List[int]:
connection: AsyncConnection, node_ids: List[int]
) -> List[int]:
""" """
Поочередно удаляет узлы из базы данных. Поочередно удаляет узлы из базы данных.
Возвращает список успешно удаленных ID узлов. Возвращает список успешно удаленных ID узлов.
Выбрасывает исключение при первой ошибке.
""" """
successfully_deleted = [] successfully_deleted = []
for node_id in node_ids: 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: if success:
successfully_deleted.append(node_id) successfully_deleted.append(node_id)
else: else:
raise Exception(f"Failed to delete node {node_id}: {error_message}") raise Exception(f"Failed to delete node {node_id}: {error_message}")
return successfully_deleted return successfully_deleted

View File

@@ -17,7 +17,7 @@ from api.db.logic.ps_node import (
get_ps_node_by_id, get_ps_node_by_id,
check_node_connection, check_node_connection,
get_nodes_for_deletion_ordered, 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 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) ordered_node_ids = await get_nodes_for_deletion_ordered(connection, ps_node_delete_data.next_node_id)
try: 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: except Exception as e:
raise create_server_error( raise create_server_error(
message="Failed to delete nodes", message="Failed to delete nodes",

View File

@@ -22,6 +22,7 @@ class BaseError(BaseModel):
""" """
Базовая модель ошибки. Базовая модель ошибки.
""" """
error_type: ErrorType error_type: ErrorType
message: str message: str
details: Optional[Dict[str, Any]] = None details: Optional[Dict[str, Any]] = None
@@ -31,6 +32,7 @@ class ServerError(BaseError):
""" """
Критические серверные ошибки (БД, соединения и прочие неприятности). Критические серверные ошибки (БД, соединения и прочие неприятности).
""" """
error_type: ErrorType = ErrorType.SERVER error_type: ErrorType = ErrorType.SERVER
@@ -38,6 +40,7 @@ class AccessError(BaseError):
""" """
Ошибки доступа (несоответствие тенантности, ролям доступа). Ошибки доступа (несоответствие тенантности, ролям доступа).
""" """
error_type: ErrorType = ErrorType.ACCESS error_type: ErrorType = ErrorType.ACCESS
@@ -45,6 +48,7 @@ class OperationError(BaseError):
""" """
Ошибки операции (несоответствие прохождению верификации, ошибки в датасете). Ошибки операции (несоответствие прохождению верификации, ошибки в датасете).
""" """
error_type: ErrorType = ErrorType.OPERATION error_type: ErrorType = ErrorType.OPERATION
@@ -52,5 +56,6 @@ class ValidationError(BaseError):
""" """
Ошибки валидации (несоответствие первичной валидации). Ошибки валидации (несоответствие первичной валидации).
""" """
error_type: ErrorType = ErrorType.VALIDATION error_type: ErrorType = ErrorType.VALIDATION
field_errors: Optional[Dict[str, str]] = None field_errors: Optional[Dict[str, str]] = None