From 512ad0356bec7f98639bb94be28cfaeb019eab7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:38:53 +0000 Subject: [PATCH 1/2] Initial plan From 91b4e761803926d3a5af555b7dc05a3b13d21f72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:57:38 +0000 Subject: [PATCH 2/2] Optimize getUsersWithAnalytics: batch queries + pagination (fixes N+1 problem) Co-authored-by: ElenaSpb <15213856+ElenaSpb@users.noreply.github.com> --- .../projection/UserStatisticsWithIdView.kt | 11 ++++ .../epam/brn/repo/StudyHistoryRepository.kt | 26 ++++++++ .../epam/brn/repo/UserAccountRepository.kt | 8 +++ .../service/impl/UserAccountServiceImpl.kt | 2 +- .../service/impl/UserAnalyticsServiceImpl.kt | 62 ++++++++++++++----- .../brn/service/UserAccountServiceTest.kt | 2 +- .../brn/service/UserAnalyticsServiceTest.kt | 27 ++++---- 7 files changed, 107 insertions(+), 31 deletions(-) create mode 100644 src/main/kotlin/com/epam/brn/model/projection/UserStatisticsWithIdView.kt diff --git a/src/main/kotlin/com/epam/brn/model/projection/UserStatisticsWithIdView.kt b/src/main/kotlin/com/epam/brn/model/projection/UserStatisticsWithIdView.kt new file mode 100644 index 000000000..d614e1a3c --- /dev/null +++ b/src/main/kotlin/com/epam/brn/model/projection/UserStatisticsWithIdView.kt @@ -0,0 +1,11 @@ +package com.epam.brn.model.projection + +import java.time.LocalDateTime + +interface UserStatisticsWithIdView { + val userId: Long + val firstStudy: LocalDateTime? + val lastStudy: LocalDateTime? + val spentTime: Long + val doneExercises: Int +} diff --git a/src/main/kotlin/com/epam/brn/repo/StudyHistoryRepository.kt b/src/main/kotlin/com/epam/brn/repo/StudyHistoryRepository.kt index 68133cef8..a48606490 100644 --- a/src/main/kotlin/com/epam/brn/repo/StudyHistoryRepository.kt +++ b/src/main/kotlin/com/epam/brn/repo/StudyHistoryRepository.kt @@ -4,6 +4,7 @@ import com.epam.brn.model.Exercise import com.epam.brn.model.StudyHistory import com.epam.brn.model.projection.ExerciseLastAttemptView import com.epam.brn.model.projection.UserStatisticView +import com.epam.brn.model.projection.UserStatisticsWithIdView import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.CrudRepository import org.springframework.data.repository.query.Param @@ -167,4 +168,29 @@ interface StudyHistoryRepository : CrudRepository { "select count (s) > 0 from StudyHistory s where s.userAccount.id = :userId", ) fun isUserHasStatistics(userId: Long): Boolean + + @Query( + "SELECT s FROM StudyHistory s " + + "JOIN FETCH s.exercise e " + + "LEFT JOIN FETCH e.subGroup sg " + + "LEFT JOIN FETCH sg.series " + + "WHERE s.startTime >= :from " + + "AND s.startTime <= :to " + + "AND s.userAccount.id IN :userIds " + + "ORDER BY s.startTime", + ) + fun getHistoriesByUserIds( + @Param("userIds") userIds: Collection, + @Param("from") from: LocalDateTime, + @Param("to") to: LocalDateTime, + ): List + + @Query( + "SELECT s.userAccount.id AS userId, MIN(s.startTime) AS firstStudy, MAX(s.startTime) AS lastStudy," + + " COALESCE(SUM(s.spentTimeInSeconds), 0) AS spentTime, COUNT(DISTINCT s.exercise.id) AS doneExercises" + + " FROM StudyHistory s WHERE s.userAccount.id IN :userIds GROUP BY s.userAccount.id", + ) + fun getStatisticsByUserIds( + @Param("userIds") userIds: Collection, + ): List } diff --git a/src/main/kotlin/com/epam/brn/repo/UserAccountRepository.kt b/src/main/kotlin/com/epam/brn/repo/UserAccountRepository.kt index cde382fb8..958aff59d 100644 --- a/src/main/kotlin/com/epam/brn/repo/UserAccountRepository.kt +++ b/src/main/kotlin/com/epam/brn/repo/UserAccountRepository.kt @@ -53,6 +53,14 @@ interface UserAccountRepository : JpaRepository { ) fun findUsersAccountsByRole(roleName: String): List + @Query( + """select u FROM UserAccount u join u.roleSet roles where roles.name = :roleName""", + ) + fun findUsersAccountsByRole( + roleName: String, + pageable: Pageable, + ): List + @Transactional @Modifying @Query( diff --git a/src/main/kotlin/com/epam/brn/service/impl/UserAccountServiceImpl.kt b/src/main/kotlin/com/epam/brn/service/impl/UserAccountServiceImpl.kt index df1c3c2ae..952129c13 100644 --- a/src/main/kotlin/com/epam/brn/service/impl/UserAccountServiceImpl.kt +++ b/src/main/kotlin/com/epam/brn/service/impl/UserAccountServiceImpl.kt @@ -91,7 +91,7 @@ class UserAccountServiceImpl( pageable: Pageable, role: String, ): List = userAccountRepository - .findUsersAccountsByRole(role) + .findUsersAccountsByRole(role, pageable) .map { it.toDto() } override fun updateAvatarForCurrentUser(avatarUrl: String): UserAccountDto { diff --git a/src/main/kotlin/com/epam/brn/service/impl/UserAnalyticsServiceImpl.kt b/src/main/kotlin/com/epam/brn/service/impl/UserAnalyticsServiceImpl.kt index 2d74d7a02..5cf7de077 100644 --- a/src/main/kotlin/com/epam/brn/service/impl/UserAnalyticsServiceImpl.kt +++ b/src/main/kotlin/com/epam/brn/service/impl/UserAnalyticsServiceImpl.kt @@ -3,6 +3,7 @@ package com.epam.brn.service.impl import com.epam.brn.dto.AudioFileMetaData import com.epam.brn.dto.response.UserWithAnalyticsResponse import com.epam.brn.dto.statistics.DayStudyStatistics +import com.epam.brn.dto.statistics.UserExercisingPeriod import com.epam.brn.enums.ExerciseType import com.epam.brn.enums.Voice import com.epam.brn.exception.EntityNotFoundException @@ -17,7 +18,7 @@ import com.epam.brn.service.TimeService import com.epam.brn.service.UserAccountService import com.epam.brn.service.UserAnalyticsService import com.epam.brn.service.WordsService -import com.epam.brn.service.statistics.UserPeriodStatisticsService +import com.epam.brn.service.statistics.progress.status.ProgressStatusManager import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import java.io.InputStream @@ -33,12 +34,12 @@ class UserAnalyticsServiceImpl( private val userAccountRepository: UserAccountRepository, private val studyHistoryRepository: StudyHistoryRepository, private val exerciseRepository: ExerciseRepository, - private val userDayStatisticsService: UserPeriodStatisticsService, private val timeService: TimeService, private val textToSpeechService: TextToSpeechService, private val userAccountService: UserAccountService, private val exerciseService: ExerciseService, private val wordsService: WordsService, + private val progressManager: ProgressStatusManager>, ) : UserAnalyticsService { private val listTextExercises = listOf(ExerciseType.SENTENCE, ExerciseType.PHRASES) @@ -46,7 +47,10 @@ class UserAnalyticsServiceImpl( pageable: Pageable, role: String, ): List { - val users = userAccountRepository.findUsersAccountsByRole(role).map { it.toAnalyticsDto() } + val users = userAccountRepository.findUsersAccountsByRole(role, pageable).map { it.toAnalyticsDto() } + if (users.isEmpty()) return emptyList() + + val userIds = users.mapNotNull { it.id } val now = timeService.now() val firstWeekDay = WeekFields.of(Locale.getDefault()).dayOfWeek() @@ -55,24 +59,52 @@ class UserAnalyticsServiceImpl( val to = startDay.plusDays(7L).with(LocalTime.MAX) val startOfCurrentMonth = now.withDayOfMonth(1).with(LocalTime.MIN) + val weekHistoriesByUserId = + studyHistoryRepository + .getHistoriesByUserIds(userIds, from, to) + .groupBy { it.userAccount.id } + + val monthHistoriesByUserId = + studyHistoryRepository + .getHistoriesByUserIds(userIds, startOfCurrentMonth, now) + .groupBy { it.userAccount.id } + + val statisticsByUserId = + studyHistoryRepository + .getStatisticsByUserIds(userIds) + .associateBy { it.userId } + users.onEach { user -> - user.lastWeek = userDayStatisticsService.getStatisticsForPeriod(from, to, user.id) - user.studyDaysInCurrentMonth = - countWorkDaysForMonth( - userDayStatisticsService.getStatisticsForPeriod(startOfCurrentMonth, now, user.id), - ) - - val userStatistic = studyHistoryRepository.getStatisticsByUserAccountId(user.id) - user.apply { - this.firstDone = userStatistic.firstStudy - this.lastDone = userStatistic.lastStudy - this.spentTime = userStatistic.spentTime.toDuration(DurationUnit.SECONDS) - this.doneExercises = userStatistic.doneExercises + val weekHistories = weekHistoriesByUserId[user.id] ?: emptyList() + user.lastWeek = computeDayStatistics(weekHistories) + + val monthHistories = monthHistoriesByUserId[user.id] ?: emptyList() + user.studyDaysInCurrentMonth = countWorkDaysForMonth(computeDayStatistics(monthHistories)) + + val userStatistic = statisticsByUserId[user.id] + if (userStatistic != null) { + user.apply { + this.firstDone = userStatistic.firstStudy + this.lastDone = userStatistic.lastStudy + this.spentTime = userStatistic.spentTime.toDuration(DurationUnit.SECONDS) + this.doneExercises = userStatistic.doneExercises + } } } return users } + private fun computeDayStatistics(histories: List): List { + val byDate = histories.groupBy { it.startTime.toLocalDate() } + return byDate.map { (_, dayHistories) -> + DayStudyStatistics( + exercisingTimeSeconds = dayHistories.sumOf { it.executionSeconds }, + date = dayHistories.first().startTime, + progress = progressManager.getStatus(UserExercisingPeriod.DAY, dayHistories), + ) + } + } + override fun prepareAudioStreamForUser( exerciseId: Long, audioFileMetaData: AudioFileMetaData, diff --git a/src/test/kotlin/com/epam/brn/service/UserAccountServiceTest.kt b/src/test/kotlin/com/epam/brn/service/UserAccountServiceTest.kt index 791e3746d..b99c5e2bd 100644 --- a/src/test/kotlin/com/epam/brn/service/UserAccountServiceTest.kt +++ b/src/test/kotlin/com/epam/brn/service/UserAccountServiceTest.kt @@ -499,7 +499,7 @@ internal class UserAccountServiceTest { fun `should return all users`() { // GIVEN val usersList = listOf(userAccount, userAccount, userAccount) - every { userAccountRepository.findUsersAccountsByRole(BrnRole.USER) } returns usersList + every { userAccountRepository.findUsersAccountsByRole(BrnRole.USER, pageable) } returns usersList // WHEN val userAccountDtos = userAccountService.getUsers(pageable = pageable, BrnRole.USER) // THEN diff --git a/src/test/kotlin/com/epam/brn/service/UserAnalyticsServiceTest.kt b/src/test/kotlin/com/epam/brn/service/UserAnalyticsServiceTest.kt index b4d17dc8e..785f43ca7 100644 --- a/src/test/kotlin/com/epam/brn/service/UserAnalyticsServiceTest.kt +++ b/src/test/kotlin/com/epam/brn/service/UserAnalyticsServiceTest.kt @@ -9,12 +9,12 @@ import com.epam.brn.enums.ExerciseType import com.epam.brn.enums.Voice import com.epam.brn.model.StudyHistory import com.epam.brn.model.UserAccount -import com.epam.brn.model.projection.UserStatisticView +import com.epam.brn.model.projection.UserStatisticsWithIdView import com.epam.brn.repo.ExerciseRepository import com.epam.brn.repo.StudyHistoryRepository import com.epam.brn.repo.UserAccountRepository import com.epam.brn.service.impl.UserAnalyticsServiceImpl -import com.epam.brn.service.statistics.UserPeriodStatisticsService +import com.epam.brn.service.statistics.progress.status.ProgressStatusManager import com.epam.brn.exception.EntityNotFoundException import io.kotest.matchers.shouldBe import io.mockk.every @@ -45,9 +45,6 @@ internal class UserAnalyticsServiceTest { @MockK lateinit var exerciseRepository: ExerciseRepository - @MockK - lateinit var userDayStatisticService: UserPeriodStatisticsService - @MockK lateinit var timeService: TimeService @@ -70,7 +67,7 @@ internal class UserAnalyticsServiceTest { lateinit var dayStudyStatistics: DayStudyStatistics @MockK - lateinit var userStatisticView: UserStatisticView + lateinit var progressManager: ProgressStatusManager> @MockK lateinit var wordsService: WordsService @@ -78,16 +75,17 @@ internal class UserAnalyticsServiceTest { @Test fun `should return all users with analytics`() { val usersList = listOf(doctorAccount, doctorAccount) - val dayStatisticList = listOf(dayStudyStatistics, dayStudyStatistics) + val userStatisticView = mockk() + every { userStatisticView.userId } returns 1L every { userStatisticView.firstStudy } returns LocalDateTime.now() every { userStatisticView.lastStudy } returns LocalDateTime.now() every { userStatisticView.spentTime } returns 10000L every { userStatisticView.doneExercises } returns 1 - every { userAccountRepository.findUsersAccountsByRole(BrnRole.ADMIN) } returns usersList - every { userDayStatisticService.getStatisticsForPeriod(any(), any(), any()) } returns dayStatisticList + every { userAccountRepository.findUsersAccountsByRole(BrnRole.ADMIN, pageable) } returns usersList every { timeService.now() } returns LocalDateTime.now() - every { studyHistoryRepository.getStatisticsByUserAccountId(any()) } returns userStatisticView + every { studyHistoryRepository.getHistoriesByUserIds(any(), any(), any()) } returns emptyList() + every { studyHistoryRepository.getStatisticsByUserIds(any()) } returns listOf(userStatisticView) val userAnalyticsDtos = userAnalyticsService.getUsersWithAnalytics(pageable, BrnRole.ADMIN) @@ -97,16 +95,17 @@ internal class UserAnalyticsServiceTest { @Test fun `should not return user with analytics`() { val usersList = listOf(doctorAccount) - val dayStatisticList = emptyList() + val userStatisticView = mockk() + every { userStatisticView.userId } returns 1L every { userStatisticView.firstStudy } returns LocalDateTime.now() every { userStatisticView.lastStudy } returns LocalDateTime.now() every { userStatisticView.spentTime } returns 10000L every { userStatisticView.doneExercises } returns 1 - every { userAccountRepository.findUsersAccountsByRole(BrnRole.ADMIN) } returns usersList - every { userDayStatisticService.getStatisticsForPeriod(any(), any(), any()) } returns dayStatisticList + every { userAccountRepository.findUsersAccountsByRole(BrnRole.ADMIN, pageable) } returns usersList every { timeService.now() } returns LocalDateTime.now() - every { studyHistoryRepository.getStatisticsByUserAccountId(any()) } returns userStatisticView + every { studyHistoryRepository.getHistoriesByUserIds(any(), any(), any()) } returns emptyList() + every { studyHistoryRepository.getStatisticsByUserIds(any()) } returns emptyList() val userAnalyticsDtos = userAnalyticsService.getUsersWithAnalytics(pageable, BrnRole.ADMIN)