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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified gradlew
100644 → 100755
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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.tags.Tag;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import lv.ctco.springboottemplate.features.statistics.dto.ResponseFormat;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/statistics")
@Tag(name = "Todo Controller", description = "Todo management endpoints")
public class StatisticsController {
private final StatisticsService statisticsService;

public StatisticsController(StatisticsService statisticsService) {
this.statisticsService = statisticsService;
}

@GetMapping
@Operation(summary = "Get todo statistics")
public ResponseEntity<Object> getStatistics(
@Parameter(description = "From date in ISO format (yyyy-MM-dd)")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
@Nullable
@RequestParam(name = "from", required = false)
LocalDate from,
@Parameter(description = "To date in ISO format (yyyy-MM-dd)")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
@Nullable
@RequestParam(name = "to", required = false)
LocalDate to,
@Parameter(
description =
"""
Response format. **Allowed values**:<br>
- `summary`: returns overview information.<br>
- `detailed`: returns overview information and separate complete and pending Todos.
""",
example = "summary")
@RequestParam(name = "format")
String formatString) {

ResponseFormat format;
try {
format = ResponseFormat.valueOf(formatString.toUpperCase());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body("Method parameter 'format': Failed to convert");
}

if (from == null && to == null) {
return ResponseEntity.badRequest().body("Either 'from' or 'to' should be provided");
}

Instant fromInstant = from == null ? null : from.atStartOfDay(ZoneOffset.UTC).toInstant();
Instant toInstant = to == null ? null : to.atStartOfDay(ZoneOffset.UTC).toInstant();

return ResponseEntity.ok(statisticsService.getStatistics(format, fromInstant, toInstant));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package lv.ctco.springboottemplate.features.statistics;

import java.time.Instant;
import lv.ctco.springboottemplate.features.statistics.dto.ResponseFormat;
import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsDto;
import org.springframework.stereotype.Service;

@Service
public class StatisticsService {
private final TodoStatsRepository todoStatsRepository;

public StatisticsService(TodoStatsRepository todoRepository) {
this.todoStatsRepository = todoRepository;
}

public TodoStatsDto getStatistics(ResponseFormat format, Instant from, Instant to) {
return todoStatsRepository.getStats(format, from, to);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package lv.ctco.springboottemplate.features.statistics;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import lv.ctco.springboottemplate.features.statistics.dto.*;
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;

@Repository
public class TodoStatsRepository {

private final MongoTemplate mongoTemplate;

public TodoStatsRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}

Aggregation getSummaryStatsAggregation(ResponseFormat format, Instant from, Instant to) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO it should be a private method

Criteria criteria = new Criteria();

if (from != null && to != null) {
criteria = Criteria.where("createdAt").gte(from).lte(to);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to have string constans or enums for field names

criteria.lte(to);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lte is called twice

} else if (from != null) {
criteria = Criteria.where("createdAt").gte(from);
} else if (to != null) {
criteria = Criteria.where("createdAt").lte(to);
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like lines 23-32 should be:

Criteria criteria = new Criteria();
if (from != null) {
criteria.gte(from);
}
if (to != null) {
criteria.lte(to);
}

MatchOperation matchStage = Aggregation.match(criteria);

// --- First facet: counts ---
FacetOperation countsFacet =
Aggregation.facet(
Aggregation.group()
.count()
.as("totalTodos")
.sum(
ConditionalOperators.when(Criteria.where("completed").is(true))
.then(1)
.otherwise(0))
.as("completedTodos")
.sum(
ConditionalOperators.when(Criteria.where("completed").is(false))
.then(1)
.otherwise(0))
.as("pendingTodos"))
.as("counts")

// --- Second facet: userStats ---
.and(Aggregation.group("createdBy").count().as("count"))
.as("userStats");

if (format == ResponseFormat.DETAILED) {
// --- Third facet: completed todos
countsFacet =
countsFacet
.and(
Aggregation.match(Criteria.where("completed").is(true)),
Aggregation.project("id", "title", "createdBy", "createdAt", "updatedAt")
.and("updatedAt")
.as("completedAt"))
.as("completedTodos")

// --- Facet 4: pending todos ---
.and(
Aggregation.match(Criteria.where("completed").is(false)),
Aggregation.project("id", "title", "createdBy", "createdAt"))
.as("pendingTodos");
}

// --- Final project stage ---
ProjectionOperation project =
Aggregation.project()
.and(ArrayOperators.ArrayElemAt.arrayOf("counts.totalTodos").elementAt(0))
.as("totalTodos")
.and(ArrayOperators.ArrayElemAt.arrayOf("counts.completedTodos").elementAt(0))
.as("completedTodos")
.and(ArrayOperators.ArrayElemAt.arrayOf("counts.pendingTodos").elementAt(0))
.as("pendingTodos")
.and("userStats")
.as("userStats");

if (format == ResponseFormat.DETAILED) {
project =
project
.and(ConditionalOperators.ifNull("completedTodos").then(List.of()))
.as("todos.completed")
.and(ConditionalOperators.ifNull("pendingTodos").then(List.of()))
.as("todos.pending");
}

// Build the full aggregation
return Aggregation.newAggregation(matchStage, countsFacet, project);
}

Long extractLong(Document doc, String key) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be private

Number n = doc.get(key, Number.class);
if (n == null) {
return 0L;
}
return n.longValue();
}

public TodoStatsDto getStats(ResponseFormat format, Instant from, Instant to) {
Aggregation aggregation = getSummaryStatsAggregation(format, from, to);

Document doc =
mongoTemplate.aggregate(aggregation, "todos", Document.class).getUniqueMappedResult();

if (doc == null) {
return new TodoStatsDto(0, 0, 0, Map.of(), null);
}

List<Document> userStatsList = (List<Document>) doc.get("userStats");
Map<String, Long> userStats =
userStatsList.stream()
.collect(
java.util.stream.Collectors.toMap(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you need package name (java.util.stream) as a prefix?

d -> d.getString("_id"), d -> ((Number) d.get("count")).longValue()));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractLong?


TodoDetailsDto details = null;

if (format == ResponseFormat.DETAILED) {
List<CompletedTodoDto> completedTodos =
((List<Document>) ((Document) doc.get("todos")).get("completed"))
.stream()
.map(
d ->
new CompletedTodoDto(
d.getString("id"),
d.getString("title"),
d.getString("createdBy"),
d.getDate("createdAt").toInstant(),
d.getDate("completedAt").toInstant()))
.toList();

List<PendingTodoDto> pendingTodos =
((List<Document>) ((Document) doc.get("todos")).get("pending"))
.stream()
.map(
d ->
new PendingTodoDto(
d.getString("id"),
d.getString("title"),
d.getString("createdBy"),
d.getDate("createdAt").toInstant()))
.toList();

details = new TodoDetailsDto(completedTodos, pendingTodos);
}

return new TodoStatsDto(
extractLong(doc, "totalTodos"),
extractLong(doc, "completedTodos"),
extractLong(doc, "pendingTodos"),
userStats,
details);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package lv.ctco.springboottemplate.features.statistics.dto;

import java.time.Instant;

public record CompletedTodoDto(
String id, String title, String createdBy, Instant createdAt, Instant completedAt) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package lv.ctco.springboottemplate.features.statistics.dto;

import java.time.Instant;

public record PendingTodoDto(String id, String title, String createdBy, Instant createdAt) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package lv.ctco.springboottemplate.features.statistics.dto;

public enum ResponseFormat {
SUMMARY("summary"),
DETAILED("detailed");

private final String value;

ResponseFormat(String format) {
this.value = format;
}

public String getValue() {
return value;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package lv.ctco.springboottemplate.features.statistics.dto;

import java.util.List;

public record TodoDetailsDto(List<CompletedTodoDto> completed, List<PendingTodoDto> pending) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package lv.ctco.springboottemplate.features.statistics.dto;

import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.Map;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record TodoStatsDto(
long totalTodos,
long completedTodos,
long pendingTodos,
Map<String, Long> userStats,
TodoDetailsDto todos) {}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package lv.ctco.springboottemplate.features.todo;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -19,6 +20,8 @@ CommandLineRunner initDatabase(TodoRepository todoRepository) {
todoRepository.deleteAll(); // Clear existing data

var now = Instant.now();
var oldInstant = Instant.now().minus(10L, ChronoUnit.DAYS);

var todos =
List.of(
new Todo(
Expand Down Expand Up @@ -58,7 +61,34 @@ CommandLineRunner initDatabase(TodoRepository todoRepository) {
"system",
"system",
now,
now));
now),
new Todo(
null,
"Plan Task 2",
"Research mongo aggregates",
true,
"warlock",
"warlock",
now,
now),
new Todo(
null,
"Task 2 detailed dto",
"Research mongo aggregates",
false,
"warlock",
"warlock",
now,
now),
new Todo(
null,
"Task 2 - learn basics",
"Research various java techniques",
true,
"warlock",
"warlock",
oldInstant,
oldInstant));

todoRepository.saveAll(todos);
log.info("Initialized database with {} todo items", todos.size());
Expand Down
Loading