Passed
Pull Request — master (#144)
by Matt
04:35
created

StatsService::getWanderStats()   B

Complexity

Conditions 4
Paths 1

Size

Total Lines 123
Code Lines 65

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 4
eloc 65
c 3
b 0
f 0
nc 1
nop 0
dl 0
loc 123
rs 8.7636

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace App\Service;
4
5
use App\Entity\Wander;
6
use App\Repository\ImageRepository;
7
use App\Repository\WanderRepository;
8
use Carbon\Carbon;
9
use Carbon\CarbonInterval;
10
use Doctrine\ORM\EntityManager;
11
use Doctrine\ORM\EntityManagerInterface;
12
use Doctrine\ORM\EntityRepository;
13
use Exception;
14
use Symfony\Contracts\Cache\CacheInterface;
15
use Symfony\Contracts\Cache\ItemInterface;
16
use Symfony\Contracts\Cache\TagAwareCacheInterface;
17
18
class StatsService
19
{
20
    /** @var ImageRepository */
21
    private $imageRepository;
22
23
    /** @var WanderRepository */
24
    private $wanderRepository;
25
26
    /** @var TagAwareCacheInterface */
27
    private $cache;
28
29
    /** @var EntityManagerInterface */
30
    private $entityManager;
31
32
    public function __construct(
33
        ImageRepository $imageRepository,
34
        WanderRepository $wanderRepository,
35
        TagAwareCacheInterface $cache,
36
        EntityManagerInterface $entityManager)
37
    {
38
        $this->imageRepository = $imageRepository;
39
        $this->wanderRepository = $wanderRepository;
40
        $this->cache = $cache;
41
        $this->entityManager = $entityManager;
42
    }
43
44
    /**
45
     * @return array<mixed>
46
     */
47
    public function getImageStats(): array
48
    {
49
        $stats = $this->cache->get('image_stats', function(ItemInterface $item) {
50
            $item->tag('stats');
51
            $imageStats = $this->imageRepository
52
                ->createQueryBuilder('i')
53
                ->select('COUNT(i.id) as totalCount')
54
                ->addSelect('COUNT(i.latlng) as countWithCoords')
55
                ->addSelect('COUNT(i.title) as countWithTitle')
56
                ->addSelect('COUNT(i.description) as countWithDescription')
57
                ->getQuery()
58
                ->getOneOrNullResult();
59
            return $imageStats;
60
        });
61
        return $stats;
62
    }
63
64
    /**
65
     * @return array<mixed>
66
     */
67
    public function getWanderStats(): array
68
    {
69
        $stats = $this->cache->get('wander_stats', function(ItemInterface $item) {
70
            $item->tag('stats');
71
72
            // General statistics
73
            $wanderStats = $this->wanderRepository
74
                ->createQueryBuilder('w')
75
                ->select('COUNT(w.id) as totalCount')
76
                ->addSelect('COUNT(w.title) as countWithTitle')
77
                ->addSelect('COUNT(w.description) as countWithDescription')
78
                ->addSelect('COALESCE(SUM(w.distance), 0) as totalDistance')
79
                ->addSelect('COALESCE(SUM(w.cumulativeElevationGain), 0) as totalCumulativeElevationGain')
80
                ->getQuery()
81
                ->getOneOrNullResult();
82
83
            $wanderStats['hasWanders'] = $wanderStats['totalCount'] > 0;
84
85
            // Durations
86
            // Doctrine doesn't support calculating a difference
87
            // in seconds from two datetime values via ORM. Let's
88
            // go raw. Seeing as we're aggregating over all wanders
89
            // and want to process the results into Carbon dates,
90
            // we might as well also get the earliest and latest
91
            // wanders, too. These will also be helpful for our monthly
92
            // chart where we don't want to skip missing months.
93
            $conn = $this->entityManager->getConnection();
94
            $sql = 'SELECT
95
                        COALESCE(SUM(TIME_TO_SEC(TIMEDIFF(end_time, start_time))), 0) AS totalDuration,
96
                        COALESCE(AVG(TIME_TO_SEC(TIMEDIFF(end_time, start_time))), 0) AS averageDuration,
97
                        MIN(start_time) AS firstWanderStartTime,
98
                        MAX(start_time) AS latestWanderStartTime
99
                    FROM wander
100
                ';
101
            $stmt = $conn->prepare($sql);
102
            $result = $stmt->executeQuery();
103
            $overallTimeStats = $result->fetchAssociative();
104
105
            if ($overallTimeStats === false) {
106
                throw new Exception("Got no results when finding duration stats.");
107
            }
108
109
            $wanderStats['totalDuration'] = CarbonInterval::seconds($overallTimeStats['totalDuration'])->cascade();
110
            $wanderStats['averageDuration'] = CarbonInterval::seconds($overallTimeStats['averageDuration'])->cascade();
111
            $firstWanderStartTime = Carbon::parse($overallTimeStats['firstWanderStartTime']);
112
            $latestWanderStartTime = Carbon::parse($overallTimeStats['latestWanderStartTime']);
113
            $wanderStats['firstWanderStartTime'] = $firstWanderStartTime;
114
            $wanderStats['latestWanderStartTime'] = $latestWanderStartTime;
115
116
            $wanderStats['totalDurationForHumans'] = $wanderStats['totalDuration']
117
                ->forHumans(['short' => true, 'options' => 0]); // https://github.com/briannesbitt/Carbon/issues/2035
118
            $wanderStats['averageDurationForHumans'] = $wanderStats['averageDuration']
119
                ->forHumans(['short' => true, 'options' => 0]); // https://github.com/briannesbitt/Carbon/issues/2035
120
121
            // Distances
122
            $wanderStats['shortestWanderDistance'] = $this->wanderRepository->findShortest();
123
            $wanderStats['longestWanderDistance'] = $this->wanderRepository->findLongest();
124
            $wanderStats['averageWanderDistance'] = $this->wanderRepository->findAverageDistance();
125
126
            // Stats per month. It would be most efficient to write some complicated SQL query that
127
            // groups the lot together, including filling in months with missing data using some kind
128
            // of row generator or date dimension table, but frankly this is still fast enough,
129
            // especially as it's cached and invalidated quite sensibly.
130
            $sql = 'SELECT
131
                        COUNT(*) AS number_of_wanders,
132
                        COALESCE(SUM(w.distance), 0) AS total_distance_metres,
133
                        COALESCE(SUM(w.distance), 0) / 1000.0 AS total_distance_km,
134
                        COALESCE(SUM((SELECT COUNT(*) FROM image i WHERE i.wander_id = w.id)), 0) AS number_of_images,
135
                        COALESCE(SUM(TIME_TO_SEC(TIMEDIFF(w.end_time, w.start_time))), 0) AS total_duration_seconds,
136
                        COALESCE(AVG(TIME_TO_SEC(TIMEDIFF(w.end_time, w.start_time))), 0) AS average_duration_seconds
137
                    FROM
138
                        wander w
139
                    WHERE
140
                        w.start_time >= :start AND
141
                        w.start_time < :end';
142
143
            $stmt = $conn->prepare($sql);
144
145
            $monthlyStats = [];
146
147
            $firstWanderMonth = $firstWanderStartTime->startOfMonth();
148
            $latestWanderMonth = $latestWanderStartTime->startOfMonth();
149
150
            for ($currMonth = $firstWanderMonth; $currMonth <= $latestWanderMonth; $currMonth->addMonths(1)) {
151
                $nextMonth = $currMonth->copy()->addMonths(1);
152
                $result = $stmt->executeQuery([
153
                    'start' => $currMonth,
154
                    'end' => $nextMonth
155
                ]);
156
                $row = $result->fetchAssociative();
157
                if ($row === false) {
158
                    // It's entirely aggregated, so even if no rows match the WHERE there should always be a row
159
                    // returned.
160
                    throw new \Exception("Expected to get a row back from the database no matter what with this query.");
161
                }
162
                $monthlyStats[] = [
163
                    'firstOfMonthDate' => $currMonth,
164
                    'monthLabel' => $currMonth->isoFormat('MMM YYYY'),
165
                    'year' => $currMonth->year,
166
                    'month' => $currMonth->month,
167
                    'numberOfWanders' => (int) $row['number_of_wanders'],
168
                    'totalDistanceMetres' => (float) $row['total_distance_metres'],
169
                    'numberOfImages' => (int) $row['number_of_images'],
170
                    'totalDurationInterval' => CarbonInterval::seconds($row['total_duration_seconds'])->cascade(),
171
                    'averageDurationInterval' => CarbonInterval::seconds($row['average_duration_seconds'])->cascade(),
172
                ];
173
            }
174
175
            $wanderStats['monthlyStats'] = $monthlyStats;
176
177
            // Specialist stuff
178
            $qb = $this->wanderRepository
179
                ->createQueryBuilder('w');
180
            $wanderStats['imageProcessingBacklog'] = $this->wanderRepository
181
                ->addWhereHasImages($qb, false)
182
                ->select('COUNT(w.id)')
183
                ->getQuery()
184
                ->getSingleScalarResult();
185
186
            return $wanderStats;
187
        });
188
189
        return $stats;
190
    }
191
}
192