diff --git a/adapter/pom.xml b/adapter/pom.xml index c56fdf2..ba2ab42 100644 --- a/adapter/pom.xml +++ b/adapter/pom.xml @@ -34,6 +34,10 @@ org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-data-mongodb + com.mysql mysql-connector-j @@ -61,6 +65,22 @@ mysql test + + org.testcontainers + mongodb + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + junit-jupiter + test + + diff --git a/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/CartLineItemMongoEntity.java b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/CartLineItemMongoEntity.java new file mode 100644 index 0000000..805806a --- /dev/null +++ b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/CartLineItemMongoEntity.java @@ -0,0 +1,21 @@ +package eu.happycoders.shop.adapter.out.persistence.mongo; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.mongodb.core.mapping.DBRef; +import org.springframework.data.mongodb.core.mapping.Document; + +/** + * MongoDB document class for a shopping cart line item. + * + * @author Alfredo Rueda & Francisco José Nebrera + */ +@Document(collection = "CartLineItem") +@Getter +@Setter +public class CartLineItemMongoEntity { + + @DBRef private ProductMongoEntity product; + + private int quantity; +} diff --git a/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/CartMapper.java b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/CartMapper.java new file mode 100644 index 0000000..f27c330 --- /dev/null +++ b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/CartMapper.java @@ -0,0 +1,52 @@ +package eu.happycoders.shop.adapter.out.persistence.mongo; + +import eu.happycoders.shop.model.cart.Cart; +import eu.happycoders.shop.model.cart.CartLineItem; +import eu.happycoders.shop.model.customer.CustomerId; + +/** + * Maps model carts and line items to MongoDB carts and line items - and vice versa. + * + * @author Alfredo Rueda & Francisco José Nebrera + */ +final class CartMapper { + + private CartMapper() {} + + static CartMongoEntity toMongoEntity(Cart cart) { + CartMongoEntity cartMongoEntity = new CartMongoEntity(); + cartMongoEntity.setCustomerId(cart.id().value()); + + cartMongoEntity.setLineItems( + cart.lineItems().stream() + .map(lineItem -> toMongoEntity(cartMongoEntity, lineItem)) + .toList()); + + return cartMongoEntity; + } + + static CartLineItemMongoEntity toMongoEntity( + CartMongoEntity cartMongoEntity, CartLineItem lineItem) { + ProductMongoEntity productMongoEntity = new ProductMongoEntity(); + productMongoEntity.setId(lineItem.product().id().value()); + + CartLineItemMongoEntity entity = new CartLineItemMongoEntity(); + entity.setProduct(productMongoEntity); + entity.setQuantity(lineItem.quantity()); + + return entity; + } + + static Cart toModelEntity(CartMongoEntity cartMongoEntity) { + CustomerId customerId = new CustomerId(cartMongoEntity.getCustomerId()); + Cart cart = new Cart(customerId); + + for (CartLineItemMongoEntity lineItemMongoEntity : cartMongoEntity.getLineItems()) { + cart.putProductIgnoringNotEnoughItemsInStock( + ProductMapper.toModelEntity(lineItemMongoEntity.getProduct()), + lineItemMongoEntity.getQuantity()); + } + + return cart; + } +} diff --git a/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/CartMongoEntity.java b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/CartMongoEntity.java new file mode 100644 index 0000000..e9ccff0 --- /dev/null +++ b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/CartMongoEntity.java @@ -0,0 +1,23 @@ +package eu.happycoders.shop.adapter.out.persistence.mongo; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + +/** + * MongoDB document class for a shopping cart. + * + * @author Alfredo Rueda & Francisco José Nebrera + */ +@Document(collection = "Cart") +@Getter +@Setter +public class CartMongoEntity { + + @Id private int customerId; + + private List lineItems; +} diff --git a/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoCartRepository.java b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoCartRepository.java new file mode 100644 index 0000000..b83f8df --- /dev/null +++ b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoCartRepository.java @@ -0,0 +1,46 @@ +package eu.happycoders.shop.adapter.out.persistence.mongo; + +import eu.happycoders.shop.application.port.out.persistence.CartRepository; +import eu.happycoders.shop.model.cart.Cart; +import eu.happycoders.shop.model.customer.CustomerId; +import jakarta.transaction.Transactional; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * Persistence adapter: Stores carts via MongoDB in a database. + * @author Alfredo Rueda & Francisco José Nebrera + */ +@ConditionalOnProperty(name = "persistence", havingValue = "mongodb") +@Repository +public class MongoCartRepository implements CartRepository { + + private final MongoCartSpringDataRepository mongoCartSpringDataRepository; + + public MongoCartRepository( + MongoCartSpringDataRepository mongoCartSpringDataRepository) { + this.mongoCartSpringDataRepository = mongoCartSpringDataRepository; + } + + @Override + @Transactional + public void save(Cart cart) { + mongoCartSpringDataRepository.save(CartMapper.toMongoEntity(cart)); + } + + @Override + @Transactional + public Optional findByCustomerId(CustomerId customerId) { + Optional cartMongoEntity = + mongoCartSpringDataRepository.findById(customerId.value()); + return cartMongoEntity.map(CartMapper::toModelEntity); + } + + @Override + @Transactional + public void deleteByCustomerId(CustomerId customerId) { + mongoCartSpringDataRepository.deleteById(customerId.value()); + } +} diff --git a/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoCartSpringDataRepository.java b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoCartSpringDataRepository.java new file mode 100644 index 0000000..35cfaff --- /dev/null +++ b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoCartSpringDataRepository.java @@ -0,0 +1,12 @@ +package eu.happycoders.shop.adapter.out.persistence.mongo; + +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +/** + * Spring Data repository for {@link CartMongoEntity}. + * + * @author Alfredo Rueda & Francisco José Nebrera + */ +@Repository +public interface MongoCartSpringDataRepository extends MongoRepository {} diff --git a/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoProductRepository.java b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoProductRepository.java new file mode 100644 index 0000000..e33065b --- /dev/null +++ b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoProductRepository.java @@ -0,0 +1,56 @@ +package eu.happycoders.shop.adapter.out.persistence.mongo; + +import eu.happycoders.shop.adapter.out.persistence.DemoProducts; +import eu.happycoders.shop.application.port.out.persistence.ProductRepository; +import eu.happycoders.shop.model.product.Product; +import eu.happycoders.shop.model.product.ProductId; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * Persistence adapter: Stores products via MongoDB. + * + * @author Alfredo Rueda & Francisco José Nebrera + */ +@ConditionalOnProperty(name = "persistence", havingValue = "mongodb") +@Repository +public class MongoProductRepository implements ProductRepository { + + private final MongoProductSpringDataRepository springDataRepository; + + public MongoProductRepository(MongoProductSpringDataRepository springDataRepository) { + this.springDataRepository = springDataRepository; + } + + @PostConstruct + void createDemoProducts() { + DemoProducts.DEMO_PRODUCTS.forEach(this::save); + } + + @Override + @Transactional + public void save(Product product) { + springDataRepository.save(ProductMapper.toMongoEntity(product)); + } + + @Override + @Transactional + public Optional findById(ProductId productId) { + Optional mongoEntity = springDataRepository.findById(productId.value()); + return mongoEntity.map(ProductMapper::toModelEntity); + } + + @Override + @Transactional + public List findByNameOrDescription(String queryString) { + List entities = + springDataRepository.findByNameOrDescriptionLike(queryString); + + return ProductMapper.toModelEntities(entities); + } +} diff --git a/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoProductSpringDataRepository.java b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoProductSpringDataRepository.java new file mode 100644 index 0000000..4cad5e0 --- /dev/null +++ b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoProductSpringDataRepository.java @@ -0,0 +1,21 @@ +package eu.happycoders.shop.adapter.out.persistence.mongo; + +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Spring Data repository for {@link ProductMongoEntity}. + * + * @author Alfredo Rueda & Francisco José Nebrera + */ +@Repository +public interface MongoProductSpringDataRepository + extends MongoRepository { + + @Query("{ '$or': [ { 'name': { '$regex': ?0, '$options': 'i' } }, { 'description': { '$regex': ?0, '$options': 'i' } } ] }") + List findByNameOrDescriptionLike(String pattern); + +} diff --git a/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/ProductMapper.java b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/ProductMapper.java new file mode 100644 index 0000000..f03a46d --- /dev/null +++ b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/ProductMapper.java @@ -0,0 +1,45 @@ +package eu.happycoders.shop.adapter.out.persistence.mongo; + +import eu.happycoders.shop.model.money.Money; +import eu.happycoders.shop.model.product.Product; +import eu.happycoders.shop.model.product.ProductId; + +import java.util.Currency; +import java.util.List; + +/** + * Maps a model product to a MongoDB product and vice versa. + * + * @author Alfredo Rueda & Francisco José Nebrera + */ +final class ProductMapper { + + private ProductMapper() {} + + static ProductMongoEntity toMongoEntity(Product product) { + ProductMongoEntity mongoEntity = new ProductMongoEntity(); + + mongoEntity.setId(product.id().value()); + mongoEntity.setName(product.name()); + mongoEntity.setDescription(product.description()); + mongoEntity.setPriceCurrency(product.price().currency().getCurrencyCode()); + mongoEntity.setPriceAmount(product.price().amount()); + mongoEntity.setItemsInStock(product.itemsInStock()); + + return mongoEntity; + } + + static Product toModelEntity(ProductMongoEntity mongoEntity) { + return new Product( + new ProductId(mongoEntity.getId()), + mongoEntity.getName(), + mongoEntity.getDescription(), + new Money( + Currency.getInstance(mongoEntity.getPriceCurrency()), mongoEntity.getPriceAmount()), + mongoEntity.getItemsInStock()); + } + + static List toModelEntities(List mongoEntities) { + return mongoEntities.stream().map(ProductMapper::toModelEntity).toList(); + } +} diff --git a/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/ProductMongoEntity.java b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/ProductMongoEntity.java new file mode 100644 index 0000000..6dd8665 --- /dev/null +++ b/adapter/src/main/java/eu/happycoders/shop/adapter/out/persistence/mongo/ProductMongoEntity.java @@ -0,0 +1,37 @@ +package eu.happycoders.shop.adapter.out.persistence.mongo; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.math.BigDecimal; + +/** + * MongoDB document class for a product. + * + * @author Alfredo Rueda & Francisco José Nebrera + */ +@Document(collection = "Product") +@Getter +@Setter +public class ProductMongoEntity { + + @Id private String id; + + @Field("name") + private String name; + + @Field("description") + private String description; + + @Field("price_currency") + private String priceCurrency; + + @Field("price_amount") + private BigDecimal priceAmount; + + @Field("items_in_stock") + private int itemsInStock; +} diff --git a/adapter/src/test/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoDBCartRepositoryTest.java b/adapter/src/test/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoDBCartRepositoryTest.java new file mode 100644 index 0000000..7fd949d --- /dev/null +++ b/adapter/src/test/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoDBCartRepositoryTest.java @@ -0,0 +1,19 @@ +package eu.happycoders.shop.adapter.out.persistence.mongo; + +import eu.happycoders.shop.adapter.out.persistence.AbstractCartRepositoryTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@ActiveProfiles("test-with-mongodb") +class MongoDBCartRepositoryTest extends AbstractCartRepositoryTest { + + @DynamicPropertySource + static void mongoDbProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.mongodb.uri", () -> MongoDBTestContainerConfig.getInstance().getReplicaSetUrl()); + } +} diff --git a/adapter/src/test/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoDBTestContainerConfig.java b/adapter/src/test/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoDBTestContainerConfig.java new file mode 100644 index 0000000..20319c4 --- /dev/null +++ b/adapter/src/test/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoDBTestContainerConfig.java @@ -0,0 +1,23 @@ +package eu.happycoders.shop.adapter.out.persistence.mongo; + +import org.springframework.context.annotation.Profile; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Profile("test-with-mongodb") +@Testcontainers +public class MongoDBTestContainerConfig { + + // See: https://java.testcontainers.org/test_framework_integration/manual_lifecycle_control/#singleton-containers + + private static final MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:4.2.8"); + + static { + mongoDBContainer.start(); + } + + public static MongoDBContainer getInstance() { + return mongoDBContainer; + } + +} \ No newline at end of file diff --git a/adapter/src/test/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoProductRepositoryTest.java b/adapter/src/test/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoProductRepositoryTest.java new file mode 100644 index 0000000..2a5af17 --- /dev/null +++ b/adapter/src/test/java/eu/happycoders/shop/adapter/out/persistence/mongo/MongoProductRepositoryTest.java @@ -0,0 +1,19 @@ +package eu.happycoders.shop.adapter.out.persistence.mongo; + +import eu.happycoders.shop.adapter.out.persistence.AbstractProductRepositoryTest; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@ActiveProfiles("test-with-mongodb") +class MongoProductRepositoryTest extends AbstractProductRepositoryTest { + + @DynamicPropertySource + static void mongoDbProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.mongodb.uri", () -> MongoDBTestContainerConfig.getInstance().getReplicaSetUrl()); + } +} diff --git a/adapter/src/test/resources/application-test-with-mongodb.properties b/adapter/src/test/resources/application-test-with-mongodb.properties new file mode 100644 index 0000000..49b0df0 --- /dev/null +++ b/adapter/src/test/resources/application-test-with-mongodb.properties @@ -0,0 +1,5 @@ +# spring.data.mongodb.uri is set to a test container via Java code +# because is not possible to set up a test container in a properties file with MongoDB (as it is with MySQL) +persistence=mongodb + + diff --git a/bootstrap/pom.xml b/bootstrap/pom.xml index 5e3a376..e702ce3 100644 --- a/bootstrap/pom.xml +++ b/bootstrap/pom.xml @@ -53,6 +53,16 @@ mysql test + + org.testcontainers + mongodb + test + + + org.testcontainers + testcontainers + test + diff --git a/bootstrap/src/main/resources/application-mongodb.properties b/bootstrap/src/main/resources/application-mongodb.properties new file mode 100644 index 0000000..46efdba --- /dev/null +++ b/bootstrap/src/main/resources/application-mongodb.properties @@ -0,0 +1,5 @@ +spring.data.mongodb.database=shop +spring.data.mongodb.uri=mongodb://localhost:27017/shop + +persistence=mongodb + diff --git a/bootstrap/src/test/java/eu/happycoders/shop/bootstrap/e2e/CartTest.java b/bootstrap/src/test/java/eu/happycoders/shop/bootstrap/e2e/CartTest.java index 111b362..56ee4f1 100644 --- a/bootstrap/src/test/java/eu/happycoders/shop/bootstrap/e2e/CartTest.java +++ b/bootstrap/src/test/java/eu/happycoders/shop/bootstrap/e2e/CartTest.java @@ -21,6 +21,8 @@ @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @ActiveProfiles("test-with-mysql") +// Uncomment the following line to run the tests with MongoDB (and comment the previous line) +//@ActiveProfiles("test-with-mongodb") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class CartTest { diff --git a/bootstrap/src/test/java/eu/happycoders/shop/bootstrap/e2e/FindProductsTest.java b/bootstrap/src/test/java/eu/happycoders/shop/bootstrap/e2e/FindProductsTest.java index 3a152b7..6ae74d5 100644 --- a/bootstrap/src/test/java/eu/happycoders/shop/bootstrap/e2e/FindProductsTest.java +++ b/bootstrap/src/test/java/eu/happycoders/shop/bootstrap/e2e/FindProductsTest.java @@ -15,6 +15,8 @@ @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @ActiveProfiles("test-with-mysql") +// Uncomment the following line to run the tests with MongoDB (and comment the previous line) +//@ActiveProfiles("test-with-mongodb") class FindProductsTest { @LocalServerPort private Integer port;