-
Notifications
You must be signed in to change notification settings - Fork 1
feat(task-2): Create the "Statistics" Feature #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: margarita-pashnina
Are you sure you want to change the base?
Changes from all commits
5d93d1b
e807d65
e085e39
a6016bb
44503ae
4386643
4611cd5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package lv.ctco.springboottemplate.common; | ||
|
|
||
| import jakarta.validation.ConstraintViolationException; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.ControllerAdvice; | ||
| import org.springframework.web.bind.annotation.ExceptionHandler; | ||
| import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; | ||
|
|
||
| @ControllerAdvice | ||
| public class GlobalExceptionHandler { | ||
|
|
||
| @ExceptionHandler(ConstraintViolationException.class) | ||
| public ResponseEntity<String> handleConstraintViolationException( | ||
| ConstraintViolationException ex) { | ||
| return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); | ||
| } | ||
|
|
||
| @ExceptionHandler(MethodArgumentTypeMismatchException.class) | ||
| public ResponseEntity<String> handleMethodArgumentTypeMismatchException( | ||
| MethodArgumentTypeMismatchException ex) { | ||
| return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package lv.ctco.springboottemplate.features.statistics; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonInclude; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Optional; | ||
|
|
||
| @JsonInclude(JsonInclude.Include.NON_ABSENT) | ||
| public record Statistics( | ||
| int totalTodos, | ||
| int completedTodos, | ||
| int pendingTodos, | ||
| Map<String, Integer> userStats, | ||
| Optional<Map<String, List<StatisticsTodo>>> todos) {} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| package lv.ctco.springboottemplate.features.statistics; | ||
|
|
||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.Parameter; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import java.time.LocalDate; | ||
| import java.util.Optional; | ||
| import org.springframework.format.annotation.DateTimeFormat; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.validation.annotation.Validated; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RequestParam; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/statistics") | ||
| @Tag(name = "Statistics Controller", description = "Statistics endpoint") | ||
| @Validated | ||
| public class StatisticsController { | ||
| private final StatisticsService statisticsService; | ||
|
|
||
| public StatisticsController(StatisticsService statisticsService) { | ||
| this.statisticsService = statisticsService; | ||
| } | ||
|
|
||
| @GetMapping | ||
| @Operation(summary = "Get todo statistics") | ||
| public ResponseEntity<Statistics> getStatistics( | ||
| @Parameter(description = "Start date. Format: YYYY-MM-DD", example = "2023-01-01") | ||
| @RequestParam | ||
| @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) | ||
| Optional<LocalDate> from, | ||
| @Parameter(description = "End date. Format: YYYY-MM-DD", example = "2023-12-31") | ||
| @RequestParam | ||
| @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) | ||
| Optional<LocalDate> to, | ||
| @Parameter(description = "Response format", example = "summary") | ||
| @Schema(allowableValues = {"summary", "detailed"}) | ||
| @RequestParam | ||
| @StatisticsFormat | ||
| String format) { | ||
| var statisticsFormat = | ||
| "summary".equals(format) ? StatisticsFormats.SUMMARY : StatisticsFormats.DETAILED; | ||
| return ResponseEntity.ok(statisticsService.getStatistics(from, to, statisticsFormat)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package lv.ctco.springboottemplate.features.statistics; | ||
|
|
||
| import jakarta.validation.Constraint; | ||
| import jakarta.validation.Payload; | ||
| import java.lang.annotation.*; | ||
|
|
||
| @Documented | ||
| @Constraint(validatedBy = StatisticsFormatValidator.class) | ||
| @Target({ElementType.PARAMETER}) | ||
| @Retention(RetentionPolicy.RUNTIME) | ||
| public @interface StatisticsFormat { | ||
| String message() default "Either 'summary' or 'detailed' must be provided for format"; | ||
|
|
||
| Class<?>[] groups() default {}; | ||
|
|
||
| Class<? extends Payload>[] payload() default {}; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package lv.ctco.springboottemplate.features.statistics; | ||
|
|
||
| import jakarta.validation.ConstraintValidator; | ||
| import jakarta.validation.ConstraintValidatorContext; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Component | ||
| public class StatisticsFormatValidator implements ConstraintValidator<StatisticsFormat, String> { | ||
| @Override | ||
| public boolean isValid(String value, ConstraintValidatorContext context) { | ||
| return "summary".equals(value) || "detailed".equals(value); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package lv.ctco.springboottemplate.features.statistics; | ||
|
|
||
| public enum StatisticsFormats { | ||
| SUMMARY, | ||
| DETAILED | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| package lv.ctco.springboottemplate.features.statistics; | ||
|
|
||
| import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; | ||
| import static org.springframework.data.mongodb.core.query.Criteria.where; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.time.LocalTime; | ||
| import java.util.*; | ||
| import java.util.stream.Collectors; | ||
| import java.util.stream.Stream; | ||
| 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.Service; | ||
|
|
||
| @Service | ||
| public class StatisticsService { | ||
| private final MongoTemplate mongoTemplate; | ||
|
|
||
| public StatisticsService(MongoTemplate mongoTemplate) { | ||
| this.mongoTemplate = mongoTemplate; | ||
| } | ||
|
|
||
| public Statistics getStatistics( | ||
| Optional<LocalDate> from, Optional<LocalDate> to, StatisticsFormats format) { | ||
| boolean isSummary = StatisticsFormats.SUMMARY.equals(format); | ||
| Document result = | ||
| isSummary | ||
| ? getSummaryStatisticsAggregation(from, to).getUniqueMappedResult() | ||
| : getDetailedStatisticsAggregation(from, to).getUniqueMappedResult(); | ||
|
|
||
| int totalTodos = getTodoCount(result, "totalTodos"); | ||
| int completedTodos = getTodoCount(result, "completedTodos"); | ||
| int pendingTodos = getTodoCount(result, "pendingTodos"); | ||
|
|
||
| Map<String, Integer> userStats = getUserStats(result); | ||
|
|
||
| if (isSummary) { | ||
| return new Statistics(totalTodos, completedTodos, pendingTodos, userStats, Optional.empty()); | ||
| } | ||
|
|
||
| Map<String, List<StatisticsTodo>> todos = getStatisticsTodos(result); | ||
|
|
||
| return new Statistics(totalTodos, completedTodos, pendingTodos, userStats, Optional.of(todos)); | ||
| } | ||
|
|
||
| private AggregationResults<Document> getDetailedStatisticsAggregation( | ||
| Optional<LocalDate> from, Optional<LocalDate> to) { | ||
| ProjectionOperation projectPendingFields = project("id", "title", "createdBy", "createdAt"); | ||
| ProjectionOperation projectCompletedFields = | ||
| projectPendingFields.andExpression("updatedAt").as("completedAt"); | ||
|
|
||
| FacetOperation detailsFacet = | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here I would split this method into two, for summary and detailed data to be returned. If I understood correctly, based on format type we return only one of the facetOperation results, mething that those initializations can be done and hidden WITHIN an IF or in a separate methods
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
| facet(countTodos()) | ||
| .as("totalTodos") | ||
| .and(matchTodosByCompletedState(true), countTodos()) | ||
| .as("completedTodos") | ||
| .and(matchTodosByCompletedState(false), countTodos()) | ||
| .as("pendingTodos") | ||
| .and(countTodosPerUser()) | ||
| .as("userStats") | ||
| .and(matchTodosByCompletedState(false), projectPendingFields) | ||
| .as("pending") | ||
| .and(matchTodosByCompletedState(true), projectCompletedFields) | ||
| .as("completed"); | ||
|
|
||
| Aggregation detailedAggregation = newAggregation(matchDateRange(from, to), detailsFacet); | ||
|
|
||
| return this.mongoTemplate.aggregate(detailedAggregation, "todos", Document.class); | ||
| } | ||
|
|
||
| private AggregationResults<Document> getSummaryStatisticsAggregation( | ||
| Optional<LocalDate> from, Optional<LocalDate> to) { | ||
| FacetOperation summaryFacet = | ||
| facet(countTodos()) | ||
| .as("totalTodos") | ||
| .and(matchTodosByCompletedState(true), countTodos()) | ||
| .as("completedTodos") | ||
| .and(matchTodosByCompletedState(false), countTodos()) | ||
| .as("pendingTodos") | ||
| .and(countTodosPerUser()) | ||
| .as("userStats"); | ||
|
|
||
| Aggregation summaryAggregation = newAggregation(matchDateRange(from, to), summaryFacet); | ||
|
|
||
| return this.mongoTemplate.aggregate(summaryAggregation, "todos", Document.class); | ||
| } | ||
|
|
||
| private MatchOperation matchDateRange(Optional<LocalDate> from, Optional<LocalDate> to) { | ||
| Criteria criteria = | ||
| Stream.of( | ||
| from.map(f -> where("createdAt").gte(f.atStartOfDay())), | ||
| to.map(t -> where("createdAt").lte(t.atTime(LocalTime.MAX)))) | ||
| .flatMap(Optional::stream) | ||
| .reduce( | ||
| new Criteria(), | ||
| (c1, c2) -> | ||
| c1.getCriteriaObject().isEmpty() ? c2 : new Criteria().andOperator(c1, c2)); | ||
| return Aggregation.match(criteria); | ||
| } | ||
|
|
||
| private MatchOperation matchTodosByCompletedState(boolean completed) { | ||
| return match(where("completed").is(completed)); | ||
| } | ||
|
|
||
| private GroupOperation countTodos() { | ||
| return group().count().as("count"); | ||
| } | ||
|
|
||
| private GroupOperation countTodosPerUser() { | ||
| return group("createdBy").count().as("count"); | ||
| } | ||
|
|
||
| private int getTodoCount(Document result, String key) { | ||
| List<Document> totalTodosDocument = result.getList(key, Document.class); | ||
| if (totalTodosDocument.isEmpty()) { | ||
| return 0; | ||
| } | ||
| return (int) totalTodosDocument.getFirst().get("count"); | ||
| } | ||
|
|
||
| private Map<String, Integer> getUserStats(Document result) { | ||
| List<Document> userStatsDocument = result.getList("userStats", Document.class); | ||
| return userStatsDocument.stream() | ||
| .collect( | ||
| Collectors.toMap( | ||
| stat -> (String) stat.get("_id"), stat -> (Integer) stat.get("count"))); | ||
| } | ||
|
|
||
| private Map<String, List<StatisticsTodo>> getStatisticsTodos(Document result) { | ||
| List<Document> pendingDocument = result.getList("pending", Document.class); | ||
| List<StatisticsTodo> pending = | ||
| pendingDocument.stream() | ||
| .map( | ||
| item -> | ||
| new StatisticsTodo( | ||
| item.get("_id").toString(), | ||
| item.get("title").toString(), | ||
| item.get("createdBy").toString(), | ||
| ((Date) item.get("createdAt")).toInstant(), | ||
| Optional.empty())) | ||
| .toList(); | ||
|
|
||
| List<Document> completedDocument = result.getList("completed", Document.class); | ||
| List<StatisticsTodo> completed = | ||
| completedDocument.stream() | ||
| .map( | ||
| item -> | ||
| new StatisticsTodo( | ||
| item.get("_id").toString(), | ||
| item.get("title").toString(), | ||
| item.get("createdBy").toString(), | ||
| ((Date) item.get("createdAt")).toInstant(), | ||
| Optional.ofNullable(((Date) item.get("completedAt")).toInstant()))) | ||
| .toList(); | ||
| Map<String, List<StatisticsTodo>> todos = new HashMap<>(); | ||
| todos.put("completed", completed); | ||
| todos.put("pending", pending); | ||
| return todos; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package lv.ctco.springboottemplate.features.statistics; | ||
|
|
||
| import java.time.Instant; | ||
| import java.util.Optional; | ||
| import org.springframework.data.annotation.Id; | ||
|
|
||
| public record StatisticsTodo( | ||
| @Id String id, | ||
| String title, | ||
| String createdBy, | ||
| Instant createdAt, | ||
| Optional<Instant> completedAt) {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unusual formatting. If you use IDEA you can press Ctrl+Llt+L or check "Reformat code" in commit options.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spotless check on build fails otherwise, maybe it's supposed to look like this