From 71fd6f79ab70b8402be0f76b4c95ccbd025784c5 Mon Sep 17 00:00:00 2001 From: maxsonferovante Date: Sat, 5 Sep 2026 09:30:44 -0300 Subject: [PATCH 1/4] feat: implement DynamoDB single-table design pattern - Add dynamodb_keys.py with PK/SK/GSI key generation helpers - Refactor all 4 repositories for single-table design: - OrderRepository: GSI2 (email), GSI3 (product) - CertificateRepository: GSI1 (UUID), GSI2 (email), GSI3 (product), GSI4 (success) - ProductRepository: GSI3 (name) - ParticipantRepository: GSI2 (email), GSI5 (city) - Add EntityType filter to GSI queries to prevent cross-entity returns - Add get_all() using scan with EntityType filter - Update config to return single table name - Update dependency_container to not pass table_name to repositories - Add integration test for single-table CRUD operations --- src/infrastructure/aws/dynamodb_keys.py | 96 ++++++ src/infrastructure/config/config.py | 62 +--- .../container/dependency_container.py | 8 +- .../repository/certificate_repository_impl.py | 142 ++++---- .../repository/order_repository_impl.py | 93 +++--- .../repository/participant_repository_impl.py | 116 +++++-- .../repository/product_repository_impl.py | 85 ++--- tests/test_dynamodb_repository_derivations.py | 8 +- tests/test_single_table_integration.py | 315 ++++++++++++++++++ 9 files changed, 701 insertions(+), 224 deletions(-) create mode 100644 src/infrastructure/aws/dynamodb_keys.py create mode 100644 tests/test_single_table_integration.py diff --git a/src/infrastructure/aws/dynamodb_keys.py b/src/infrastructure/aws/dynamodb_keys.py new file mode 100644 index 0000000..4a6cba4 --- /dev/null +++ b/src/infrastructure/aws/dynamodb_keys.py @@ -0,0 +1,96 @@ +from enum import Enum +from typing import Optional + + +class EntityType(str, Enum): + ORDER = "ORDER" + PRODUCT = "PRODUCT" + PARTICIPANT = "PARTICIPANT" + CERTIFICATE = "CERTIFICATE" + + +def pk(entity_type: EntityType, entity_id: str | int) -> str: + return f"{entity_type.value}#{entity_id}" + + +def sk(entity_type: EntityType, entity_id: str | int) -> str: + return f"{entity_type.value}#{entity_id}" + + +def sk_order(order_id: int) -> str: + return sk(EntityType.ORDER, order_id) + + +def sk_product(product_id: int) -> str: + return sk(EntityType.PRODUCT, product_id) + + +def sk_participant(participant_id: str) -> str: + return sk(EntityType.PARTICIPANT, participant_id) + + +def sk_certificate(order_id: int) -> str: + return sk(EntityType.CERTIFICATE, order_id) + + +def gsi1pk_email(email: str) -> str: + return f"EMAIL#{email.lower().strip()}" + + +def gsi1sk_participant(participant_id: str) -> str: + return f"PARTICIPANT#{participant_id}" + + +def gsi1sk_order(order_id: int) -> str: + return f"ORDER#{order_id}" + + +def gsi1sk_certificate(order_id: int) -> str: + return f"CERTIFICATE#{order_id}" + + +def gsi2pk_product(product_id: int) -> str: + return f"PRODUCT#{product_id}" + + +def gsi2sk_order(order_id: int) -> str: + return f"ORDER#{order_id}" + + +def gsi2sk_certificate(order_id: int) -> str: + return f"CERTIFICATE#{order_id}" + + +def gsi3pk_success(success: bool) -> str: + return f"SUCCESS#{1 if success else 0}" + + +def gsi3sk_certificate(order_id: int) -> str: + return f"CERTIFICATE#{order_id}" + + +def gsi4pk_city(city: str) -> str: + return f"CITY#{city}" + + +def gsi4sk_participant(participant_id: str) -> str: + return f"PARTICIPANT#{participant_id}" + + +def gsi1pk_cert_id(cert_id: str) -> str: + return f"CERT#{cert_id}" + + +def gsi1sk_cert(cert_id: str) -> str: + return f"CERTIFICATE#{cert_id}" + + +def parse_pk(pk: str) -> tuple[EntityType, str]: + parts = pk.split("#", 1) + if len(parts) != 2: + raise ValueError(f"Invalid PK format: {pk}") + return EntityType(parts[0]), parts[1] + + +def parse_sk(sk: str) -> tuple[EntityType, str]: + return parse_pk(sk) diff --git a/src/infrastructure/config/config.py b/src/infrastructure/config/config.py index fb1481f..0ef9b42 100644 --- a/src/infrastructure/config/config.py +++ b/src/infrastructure/config/config.py @@ -18,64 +18,28 @@ class Config: env_file_encoding = "utf-8" @property - def dynamodb_tables(self) -> Dict[str, Dict[str, str]]: + def dynamodb_table(self) -> Dict[str, str]: """ - Retorna as configurações das tabelas do DynamoDB baseadas no ambiente. - Segue o padrão da infraestrutura Terraform criada. + Retorna a configuração da tabela única do DynamoDB baseada no ambiente. + Single Table Design - todas as entidades em uma única tabela. """ base_name = f"{self.PROJECT_NAME}" environment = self.ENVIRONMENT return { - "certificates": { - "name": f"{base_name}-certificates-{environment}", - "arn": f"arn:aws:dynamodb:{self.REGION}:*:table/{base_name}-certificates-{environment}" - }, - "orders": { - "name": f"{base_name}-orders-{environment}", - "arn": f"arn:aws:dynamodb:{self.REGION}:*:table/{base_name}-orders-{environment}" - }, - "participants": { - "name": f"{base_name}-participants-{environment}", - "arn": f"arn:aws:dynamodb:{self.REGION}:*:table/{base_name}-participants-{environment}" - }, - "products": { - "name": f"{base_name}-products-{environment}", - "arn": f"arn:aws:dynamodb:{self.REGION}:*:table/{base_name}-products-{environment}" - } + "name": f"{base_name}-{environment}", + "arn": f"arn:aws:dynamodb:{self.REGION}:*:table/{base_name}-{environment}" } - def get_table_name(self, entity: str) -> str: - """ - Retorna o nome da tabela para uma entidade específica. - - Args: - entity: Nome da entidade (certificates, orders, participants, products) - - Returns: - str: Nome da tabela no DynamoDB - """ - tables = self.dynamodb_tables - if entity not in tables: - raise ValueError(f"Entidade '{entity}' não encontrada. Entidades disponíveis: {list(tables.keys())}") - - return tables[entity]["name"] + @property + def dynamodb_tables(self) -> Dict[str, Dict[str, str]]: + return {"single": self.dynamodb_table} - def get_table_arn(self, entity: str) -> str: - """ - Retorna o ARN da tabela para uma entidade específica. - - Args: - entity: Nome da entidade (certificates, orders, participants, products) - - Returns: - str: ARN da tabela no DynamoDB - """ - tables = self.dynamodb_tables - if entity not in tables: - raise ValueError(f"Entidade '{entity}' não encontrada. Entidades disponíveis: {list(tables.keys())}") - - return tables[entity]["arn"] + def get_table_name(self, entity: str = None) -> str: + return self.dynamodb_table["name"] + + def get_table_arn(self, entity: str = None) -> str: + return self.dynamodb_table["arn"] config = Config() diff --git a/src/infrastructure/container/dependency_container.py b/src/infrastructure/container/dependency_container.py index 2d87b1b..665415b 100644 --- a/src/infrastructure/container/dependency_container.py +++ b/src/infrastructure/container/dependency_container.py @@ -104,22 +104,22 @@ def _create_dynamodb_service(self) -> DynamoDBService: def _create_certificate_repository(self) -> CertificateRepositoryImpl: """Cria uma instância do CertificateRepositoryImpl.""" dynamodb_service = self.get('dynamodb_service') - return CertificateRepositoryImpl(dynamodb_service, "certificates") + return CertificateRepositoryImpl(dynamodb_service) def _create_participant_repository(self) -> ParticipantRepositoryImpl: """Cria uma instância do ParticipantRepositoryImpl.""" dynamodb_service = self.get('dynamodb_service') - return ParticipantRepositoryImpl(dynamodb_service, "participants") + return ParticipantRepositoryImpl(dynamodb_service) def _create_product_repository(self) -> ProductRepositoryImpl: """Cria uma instância do ProductRepositoryImpl.""" dynamodb_service = self.get('dynamodb_service') - return ProductRepositoryImpl(dynamodb_service, "products") + return ProductRepositoryImpl(dynamodb_service) def _create_order_repository(self) -> OrderRepositoryImpl: """Cria uma instância do OrderRepositoryImpl.""" dynamodb_service = self.get('dynamodb_service') - return OrderRepositoryImpl(dynamodb_service, "orders") + return OrderRepositoryImpl(dynamodb_service) def _create_send_for_build_certificate(self): """ diff --git a/src/infrastructure/repository/certificate_repository_impl.py b/src/infrastructure/repository/certificate_repository_impl.py index 188986a..2d6a2ee 100644 --- a/src/infrastructure/repository/certificate_repository_impl.py +++ b/src/infrastructure/repository/certificate_repository_impl.py @@ -5,6 +5,17 @@ from src.domain.entity.certificate import Certificate from src.domain.repository.certificate_repository import CertificateRepository from src.infrastructure.aws.dynamodb_service import DynamoDBService +from src.infrastructure.aws.dynamodb_keys import ( + EntityType, + pk, + sk, + gsi1pk_email, + gsi1sk_certificate, + gsi2pk_product, + gsi2sk_certificate, + gsi3pk_success, + gsi3sk_certificate, +) logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -16,30 +27,20 @@ def _normalize_email(email: Optional[str]) -> Optional[str]: return email.strip().lower() -def _email_product_key(email: Optional[str], product_id: Optional[int]) -> Optional[str]: - normalized_email = _normalize_email(email) - if not normalized_email or product_id is None: - return None - return f"{normalized_email}#{product_id}" - - def _success_flag(success: Optional[bool]) -> int: return 1 if success else 0 class CertificateRepositoryImpl(CertificateRepository): - def __init__(self, dynamodb_service: DynamoDBService, table_name: str = "certificates"): + def __init__(self, dynamodb_service: DynamoDBService): self.dynamodb_service = dynamodb_service - self.table_name = table_name def create(self, entity: Certificate) -> Certificate: try: item = self._prepare_item(entity) - self.dynamodb_service.put_item(item, self.table_name) - + self.dynamodb_service.put_item(item, "single") logger.info(f"Certificado criado com sucesso: {entity.id}") return entity - except Exception as e: logger.error(f"Erro ao criar certificado: {str(e)}") raise @@ -49,21 +50,21 @@ def get_by_id(self, entity_id: str, order_id: int = None) -> Optional[Certificat certificate = self.find_by_id(entity_id) if not certificate: return None - if order_id is not None and certificate.order_id != order_id: return None - return certificate - except Exception as e: logger.error(f"Erro ao buscar certificado por ID {entity_id}: {str(e)}") raise def get_all(self) -> List[Certificate]: try: - items = self.dynamodb_service.scan_table(self.table_name) + items = self.dynamodb_service.scan_table( + "single", + filter_expression="EntityType = :entity_type", + expression_values={":entity_type": EntityType.CERTIFICATE.value}, + ) return [Certificate(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar todos os certificados: {str(e)}") raise @@ -75,8 +76,9 @@ def update(self, entity_id: str, entity: Certificate) -> Optional[Certificate]: return None update_data = self._prepare_item(entity) + update_data.pop("PK", None) + update_data.pop("SK", None) update_data.pop("id", None) - update_data.pop("order_id", None) update_expression = "SET " expression_values = {} @@ -91,10 +93,11 @@ def update(self, entity_id: str, entity: Certificate) -> Optional[Certificate]: update_expression = update_expression.rstrip(", ") response = self.dynamodb_service.update_item( - {"order_id": existing_certificate.order_id}, + {"PK": pk(EntityType.CERTIFICATE, existing_certificate.order_id), + "SK": sk(EntityType.CERTIFICATE, existing_certificate.order_id)}, update_expression, expression_values, - self.table_name, + "single", expression_attribute_names=expression_names, ) @@ -102,7 +105,6 @@ def update(self, entity_id: str, entity: Certificate) -> Optional[Certificate]: result_dict = self.dynamodb_service._convert_from_dynamodb_format(response["Attributes"]) return Certificate(**result_dict) return None - except Exception as e: logger.error(f"Erro ao atualizar certificado {entity_id}: {str(e)}") raise @@ -113,10 +115,13 @@ def delete(self, entity_id: str, order_id: int = None) -> bool: if not certificate: return False - self.dynamodb_service.delete_item({"order_id": certificate.order_id}, self.table_name) + self.dynamodb_service.delete_item( + {"PK": pk(EntityType.CERTIFICATE, certificate.order_id), + "SK": sk(EntityType.CERTIFICATE, certificate.order_id)}, + "single" + ) logger.info(f"Certificado {entity_id} removido com sucesso") return True - except Exception as e: logger.error(f"Erro ao remover certificado {entity_id}: {str(e)}") return False @@ -124,7 +129,6 @@ def delete(self, entity_id: str, order_id: int = None) -> bool: def exists(self, entity_id: str, order_id: int = None) -> bool: try: return self.get_by_id(entity_id, order_id) is not None - except Exception as e: logger.error(f"Erro ao verificar existência do certificado {entity_id}: {str(e)}") return False @@ -133,26 +137,29 @@ def find_by_id(self, entity_id: Union[str, uuid.UUID]) -> Optional[Certificate]: try: normalized_id = str(entity_id) items = self.dynamodb_service.query_table( - self.table_name, - "id = :id", - {":id": normalized_id}, - index_name="certificate_id_idx", + "single", + "GSI1PK = :gsi1pk", + {":gsi1pk": f"CERT#{normalized_id}"}, + index_name="GSI1", ) if items: return Certificate(**items[0]) return None - except Exception as e: logger.error(f"Erro ao buscar certificado por UUID {entity_id}: {str(e)}") raise def get_by_order_id(self, order_id: int) -> List[Certificate]: try: - item = self.dynamodb_service.get_item({"order_id": order_id}, self.table_name) - if not item: + items = self.dynamodb_service.query_table( + "single", + "PK = :pk AND SK = :sk", + {":pk": pk(EntityType.CERTIFICATE, order_id), + ":sk": sk(EntityType.CERTIFICATE, order_id)}, + ) + if not items: return [] - return [Certificate(**item)] - + return [Certificate(**items[0])] except Exception as e: logger.error(f"Erro ao buscar certificados por order_id {order_id}: {str(e)}") raise @@ -160,28 +167,30 @@ def get_by_order_id(self, order_id: int) -> List[Certificate]: def get_by_participant_email(self, email: str) -> List[Certificate]: try: items = self.dynamodb_service.query_table( - self.table_name, - "participant_email = :email", - {":email": _normalize_email(email)}, - index_name="certificates_by_email_idx", + "single", + "GSI2PK = :gsi2pk AND begins_with(GSI2SK, :sk_prefix)", + {":gsi2pk": gsi1pk_email(email), ":sk_prefix": "CERTIFICATE#", ":entity_type": EntityType.CERTIFICATE.value}, + index_name="GSI2", scan_index_forward=False, + filter_expression="EntityType = :entity_type", ) return [Certificate(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar certificados por email {email}: {str(e)}") raise def get_by_email_and_product_id(self, email: str, product_id: int) -> List[Certificate]: try: + normalized_email = _normalize_email(email) items = self.dynamodb_service.query_table( - self.table_name, - "participant_email_product_key = :email_product_key", - {":email_product_key": _email_product_key(email, product_id)}, - index_name="certificates_by_email_product_idx", + "single", + "GSI2PK = :gsi2pk AND begins_with(GSI2SK, :sk_prefix)", + {":gsi2pk": gsi1pk_email(normalized_email), ":sk_prefix": "CERTIFICATE#", ":entity_type": EntityType.CERTIFICATE.value}, + index_name="GSI2", scan_index_forward=False, + filter_expression="EntityType = :entity_type", ) - certificates = [Certificate(**item) for item in items] + certificates = [Certificate(**item) for item in items if item.get("product_id") == product_id] logger.info( "Encontrados %s certificados para email %s e product_id %s", len(certificates), @@ -189,7 +198,6 @@ def get_by_email_and_product_id(self, email: str, product_id: int) -> List[Certi product_id, ) return certificates - except Exception as e: logger.error(f"Erro ao buscar certificados por email {email} e product_id {product_id}: {str(e)}") raise @@ -197,14 +205,13 @@ def get_by_email_and_product_id(self, email: str, product_id: int) -> List[Certi def get_by_product_id(self, product_id: int) -> List[Certificate]: try: items = self.dynamodb_service.query_table( - self.table_name, - "product_id = :product_id", - {":product_id": product_id}, - index_name="certificates_by_product_idx", + "single", + "GSI3PK = :gsi3pk AND begins_with(GSI3SK, :sk_prefix)", + {":gsi3pk": gsi2pk_product(product_id), ":sk_prefix": "CERTIFICATE#"}, + index_name="GSI3", scan_index_forward=False, ) return [Certificate(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar certificados por product_id {product_id}: {str(e)}") raise @@ -212,25 +219,36 @@ def get_by_product_id(self, product_id: int) -> List[Certificate]: def get_successful_certificates(self) -> List[Certificate]: try: items = self.dynamodb_service.query_table( - self.table_name, - "success_flag = :success_flag", - {":success_flag": 1}, - index_name="certificates_by_success_idx", + "single", + "GSI4PK = :gsi4pk AND begins_with(GSI4SK, :sk_prefix)", + {":gsi4pk": gsi3pk_success(True), ":sk_prefix": "CERTIFICATE#"}, + index_name="GSI4", scan_index_forward=False, ) return [Certificate(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar certificados bem-sucedidos: {str(e)}") raise def _prepare_item(self, entity: Certificate) -> dict: item = entity.model_dump() - item["id"] = str(entity.id) - item["participant_email"] = _normalize_email(item.get("participant_email")) - item["participant_email_product_key"] = _email_product_key( - item.get("participant_email"), - item.get("product_id"), - ) - item["success_flag"] = _success_flag(item.get("success")) - return item + cert_id = str(entity.id) + order_id = entity.order_id + email = _normalize_email(item.get("participant_email")) + success = item.get("success", False) + + item["PK"] = pk(EntityType.CERTIFICATE, order_id) + item["SK"] = sk(EntityType.CERTIFICATE, order_id) + item["EntityType"] = EntityType.CERTIFICATE.value + item["GSI1PK"] = f"CERT#{cert_id}" + item["GSI1SK"] = f"CERTIFICATE#{order_id}" + item["GSI2PK"] = gsi1pk_email(email) if email else None + item["GSI2SK"] = f"CERTIFICATE#{order_id}" + item["GSI3PK"] = gsi2pk_product(entity.product_id) + item["GSI3SK"] = gsi2sk_certificate(order_id) + item["GSI4PK"] = gsi3pk_success(success) + item["GSI4SK"] = gsi3sk_certificate(order_id) + item["id"] = cert_id + item["participant_email"] = email + item["success_flag"] = _success_flag(success) + return {k: v for k, v in item.items() if v is not None} diff --git a/src/infrastructure/repository/order_repository_impl.py b/src/infrastructure/repository/order_repository_impl.py index b41e4b6..2321d4b 100644 --- a/src/infrastructure/repository/order_repository_impl.py +++ b/src/infrastructure/repository/order_repository_impl.py @@ -6,6 +6,13 @@ from src.domain.entity.order import Order from src.domain.repository.order_repository import OrderRepository from src.infrastructure.aws.dynamodb_service import DynamoDBService +from src.infrastructure.aws.dynamodb_keys import ( + EntityType, + pk, + sk, + gsi1pk_email, + gsi2pk_product, +) logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -44,27 +51,28 @@ def _order_date_order_id(value: str, order_id: int) -> str: class OrderRepositoryImpl(OrderRepository): - def __init__(self, dynamodb_service: DynamoDBService, table_name: str = "orders"): + def __init__(self, dynamodb_service: DynamoDBService): self.dynamodb_service = dynamodb_service - self.table_name = table_name def create(self, entity: Order) -> Order: try: item = self._prepare_item(entity) - self.dynamodb_service.put_item(item, self.table_name) + self.dynamodb_service.put_item(item, "single") return entity - except Exception as e: logger.error(f"Erro ao criar pedido: {str(e)}") raise def get_by_id(self, entity_id: int) -> Optional[Order]: try: - item = self.dynamodb_service.get_item({"order_id": entity_id}, self.table_name) - if item: - return Order(**item) + items = self.dynamodb_service.query_table( + "single", + "PK = :pk AND SK = :sk", + {":pk": pk(EntityType.ORDER, entity_id), ":sk": sk(EntityType.ORDER, entity_id)}, + ) + if items: + return Order(**items[0]) return None - except Exception as e: logger.error(f"Erro ao buscar pedido por ID {entity_id}: {str(e)}") raise @@ -76,9 +84,7 @@ def find_by_id(self, entity_id: Union[str, uuid.UUID]) -> Optional[Order]: id_int = int(id_str.replace("-", "")[:10]) else: id_int = int(str(entity_id)) - return self.get_by_id(id_int) - except (ValueError, TypeError) as e: logger.error(f"Erro ao converter ID {entity_id} para int: {str(e)}") return None @@ -88,9 +94,12 @@ def find_by_id(self, entity_id: Union[str, uuid.UUID]) -> Optional[Order]: def get_all(self) -> List[Order]: try: - items = self.dynamodb_service.scan_table(self.table_name) + items = self.dynamodb_service.query_table( + "single", + "EntityType = :entity_type", + {":entity_type": EntityType.ORDER.value}, + ) return [Order(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar todos os pedidos: {str(e)}") raise @@ -101,7 +110,8 @@ def update(self, entity_id: int, entity: Order) -> Optional[Order]: return None update_data = self._prepare_item(entity) - update_data.pop("order_id", None) + update_data.pop("PK", None) + update_data.pop("SK", None) update_expression = "SET " expression_values = {} @@ -116,10 +126,10 @@ def update(self, entity_id: int, entity: Order) -> Optional[Order]: update_expression = update_expression.rstrip(", ") response = self.dynamodb_service.update_item( - {"order_id": entity_id}, + {"PK": pk(EntityType.ORDER, entity_id), "SK": sk(EntityType.ORDER, entity_id)}, update_expression, expression_values, - self.table_name, + "single", expression_attribute_names=expression_names, ) @@ -127,16 +137,17 @@ def update(self, entity_id: int, entity: Order) -> Optional[Order]: result_dict = self.dynamodb_service._convert_from_dynamodb_format(response["Attributes"]) return Order(**result_dict) return None - except Exception as e: logger.error(f"Erro ao atualizar pedido {entity_id}: {str(e)}") raise def delete(self, entity_id: int) -> bool: try: - self.dynamodb_service.delete_item({"order_id": entity_id}, self.table_name) + self.dynamodb_service.delete_item( + {"PK": pk(EntityType.ORDER, entity_id), "SK": sk(EntityType.ORDER, entity_id)}, + "single" + ) return True - except Exception as e: logger.error(f"Erro ao remover pedido {entity_id}: {str(e)}") return False @@ -144,30 +155,25 @@ def delete(self, entity_id: int) -> bool: def exists(self, entity_id: int) -> bool: try: return self.get_by_id(entity_id) is not None - except Exception as e: logger.error(f"Erro ao verificar existência do pedido {entity_id}: {str(e)}") return False def get_by_order_id(self, order_id: int) -> Optional[Order]: - try: - return self.get_by_id(order_id) - - except Exception as e: - logger.error(f"Erro ao buscar pedido por order_id {order_id}: {str(e)}") - raise + return self.get_by_id(order_id) def get_by_participant_email(self, email: str) -> List[Order]: try: + normalized_email = _normalize_email(email) items = self.dynamodb_service.query_table( - self.table_name, - "participant_email = :email", - {":email": _normalize_email(email)}, - index_name="orders_by_email_idx", + "single", + "GSI2PK = :gsi2pk AND begins_with(GSI2SK, :sk_prefix)", + {":gsi2pk": gsi1pk_email(normalized_email), ":sk_prefix": "ORDER#", ":entity_type": EntityType.ORDER.value}, + index_name="GSI2", scan_index_forward=False, + filter_expression="EntityType = :entity_type", ) return [Order(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar pedidos por email {email}: {str(e)}") raise @@ -175,14 +181,13 @@ def get_by_participant_email(self, email: str) -> List[Order]: def get_by_product_id(self, product_id: int) -> List[Order]: try: items = self.dynamodb_service.query_table( - self.table_name, - "product_id = :product_id", - {":product_id": product_id}, - index_name="orders_by_product_idx", + "single", + "GSI3PK = :gsi3pk AND begins_with(GSI3SK, :sk_prefix)", + {":gsi3pk": gsi2pk_product(product_id), ":sk_prefix": "ORDER#"}, + index_name="GSI3", scan_index_forward=False, ) return [Order(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar pedidos por product_id {product_id}: {str(e)}") raise @@ -206,27 +211,35 @@ def get_orders_by_date_range(self, start_date: str, end_date: str) -> List[Order month_end = min(end_dt, month_end_boundary) month_items = self.dynamodb_service.query_table( - self.table_name, + "single", "order_year_month = :order_year_month AND order_date_order_id BETWEEN :start_range AND :end_range", { ":order_year_month": month_key, ":start_range": f"{month_start.isoformat()}#00000000000000000000", ":end_range": f"{month_end.isoformat()}#99999999999999999999", }, - index_name="orders_by_month_idx", ) items.extend(month_items) current_month = next_month return [Order(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar pedidos por intervalo de datas {start_date} - {end_date}: {str(e)}") raise def _prepare_item(self, entity: Order) -> dict: item = entity.model_dump() - item["participant_email"] = _normalize_email(item["participant_email"]) + order_id = item["order_id"] + email = _normalize_email(item["participant_email"]) + + item["PK"] = pk(EntityType.ORDER, order_id) + item["SK"] = sk(EntityType.ORDER, order_id) + item["EntityType"] = EntityType.ORDER.value + item["GSI2PK"] = gsi1pk_email(email) + item["GSI2SK"] = f"ORDER#{order_id}" + item["GSI3PK"] = gsi2pk_product(item["product_id"]) + item["GSI3SK"] = f"ORDER#{order_id}" + item["participant_email"] = email item["order_year_month"] = _order_year_month(item["order_date"]) - item["order_date_order_id"] = _order_date_order_id(item["order_date"], item["order_id"]) + item["order_date_order_id"] = _order_date_order_id(item["order_date"], order_id) return item diff --git a/src/infrastructure/repository/participant_repository_impl.py b/src/infrastructure/repository/participant_repository_impl.py index e5ad7dd..5586eee 100644 --- a/src/infrastructure/repository/participant_repository_impl.py +++ b/src/infrastructure/repository/participant_repository_impl.py @@ -5,6 +5,12 @@ from src.domain.entity.participant import Participant from src.domain.repository.participant_repository import ParticipantRepository from src.infrastructure.aws.dynamodb_service import DynamoDBService +from src.infrastructure.aws.dynamodb_keys import ( + EntityType, + pk, + sk, + gsi1pk_email, +) logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -17,16 +23,13 @@ def _normalize_email(email: Optional[str]) -> Optional[str]: class ParticipantRepositoryImpl(ParticipantRepository): - def __init__(self, dynamodb_service: DynamoDBService, table_name: str = "participants"): + def __init__(self, dynamodb_service: DynamoDBService): self.dynamodb_service = dynamodb_service - self.table_name = table_name def create(self, entity: Participant) -> Participant: try: - item = entity.model_dump() - item["id"] = str(entity.id) - item["email"] = _normalize_email(item.get("email")) - self.dynamodb_service.put_item(item, self.table_name) + item = self._prepare_item(entity) + self.dynamodb_service.put_item(item, "single") return entity except Exception as e: logger.error(f"Erro ao criar participante: {str(e)}") @@ -34,11 +37,14 @@ def create(self, entity: Participant) -> Participant: def get_by_id(self, entity_id: str) -> Optional[Participant]: try: - item = self.dynamodb_service.get_item({"id": entity_id}, self.table_name) - if item: - return Participant(**item) + items = self.dynamodb_service.query_table( + "single", + "PK = :pk AND SK = :sk", + {":pk": pk(EntityType.PARTICIPANT, entity_id), ":sk": sk(EntityType.PARTICIPANT, entity_id)}, + ) + if items: + return Participant(**items[0]) return None - except Exception as e: logger.error(f"Erro ao buscar participante por ID {entity_id}: {str(e)}") raise @@ -47,16 +53,18 @@ def find_by_id(self, entity_id: Union[str, uuid.UUID]) -> Optional[Participant]: try: id_str = str(entity_id) if isinstance(entity_id, uuid.UUID) else entity_id return self.get_by_id(id_str) - except Exception as e: logger.error(f"Erro ao buscar participante por ID {entity_id}: {str(e)}") raise def get_all(self) -> List[Participant]: try: - items = self.dynamodb_service.scan_table(self.table_name) + items = self.dynamodb_service.query_table( + "single", + "EntityType = :entity_type", + {":entity_type": EntityType.PARTICIPANT.value}, + ) return [Participant(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar todos os participantes: {str(e)}") raise @@ -66,9 +74,9 @@ def update(self, entity_id: str, entity: Participant) -> Optional[Participant]: if not self.exists(entity_id): return None - update_data = entity.model_dump() - update_data["email"] = _normalize_email(update_data.get("email")) - update_data.pop("id", None) + update_data = self._prepare_item(entity) + update_data.pop("PK", None) + update_data.pop("SK", None) update_expression = "SET " expression_values = {} @@ -83,10 +91,10 @@ def update(self, entity_id: str, entity: Participant) -> Optional[Participant]: update_expression = update_expression.rstrip(", ") response = self.dynamodb_service.update_item( - {"id": entity_id}, + {"PK": pk(EntityType.PARTICIPANT, entity_id), "SK": sk(EntityType.PARTICIPANT, entity_id)}, update_expression, expression_values, - self.table_name, + "single", expression_attribute_names=expression_names, ) @@ -94,16 +102,17 @@ def update(self, entity_id: str, entity: Participant) -> Optional[Participant]: result_dict = self.dynamodb_service._convert_from_dynamodb_format(response["Attributes"]) return Participant(**result_dict) return None - except Exception as e: logger.error(f"Erro ao atualizar participante {entity_id}: {str(e)}") raise def delete(self, entity_id: str) -> bool: try: - self.dynamodb_service.delete_item({"id": entity_id}, self.table_name) + self.dynamodb_service.delete_item( + {"PK": pk(EntityType.PARTICIPANT, entity_id), "SK": sk(EntityType.PARTICIPANT, entity_id)}, + "single" + ) return True - except Exception as e: logger.error(f"Erro ao remover participante {entity_id}: {str(e)}") return False @@ -111,7 +120,6 @@ def delete(self, entity_id: str) -> bool: def exists(self, entity_id: str) -> bool: try: return self.get_by_id(entity_id) is not None - except Exception as e: logger.error(f"Erro ao verificar existência do participante {entity_id}: {str(e)}") return False @@ -119,24 +127,76 @@ def exists(self, entity_id: str) -> bool: def get_by_email(self, email: str) -> Optional[Participant]: try: items = self.dynamodb_service.query_table( - self.table_name, - "email = :email", - {":email": _normalize_email(email)}, - index_name="participants_by_email_idx", + "single", + "GSI2PK = :gsi2pk", + {":gsi2pk": gsi1pk_email(email), ":entity_type": EntityType.PARTICIPANT.value}, + index_name="GSI2", + filter_expression="EntityType = :entity_type", ) if items: return Participant(**items[0]) return None - except Exception as e: logger.error(f"Erro ao buscar participante por email {email}: {str(e)}") raise + def get_by_cpf(self, cpf: str) -> Optional[Participant]: + try: + items = self.dynamodb_service.query_table( + "single", + "EntityType = :entity_type AND cpf = :cpf", + {":entity_type": EntityType.PARTICIPANT.value, ":cpf": cpf}, + ) + if items: + return Participant(**items[0]) + return None + except Exception as e: + logger.error(f"Erro ao buscar participante por CPF {cpf}: {str(e)}") + raise + + def get_by_city(self, city: str) -> List[Participant]: + try: + items = self.dynamodb_service.query_table( + "single", + "GSI5PK = :gsi5pk", + {":gsi5pk": f"CITY#{city}"}, + index_name="GSI5", + ) + return [Participant(**item) for item in items] + except Exception as e: + logger.error(f"Erro ao buscar participantes por cidade {city}: {str(e)}") + raise + def email_exists(self, email: str) -> bool: try: participant = self.get_by_email(email) return participant is not None - except Exception as e: logger.error(f"Erro ao verificar existência do email {email}: {str(e)}") return False + + def cpf_exists(self, cpf: str) -> bool: + try: + participant = self.get_by_cpf(cpf) + return participant is not None + except Exception as e: + logger.error(f"Erro ao verificar existência do CPF {cpf}: {str(e)}") + return False + + def _prepare_item(self, entity: Participant) -> dict: + item = entity.model_dump() + participant_id = str(entity.id) + email = _normalize_email(item.get("email")) + city = item.get("city") + + item["PK"] = pk(EntityType.PARTICIPANT, participant_id) + item["SK"] = sk(EntityType.PARTICIPANT, participant_id) + item["EntityType"] = EntityType.PARTICIPANT.value + item["GSI2PK"] = gsi1pk_email(email) if email else None + item["GSI2SK"] = f"PARTICIPANT#{participant_id}" + if city: + item["GSI5PK"] = f"CITY#{city}" + item["GSI5SK"] = f"PARTICIPANT#{participant_id}" + item["id"] = participant_id + item["email"] = email + return {k: v for k, v in item.items() if v is not None} diff --git a/src/infrastructure/repository/product_repository_impl.py b/src/infrastructure/repository/product_repository_impl.py index a9051e4..c881d12 100644 --- a/src/infrastructure/repository/product_repository_impl.py +++ b/src/infrastructure/repository/product_repository_impl.py @@ -5,6 +5,11 @@ from src.domain.entity.product import Product from src.domain.repository.product_repository import ProductRepository from src.infrastructure.aws.dynamodb_service import DynamoDBService +from src.infrastructure.aws.dynamodb_keys import ( + EntityType, + pk, + sk, +) logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -15,27 +20,28 @@ def _flag(value: Optional[str]) -> int: class ProductRepositoryImpl(ProductRepository): - def __init__(self, dynamodb_service: DynamoDBService, table_name: str = "products"): + def __init__(self, dynamodb_service: DynamoDBService): self.dynamodb_service = dynamodb_service - self.table_name = table_name def create(self, entity: Product) -> Product: try: item = self._prepare_item(entity) - self.dynamodb_service.put_item(item, self.table_name) + self.dynamodb_service.put_item(item, "single") return entity - except Exception as e: logger.error(f"Erro ao criar produto: {str(e)}") raise def get_by_id(self, entity_id: int) -> Optional[Product]: try: - item = self.dynamodb_service.get_item({"product_id": entity_id}, self.table_name) - if item: - return Product(**item) + items = self.dynamodb_service.query_table( + "single", + "PK = :pk AND SK = :sk", + {":pk": pk(EntityType.PRODUCT, entity_id), ":sk": sk(EntityType.PRODUCT, entity_id)}, + ) + if items: + return Product(**items[0]) return None - except Exception as e: logger.error(f"Erro ao buscar produto por ID {entity_id}: {str(e)}") raise @@ -47,9 +53,7 @@ def find_by_id(self, entity_id: Union[str, uuid.UUID]) -> Optional[Product]: id_int = int(id_str.replace("-", "")[:10]) else: id_int = int(str(entity_id)) - return self.get_by_id(id_int) - except (ValueError, TypeError) as e: logger.error(f"Erro ao converter ID {entity_id} para int: {str(e)}") return None @@ -59,9 +63,12 @@ def find_by_id(self, entity_id: Union[str, uuid.UUID]) -> Optional[Product]: def get_all(self) -> List[Product]: try: - items = self.dynamodb_service.scan_table(self.table_name) + items = self.dynamodb_service.query_table( + "single", + "EntityType = :entity_type", + {":entity_type": EntityType.PRODUCT.value}, + ) return [Product(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar todos os produtos: {str(e)}") raise @@ -72,7 +79,8 @@ def update(self, entity_id: int, entity: Product) -> Optional[Product]: return None update_data = self._prepare_item(entity) - update_data.pop("product_id", None) + update_data.pop("PK", None) + update_data.pop("SK", None) update_expression = "SET " expression_values = {} @@ -87,10 +95,10 @@ def update(self, entity_id: int, entity: Product) -> Optional[Product]: update_expression = update_expression.rstrip(", ") response = self.dynamodb_service.update_item( - {"product_id": entity_id}, + {"PK": pk(EntityType.PRODUCT, entity_id), "SK": sk(EntityType.PRODUCT, entity_id)}, update_expression, expression_values, - self.table_name, + "single", expression_attribute_names=expression_names, ) @@ -98,16 +106,17 @@ def update(self, entity_id: int, entity: Product) -> Optional[Product]: result_dict = self.dynamodb_service._convert_from_dynamodb_format(response["Attributes"]) return Product(**result_dict) return None - except Exception as e: logger.error(f"Erro ao atualizar produto {entity_id}: {str(e)}") raise def delete(self, entity_id: int) -> bool: try: - self.dynamodb_service.delete_item({"product_id": entity_id}, self.table_name) + self.dynamodb_service.delete_item( + {"PK": pk(EntityType.PRODUCT, entity_id), "SK": sk(EntityType.PRODUCT, entity_id)}, + "single" + ) return True - except Exception as e: logger.error(f"Erro ao remover produto {entity_id}: {str(e)}") return False @@ -115,29 +124,22 @@ def delete(self, entity_id: int) -> bool: def exists(self, entity_id: int) -> bool: try: return self.get_by_id(entity_id) is not None - except Exception as e: logger.error(f"Erro ao verificar existência do produto {entity_id}: {str(e)}") return False def get_by_product_id(self, product_id: int) -> Optional[Product]: - try: - return self.get_by_id(product_id) - - except Exception as e: - logger.error(f"Erro ao buscar produto por product_id {product_id}: {str(e)}") - raise + return self.get_by_id(product_id) def get_by_name(self, product_name: str) -> List[Product]: try: items = self.dynamodb_service.query_table( - self.table_name, - "product_name = :product_name", - {":product_name": product_name}, - index_name="products_by_name_idx", + "single", + "GSI3PK = :gsi3pk", + {":gsi3pk": f"PRODUCT#{product_name}"}, + index_name="GSI3", ) return [Product(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar produtos por nome {product_name}: {str(e)}") raise @@ -145,13 +147,11 @@ def get_by_name(self, product_name: str) -> List[Product]: def get_products_with_logo(self) -> List[Product]: try: items = self.dynamodb_service.query_table( - self.table_name, - "has_certificate_logo_flag = :flag", - {":flag": 1}, - index_name="products_by_has_logo_idx", + "single", + "EntityType = :entity_type AND has_certificate_logo_flag = :flag", + {":entity_type": EntityType.PRODUCT.value, ":flag": 1}, ) return [Product(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar produtos com logo: {str(e)}") raise @@ -159,19 +159,24 @@ def get_products_with_logo(self) -> List[Product]: def get_products_with_background(self) -> List[Product]: try: items = self.dynamodb_service.query_table( - self.table_name, - "has_certificate_background_flag = :flag", - {":flag": 1}, - index_name="products_by_has_background_idx", + "single", + "EntityType = :entity_type AND has_certificate_background_flag = :flag", + {":entity_type": EntityType.PRODUCT.value, ":flag": 1}, ) return [Product(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar produtos com background: {str(e)}") raise def _prepare_item(self, entity: Product) -> dict: item = entity.model_dump() + product_id = item["product_id"] + + item["PK"] = pk(EntityType.PRODUCT, product_id) + item["SK"] = sk(EntityType.PRODUCT, product_id) + item["EntityType"] = EntityType.PRODUCT.value + item["GSI3PK"] = f"PRODUCT#{item['product_name']}" + item["GSI3SK"] = f"PRODUCT#{product_id}" item["has_certificate_logo_flag"] = _flag(item.get("certificate_logo")) item["has_certificate_background_flag"] = _flag(item.get("certificate_background")) return item diff --git a/tests/test_dynamodb_repository_derivations.py b/tests/test_dynamodb_repository_derivations.py index 0d91582..3d7c3eb 100644 --- a/tests/test_dynamodb_repository_derivations.py +++ b/tests/test_dynamodb_repository_derivations.py @@ -63,9 +63,15 @@ def test_certificate_prepare_item_adds_query_keys(self): item = repository._prepare_item(certificate) self.assertEqual(item["participant_email"], "user+test@example.com") - self.assertEqual(item["participant_email_product_key"], "user+test@example.com#100") + self.assertEqual(item["GSI1PK"], f"CERT#{certificate.id}") + self.assertEqual(item["GSI2PK"], "EMAIL#user+test@example.com") + self.assertEqual(item["GSI3PK"], "PRODUCT#100") + self.assertEqual(item["GSI4PK"], "SUCCESS#1") self.assertEqual(item["success_flag"], 1) self.assertEqual(item["id"], str(certificate.id)) + self.assertEqual(item["PK"], "CERTIFICATE#1") + self.assertEqual(item["SK"], "CERTIFICATE#1") + self.assertEqual(item["EntityType"], "CERTIFICATE") def test_order_prepare_item_adds_month_and_sort_key(self): repository = OrderRepositoryImpl(FakeDynamoDBService()) diff --git a/tests/test_single_table_integration.py b/tests/test_single_table_integration.py new file mode 100644 index 0000000..034e80d --- /dev/null +++ b/tests/test_single_table_integration.py @@ -0,0 +1,315 @@ +""" +Teste de integração end-to-end com MiniStack. +Este teste valida o design single-table com operações CRUD reais. +""" + +import os +import sys +import uuid + +os.environ.setdefault("REGION", "us-west-2") +os.environ.setdefault("AWS_ACCESS_KEY_ID", "test") +os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test") + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import boto3 +from src.domain.entity.order import Order +from src.domain.entity.certificate import Certificate +from src.domain.entity.product import Product +from src.domain.entity.participant import Participant + + +class DynamoDBServiceMiniStack: + def __init__(self): + import os + endpoint_url = os.environ.get("ENDPOINT_URL", "http://localhost:4566") + self.aws = boto3.client( + 'dynamodb', + region_name=os.environ.get("REGION", "us-west-2"), + aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID", "test"), + aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY", "test"), + endpoint_url=endpoint_url + ) + self.table_name = "tech-floripa-certificates-dev" + + def put_item(self, item: dict, table_name: str = None) -> dict: + item = self._convert_to_dynamodb_format(item) + return self.aws.put_item( + TableName=self.table_name, + Item=item + ) + + def get_item(self, key: dict, table_name: str = None) -> dict: + key = self._convert_to_dynamodb_format(key) + response = self.aws.get_item( + TableName=self.table_name, + Key=key + ) + if 'Item' in response: + return self._convert_from_dynamodb_format(response['Item']) + return None + + def query_table(self, table_name: str, key_condition_expression: str, expression_values: dict, + index_name: str = None, scan_index_forward: bool = True, filter_expression: str = None) -> list: + expression_values = self._convert_to_dynamodb_format(expression_values) + kwargs = { + "TableName": self.table_name, + "KeyConditionExpression": key_condition_expression, + "ExpressionAttributeValues": expression_values, + "ScanIndexForward": scan_index_forward, + } + if index_name: + kwargs['IndexName'] = index_name + if filter_expression: + kwargs['FilterExpression'] = filter_expression + + items = [] + while True: + response = self.aws.query(**kwargs) + if 'Items' in response: + for item in response['Items']: + items.append(self._convert_from_dynamodb_format(item)) + if 'LastEvaluatedKey' not in response: + break + kwargs['ExclusiveStartKey'] = response['LastEvaluatedKey'] + return items + + def scan_table(self, table_name: str, filter_expression: str = None, expression_values: dict = None) -> list: + kwargs = {"TableName": self.table_name} + if filter_expression and expression_values: + kwargs['FilterExpression'] = filter_expression + kwargs['ExpressionAttributeValues'] = self._convert_to_dynamodb_format(expression_values) + items = [] + while True: + response = self.aws.scan(**kwargs) + if 'Items' in response: + for item in response['Items']: + items.append(self._convert_from_dynamodb_format(item)) + if 'LastEvaluatedKey' not in response: + break + kwargs['ExclusiveStartKey'] = response['LastEvaluatedKey'] + return items + + def update_item(self, key: dict, update_expression: str, expression_values: dict, + table_name: str, expression_attribute_names: dict = None) -> dict: + key = self._convert_to_dynamodb_format(key) + expression_values = self._convert_to_dynamodb_format(expression_values) + kwargs = { + "TableName": self.table_name, + "Key": key, + "UpdateExpression": update_expression, + "ExpressionAttributeValues": expression_values, + "ReturnValues": "ALL_NEW", + } + if expression_attribute_names: + kwargs["ExpressionAttributeNames"] = expression_attribute_names + return self.aws.update_item(**kwargs) + + def delete_item(self, key: dict, table_name: str = None) -> dict: + key = self._convert_to_dynamodb_format(key) + return self.aws.delete_item( + TableName=self.table_name, + Key=key + ) + + def _convert_to_dynamodb_format(self, data): + if isinstance(data, dict): + return {k: self._convert_to_dynamodb_format(v) for k, v in data.items()} + elif isinstance(data, list): + return [self._convert_to_dynamodb_format(item) for item in data] + elif isinstance(data, str): + return {'S': data} + elif isinstance(data, uuid.UUID): + return {'S': str(data)} + elif isinstance(data, bool): + return {'BOOL': data} + elif isinstance(data, int): + return {'N': str(data)} + elif isinstance(data, float): + return {'N': str(data)} + elif data is None: + return {'NULL': True} + else: + return {'S': str(data)} + + def _convert_from_dynamodb_format(self, data): + if isinstance(data, dict): + if len(data) == 1: + key = list(data.keys())[0] + if key == 'S': + return data['S'] + elif key == 'N': + return float(data['N']) + elif key == 'BOOL': + return data['BOOL'] + elif key == 'L': + return [self._convert_from_dynamodb_format(item) for item in data['L']] + elif key == 'M': + return {k: self._convert_from_dynamodb_format(v) for k, v in data['M'].items()} + elif key == 'NULL': + return None + return {k: self._convert_from_dynamodb_format(v) for k, v in data.items()} + elif isinstance(data, list): + return [self._convert_from_dynamodb_format(item) for item in data] + else: + return data + + +from src.infrastructure.repository.order_repository_impl import OrderRepositoryImpl +from src.infrastructure.repository.certificate_repository_impl import CertificateRepositoryImpl +from src.infrastructure.repository.product_repository_impl import ProductRepositoryImpl +from src.infrastructure.repository.participant_repository_impl import ParticipantRepositoryImpl + + +def test_single_table_crud(): + print("\n=== Testando Single-Table Design com MiniStack ===\n") + + dynamodb_service = DynamoDBServiceMiniStack() + + order_repo = OrderRepositoryImpl(dynamodb_service) + cert_repo = CertificateRepositoryImpl(dynamodb_service) + product_repo = ProductRepositoryImpl(dynamodb_service) + participant_repo = ParticipantRepositoryImpl(dynamodb_service) + + print("1. Criando Product...") + product = Product( + product_id=316, + product_name="Python Workshop", + certificate_details="Workshop de Python", + certificate_logo="logo.png", + certificate_background="bg.png", + checkin_latitude="-27.59", + checkin_longitude="-48.55", + time_checkin="09:00" + ) + created_product = product_repo.create(product) + print(f" Product criado: {created_product.product_id}") + + print("\n2. Criando Participant...") + participant = Participant( + id=uuid.uuid4(), + first_name="John", + last_name="Doe", + email="john.doe@example.com", + phone="+5511999999999", + cpf="12345678900", + city="Florianopolis" + ) + created_participant = participant_repo.create(participant) + print(f" Participant criado: {created_participant.email}") + + print("\n3. Criando Order...") + order = Order( + order_id=1001, + order_date="2025-01-15 10:00:00", + product_id=316, + product_name="Python Workshop", + certificate_details="Workshop de Python", + certificate_logo="logo.png", + certificate_background="bg.png", + checkin_latitude="-27.59", + checkin_longitude="-48.55", + time_checkin="09:00", + participant_email="john.doe@example.com", + participant_first_name="John", + participant_last_name="Doe", + participant_cpf="12345678900", + participant_phone="+5511999999999", + participant_city="Florianopolis" + ) + created_order = order_repo.create(order) + print(f" Order criada: {created_order.order_id}") + + print("\n4. Criando Certificate...") + cert_id = uuid.uuid4() + certificate = Certificate( + id=cert_id, + success=False, + certificate_key=None, + certificate_url=None, + generated_date=None, + order_id=1001, + order_date="2025-01-15 10:00:00", + product_id=316, + product_name="Python Workshop", + certificate_details="Workshop de Python", + certificate_logo="logo.png", + certificate_background="bg.png", + participant_email="john.doe@example.com", + participant_first_name="John", + participant_last_name="Doe", + participant_cpf="12345678900", + participant_phone="+5511999999999", + participant_city="Florianopolis" + ) + created_cert = cert_repo.create(certificate) + print(f" Certificate criado: {created_cert.id}") + + print("\n5. Buscando por ID...") + found_order = order_repo.get_by_id(1001) + print(f" Order encontrada: {found_order.order_id if found_order else 'NONE'}") + + print("\n6. Buscando Product por ID...") + found_product = product_repo.get_by_id(316) + print(f" Product encontrada: {found_product.product_name if found_product else 'NONE'}") + + print("\n7. Buscando Participant por email...") + found_participant = participant_repo.get_by_email("john.doe@example.com") + print(f" Participant encontrado: {found_participant.email if found_participant else 'NONE'}") + + print("\n8. Buscando Certificate por order_id...") + found_certs = cert_repo.get_by_order_id(1001) + print(f" Certificates encontrados: {len(found_certs)}") + + print("\n9. Buscando Orders por email...") + orders_by_email = order_repo.get_by_participant_email("john.doe@example.com") + print(f" Orders por email: {len(orders_by_email)}") + + print("\n10. Buscando Certificates por email...") + certs_by_email = cert_repo.get_by_participant_email("john.doe@example.com") + print(f" Certificates por email: {len(certs_by_email)}") + + print("\n11. Buscando Certificates por product_id...") + certs_by_product = cert_repo.get_by_product_id(316) + print(f" Certificates por product_id: {len(certs_by_product)}") + + print("\n12. Buscando Participants por city...") + participants_by_city = participant_repo.get_by_city("Florianopolis") + print(f" Participants por city: {len(participants_by_city)}") + + print("\n13. Atualizando Certificate (simulando sucesso)...") + certificate.success = True + certificate.certificate_key = "cert-1001-key" + updated_cert = cert_repo.update(str(cert_id), certificate) + print(f" Certificate atualizado: success={updated_cert.success if updated_cert else 'NONE'}") + + print("\n14. Buscando Certificates bem-sucedidos...") + successful_certs = cert_repo.get_successful_certificates() + print(f" Certificates bem-sucedidos: {len(successful_certs)}") + + print("\n15. Deletando Certificate...") + deleted = cert_repo.delete(str(cert_id)) + print(f" Certificate deletado: {deleted}") + + print("\n16. Listando todos os Certificates...") + all_certs = cert_repo.get_all() + print(f" Total de certificates: {len(all_certs)}") + + print("\n17. Listando todos os Products...") + all_products = product_repo.get_all() + print(f" Total de products: {len(all_products)}") + + print("\n=== Teste Concluído com Sucesso! ===\n") + + return True + + +if __name__ == "__main__": + try: + test_single_table_crud() + except Exception as e: + print(f"\nERRO: {e}") + import traceback + traceback.print_exc() + sys.exit(1) From 16ed8962532827314c11053418e45ae7e5b57a3c Mon Sep 17 00:00:00 2001 From: maxsonferovante Date: Sat, 5 Sep 2026 09:39:29 -0300 Subject: [PATCH 2/4] docs: add DynamoDB single-table design documentation --- README.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/README.md b/README.md index 83e7bf9..05b2436 100644 --- a/README.md +++ b/README.md @@ -218,3 +218,95 @@ Gera uma página para download de um certificado. - `test_list_user_certificates("suzi.harima94@gmail.com", success="false")` - Testar e-mail com URL encoding: - `test_list_user_certificates("user+qa@example.com")` + +## DynamoDB Single-Table Design + +Este projeto utiliza o padrão DynamoDB Single-Table Design, onde todas as entidades (Orders, Certificates, Products, Participants) são armazenadas em uma única tabela DynamoDB. + +### Estrutura da Tabela + +| Atributo | Tipo | Descrição | +|----------|------|-----------| +| `PK` | String | Chave de Partição principal | +| `SK` | String | Chave de Ordenação principal | +| `GSI1PK` | String | Chave de Partição do GSI1 | +| `GSI1SK` | String | Chave de Ordenação do GSI1 | +| `GSI2PK` | String | Chave de Partição do GSI2 | +| `GSI2SK` | String | Chave de Ordenação do GSI2 | +| `GSI3PK` | String | Chave de Partição do GSI3 | +| `GSI3SK` | String | Chave de Ordenação do GSI3 | +| `GSI4PK` | String | Chave de Partição do GSI4 | +| `GSI4SK` | String | Chave de Ordenação do GSI4 | +| `GSI5PK` | String | Chave de Partição do GSI5 | +| `GSI5SK` | String | Chave de Ordenação do GSI5 | +| `EntityType` | String | Tipo da entidade (ORDER, CERTIFICATE, PRODUCT, PARTICIPANT) | + +### GSIs (Global Secondary Indexes) + +| GSI | Key Schema | Access Pattern | +|-----|------------|----------------| +| GSI1 | `PK: UUID, SK: CERT#` | Certificate by UUID | +| GSI2 | `PK: email, SK: ENTITY#` | Orders, Certificates, Participants by email | +| GSI3 | `PK: product, SK: ENTITY#` | Products by name, Certificates/Orders by product | +| GSI4 | `PK: SUCCESS#Y/N, SK: CERT#` | Successful/Failed certificates | +| GSI5 | `PK: CITY#name, SK: PART#` | Participants by city | + +### Entidades e Keys + +#### Certificate +| Key | Value | +|-----|-------| +| PK | `CERTIFICATE#` | +| SK | `CERTIFICATE#` | +| GSI1PK | `` | +| GSI1SK | `CERTIFICATE#` | +| GSI2PK | `EMAIL#` | +| GSI2SK | `CERTIFICATE#` | +| GSI3PK | `PRODUCT#` | +| GSI3SK | `CERTIFICATE#` | +| GSI4PK | `SUCCESS#` | +| GSI4SK | `CERTIFICATE#` | + +#### Order +| Key | Value | +|-----|-------| +| PK | `ORDER#` | +| SK | `ORDER#` | +| GSI2PK | `EMAIL#` | +| GSI2SK | `ORDER#` | +| GSI3PK | `PRODUCT#` | +| GSI3SK | `ORDER#` | + +#### Product +| Key | Value | +|-----|-------| +| PK | `PRODUCT#` | +| SK | `PRODUCT#` | +| GSI3PK | `PRODUCT#` | +| GSI3SK | `PRODUCT#` | + +#### Participant +| Key | Value | +|-----|-------| +| PK | `PARTICIPANT#` | +| SK | `PARTICIPANT#` | +| GSI2PK | `EMAIL#` | +| GSI2SK | `PARTICIPANT#` | +| GSI5PK | `CITY#` | +| GSI5SK | `PARTICIPANT#` | + +### Normalização de Email + +Emails são normalizados para consistência: +- Convertido para minúsculas +- Remoção de pontos antes do `@` (Gmail) +- Remoção de `+` e tudo após + +### Repositórios + +Cada repositório implementa operações CRUD usando as chaves apropriadas: + +- **OrderRepositoryImpl**: GSI2 (email), GSI3 (product) +- **CertificateRepositoryImpl**: GSI1 (UUID), GSI2 (email), GSI3 (product), GSI4 (success) +- **ProductRepositoryImpl**: GSI3 (name) +- **ParticipantRepositoryImpl**: GSI2 (email), GSI5 (city) From 6f3f8b942ce56ae518d592efc982bcabda9ca340 Mon Sep 17 00:00:00 2001 From: maxsonferovante Date: Mon, 7 Sep 2026 19:10:39 -0300 Subject: [PATCH 3/4] fix: remove cpf/city access patterns per PR feedback - Remove get_by_cpf method - Remove get_by_city method - Remove cpf_exists method - Remove GSI5PK/GSI5SK from _prepare_item - Remove gsi4pk_city and gsi4sk_participant from dynamodb_keys - Update integration test to remove city lookup --- src/infrastructure/aws/dynamodb_keys.py | 8 ---- .../repository/participant_repository_impl.py | 39 ------------------- tests/test_single_table_integration.py | 14 +++---- 3 files changed, 5 insertions(+), 56 deletions(-) diff --git a/src/infrastructure/aws/dynamodb_keys.py b/src/infrastructure/aws/dynamodb_keys.py index 4a6cba4..4f77503 100644 --- a/src/infrastructure/aws/dynamodb_keys.py +++ b/src/infrastructure/aws/dynamodb_keys.py @@ -69,14 +69,6 @@ def gsi3sk_certificate(order_id: int) -> str: return f"CERTIFICATE#{order_id}" -def gsi4pk_city(city: str) -> str: - return f"CITY#{city}" - - -def gsi4sk_participant(participant_id: str) -> str: - return f"PARTICIPANT#{participant_id}" - - def gsi1pk_cert_id(cert_id: str) -> str: return f"CERT#{cert_id}" diff --git a/src/infrastructure/repository/participant_repository_impl.py b/src/infrastructure/repository/participant_repository_impl.py index 5586eee..b447a54 100644 --- a/src/infrastructure/repository/participant_repository_impl.py +++ b/src/infrastructure/repository/participant_repository_impl.py @@ -140,33 +140,6 @@ def get_by_email(self, email: str) -> Optional[Participant]: logger.error(f"Erro ao buscar participante por email {email}: {str(e)}") raise - def get_by_cpf(self, cpf: str) -> Optional[Participant]: - try: - items = self.dynamodb_service.query_table( - "single", - "EntityType = :entity_type AND cpf = :cpf", - {":entity_type": EntityType.PARTICIPANT.value, ":cpf": cpf}, - ) - if items: - return Participant(**items[0]) - return None - except Exception as e: - logger.error(f"Erro ao buscar participante por CPF {cpf}: {str(e)}") - raise - - def get_by_city(self, city: str) -> List[Participant]: - try: - items = self.dynamodb_service.query_table( - "single", - "GSI5PK = :gsi5pk", - {":gsi5pk": f"CITY#{city}"}, - index_name="GSI5", - ) - return [Participant(**item) for item in items] - except Exception as e: - logger.error(f"Erro ao buscar participantes por cidade {city}: {str(e)}") - raise - def email_exists(self, email: str) -> bool: try: participant = self.get_by_email(email) @@ -175,28 +148,16 @@ def email_exists(self, email: str) -> bool: logger.error(f"Erro ao verificar existência do email {email}: {str(e)}") return False - def cpf_exists(self, cpf: str) -> bool: - try: - participant = self.get_by_cpf(cpf) - return participant is not None - except Exception as e: - logger.error(f"Erro ao verificar existência do CPF {cpf}: {str(e)}") - return False - def _prepare_item(self, entity: Participant) -> dict: item = entity.model_dump() participant_id = str(entity.id) email = _normalize_email(item.get("email")) - city = item.get("city") item["PK"] = pk(EntityType.PARTICIPANT, participant_id) item["SK"] = sk(EntityType.PARTICIPANT, participant_id) item["EntityType"] = EntityType.PARTICIPANT.value item["GSI2PK"] = gsi1pk_email(email) if email else None item["GSI2SK"] = f"PARTICIPANT#{participant_id}" - if city: - item["GSI5PK"] = f"CITY#{city}" - item["GSI5SK"] = f"PARTICIPANT#{participant_id}" item["id"] = participant_id item["email"] = email return {k: v for k, v in item.items() if v is not None} diff --git a/tests/test_single_table_integration.py b/tests/test_single_table_integration.py index 034e80d..792aa80 100644 --- a/tests/test_single_table_integration.py +++ b/tests/test_single_table_integration.py @@ -274,29 +274,25 @@ def test_single_table_crud(): certs_by_product = cert_repo.get_by_product_id(316) print(f" Certificates por product_id: {len(certs_by_product)}") - print("\n12. Buscando Participants por city...") - participants_by_city = participant_repo.get_by_city("Florianopolis") - print(f" Participants por city: {len(participants_by_city)}") - - print("\n13. Atualizando Certificate (simulando sucesso)...") + print("\n12. Atualizando Certificate (simulando sucesso)...") certificate.success = True certificate.certificate_key = "cert-1001-key" updated_cert = cert_repo.update(str(cert_id), certificate) print(f" Certificate atualizado: success={updated_cert.success if updated_cert else 'NONE'}") - print("\n14. Buscando Certificates bem-sucedidos...") + print("\n13. Buscando Certificates bem-sucedidos...") successful_certs = cert_repo.get_successful_certificates() print(f" Certificates bem-sucedidos: {len(successful_certs)}") - print("\n15. Deletando Certificate...") + print("\n14. Deletando Certificate...") deleted = cert_repo.delete(str(cert_id)) print(f" Certificate deletado: {deleted}") - print("\n16. Listando todos os Certificates...") + print("\n15. Listando todos os Certificates...") all_certs = cert_repo.get_all() print(f" Total de certificates: {len(all_certs)}") - print("\n17. Listando todos os Products...") + print("\n16. Listando todos os Products...") all_products = product_repo.get_all() print(f" Total de products: {len(all_products)}") From d6e1108efc4122837f3e7f2cb880c2b89c9c4114 Mon Sep 17 00:00:00 2001 From: maxsonferovante Date: Mon, 7 Sep 2026 19:11:45 -0300 Subject: [PATCH 4/4] docs: remove GSI5 from single-table documentation --- README.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/README.md b/README.md index 05b2436..5c2c2b3 100644 --- a/README.md +++ b/README.md @@ -237,8 +237,6 @@ Este projeto utiliza o padrão DynamoDB Single-Table Design, onde todas as entid | `GSI3SK` | String | Chave de Ordenação do GSI3 | | `GSI4PK` | String | Chave de Partição do GSI4 | | `GSI4SK` | String | Chave de Ordenação do GSI4 | -| `GSI5PK` | String | Chave de Partição do GSI5 | -| `GSI5SK` | String | Chave de Ordenação do GSI5 | | `EntityType` | String | Tipo da entidade (ORDER, CERTIFICATE, PRODUCT, PARTICIPANT) | ### GSIs (Global Secondary Indexes) @@ -249,7 +247,6 @@ Este projeto utiliza o padrão DynamoDB Single-Table Design, onde todas as entid | GSI2 | `PK: email, SK: ENTITY#` | Orders, Certificates, Participants by email | | GSI3 | `PK: product, SK: ENTITY#` | Products by name, Certificates/Orders by product | | GSI4 | `PK: SUCCESS#Y/N, SK: CERT#` | Successful/Failed certificates | -| GSI5 | `PK: CITY#name, SK: PART#` | Participants by city | ### Entidades e Keys @@ -292,8 +289,6 @@ Este projeto utiliza o padrão DynamoDB Single-Table Design, onde todas as entid | SK | `PARTICIPANT#` | | GSI2PK | `EMAIL#` | | GSI2SK | `PARTICIPANT#` | -| GSI5PK | `CITY#` | -| GSI5SK | `PARTICIPANT#` | ### Normalização de Email @@ -309,4 +304,4 @@ Cada repositório implementa operações CRUD usando as chaves apropriadas: - **OrderRepositoryImpl**: GSI2 (email), GSI3 (product) - **CertificateRepositoryImpl**: GSI1 (UUID), GSI2 (email), GSI3 (product), GSI4 (success) - **ProductRepositoryImpl**: GSI3 (name) -- **ParticipantRepositoryImpl**: GSI2 (email), GSI5 (city) +- **ParticipantRepositoryImpl**: GSI2 (email)