From c6158328da6b719aa6cdba3d4b54d12804445591 Mon Sep 17 00:00:00 2001 From: PaulBredl Date: Wed, 17 Apr 2024 22:43:57 +0200 Subject: [PATCH] add abstract service for typical operations --- build.gradle | 1 + .../common/service/AbstractCrudService.java | 196 ++++++++++++++++ .../service/AbstractCrudServiceTest.java | 220 ++++++++++++++++++ .../service/AbstractCrudServiceTestImpl.java | 119 ++++++++++ 4 files changed, 536 insertions(+) create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudService.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTest.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTestImpl.java diff --git a/build.gradle b/build.gradle index c10a0d6..2aa44a7 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,7 @@ dependencies { implementation 'jakarta.validation:jakarta.validation-api' implementation 'io.dapr:dapr-sdk:1.+' // Dapr's core SDK with all features, except Actors. implementation 'io.dapr:dapr-sdk-springboot:1.+' // Dapr's SDK integration with SpringBoot + implementation 'org.modelmapper:modelmapper:3.+' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudService.java b/src/main/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudService.java new file mode 100644 index 0000000..bf5b008 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudService.java @@ -0,0 +1,196 @@ +package de.unistuttgart.iste.meitrex.common.service; + +import de.unistuttgart.iste.meitrex.common.exception.MeitrexNotFoundException; +import de.unistuttgart.iste.meitrex.common.persistence.IWithId; +import de.unistuttgart.iste.meitrex.common.persistence.MeitrexRepository; +import org.modelmapper.ModelMapper; + +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Abstract service for CRUD operations. + * Services extending this class can utilize the helper methods to perform CRUD operations on entities. + * + * @param the type of the entity id, e.g., UUID + * @param the type of the entity, e.g., UserEntity + * @param the type of the DTO, e.g., User + */ +public abstract class AbstractCrudService, D> { + + /** + * @return the class of the entity, e.g., UserEntity.class + */ + protected abstract Class getEntityClass(); + + /** + * @return the class of the DTO, e.g., User.class + */ + protected abstract Class getDtoClass(); + + /** + * Get the model mapper used to map between entities and DTOs. + * This method should return a pre-configured model mapper. + * The model mapper should be set up to map between the entity and DTO classes, + * and between input classes and the entity class. + * + * @return the model mapper used to map between entities and DTOs + */ + protected abstract ModelMapper getModelMapper(); + + /** + * @return the repository used to access the entities + */ + protected abstract MeitrexRepository getRepository(); + + /** + * Get all entities from the repository and convert them to DTOs. + * + * @return a list of all entities as DTOs + */ + protected List getAll() { + final List entities = getRepository().findAll(); + return convertToDtos(entities); + } + + /** + * Find an entity by its id and convert it to a DTO. + * + * @param id the id of the entity to find + * @return the entity as a DTO, or an empty optional if no entity with the given id exists + */ + protected Optional find(final I id) { + return getRepository() + .findById(id) + .map(this::convertToDto); + } + + /** + * Gets an entity by its id and convert it to a DTO. + * If no entity with the given id exists, throws an exception. + * + * @param id the id of the entity to get + * @return the entity as a DTO + * @throws MeitrexNotFoundException if no entity with the given id exists + */ + protected D getOrThrow(final I id) { + return convertToDto(getRepository().findByIdOrThrow(id)); + } + + /** + * Creates an entity using the given entity creator and saves it to the repository. + * + * @param entityCreator supplier that returns the entity to create + * @return the created entity as an entity + */ + protected E createEntity(final Supplier entityCreator) { + final E entity = entityCreator.get(); + return getRepository().save(entity); + } + + /** + * Creates an entity by mapping the given input to an entity and saves it to the repository. + * Note: This requires the model mapper to be set up correctly. + * + * @param createInput the input to map to an entity + * @return the created entity as an entity + */ + protected E createEntity(final Object createInput) { + return createEntity(() -> getModelMapper().map(createInput, getEntityClass())); + } + + /** + * Creates an entity using the given entity creator, saves it to the repository + * and converts it to a DTO. + * + * @param entityCreator supplier that returns the entity to create + * @return the created entity as a DTO + */ + protected D create(final Supplier entityCreator) { + final E entity = createEntity(entityCreator); + + return convertToDto(entity); + } + + /** + * Creates an entity by mapping the given input to an entity, saves it to the repository + * and converts it to a DTO. + * Note: This requires the model mapper to be set up correctly. + * + * @param createInput the input to map to an entity + * @return the created entity as a DTO + */ + protected D create(final Object createInput) { + return create(() -> getModelMapper().map(createInput, getEntityClass())); + } + + /** + * Updates an entity by its id using the given entity updater and saves it to the repository. + * + * @param id the id of the entity to update + * @param entityUpdater consumer that updates the entity + * @return the updated entity as a DTO + */ + protected D update(final I id, final Consumer entityUpdater) { + final E entity = getRepository().findByIdOrThrow(id); + entityUpdater.accept(entity); + + final E savedEntity = getRepository().save(entity); + + return convertToDto(savedEntity); + } + + /** + * Updates an entity by its id by mapping the given input + * to the existing entity and saves it to the repository. + * This will not overwrite the entity, but update only the fields + * that are defined in the input. + * + * @param id the id of the entity to update + * @param updateInput the input to map to an entity + * @return the updated entity as a DTO + */ + protected D update(final I id, final Object updateInput) { + return update(id, entity -> getModelMapper().map(updateInput, entity)); + } + + /** + * Deletes an entity by its id. + * + * @param id the id of the entity to delete + * @return true if the entity was deleted, false if no entity with the given id exists + * @apiNote subclasses will likely require a more sophisticated deletion logic + */ + protected boolean delete(final I id) { + if (!getRepository().existsById(id)) { + return false; + } + + getRepository().deleteById(id); + return true; + } + + /** + * Converts an entity to a DTO. + * + * @param entity the entity to convert + * @return the entity as a DTO + */ + protected D convertToDto(final E entity) { + return getModelMapper().map(entity, getDtoClass()); + } + + /** + * Converts a list of entities to a list of DTOs. + * + * @param entities the entities to convert + * @return the entities as DTOs + */ + protected List convertToDtos(final List entities) { + return entities.stream() + .map(this::convertToDto) + .toList(); + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTest.java new file mode 100644 index 0000000..ff747b9 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTest.java @@ -0,0 +1,220 @@ +package de.unistuttgart.iste.meitrex.common.service; + +import de.unistuttgart.iste.meitrex.common.exception.MeitrexNotFoundException; +import de.unistuttgart.iste.meitrex.common.persistence.MeitrexRepository; +import de.unistuttgart.iste.meitrex.common.service.AbstractCrudServiceTestImpl.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.AdditionalAnswers.returnsFirstArg; +import static org.mockito.Mockito.*; + +class AbstractCrudServiceTest { + + private AbstractCrudServiceTestImpl service; + private MeitrexRepository repository; + + @BeforeEach + public void setUp() { + repository = Mockito.mock(TestRepository.class); + service = new AbstractCrudServiceTestImpl(repository); + } + + @Test + void testGetAll() { + when(repository.findAll()).thenReturn(List.of(new TestEntity(1L, "Test"))); + + List result = service.getAll(); + + assertThat(result, hasSize(1)); + assertThat(result.getFirst().getId(), is(1L)); + assertThat(result.getFirst().getName(), is("Test")); + + verify(repository, times(1)).findAll(); + } + + @Test + void testFind() { + Long id = 1L; + when(repository.findById(id)).thenReturn(Optional.of(new TestEntity(id, "Test"))); + + Optional result = service.find(id); + + assertThat(result.isPresent(), is(true)); + assertThat(result.get().getId(), is(1L)); + assertThat(result.get().getName(), is("Test")); + + verify(repository, times(1)).findById(id); + } + + @Test + void testFindNotFound() { + Long id = 1L; + when(repository.findById(id)).thenReturn(Optional.empty()); + + Optional result = service.find(id); + + assertThat(result.isEmpty(), is(true)); + verify(repository, times(1)).findById(id); + } + + @Test + void testGetOrThrow() { + Long id = 1L; + when(repository.findByIdOrThrow(id)).thenReturn(new TestEntity(id, "Test")); + + TestDto result = service.getOrThrow(id); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Test")); + + verify(repository, times(1)).findByIdOrThrow(id); + } + + @Test + void testGetOrThrowNotFound() { + Long id = 1L; + when(repository.findByIdOrThrow(id)).thenThrow(new MeitrexNotFoundException("Entity not found.")); + + assertThrows(MeitrexNotFoundException.class, () -> service.getOrThrow(id)); + + verify(repository, times(1)).findByIdOrThrow(id); + } + + @Test + void testCreateEntity() { + TestEntity entity = new TestEntity(1L, "Test"); + when(repository.save(any())).thenAnswer(returnsFirstArg()); + + TestEntity result = service.createEntity(() -> entity); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Test")); + + verify(repository, times(1)).save(entity); + } + + @Test + void testCreateEntityFromInput() { + when(repository.save(any())).thenAnswer(invocation -> { + TestEntity entity = invocation.getArgument(0); + entity.setId(1L); + return entity; + }); + + TestEntity result = service.createEntity(new TestInputDto("Test")); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Test")); + + verify(repository, times(1)).save(any()); + } + + @Test + void testCreate() { + TestEntity entity = new TestEntity(1L, "Test"); + when(repository.save(any())).thenReturn(entity); + + TestDto result = service.create(() -> entity); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Test")); + + verify(repository, times(1)).save(entity); + } + + @Test + void testCreateFromInput() { + when(repository.save(any())).thenAnswer(invocation -> { + TestEntity entity = invocation.getArgument(0); + entity.setId(1L); + return entity; + }); + + TestDto result = service.create(new TestInputDto("Test")); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Test")); + + verify(repository, times(1)).save(any()); + } + + @Test + void testUpdate() { + Long id = 1L; + TestEntity entity = new TestEntity(id, "Test"); + + when(repository.findByIdOrThrow(id)).thenReturn(entity); + when(repository.save(any())).thenReturn(entity); + + TestDto result = service.update(id, e -> e.setName("Updated")); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Updated")); + + verify(repository, times(1)).findByIdOrThrow(id); + verify(repository, times(1)).save(any()); + } + + @Test + void testUpdateNotFound() { + Long id = 1L; + when(repository.findByIdOrThrow(id)).thenThrow(new MeitrexNotFoundException("Entity not found.")); + + assertThrows(MeitrexNotFoundException.class, () -> service.update(id, e -> e.setName("Updated"))); + + verify(repository, times(1)).findByIdOrThrow(id); + verify(repository, never()).save(any()); + } + + @Test + void testUpdateFromInput() { + Long id = 1L; + TestEntity entity = new TestEntity(id, "Test"); + + when(repository.findByIdOrThrow(id)).thenReturn(entity); + when(repository.save(any())).thenReturn(entity); + + TestDto result = service.update(id, new TestInputDto("Updated")); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Updated")); + + verify(repository, times(1)).findByIdOrThrow(id); + verify(repository, times(1)).save(any()); + } + + @Test + void testDelete() { + Long id = 1L; + when(repository.existsById(id)).thenReturn(true); + + boolean result = service.delete(id); + + assertThat(result, is(true)); + + verify(repository, times(1)).existsById(id); + verify(repository, times(1)).deleteById(id); + } + + @Test + void testDeleteNotFound() { + Long id = 1L; + when(repository.existsById(id)).thenReturn(false); + + boolean result = service.delete(id); + + assertThat(result, is(false)); + + verify(repository, times(1)).existsById(id); + verify(repository, never()).deleteById(id); + } +} \ No newline at end of file diff --git a/src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTestImpl.java b/src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTestImpl.java new file mode 100644 index 0000000..84c86a5 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTestImpl.java @@ -0,0 +1,119 @@ +package de.unistuttgart.iste.meitrex.common.service; + +import de.unistuttgart.iste.meitrex.common.persistence.IWithId; +import de.unistuttgart.iste.meitrex.common.persistence.MeitrexRepository; +import org.modelmapper.ModelMapper; + +public class AbstractCrudServiceTestImpl + extends AbstractCrudService { + + private MeitrexRepository repository; + private ModelMapper modelMapper; + + public AbstractCrudServiceTestImpl(MeitrexRepository repository) { + this.repository = repository; + this.modelMapper = new ModelMapper(); + modelMapper.createTypeMap(TestInputDto.class, TestEntity.class) + .addMappings(mapper -> mapper.skip(TestEntity::setId)) + .addMappings(mapper -> mapper.map(TestInputDto::getInputName, TestEntity::setName)); + } + + @Override + protected Class getEntityClass() { + return TestEntity.class; + } + + @Override + protected Class getDtoClass() { + return TestDto.class; + } + + @Override + protected ModelMapper getModelMapper() { + return modelMapper; + } + + @Override + protected MeitrexRepository getRepository() { + return repository; + } + + public static class TestEntity implements IWithId { + private Long id; + private String name; + + public TestEntity() { + } + + public TestEntity(Long id, String name) { + this.id = id; + this.name = name; + } + + @Override + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + public static class TestDto { + private Long id; + private String name; + + public TestDto() { + } + + public TestDto(Long id, String name) { + this.id = id; + this.name = name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + public static class TestInputDto { + private String inputName; + + public TestInputDto(String inputName) { + this.inputName = inputName; + } + + public String getInputName() { + return inputName; + } + + public TestInputDto setInputName(String inputName) { + this.inputName = inputName; + return this; + } + } + + public interface TestRepository extends MeitrexRepository { + } +}