# Importações de bibliotecas padrão from typing import Generic, TypeVar, Dict, Type, Union, Any, List from uuid import UUID import copy # Importações de bibliotecas de terceiros from sqlalchemy import BinaryExpression, literal from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.inspection import inspect from sqlalchemy.future import select from sqlalchemy import delete from pydantic import BaseModel from fastapi import HTTPException from app.database.audit_log import audit_log # Importações do seu próprio projeto from app.database.RepositoryBase import ( RepositoryBase, IntegrityError, IntegrityConflictException, SnippetException, NotFoundException ) from app.database import models Model = TypeVar("Model", bound=models.Base) Schema = TypeVar("Schema", bound=BaseModel) class RelationalTableRepository(RepositoryBase[Model], Generic[Model]): def __init__(self, model: type[Model], session: AsyncSession) -> None: super().__init__(model, session) async def filter( self, *expressions: BinaryExpression, ) -> list[Model]: query = select(self.model) if expressions: query = query.where(*expressions) return list(await self.session.scalars(query)) async def create(self, data_one: Schema, *args: Any, **kwargs: Any) -> Model: try: if kwargs.get("related_info_extra_columns"): db_model = await self.process_related_items_with_extra_columns(data_one, **kwargs) else: db_model = await self.process_related_items(data_one, **kwargs) # Commit e refresh do modelo await self.session.commit() await self.session.refresh(db_model) return db_model except HTTPException as e: # Repassa a HTTPException específica raise e except Exception as e: raise HTTPException(status_code=500, detail="Erro ao criar relação com entidades relacionadas") from e async def create_many(self, data: List[Schema], return_models: bool = False, *args: Any, **kwargs: Any) -> ( list[Model] | bool): db_models = [] if kwargs.get("related_info_extra_columns"): for single_data in data: db_model = await self.process_related_items_with_extra_columns(single_data, **kwargs) db_models.append(db_model) else: for single_data in data: db_model = await self.process_related_items(single_data, **kwargs) db_models.append(db_model) try: self.session.add_all(db_models) await self.session.commit() except IntegrityError: raise IntegrityConflictException( f"Na tabela {self.model.__tablename__} existe conflito com dados já cadastrados em campo único.", ) except Exception as e: raise SnippetException(f"Unknown error occurred: {e}") from e if not return_models: return True for m in db_models: await self.session.refresh(m) return db_models # async def update_by_id(self, update: Schema, coluna: str, *args: Any, **kwargs: Any) -> Model: # related_info_append = kwargs.get('related_info_append', []) # related_info_add = kwargs.get('related_info_add', []) # # # Prepara as variáveis para os relacionamentos simples e muitos para muitos # simple_relationship_fields = { # info["key"]: {"related_model": info["related_model"], "exclude_field": info["foreign_key"]} # for info in related_info_add # } # many_to_many_relationship_fields = { # info["key"]: info["related_model"] for info in related_info_append # } # # uuid = str(update.uuid) # db_model = await self.get_one_by_id(uuid, coluna, with_for_update=True) # # if not db_model: # raise NotFoundException( # f"{self.model.__tablename__} {coluna}={uuid} não encontrada." # ) # # values = update.model_dump(exclude_unset=True) # # # Atualiza campos simples # for k, v in values.items(): # if k not in simple_relationship_fields and k not in many_to_many_relationship_fields: # setattr(db_model, k, v) # # # Atualiza relacionamentos simples # for field, details in simple_relationship_fields.items(): # related_model = details["related_model"] # foreign_key = details["exclude_field"] # Isso é o "fk_pessoa_uuid" # # # Se a chave não estiver presente ou estiver com uma lista vazia, remove os itens relacionados # if field not in values or values.get(field) == []: # foreign_key_column = getattr(related_model, foreign_key) # # # Garantimos que a comparação é feita entre expressões SQL válidas # await self.session.execute( # delete(related_model).where(foreign_key_column == literal(db_model.uuid)) # ) # await self.session.flush() # else: # # Faz a Atualização de valores da lista e exclusão do que não estão na lista # related_items = values.get(field) # await self.update_simple_relationship(db_model=db_model, field=field, related_items=related_items, # related_model=related_model, session=self.session, # foreign_key=foreign_key) # # # Atualiza relacionamentos muitos para muitos # for field, related_model in many_to_many_relationship_fields.items(): # # Se a chave não estiver presente ou estiver com uma lista vazia, remove os itens relacionados # if field not in values or values.get(field) == []: # setattr(db_model, field, []) # else: # related_item_ids = values.get(field) # await self.update_many_to_many_relationship(db_model, field, related_item_ids, related_model, # coluna=coluna) # # try: # await self.session.commit() # await self.session.refresh(db_model) # return db_model # except IntegrityError: # raise IntegrityConflictException( # f"{self.model.__tablename__} {coluna}={uuid} conflito com dados existentes." # ) # @audit_log async def update_by_id(self, update: Schema, coluna: str, *args: Any, **kwargs: Any) -> Model: # Obtém as configurações dos relacionamentos related_info_append = kwargs.get('related_info_append', []) related_info_add = kwargs.get('related_info_add', []) related_info_extra = kwargs.get('related_info_extra_columns', []) # Preparação dos relacionamentos simples (one-to-many) e muitos-para-muitos simples simple_relationship_fields = { info["key"]: {"related_model": info["related_model"], "exclude_field": info["foreign_key"]} for info in related_info_add } many_to_many_relationship_fields = { info["key"]: info["related_model"] for info in related_info_append } # Para relacionamentos com extra columns, armazenamos a configuração completa extra_relationship_fields = {info["key"]: info for info in related_info_extra} # Busca o objeto base a ser atualizado uuid = str(update.uuid) db_model = await self.get_one_by_id(uuid, coluna, with_for_update=True) if not db_model: raise NotFoundException(f"{self.model.__tablename__} {coluna}={uuid} não encontrada.") # Guardar o estado atual antes da modificação (cópia profunda para evitar problemas com lazy attributes) original_model = copy.deepcopy(db_model) values = update.model_dump(exclude_unset=True) # Atualiza os campos simples (excluindo os campos que representam relacionamentos) all_relationship_keys = set(simple_relationship_fields.keys()) | set( many_to_many_relationship_fields.keys()) | set(extra_relationship_fields.keys()) for k, v in values.items(): if k not in all_relationship_keys: setattr(db_model, k, v) # Atualiza relacionamentos simples (one-to-many) for field, details in simple_relationship_fields.items(): related_model = details["related_model"] foreign_key = details["exclude_field"] # por exemplo, "fk_pessoa_uuid" if field not in values or values.get(field) == []: foreign_key_column = getattr(related_model, foreign_key) await self.session.execute( delete(related_model).where(foreign_key_column == literal(db_model.uuid)) ) await self.session.flush() else: related_items = values.get(field) await self.update_simple_relationship( db_model=db_model, field=field, related_items=related_items, related_model=related_model, session=self.session, foreign_key=foreign_key ) # Atualiza relacionamentos muitos-para-muitos simples for field, related_model in many_to_many_relationship_fields.items(): if field not in values or values.get(field) == []: setattr(db_model, field, []) else: related_item_ids = values.get(field) await self.update_many_to_many_relationship( db_model=db_model, field=field, related_item_ids=related_item_ids, related_model=related_model, coluna=coluna) # Atualiza relacionamentos muitos-para-muitos com campos extras for field, config in extra_relationship_fields.items(): # Se o campo não foi enviado no update, ignore a atualização desse relacionamento. if field not in values: continue new_items = values.get(field) # Espera-se que seja uma lista (pode ser vazia) await self.update_many_to_many_extra_relationship( db_model=db_model, field=field, new_items=new_items, association_model=config["association_model"], base_foreign_key=config["base_foreign_key"], related_foreign_key=config["related_foreign_key"], extra_fields=config.get("extra_fields", []) ) try: await self.session.commit() await self.session.refresh(db_model) return db_model except IntegrityError: raise IntegrityConflictException( f"{self.model.__tablename__} {coluna}={uuid} conflito com dados existentes." ) async def update_many_by_ids(self, *args, **kwargs): raise NotImplementedError("Update many não implementado para relacionamentos.") @staticmethod def _create_base_model(data: Schema, db_data, related_info_append: List[Dict[str, Any]], related_info_add: List[Dict[str, Any]], related_info_extra_columns: List[Dict[str, Any]]) -> Model: # Inicia com as chaves dos relacionamentos simples (append e add) exclude_keys = {info["key"] for info in related_info_append + related_info_add + related_info_extra_columns} return db_data(**data.model_dump(exclude=exclude_keys)) async def _collect_related_items(self, data: Schema, related_info_append: List[Dict[str, Any]]) -> ( Dict[str, List[Any]]): related_items_to_append = {} for info in related_info_append: key = info["key"] related_model = info["related_model"] foreign_key_field = info["foreign_key_field"] related_items_to_append[key] = [] related_data = getattr(data, key, []) if not related_data: continue # Pula para a próxima iteração se não houver dados para este campo for related_item in related_data: related_item_id = related_item[foreign_key_field] if isinstance(related_item, dict) else getattr( related_item, foreign_key_field) related_item_instance = await self.session.get(related_model, related_item_id) if not related_item_instance: raise HTTPException(status_code=400, detail=f"ID {related_item_id} inválido") related_items_to_append[key].append(related_item_instance) return related_items_to_append async def process_related_items(self, data: Schema, **kwargs: Any) -> Model: # Obtendo argumentos adicionais db_data = kwargs.get('db_data') related_info_append = kwargs.get('related_info_append', []) related_info_add = kwargs.get('related_info_add', []) related_info_extra_columns = [] # Criação do modelo base db_model = self._create_base_model(data, db_data, related_info_append, related_info_add, related_info_extra_columns) # Processamento de related_info_append, se presente if related_info_append: related_items_to_append = await self._collect_related_items(data, related_info_append) if related_items_to_append: # Verifica se há itens para serem relacionados self._append_related_items(db_model, related_items_to_append) # Adiciona o modelo à sessão self.session.add(db_model) await self.session.flush() # Processamento de related_info_add, se presente if related_info_add: await self._add_related_items(data, db_model, related_info_add) return db_model async def process_related_items_with_extra_columns(self, data: Schema, **kwargs: Any) -> Model: """ Processa os relacionamentos onde a tabela de associação possui colunas extras. Espera-se que em kwargs seja informado: - db_data: a classe/modelo base a ser instanciado com os dados. - related_info_extra_columns: uma lista de dicionários com as seguintes chaves: * key: nome do atributo no schema contendo os dados do relacionamento. * association_model: o modelo da tabela de associação (com colunas extras). * base_foreign_key: nome da coluna que referencia o modelo base. * related_foreign_key: nome da coluna que referencia a entidade relacionada. * related_model: modelo da entidade relacionada (para validação, por exemplo). * extra_fields: (opcional) lista de nomes dos campos extras que deverão ser extraídos. """ # Cria o modelo base usando os dados enviados no schema db_data = kwargs.get('db_data') related_info_append = [] related_info_extra_columns = kwargs.get('related_info_extra_columns', []) related_info_add = kwargs.get('related_info_add', []) db_model = self._create_base_model(data, db_data, related_info_append, related_info_add, related_info_extra_columns, ) self.session.add(db_model) await self.session.flush() # Garante que db_model possua sua PK definida (ex.: uuid) # Processa os relacionamentos com colunas extras related_info_extra = kwargs.get('related_info_extra_columns', []) for info in related_info_extra: key = info.get("key") association_model = info.get("association_model") base_foreign_key = info.get("base_foreign_key") related_foreign_key = info.get("related_foreign_key") related_model = info.get("related_model") # Para validação do item relacionado extra_fields = info.get("extra_fields", []) # Obtém os dados do relacionamento a partir do schema related_items = getattr(data, key, []) if not related_items: continue for item in related_items: # Verifica se o identificador da entidade relacionada está presente no item if not hasattr(item, related_foreign_key): raise HTTPException( status_code=400, detail=f"O campo '{related_foreign_key}' é obrigatório em '{key}'." ) related_item_id = item[related_foreign_key] if isinstance(item, dict) else getattr(item, related_foreign_key) # Valida se o item relacionado existe (usando o modelo relacionado) if not related_model: raise HTTPException( status_code=400, detail="Não foi definido o modelo relacionado para o relacionamento." ) related_instance = await self.session.get(related_model, related_item_id) if not related_instance: raise HTTPException( status_code=400, detail=f"ID {related_item_id} inválido para {related_model.__tablename__}" ) # Extrai os valores dos campos extras, se existirem extra_data = {field: getattr(item, field) for field in extra_fields if hasattr(item, field)} # Cria a instância da tabela de associação, populando as FKs e os campos extras association_instance = association_model(**{ base_foreign_key: db_model.uuid, related_foreign_key: related_item_id, **extra_data }) self.session.add(association_instance) # Realiza um flush para persistir os itens da associação antes de continuar await self.session.flush() # Se houver relacionamentos do tipo um-para-muitos (add), processa-os também related_info_add = kwargs.get('related_info_add', []) if related_info_add: await self._add_related_items(data, db_model, related_info_add) return db_model @staticmethod def _append_related_items(db_model: Model, related_items_to_append: Dict[str, List[Model]]) -> None: for key, items in related_items_to_append.items(): getattr(db_model, key).extend(items) async def _add_related_items(self, data: Schema, db_model: Model, related_info_add: List[Dict[str, Any]]) -> None: for info in related_info_add: key = info["key"] foreign_key = info["foreign_key"] related_model = info["related_model"] relations = info.get("relations", []) # Pode ser uma lista de relações related_items = getattr(data, key, []) if not related_items: continue # Pula para a próxima iteração se não houver dados para este campo for related_item in related_items: for relation in relations: related_model_fk = relation.get("related_model_fk") foreign_key_fk = relation.get("foreign_key_fk") if related_model_fk and foreign_key_fk: fk = getattr(related_item, foreign_key_fk) relacao = await self.session.get(related_model_fk, fk) if not relacao: raise HTTPException(status_code=404, detail=f"{related_model_fk.__name__} com UUID {fk} não encontrado") new_item = related_model(**related_item.model_dump(), **{foreign_key: db_model.uuid}) self.session.add(new_item) @staticmethod async def update_simple_relationship( db_model: Model, field: str, related_items: List[Dict[str, Any]], related_model: Type[Model], session: AsyncSession, foreign_key: str ) -> None: current_items = getattr(db_model, field, []) # Lista de UUIDs dos itens enviados no JSON new_item_uuids = {str(item['uuid']) for item in related_items if 'uuid' in item} # Remover itens que não estão mais presentes no JSON for item in current_items: if str(item.uuid) not in new_item_uuids: foreign_key_column = getattr(related_model, foreign_key) await session.execute( delete(related_model).where(foreign_key_column == literal(db_model.uuid)).where( related_model.uuid == literal(item.uuid) ) ) await session.flush() # Atualizar ou adicionar novos itens for related_item in related_items: if 'uuid' in related_item: item_uuid = str(related_item['uuid']) db_item = next((item for item in current_items if str(item.uuid) == item_uuid), None) if db_item: for k, v in related_item.items(): setattr(db_item, k, v) else: raise NotFoundException(f"Related item {field} with UUID {item_uuid} not found.") else: new_item = related_model(**related_item) setattr(new_item, db_model.__class__.__name__.lower() + "_uuid", db_model.uuid) session.add(new_item) current_items.append(new_item) setattr(db_model, field, current_items) await session.flush() async def update_many_to_many_relationship( self, db_model: Model, field: str, related_item_ids: List[Union[str, UUID, Dict[str, Any]]], # Aceita "strings", UUIDs ou dicionários related_model: Type[Model], # Adicionando o modelo relacionado coluna: str = "uuid" # Adicionando a coluna para uso dinâmico ) -> None: current_items = getattr(db_model, field, []) current_item_ids = {str(getattr(item, coluna)) for item in current_items} # Extrair "IDs" dos dicionários, se necessário new_item_ids = set() for item in related_item_ids: if isinstance(item, dict) and coluna in item: new_item_ids.add(str(item[coluna])) else: new_item_ids.add(str(item)) # Remover itens que não estão mais relacionados items_to_remove = current_item_ids - new_item_ids for item in current_items: if str(getattr(item, coluna)) in items_to_remove: current_items.remove(item) # Adicionar novos itens items_to_add = new_item_ids - current_item_ids for item_id in items_to_add: related_item = await self.session.get(related_model, item_id) if related_item: current_items.append(related_item) else: raise NotFoundException(f"Related item {field} with UUID {item_id} not found.") setattr(db_model, field, current_items) async def update_many_to_many_extra_relationship( self, db_model: Model, field: str, new_items: List[Union[Dict[str, Any], Any]], # Aceita dicionários ou objetos com .dict() association_model: Type[Model], base_foreign_key: str, related_foreign_key: str, extra_fields: List[str] = [], ) -> None: """ Atualiza um relacionamento muitos-para-muitos com colunas extras. Parâmetros: - db_model: objeto base que possui o relacionamento. - field: nome do atributo em db_model que contém a lista de associações. - new_items: lista de novos itens para o relacionamento (pode ser dicionário ou objeto com .dict()). - association_model: classe mapeada da tabela de associação. - base_foreign_key: nome do atributo da associação que referencia db_model (ex: 'fk_manutencao_uuid'). - related_foreign_key: nome do atributo da associação que referencia o objeto relacionado (ex: 'fk_itens_equipamentos_uuid'). - extra_fields: lista de campos extras que também devem ser atualizados ou definidos. """ current_assocs = getattr(db_model, field, []) if not new_items: # Se o payload enviar uma lista vazia, remova todas as associações for assoc in current_assocs: await self.session.delete(assoc) setattr(db_model, field, []) await self.session.flush() return new_assoc_instances = [] for new_item in new_items: # Suporta tanto dicionários quanto objetos com método .dict() new_item_data = new_item if isinstance(new_item, dict) else new_item.dict() if related_foreign_key not in new_item_data: raise HTTPException( status_code=400, detail=f"O campo '{related_foreign_key}' é obrigatório em '{field}'." ) new_related_id = new_item_data[related_foreign_key] # Procura uma associação existente que corresponda ao valor do foreign key existing_assoc = next( (assoc for assoc in current_assocs if str(getattr(assoc, related_foreign_key)) == str(new_related_id)), None ) if existing_assoc: # Atualiza os campos extras se houver valores informados for ef in extra_fields: if ef in new_item_data: setattr(existing_assoc, ef, new_item_data[ef]) new_assoc_instances.append(existing_assoc) else: # Cria uma nova associação, incluindo os campos extras extra_data = {ef: new_item_data.get(ef) for ef in extra_fields if ef in new_item_data} new_assoc = association_model(**{ base_foreign_key: db_model.uuid, related_foreign_key: new_related_id, **extra_data }) self.session.add(new_assoc) await self.session.flush() new_assoc_instances.append(new_assoc) # Mescla as associações atuais com as novas (ou atualizadas) merged_assocs = list({*current_assocs, *new_assoc_instances}) setattr(db_model, field, merged_assocs)