From c2988aa13cdc18edc9bc7e204f01e7fb373f60bb Mon Sep 17 00:00:00 2001 From: Kirill Topchy Date: Thu, 9 Oct 2025 14:19:06 +0300 Subject: [PATCH 01/10] Greetings feature implemented --- .../features/greeting/GreetingController.java | 24 +++++++++++++++++++ .../features/greeting/GreetingService.java | 23 ++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java diff --git a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java new file mode 100644 index 0000000..30ac8d8 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java @@ -0,0 +1,24 @@ +package lv.ctco.springboottemplate.features.greeting; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/greeting") +@Tag(name = "Greeting Controller", description = "Greeting related endpoints") +public class GreetingController { + private final GreetingService greetingService; + + public GreetingController(GreetingService greetingService) { + this.greetingService = greetingService; + } + + @GetMapping + @Operation(summary = "Get greeting message") + public String getGreeting() { + return greetingService.greet(); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java new file mode 100644 index 0000000..20fafda --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java @@ -0,0 +1,23 @@ +package lv.ctco.springboottemplate.features.greeting; + +import lv.ctco.springboottemplate.features.todo.TodoService; +import org.springframework.stereotype.Service; + +@Service +public class GreetingService { + private final TodoService todoService; + + public GreetingService(TodoService todoService) { + this.todoService = todoService; + } + + public String greet() { + var activeTodos = todoService + .getAllTodos() + .stream() + .filter(todo -> !todo.completed()) + .toList(); + + return "Hello from Spring! You have " + activeTodos.size() + " open tasks."; + } +} From 1a715bfe643534e53f1641c9b84dd2119b8de11e Mon Sep 17 00:00:00 2001 From: Kirill Topchy Date: Thu, 9 Oct 2025 14:22:50 +0300 Subject: [PATCH 02/10] Unit tests fixed to use local mongoDB instance instead of test container in docker --- .../GreetingServiceIntegrationTest.java | 32 +++++++++---------- .../todo/TodoServiceIntegrationTest.java | 25 +++++---------- 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java index eda5f13..6695b95 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java @@ -1,32 +1,33 @@ package lv.ctco.springboottemplate.features.greeting; +import lv.ctco.springboottemplate.features.todo.Todo; +import lv.ctco.springboottemplate.features.todo.TodoRepository; import lv.ctco.springboottemplate.features.todo.TodoService; -import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestConstructor; -import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; /** - * Integration test for {@link GreetingService}. - * - *

This test verifies that GreetingService correctly interacts with {@link TodoService} and - * reflects the number of open (not completed) todos in the message. + * Integration test for {@link GreetingService} without Testcontainers. * - *

Initially marked {@link Disabled} to be enabled by the developer after implementation. + *

This test uses a locally running MongoDB instance (expected at mongodb://localhost:27017/tododb). + * If you prefer an embedded/in-memory Mongo, add flapdoodle dependency and remove the dynamic property below. */ @SpringBootTest -@Disabled("Enable after implementing GreetingService using TodoService") -@Testcontainers @TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) class GreetingServiceIntegrationTest { - /* - - @Container static MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:6.0.8"); - + // Point Spring Data Mongo to a locally running MongoDB instead of using Testcontainers @DynamicPropertySource - static void setProperties(DynamicPropertyRegistry registry) { - registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl); + static void mongoProps(DynamicPropertyRegistry registry) { + registry.add("spring.data.mongodb.uri", () -> "mongodb://localhost:27017/tododb"); } private final TodoService todoService; @@ -94,5 +95,4 @@ void should_ignore_null_todos_or_null_completed_flags() { // then assertThat(message).contains("1 open task"); } - */ } diff --git a/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java index 7a98397..70aaa42 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java @@ -1,39 +1,30 @@ package lv.ctco.springboottemplate.features.todo; -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; -import org.springframework.test.context.TestConstructor; -import org.testcontainers.containers.MongoDBContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest -@Testcontainers -@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) class TodoServiceIntegrationTest { - @Container static MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:6.0.8"); - @DynamicPropertySource static void setProperties(DynamicPropertyRegistry registry) { - registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl); + registry.add("spring.data.mongodb.uri", () -> "mongodb://localhost:27017/tododb"); } + @Autowired private TodoRepository todoRepository; + @Autowired private TodoService todoService; - public TodoServiceIntegrationTest(TodoRepository todoRepository, TodoService todoService) { - this.todoRepository = todoRepository; - this.todoService = todoService; - } - @BeforeEach void setup() { todoRepository.deleteAll(); From 7c62fbe0610895fac6c8f9c0c51e86d5f7415be4 Mon Sep 17 00:00:00 2001 From: Kirill Topchy Date: Thu, 9 Oct 2025 16:39:02 +0300 Subject: [PATCH 03/10] feature statistics added --- .../statistics/StatisticsController.java | 41 ++++++++ .../StatisticsExceptionHandler.java | 25 +++++ .../statistics/StatisticsQueryBuilder.java | 43 +++++++++ .../models/StatisticsDetailedResponse.java | 12 +++ .../models/StatisticsErrorResponse.java | 6 ++ .../statistics/models/StatisticsFormat.java | 6 ++ .../statistics/models/StatisticsQuery.java | 28 ++++++ .../statistics/models/StatisticsRequest.java | 13 +++ .../statistics/models/StatisticsResponse.java | 13 +++ .../models/StatisticsSummaryResponse.java | 11 +++ .../statistics/models/TodosSection.java | 6 ++ .../statistics/models/TodosStatistics.java | 12 +++ .../services/StatisticsService.java | 37 ++++++++ .../AbstractStatisticsStrategy.java | 90 ++++++++++++++++++ .../DetailedStatisticsStrategy.java | 94 +++++++++++++++++++ .../StatisticsComputationStrategy.java | 12 +++ .../strategies/SummaryStatisticsStrategy.java | 60 ++++++++++++ .../features/todo/TodoDataInitializer.java | 27 +++++- 18 files changed, 533 insertions(+), 3 deletions(-) create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsExceptionHandler.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsQueryBuilder.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedResponse.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsErrorResponse.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsFormat.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsQuery.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequest.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsResponse.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryResponse.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosSection.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosStatistics.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsService.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/AbstractStatisticsStrategy.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/StatisticsComputationStrategy.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java new file mode 100644 index 0000000..393a138 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -0,0 +1,41 @@ +package lv.ctco.springboottemplate.features.statistics; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsErrorResponse; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsRequest; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsResponse; +import lv.ctco.springboottemplate.features.statistics.services.StatisticsService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/statistics") +@Tag(name = "Todo Statistics Controller", description = "Todo statistics related endpoints") +public class StatisticsController { + private final StatisticsService statisticsService; + + public StatisticsController(StatisticsService statisticsService, StatisticsQueryBuilder queryBuilder) { + this.statisticsService = statisticsService; + } + + @GetMapping + @Operation( + summary = "Get todo statistics", + description = "Returns summary or detailed todo statistics. Optional date filters are applied to createdAt field.", + responses = { + @ApiResponse(responseCode = "200", description = "Statistics computed successfully"), + @ApiResponse(responseCode = "400", description = "Invalid parameters", content = @Content(schema = @Schema(implementation = StatisticsErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "Internal server error", content = @Content(schema = @Schema(implementation = StatisticsErrorResponse.class))) + } + ) + public ResponseEntity getStatistics(StatisticsRequest request) { + StatisticsResponse response = statisticsService.computeStatistics(request); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsExceptionHandler.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsExceptionHandler.java new file mode 100644 index 0000000..84e525c --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsExceptionHandler.java @@ -0,0 +1,25 @@ +package lv.ctco.springboottemplate.features.statistics; + +import lv.ctco.springboottemplate.features.statistics.models.StatisticsErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.List; + +@RestControllerAdvice(assignableTypes = StatisticsController.class) +public class StatisticsExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.badRequest() + .body(new StatisticsErrorResponse(List.of(ex.getMessage()))); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntime(RuntimeException ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new StatisticsErrorResponse(List.of("Internal server error123"))); + } +} \ No newline at end of file diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsQueryBuilder.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsQueryBuilder.java new file mode 100644 index 0000000..6509a54 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsQueryBuilder.java @@ -0,0 +1,43 @@ +package lv.ctco.springboottemplate.features.statistics; + +import lv.ctco.springboottemplate.features.statistics.models.StatisticsFormat; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsRequest; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; + +@Component +public class StatisticsQueryBuilder { + public StatisticsQuery build(StatisticsRequest request) { + List errors = new ArrayList<>(); + LocalDate from = parseDate("from", request.from(), errors); + LocalDate to = parseDate("to", request.to(), errors); + + if (from != null && to != null && from.isAfter(to)) { + errors.add("Parameter 'from' must be before or equal to 'to'."); + } + + if (!errors.isEmpty()) { + throw new IllegalArgumentException(String.join("; ", errors)); + } + + StatisticsFormat format = StatisticsQuery.parseFormat(request.format()); + return new StatisticsQuery(from, to, format); + } + + private LocalDate parseDate(String name, String raw, List errors) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return LocalDate.parse(raw); + } catch (DateTimeParseException e) { + errors.add("Invalid '" + name + "' date: " + raw); + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedResponse.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedResponse.java new file mode 100644 index 0000000..60ce42e --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedResponse.java @@ -0,0 +1,12 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.Map; + +public record StatisticsDetailedResponse( + int totalTodos, + int completedTodos, + int pendingTodos, + Map userStats, + TodosSection todos +) implements StatisticsResponse { +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsErrorResponse.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsErrorResponse.java new file mode 100644 index 0000000..bb9b062 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsErrorResponse.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.List; + +public record StatisticsErrorResponse(List errors) { +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsFormat.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsFormat.java new file mode 100644 index 0000000..75ee135 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsFormat.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +public enum StatisticsFormat { + SUMMARY, + DETAILED +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsQuery.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsQuery.java new file mode 100644 index 0000000..775bb33 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsQuery.java @@ -0,0 +1,28 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.time.LocalDate; + +public record StatisticsQuery( + LocalDate from, + LocalDate to, + StatisticsFormat format +) { + + public StatisticsQuery { + if (from != null && to != null && from.isAfter(to)) { + throw new IllegalArgumentException("Parameter 'from' must be before or equal to 'to'."); + } + } + + public static StatisticsFormat parseFormat(String value) { + if (value == null) { + return StatisticsFormat.SUMMARY; + } + + if (value.trim().equalsIgnoreCase("detailed")) { + return StatisticsFormat.DETAILED; + } + + return StatisticsFormat.SUMMARY; + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequest.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequest.java new file mode 100644 index 0000000..17c5900 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequest.java @@ -0,0 +1,13 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record StatisticsRequest( + @Schema(description = "Start date (inclusive), format: yyyy-MM-dd", example = "2023-01-01") + String from, + @Schema(description = "End date (inclusive), format: yyyy-MM-dd", example = "2023-12-31") + String to, + @Schema(description = "Response format: summary | detailed (default: summary)", example = "summary") + String format +) { +} \ No newline at end of file diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsResponse.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsResponse.java new file mode 100644 index 0000000..3d3a7c9 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsResponse.java @@ -0,0 +1,13 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.Map; + +public interface StatisticsResponse { + int totalTodos(); + + int completedTodos(); + + int pendingTodos(); + + Map userStats(); +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryResponse.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryResponse.java new file mode 100644 index 0000000..ff03e29 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryResponse.java @@ -0,0 +1,11 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.Map; + +public record StatisticsSummaryResponse( + int totalTodos, + int completedTodos, + int pendingTodos, + Map userStats +) implements StatisticsResponse { +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosSection.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosSection.java new file mode 100644 index 0000000..18c1fa3 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosSection.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.List; + +public record TodosSection(List completed, List pending) { +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosStatistics.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosStatistics.java new file mode 100644 index 0000000..a15564f --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosStatistics.java @@ -0,0 +1,12 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.time.Instant; +import java.util.Optional; + +public record TodosStatistics( + String id, + String title, + String createdBy, + String createdAt, + Optional completedAt) { +} \ No newline at end of file diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsService.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsService.java new file mode 100644 index 0000000..8a124ae --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsService.java @@ -0,0 +1,37 @@ +package lv.ctco.springboottemplate.features.statistics.services; + +import lv.ctco.springboottemplate.features.statistics.StatisticsQueryBuilder; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsFormat; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsRequest; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsResponse; +import lv.ctco.springboottemplate.features.statistics.services.strategies.StatisticsComputationStrategy; +import org.springframework.stereotype.Service; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Service +public class StatisticsService { + private final StatisticsQueryBuilder queryBuilder; + private final Map strategies = new EnumMap<>(StatisticsFormat.class); + + public StatisticsService(List strategyImplementations, StatisticsQueryBuilder queryBuilder) { + this.queryBuilder = queryBuilder; + + for (StatisticsComputationStrategy s : strategyImplementations) { + strategies.put(s.format(), s); + } + } + + public StatisticsResponse computeStatistics(StatisticsRequest request) { + StatisticsQuery query = queryBuilder.build(request); + StatisticsComputationStrategy strategy = strategies.get(query.format()); + if (strategy == null) { + throw new IllegalStateException("No strategy registered for format: " + query.format()); + } + + return strategy.compute(query); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/AbstractStatisticsStrategy.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/AbstractStatisticsStrategy.java new file mode 100644 index 0000000..6c91245 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/AbstractStatisticsStrategy.java @@ -0,0 +1,90 @@ +package lv.ctco.springboottemplate.features.statistics.services.strategies; + +import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.aggregation.MatchOperation; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +abstract class AbstractStatisticsStrategy implements StatisticsComputationStrategy { + + protected final MongoTemplate mongoTemplate; + + protected AbstractStatisticsStrategy(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + protected MatchOperation matchDateFilter(StatisticsQuery query) { + LocalDate from = query.from(); + LocalDate to = query.to(); + if (from == null && to == null) { + return Aggregation.match(new Criteria()); + } + Criteria criteria = Criteria.where("createdAt"); + if (from != null && to != null) { + criteria.gte(from.atStartOfDay().toInstant(ZoneOffset.UTC)) + .lte(to.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC).minusMillis(1)); + } else if (from != null) { + criteria.gte(from.atStartOfDay().toInstant(ZoneOffset.UTC)); + } else { + criteria.lte(to.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC).minusMillis(1)); + } + + return Aggregation.match(criteria); + } + + protected Document aggregateSingle(Aggregation agg) { + AggregationResults results = mongoTemplate.aggregate(agg, "todos", Document.class); + + return results.getUniqueMappedResult(); + } + + protected Map extractUserStats(Document root) { + if (root == null) return Map.of(); + List userStatsDocs = getArray(root, "userStats"); + Map map = new LinkedHashMap<>(); + for (Document d : userStatsDocs) { + String user = d.getString("_id"); + int count = d.getInteger("count", 0); + map.put(user, count); + } + + return map; + } + + protected List getArray(Document doc, String key) { + if (doc == null) return List.of(); + Object val = doc.get(key); + if (val instanceof List list) { + return (list.stream() + .filter(Document.class::isInstance) + .map(Document.class::cast) + .toList()); + } + + return List.of(); + } + + protected Instant toInstant(Object obj) { + if (obj instanceof Date date) { + return date.toInstant(); + } + + if (obj instanceof Instant inst) { + return inst; + } + + return null; + } +} + diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java new file mode 100644 index 0000000..5bba145 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java @@ -0,0 +1,94 @@ +package lv.ctco.springboottemplate.features.statistics.services.strategies; + +import lv.ctco.springboottemplate.features.statistics.models.*; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.ConditionalOperators; +import org.springframework.data.mongodb.core.aggregation.ProjectionOperation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Component +class DetailedStatisticsStrategy extends AbstractStatisticsStrategy { + + private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_INSTANT; + + DetailedStatisticsStrategy(MongoTemplate mongoTemplate) { + super(mongoTemplate); + } + + @Override + public StatisticsFormat format() { + return StatisticsFormat.DETAILED; + } + + @Override + public StatisticsResponse compute(StatisticsQuery query) { + ProjectionOperation projectTodosFields = Aggregation.project("title", "createdBy", "createdAt", "updatedAt", "completed", "completedAt"); + Aggregation agg = Aggregation.newAggregation( + matchDateFilter(query), + Aggregation.facet( + Aggregation.group() + .count().as("total") + .sum(ConditionalOperators.when(Criteria.where("completed").is(true)).then(1).otherwise(0)).as("completed") + ).as("counts") + .and(Aggregation.group("createdBy").count().as("count")).as("userStats") + .and(Aggregation.match(Criteria.where("completed").is(true)), projectTodosFields).as("completedTodosSource") + .and(Aggregation.match(Criteria.where("completed").is(false)), projectTodosFields).as("pendingTodosSource") + ); + + Document root = aggregateSingle(agg); + int total = 0; + int completed = 0; + if (root != null) { + var counts = getArray(root, "counts"); + if (!counts.isEmpty()) { + Document first = counts.get(0); + total = first.getInteger("total", 0); + completed = first.getInteger("completed", 0); + } + } + int pending = total - completed; + + var userStats = extractUserStats(root); + + List completedTodos = Optional.ofNullable(root) + .map(r -> getArray(r, "completedTodosSource")).orElse(List.of()).stream() + .map(d -> toTodosStatistics(d, true)) + .collect(Collectors.toList()); + + List pendingTodos = Optional.ofNullable(root) + .map(r -> getArray(r, "pendingTodosSource")).orElse(List.of()).stream() + .map(d -> toTodosStatistics(d, false)) + .collect(Collectors.toList()); + + return new StatisticsDetailedResponse( + total, + completed, + pending, + userStats, + new TodosSection(completedTodos, pendingTodos) + ); + } + + private TodosStatistics toTodosStatistics(Document d, boolean isCompleted) { + String id = d.getObjectId("_id").toHexString(); + String title = d.getString("title"); + String createdBy = d.getString("createdBy"); + Instant createdAtInstant = toInstant(d.get("createdAt")); + String createdAt = createdAtInstant == null ? null : ISO.format(createdAtInstant); + Instant completedAt = isCompleted ? Optional.ofNullable(toInstant(d.get("completedAt"))) + .or(() -> Optional.ofNullable(toInstant(d.get("updatedAt")))) + .orElse(null) : null; + + return new TodosStatistics(id, title, createdBy, createdAt, Optional.ofNullable(completedAt)); + } +} + diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/StatisticsComputationStrategy.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/StatisticsComputationStrategy.java new file mode 100644 index 0000000..495942d --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/StatisticsComputationStrategy.java @@ -0,0 +1,12 @@ +package lv.ctco.springboottemplate.features.statistics.services.strategies; + +import lv.ctco.springboottemplate.features.statistics.models.StatisticsFormat; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsResponse; + +public interface StatisticsComputationStrategy { + StatisticsFormat format(); + + StatisticsResponse compute(StatisticsQuery query); +} + diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java new file mode 100644 index 0000000..7c4f94a --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java @@ -0,0 +1,60 @@ +package lv.ctco.springboottemplate.features.statistics.services.strategies; + +import lv.ctco.springboottemplate.features.statistics.models.StatisticsFormat; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsResponse; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryResponse; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.ConditionalOperators; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Component +class SummaryStatisticsStrategy extends AbstractStatisticsStrategy { + + SummaryStatisticsStrategy(MongoTemplate mongoTemplate) { + super(mongoTemplate); + } + + @Override + public StatisticsFormat format() { + return StatisticsFormat.SUMMARY; + } + + @Override + public StatisticsResponse compute(StatisticsQuery query) { + Aggregation agg = Aggregation.newAggregation( + matchDateFilter(query), + Aggregation.facet( + Aggregation.group() + .count().as("total") + .sum(ConditionalOperators.when(Criteria.where("completed").is(true)).then(1).otherwise(0)).as("completed") + ).as("counts") + .and( + Aggregation.group("createdBy").count().as("count") + ).as("userStats") + ); + + Document root = aggregateSingle(agg); + int total = 0; + int completed = 0; + if (root != null) { + List counts = getArray(root, "counts"); + if (!counts.isEmpty()) { + Document first = counts.get(0); + total = first.getInteger("total", 0); + completed = first.getInteger("completed", 0); + } + } + int pending = total - completed; + Map userStats = extractUserStats(root); + + return new StatisticsSummaryResponse(total, completed, pending, userStats); + } +} + diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java index 2b24a9a..fe8d438 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java @@ -1,13 +1,15 @@ package lv.ctco.springboottemplate.features.todo; -import java.time.Instant; -import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + @Configuration public class TodoDataInitializer { @@ -58,7 +60,26 @@ CommandLineRunner initDatabase(TodoRepository todoRepository) { "system", "system", now, - now)); + now), + new Todo( + null, + "Test Todo", + "Research destinations", + true, + "user1", + "user1", + LocalDate.of(2019, 1, 1).atStartOfDay().toInstant(java.time.ZoneOffset.UTC), + LocalDate.of(2023, 1, 1).atStartOfDay().toInstant(java.time.ZoneOffset.UTC)), + new Todo( + null, + "Test Todo2", + "Research destinations", + false, + "user2", + "user2", + LocalDate.of(2023, 1, 1).atStartOfDay().toInstant(java.time.ZoneOffset.UTC), + LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(java.time.ZoneOffset.UTC)) + ); todoRepository.saveAll(todos); log.info("Initialized database with {} todo items", todos.size()); From 7480ad8cd3d975097301a06d260408aafb7fd62c Mon Sep 17 00:00:00 2001 From: Kirill Topchy Date: Fri, 10 Oct 2025 11:12:08 +0300 Subject: [PATCH 04/10] warnings fix --- .../features/statistics/StatisticsController.java | 2 +- .../features/statistics/StatisticsExceptionHandler.java | 7 ++++++- .../features/statistics/models/StatisticsResponse.java | 9 --------- .../services/strategies/DetailedStatisticsStrategy.java | 2 +- .../services/strategies/SummaryStatisticsStrategy.java | 2 +- 5 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java index 393a138..b9886b6 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -20,7 +20,7 @@ public class StatisticsController { private final StatisticsService statisticsService; - public StatisticsController(StatisticsService statisticsService, StatisticsQueryBuilder queryBuilder) { + public StatisticsController(StatisticsService statisticsService) { this.statisticsService = statisticsService; } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsExceptionHandler.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsExceptionHandler.java index 84e525c..1e51b5d 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsExceptionHandler.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsExceptionHandler.java @@ -1,6 +1,8 @@ package lv.ctco.springboottemplate.features.statistics; import lv.ctco.springboottemplate.features.statistics.models.StatisticsErrorResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -11,6 +13,8 @@ @RestControllerAdvice(assignableTypes = StatisticsController.class) public class StatisticsExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(StatisticsExceptionHandler.class); + @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { return ResponseEntity.badRequest() @@ -19,7 +23,8 @@ public ResponseEntity handleIllegalArgument(IllegalArgu @ExceptionHandler(RuntimeException.class) public ResponseEntity handleRuntime(RuntimeException ex) { + log.error("Unhandled runtime exception in StatisticsController", ex); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new StatisticsErrorResponse(List.of("Internal server error123"))); + .body(new StatisticsErrorResponse(List.of("Internal server error"))); } } \ No newline at end of file diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsResponse.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsResponse.java index 3d3a7c9..f0d6e00 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsResponse.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsResponse.java @@ -1,13 +1,4 @@ package lv.ctco.springboottemplate.features.statistics.models; -import java.util.Map; - public interface StatisticsResponse { - int totalTodos(); - - int completedTodos(); - - int pendingTodos(); - - Map userStats(); } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java index 5bba145..daa3e7e 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java @@ -50,7 +50,7 @@ public StatisticsResponse compute(StatisticsQuery query) { if (root != null) { var counts = getArray(root, "counts"); if (!counts.isEmpty()) { - Document first = counts.get(0); + Document first = counts.getFirst(); total = first.getInteger("total", 0); completed = first.getInteger("completed", 0); } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java index 7c4f94a..4c10480 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java @@ -46,7 +46,7 @@ public StatisticsResponse compute(StatisticsQuery query) { if (root != null) { List counts = getArray(root, "counts"); if (!counts.isEmpty()) { - Document first = counts.get(0); + Document first = counts.getFirst(); total = first.getInteger("total", 0); completed = first.getInteger("completed", 0); } From e0336cb61b3b683974a2594e5a4069f251b19bb6 Mon Sep 17 00:00:00 2001 From: Kirill Topchy Date: Fri, 10 Oct 2025 11:50:34 +0300 Subject: [PATCH 05/10] repository and util extracted --- .../statistics/StatisticsAggregationUtil.java | 30 +++++++ .../services/StatisticsRepository.java | 74 +++++++++++++++ .../AbstractStatisticsStrategy.java | 90 ------------------- .../DetailedStatisticsStrategy.java | 43 ++++----- .../strategies/SummaryStatisticsStrategy.java | 32 +++---- 5 files changed, 131 insertions(+), 138 deletions(-) create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsAggregationUtil.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsRepository.java delete mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/AbstractStatisticsStrategy.java diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsAggregationUtil.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsAggregationUtil.java new file mode 100644 index 0000000..a5f43a3 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsAggregationUtil.java @@ -0,0 +1,30 @@ +package lv.ctco.springboottemplate.features.statistics; + +import org.bson.Document; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class StatisticsAggregationUtil { + public static Map extractUserStats(Document root) { + if (root == null) return Map.of(); + List userStatsDocs = getArray(root, "userStats"); + Map map = new LinkedHashMap<>(); + for (Document d : userStatsDocs) { + String user = d.getString("_id"); + int count = d.getInteger("count", 0); + map.put(user, count); + } + return map; + } + + public static List getArray(Document doc, String key) { + if (doc == null) return List.of(); + Object val = doc.get(key); + if (val instanceof List list) { + return list.stream().filter(Document.class::isInstance).map(Document.class::cast).toList(); + } + return List.of(); + } +} \ No newline at end of file diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsRepository.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsRepository.java new file mode 100644 index 0000000..e78e12f --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsRepository.java @@ -0,0 +1,74 @@ +package lv.ctco.springboottemplate.features.statistics.services; + +import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.*; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.ZoneOffset; + +@Repository +public class StatisticsRepository { + + private final MongoTemplate mongoTemplate; + + public StatisticsRepository(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + public Document executeSummary(StatisticsQuery query) { + Aggregation agg = Aggregation.newAggregation( + matchDateFilter(query), + Aggregation.facet( + Aggregation.group() + .count().as("total") + .sum(ConditionalOperators.when(Criteria.where("completed").is(true)).then(1).otherwise(0)).as("completed") + ).as("counts") + .and(Aggregation.group("createdBy").count().as("count")).as("userStats") + ); + return aggregate(agg); + } + + public Document executeDetailed(StatisticsQuery query) { + ProjectionOperation projectTodosFields = Aggregation.project("title", "createdBy", "createdAt", "updatedAt", "completed", "completedAt"); + Aggregation agg = Aggregation.newAggregation( + matchDateFilter(query), + Aggregation.facet( + Aggregation.group() + .count().as("total") + .sum(ConditionalOperators.when(Criteria.where("completed").is(true)).then(1).otherwise(0)).as("completed") + ).as("counts") + .and(Aggregation.group("createdBy").count().as("count")).as("userStats") + .and(Aggregation.match(Criteria.where("completed").is(true)), projectTodosFields).as("completedTodosSource") + .and(Aggregation.match(Criteria.where("completed").is(false)), projectTodosFields).as("pendingTodosSource") + ); + return aggregate(agg); + } + + private Document aggregate(Aggregation aggregation) { + AggregationResults results = mongoTemplate.aggregate(aggregation, "todos", Document.class); + return results.getUniqueMappedResult(); + } + + private MatchOperation matchDateFilter(StatisticsQuery query) { + LocalDate from = query.from(); + LocalDate to = query.to(); + if (from == null && to == null) { + return Aggregation.match(new Criteria()); + } + Criteria criteria = Criteria.where("createdAt"); + if (from != null && to != null) { + criteria.gte(from.atStartOfDay().toInstant(ZoneOffset.UTC)) + .lte(to.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC).minusMillis(1)); + } else if (from != null) { + criteria.gte(from.atStartOfDay().toInstant(ZoneOffset.UTC)); + } else { + criteria.lte(to.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC).minusMillis(1)); + } + return Aggregation.match(criteria); + } +} + diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/AbstractStatisticsStrategy.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/AbstractStatisticsStrategy.java deleted file mode 100644 index 6c91245..0000000 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/AbstractStatisticsStrategy.java +++ /dev/null @@ -1,90 +0,0 @@ -package lv.ctco.springboottemplate.features.statistics.services.strategies; - -import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; -import org.bson.Document; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.aggregation.Aggregation; -import org.springframework.data.mongodb.core.aggregation.AggregationResults; -import org.springframework.data.mongodb.core.aggregation.MatchOperation; -import org.springframework.data.mongodb.core.query.Criteria; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneOffset; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -abstract class AbstractStatisticsStrategy implements StatisticsComputationStrategy { - - protected final MongoTemplate mongoTemplate; - - protected AbstractStatisticsStrategy(MongoTemplate mongoTemplate) { - this.mongoTemplate = mongoTemplate; - } - - protected MatchOperation matchDateFilter(StatisticsQuery query) { - LocalDate from = query.from(); - LocalDate to = query.to(); - if (from == null && to == null) { - return Aggregation.match(new Criteria()); - } - Criteria criteria = Criteria.where("createdAt"); - if (from != null && to != null) { - criteria.gte(from.atStartOfDay().toInstant(ZoneOffset.UTC)) - .lte(to.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC).minusMillis(1)); - } else if (from != null) { - criteria.gte(from.atStartOfDay().toInstant(ZoneOffset.UTC)); - } else { - criteria.lte(to.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC).minusMillis(1)); - } - - return Aggregation.match(criteria); - } - - protected Document aggregateSingle(Aggregation agg) { - AggregationResults results = mongoTemplate.aggregate(agg, "todos", Document.class); - - return results.getUniqueMappedResult(); - } - - protected Map extractUserStats(Document root) { - if (root == null) return Map.of(); - List userStatsDocs = getArray(root, "userStats"); - Map map = new LinkedHashMap<>(); - for (Document d : userStatsDocs) { - String user = d.getString("_id"); - int count = d.getInteger("count", 0); - map.put(user, count); - } - - return map; - } - - protected List getArray(Document doc, String key) { - if (doc == null) return List.of(); - Object val = doc.get(key); - if (val instanceof List list) { - return (list.stream() - .filter(Document.class::isInstance) - .map(Document.class::cast) - .toList()); - } - - return List.of(); - } - - protected Instant toInstant(Object obj) { - if (obj instanceof Date date) { - return date.toInstant(); - } - - if (obj instanceof Instant inst) { - return inst; - } - - return null; - } -} - diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java index daa3e7e..0f6779a 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java @@ -1,27 +1,28 @@ package lv.ctco.springboottemplate.features.statistics.services.strategies; import lv.ctco.springboottemplate.features.statistics.models.*; +import lv.ctco.springboottemplate.features.statistics.services.StatisticsRepository; import org.bson.Document; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.aggregation.Aggregation; -import org.springframework.data.mongodb.core.aggregation.ConditionalOperators; -import org.springframework.data.mongodb.core.aggregation.ProjectionOperation; -import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.stereotype.Component; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import static lv.ctco.springboottemplate.features.statistics.StatisticsAggregationUtil.extractUserStats; +import static lv.ctco.springboottemplate.features.statistics.StatisticsAggregationUtil.getArray; + @Component -class DetailedStatisticsStrategy extends AbstractStatisticsStrategy { +class DetailedStatisticsStrategy implements StatisticsComputationStrategy { private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_INSTANT; + private final StatisticsRepository repository; - DetailedStatisticsStrategy(MongoTemplate mongoTemplate) { - super(mongoTemplate); + DetailedStatisticsStrategy(StatisticsRepository repository) { + this.repository = repository; } @Override @@ -31,20 +32,7 @@ public StatisticsFormat format() { @Override public StatisticsResponse compute(StatisticsQuery query) { - ProjectionOperation projectTodosFields = Aggregation.project("title", "createdBy", "createdAt", "updatedAt", "completed", "completedAt"); - Aggregation agg = Aggregation.newAggregation( - matchDateFilter(query), - Aggregation.facet( - Aggregation.group() - .count().as("total") - .sum(ConditionalOperators.when(Criteria.where("completed").is(true)).then(1).otherwise(0)).as("completed") - ).as("counts") - .and(Aggregation.group("createdBy").count().as("count")).as("userStats") - .and(Aggregation.match(Criteria.where("completed").is(true)), projectTodosFields).as("completedTodosSource") - .and(Aggregation.match(Criteria.where("completed").is(false)), projectTodosFields).as("pendingTodosSource") - ); - - Document root = aggregateSingle(agg); + Document root = repository.executeDetailed(query); int total = 0; int completed = 0; if (root != null) { @@ -56,8 +44,7 @@ public StatisticsResponse compute(StatisticsQuery query) { } } int pending = total - completed; - - var userStats = extractUserStats(root); + Map userStats = extractUserStats(root); List completedTodos = Optional.ofNullable(root) .map(r -> getArray(r, "completedTodosSource")).orElse(List.of()).stream() @@ -87,8 +74,12 @@ private TodosStatistics toTodosStatistics(Document d, boolean isCompleted) { Instant completedAt = isCompleted ? Optional.ofNullable(toInstant(d.get("completedAt"))) .or(() -> Optional.ofNullable(toInstant(d.get("updatedAt")))) .orElse(null) : null; - return new TodosStatistics(id, title, createdBy, createdAt, Optional.ofNullable(completedAt)); } -} + private Instant toInstant(Object obj) { + if (obj instanceof java.util.Date date) return date.toInstant(); + if (obj instanceof Instant inst) return inst; + return null; + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java index 4c10480..583d904 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java @@ -4,21 +4,23 @@ import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; import lv.ctco.springboottemplate.features.statistics.models.StatisticsResponse; import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryResponse; +import lv.ctco.springboottemplate.features.statistics.services.StatisticsRepository; import org.bson.Document; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.aggregation.Aggregation; -import org.springframework.data.mongodb.core.aggregation.ConditionalOperators; -import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.stereotype.Component; import java.util.List; import java.util.Map; +import static lv.ctco.springboottemplate.features.statistics.StatisticsAggregationUtil.extractUserStats; +import static lv.ctco.springboottemplate.features.statistics.StatisticsAggregationUtil.getArray; + @Component -class SummaryStatisticsStrategy extends AbstractStatisticsStrategy { +class SummaryStatisticsStrategy implements StatisticsComputationStrategy { + + private final StatisticsRepository repository; - SummaryStatisticsStrategy(MongoTemplate mongoTemplate) { - super(mongoTemplate); + SummaryStatisticsStrategy(StatisticsRepository repository) { + this.repository = repository; } @Override @@ -28,19 +30,7 @@ public StatisticsFormat format() { @Override public StatisticsResponse compute(StatisticsQuery query) { - Aggregation agg = Aggregation.newAggregation( - matchDateFilter(query), - Aggregation.facet( - Aggregation.group() - .count().as("total") - .sum(ConditionalOperators.when(Criteria.where("completed").is(true)).then(1).otherwise(0)).as("completed") - ).as("counts") - .and( - Aggregation.group("createdBy").count().as("count") - ).as("userStats") - ); - - Document root = aggregateSingle(agg); + Document root = repository.executeSummary(query); int total = 0; int completed = 0; if (root != null) { @@ -53,8 +43,6 @@ public StatisticsResponse compute(StatisticsQuery query) { } int pending = total - completed; Map userStats = extractUserStats(root); - return new StatisticsSummaryResponse(total, completed, pending, userStats); } } - From 1fdc6c5e610d503c3517b298adec0b7b791b6216 Mon Sep 17 00:00:00 2001 From: Kirill Topchy Date: Mon, 13 Oct 2025 18:39:12 +0300 Subject: [PATCH 06/10] PR comments fix --- .../features/greeting/GreetingService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java index 20fafda..e432afc 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java @@ -12,12 +12,12 @@ public GreetingService(TodoService todoService) { } public String greet() { - var activeTodos = todoService + var activeTodosCount = todoService .getAllTodos() .stream() .filter(todo -> !todo.completed()) - .toList(); + .count(); - return "Hello from Spring! You have " + activeTodos.size() + " open tasks."; + return String.format("Hello from Spring! You have %d open tasks.", activeTodosCount); } } From 4405ac8b8441d3a0cd4c9d890a1681c9314750d5 Mon Sep 17 00:00:00 2001 From: Kirill Topchy Date: Mon, 13 Oct 2025 18:59:47 +0300 Subject: [PATCH 07/10] fix formating issues --- .../features/greeting/GreetingController.java | 18 ++++++++-------- .../features/greeting/GreetingService.java | 21 ++++++++----------- .../GreetingServiceIntegrationTest.java | 5 +++-- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java index 30ac8d8..ed6b5b5 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java @@ -10,15 +10,15 @@ @RequestMapping("/api/greeting") @Tag(name = "Greeting Controller", description = "Greeting related endpoints") public class GreetingController { - private final GreetingService greetingService; + private final GreetingService greetingService; - public GreetingController(GreetingService greetingService) { - this.greetingService = greetingService; - } + public GreetingController(GreetingService greetingService) { + this.greetingService = greetingService; + } - @GetMapping - @Operation(summary = "Get greeting message") - public String getGreeting() { - return greetingService.greet(); - } + @GetMapping + @Operation(summary = "Get greeting message") + public String getGreeting() { + return greetingService.greet(); + } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java index e432afc..71a9f37 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java @@ -5,19 +5,16 @@ @Service public class GreetingService { - private final TodoService todoService; + private final TodoService todoService; - public GreetingService(TodoService todoService) { - this.todoService = todoService; - } + public GreetingService(TodoService todoService) { + this.todoService = todoService; + } - public String greet() { - var activeTodosCount = todoService - .getAllTodos() - .stream() - .filter(todo -> !todo.completed()) - .count(); + public String greet() { + var activeTodosCount = + todoService.getAllTodos().stream().filter(todo -> !todo.completed()).count(); - return String.format("Hello from Spring! You have %d open tasks.", activeTodosCount); - } + return String.format("Hello from Spring! You have %d open tasks.", activeTodosCount); + } } diff --git a/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java index 6695b95..849bd5f 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java @@ -17,8 +17,9 @@ /** * Integration test for {@link GreetingService} without Testcontainers. * - *

This test uses a locally running MongoDB instance (expected at mongodb://localhost:27017/tododb). - * If you prefer an embedded/in-memory Mongo, add flapdoodle dependency and remove the dynamic property below. + *

This test uses a locally running MongoDB instance (expected at + * mongodb://localhost:27017/tododb). If you prefer an embedded/in-memory Mongo, add flapdoodle + * dependency and remove the dynamic property below. */ @SpringBootTest @TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) From 037749d397173b25ff76dd95232a4e0d57f2d4e4 Mon Sep 17 00:00:00 2001 From: Kirill Topchy Date: Mon, 13 Oct 2025 18:59:47 +0300 Subject: [PATCH 08/10] fix formating issues --- .../features/greeting/GreetingController.java | 18 ++++++++-------- .../features/greeting/GreetingService.java | 21 ++++++++----------- .../GreetingServiceIntegrationTest.java | 5 +++-- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java index 30ac8d8..ed6b5b5 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java @@ -10,15 +10,15 @@ @RequestMapping("/api/greeting") @Tag(name = "Greeting Controller", description = "Greeting related endpoints") public class GreetingController { - private final GreetingService greetingService; + private final GreetingService greetingService; - public GreetingController(GreetingService greetingService) { - this.greetingService = greetingService; - } + public GreetingController(GreetingService greetingService) { + this.greetingService = greetingService; + } - @GetMapping - @Operation(summary = "Get greeting message") - public String getGreeting() { - return greetingService.greet(); - } + @GetMapping + @Operation(summary = "Get greeting message") + public String getGreeting() { + return greetingService.greet(); + } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java index e432afc..71a9f37 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java @@ -5,19 +5,16 @@ @Service public class GreetingService { - private final TodoService todoService; + private final TodoService todoService; - public GreetingService(TodoService todoService) { - this.todoService = todoService; - } + public GreetingService(TodoService todoService) { + this.todoService = todoService; + } - public String greet() { - var activeTodosCount = todoService - .getAllTodos() - .stream() - .filter(todo -> !todo.completed()) - .count(); + public String greet() { + var activeTodosCount = + todoService.getAllTodos().stream().filter(todo -> !todo.completed()).count(); - return String.format("Hello from Spring! You have %d open tasks.", activeTodosCount); - } + return String.format("Hello from Spring! You have %d open tasks.", activeTodosCount); + } } diff --git a/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java index 6695b95..849bd5f 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java @@ -17,8 +17,9 @@ /** * Integration test for {@link GreetingService} without Testcontainers. * - *

This test uses a locally running MongoDB instance (expected at mongodb://localhost:27017/tododb). - * If you prefer an embedded/in-memory Mongo, add flapdoodle dependency and remove the dynamic property below. + *

This test uses a locally running MongoDB instance (expected at + * mongodb://localhost:27017/tododb). If you prefer an embedded/in-memory Mongo, add flapdoodle + * dependency and remove the dynamic property below. */ @SpringBootTest @TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) From 750da217714095eb9a317441fe5dfab89bf83702 Mon Sep 17 00:00:00 2001 From: Kirill Topchy Date: Mon, 13 Oct 2025 19:22:53 +0300 Subject: [PATCH 09/10] fix formating again................. --- .../features/greeting/GreetingService.java | 2 +- .../features/greeting/GreetingServiceIntegrationTest.java | 4 ++-- .../features/todo/TodoServiceIntegrationTest.java | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java index 71a9f37..2d5770a 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java @@ -12,7 +12,7 @@ public GreetingService(TodoService todoService) { } public String greet() { - var activeTodosCount = + long activeTodosCount = todoService.getAllTodos().stream().filter(todo -> !todo.completed()).count(); return String.format("Hello from Spring! You have %d open tasks.", activeTodosCount); diff --git a/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java index 849bd5f..ddcc817 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java @@ -76,10 +76,10 @@ void should_return_zero_open_tasks_message_if_none_exist() { @Test void should_work_with_no_todos_at_all() { // when - String message = greetingService.greet(); + String msg = greetingService.greet(); // then - assertThat(message).contains("Hello").contains("0 open tasks"); + assertThat(msg).contains("Hello").contains("0 open tasks"); } @Test diff --git a/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java index 155c443..27436db 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java @@ -40,11 +40,11 @@ void shouldCreateAndRetrieveTodo() { // when todoService.createTodo(title, description, completed, createdBy); - List allTodos = todoService.getAllTodos(); + List todos = todoService.getAllTodos(); // then - assertThat(allTodos).hasSize(1); - Todo todo = allTodos.getFirst(); + assertThat(todos).hasSize(1); + Todo todo = todos.getFirst(); assertThat(todo.id()).isNotNull(); assertThat(todo.title()).isEqualTo(title); From 0f48ed8f1bbf29274aadbfea8b9ff52550c3924f Mon Sep 17 00:00:00 2001 From: Kirill Topchy Date: Tue, 16 Dec 2025 10:00:39 +0200 Subject: [PATCH 10/10] fix string format and count --- .../features/greeting/GreetingService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java index 20fafda..90325b3 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java @@ -12,12 +12,12 @@ public GreetingService(TodoService todoService) { } public String greet() { - var activeTodos = todoService + var activeTodosCount = todoService .getAllTodos() .stream() .filter(todo -> !todo.completed()) - .toList(); + .count(); - return "Hello from Spring! You have " + activeTodos.size() + " open tasks."; + return String.format("Hello from Spring! You have %d open tasks.", activeTodosCount); } }