Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions app/adapters/dynamodb_unit_of_work.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
from mypy_boto3_dynamodb import client

from app.adapters.internal import dynamodb_base
from app.domain.exceptions import repository_exception
from app.domain.model import product, product_version
from app.domain.ports import unit_of_work

DYNAMODB_TRANSACTION_LIMIT = 25


class DBPrefix(enum.Enum):
PRODUCT = "PRODUCT"
Expand Down Expand Up @@ -56,8 +59,17 @@ def update_attributes(self, product_id: str, **kwargs) -> None:
)

def delete(self, product_id: str) -> None:
key = self.generate_product_key(product_id)
self.delete_generic_item(key=key)
"""Deletes all records with the given product_id as partition key (product + versions)."""
pk_value = f"{DBPrefix.PRODUCT.value}#{product_id}"
request = self._create_query_by_pk_request(pk_value)
items = self._context.query_items(request)
if len(items) > DYNAMODB_TRANSACTION_LIMIT:
raise repository_exception.RepositoryException(
f"Cannot delete: {len(items)} items exceed DynamoDB transaction limit of {DYNAMODB_TRANSACTION_LIMIT}."
)
for item in items:
key = {"PK": item["PK"], "SK": item["SK"]}
self.delete_generic_item(key=key)

@staticmethod
def generate_product_key(product_id: str) -> dict:
Expand Down
21 changes: 21 additions & 0 deletions app/adapters/internal/dynamodb_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ def get_generic_item(self, request: dict) -> Any:

return item["Item"] if "Item" in item else None

def query_items(self, query: dict) -> List[dict]:
"""
Queries all items with the given partition key value.
Returns a list of items (each with PK and SK in DynamoDB format).
"""
items: List[dict] = []
while True:
response = self._dynamo_db_client.query(**query)
items.extend(response.get("Items", []))
if "LastEvaluatedKey" not in response:
break
query["ExclusiveStartKey"] = response["LastEvaluatedKey"]
return items


class DynamoDBRepository:
"""Generic DynamoDB repository."""
Expand Down Expand Up @@ -86,3 +100,10 @@ def _create_get_request(self, key: dict) -> dict:

def _create_delete_modifier(self, key: dict) -> dict:
return {"Delete": {"TableName": self._table_name, "Key": key}}

def _create_query_by_pk_request(self, pk_value: str) -> dict:
return {
"TableName": self._table_name,
"KeyConditionExpression": "PK = :pk",
"ExpressionAttributeValues": {":pk": pk_value},
}
59 changes: 58 additions & 1 deletion app/adapters/tests/test_dynamodb_unit_of_work.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pytest

from app.adapters import dynamodb_unit_of_work
from app.domain.model import product
from app.domain.model import product, product_version

TEST_TABLE_NAME = "test-table"

Expand Down Expand Up @@ -168,3 +168,60 @@ def test_delete_and_commit_should_delete_product(mock_dynamodb):
product_from_db = unit_of_work_readonly.products.get(new_product_id)

assertpy.assert_that(product_from_db).is_none()


def test_delete_should_remove_product_and_all_versions(mock_dynamodb):
"""Delete removes all records with the product_id as partition key (product + versions)."""
# Arrange
unit_of_work = dynamodb_unit_of_work.DynamoDBUnitOfWork(
table_name=TEST_TABLE_NAME, dynamodb_client=mock_dynamodb.meta.client
)
unit_of_work_readonly = dynamodb_unit_of_work.DynamoDBUnitOfWork(
table_name=TEST_TABLE_NAME, dynamodb_client=mock_dynamodb.meta.client
)
current_time = datetime.datetime.now(datetime.timezone.utc).isoformat()

new_product_id = str(uuid.uuid4())
new_product = product.Product(
id=new_product_id,
name="test-name",
description="test-description",
createDate=current_time,
lastUpdateDate=current_time,
)
version_1 = product_version.ProductVersion(
id="v1",
name="Version 1",
version="1.0.0",
createDate=current_time,
)
version_2 = product_version.ProductVersion(
id="v2",
name="Version 2",
version="2.0.0",
createDate=current_time,
)
with unit_of_work:
unit_of_work.products.add(new_product)
unit_of_work.product_versions.add(new_product_id, version_1)
unit_of_work.product_versions.add(new_product_id, version_2)
unit_of_work.commit()

# Act
with unit_of_work:
unit_of_work.products.delete(new_product_id)
unit_of_work.commit()

# Assert - product and all versions should be gone
with unit_of_work_readonly:
product_from_db = unit_of_work_readonly.products.get(new_product_id)
version_1_from_db = unit_of_work_readonly.product_versions.get(
new_product_id, "v1"
)
version_2_from_db = unit_of_work_readonly.product_versions.get(
new_product_id, "v2"
)

assertpy.assert_that(product_from_db).is_none()
assertpy.assert_that(version_1_from_db).is_none()
assertpy.assert_that(version_2_from_db).is_none()
33 changes: 33 additions & 0 deletions app/domain/command_handlers/add_product_version_command_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import uuid
from datetime import datetime, timezone
from typing import Optional

from app.domain.commands import add_product_version_command
from app.domain.model import product, product_version
from app.domain.ports import unit_of_work


def handle_add_product_version_command(
command: add_product_version_command.AddProductVersionCommand,
unit_of_work: unit_of_work.UnitOfWork,
) -> Optional[str]:

with unit_of_work:
product_obj = unit_of_work.products.get(product_id=command.product_id)
if not product_obj:
return None

current_time = datetime.now(timezone.utc).isoformat()
id = str(uuid.uuid4())

version_obj = product_version.ProductVersion(
id=id,
name=command.name,
version=command.version,
createDate=current_time,
)

unit_of_work.product_versions.add(command.product_id, version_obj)
unit_of_work.commit()

return id
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def handle_create_product_command(
command: create_product_command.CreateProductCommand,
unit_of_work: unit_of_work.UnitOfWork,
) -> str:

current_time = datetime.now(timezone.utc).isoformat()
id = str(uuid.uuid4())

Expand Down
2 changes: 1 addition & 1 deletion app/domain/command_handlers/get_product_command_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ def handle_get_product_command(
query_service: products_query_service.ProductsQueryService,
) -> Optional[product.Product]:

product_obj = query_service.get_product_by_id(command.id)
product_obj = query_service.get_product_by_id(product_id=command.id)

return product_obj
9 changes: 9 additions & 0 deletions app/domain/commands/add_product_version_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from typing import Optional

from pydantic import BaseModel


class AddProductVersionCommand(BaseModel):
product_id: str
name: Optional[str]
version: str
67 changes: 49 additions & 18 deletions app/domain/tests/test_command_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
create_product_command_handler,
delete_product_command_handler,
update_product_command_handler,
add_product_version_command_handler,
)
from app.domain.commands import (
get_product_command,
list_products_command,
create_product_command,
delete_product_command,
update_product_command,
add_product_version_command,
)
from app.domain.ports import products_query_service, unit_of_work

Expand Down Expand Up @@ -64,14 +66,21 @@ def test_list_products_should_query_from_repository():
)


def test_create_product_should_store_in_repository():
# Arrange
mock_unit_of_work = unittest.mock.create_autospec(
spec=unit_of_work.UnitOfWork, instance=True
)
mock_unit_of_work.products = unittest.mock.create_autospec(
def _create_mock_unit_of_work():
"""Create a mock UnitOfWork that works correctly as a context manager."""
mock_uow = unittest.mock.MagicMock()
mock_uow.products = unittest.mock.create_autospec(
spec=unit_of_work.ProductsRepository, instance=True
)
mock_uow.commit = unittest.mock.Mock()
mock_uow.__enter__ = unittest.mock.Mock(return_value=mock_uow)
mock_uow.__exit__ = unittest.mock.Mock(return_value=None)
return mock_uow


def test_create_product_should_store_in_repository():
# Arrange
mock_unit_of_work = _create_mock_unit_of_work()

command = create_product_command.CreateProductCommand(
name="Test Product",
Expand All @@ -93,12 +102,7 @@ def test_create_product_should_store_in_repository():

def test_update_product_should_only_update_specified_property():
# Arrange
mock_unit_of_work = unittest.mock.create_autospec(
spec=unit_of_work.UnitOfWork, instance=True
)
mock_unit_of_work.products = unittest.mock.create_autospec(
spec=unit_of_work.ProductsRepository, instance=True
)
mock_unit_of_work = _create_mock_unit_of_work()

# Update only the description
product_id = str(uuid.uuid4())
Expand All @@ -122,12 +126,7 @@ def test_update_product_should_only_update_specified_property():

def test_delete_product_should_delete_from_repository():
# Arrange
mock_unit_of_work = unittest.mock.create_autospec(
spec=unit_of_work.UnitOfWork, instance=True
)
mock_unit_of_work.products = unittest.mock.create_autospec(
spec=unit_of_work.ProductsRepository, instance=True
)
mock_unit_of_work = _create_mock_unit_of_work()

product_id = str(uuid.uuid4())
command = delete_product_command.DeleteProductCommand(id=product_id)
Expand All @@ -144,3 +143,35 @@ def test_delete_product_should_delete_from_repository():
]

assertpy.assert_that(deleted_product_id).is_equal_to(product_id)


def test_add_product_version_should_store_in_repository():
# Arrange
mock_unit_of_work = _create_mock_unit_of_work()
mock_unit_of_work.product_versions = unittest.mock.create_autospec(
spec=unit_of_work.ProductVersionsRepository, instance=True
)
mock_unit_of_work.products.get.return_value = unittest.mock.MagicMock()

command = add_product_version_command.AddProductVersionCommand(
product_id="Test Product ID",
name="Test Product",
version="Test Version",
)

# Act
add_product_version_command_handler.handle_add_product_version_command(
command=command, unit_of_work=mock_unit_of_work
)

# Assert
mock_unit_of_work.products.get.assert_called_once_with(
product_id="Test Product ID"
)
product_id = mock_unit_of_work.products.get.call_args.kwargs["product_id"]
version = mock_unit_of_work.product_versions.add.call_args.args[1]
mock_unit_of_work.commit.assert_called_once()

assertpy.assert_that(product_id).is_equal_to("Test Product ID")
assertpy.assert_that(version.name).is_equal_to("Test Product")
assertpy.assert_that(version.version).is_equal_to("Test Version")
26 changes: 26 additions & 0 deletions app/entrypoints/api/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
create_product_command_handler,
delete_product_command_handler,
update_product_command_handler,
add_product_version_command_handler,
)
from app.domain.commands import (
get_product_command,
list_products_command,
create_product_command,
delete_product_command,
update_product_command,
add_product_version_command,
)
from app.domain.exceptions.domain_exception import DomainException
from app.entrypoints.api import config
Expand Down Expand Up @@ -147,6 +149,30 @@ def delete_product(
return response.dict()


@tracer.capture_method
@app.post("/products/<id>/versions")
@utils.parse_event(model=api_model.AddProductVersionRequest, app_context=app)
def add_product_version(
request: api_model.AddProductVersionRequest, id: str
) -> api_model.CreateProductResponse:
"""Adds a version to a product."""

product = add_product_version_command_handler.handle_add_product_version_command(
command=add_product_version_command.AddProductVersionCommand(
product_id=id,
name=request.name,
version=request.version,
),
unit_of_work=unit_of_work,
)

if not product:
raise DomainException(f"Could not locate product with id: {id}.")

response = api_model.CreateProductResponse(id=id)
return response.dict()


@tracer.capture_lambda_handler
@logger.inject_lambda_context(log_event=True)
@data_classes.event_source(
Expand Down
4 changes: 4 additions & 0 deletions app/entrypoints/api/model/api_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ class Product(BaseModel):
class ListProductsResponse(BaseModel):
nextToken: Optional[Dict[str, Any]] = Field(title="LastEvaluatedKey token")
products: List[Product] = Field(..., title="Products")

class AddProductVersionRequest(BaseModel):
name: Optional[str] = Field(title="Name")
version: str = Field(..., title="Version")
Loading