Completed
Push — master ( 4aef6e...7ea4f6 )
by Matt
30s queued 12s
created

StatsService::getImageLocationStats()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 14
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 12
nc 1
nop 0
dl 0
loc 14
rs 9.8666
c 0
b 0
f 0
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\CarbonInterface;
10
use Carbon\CarbonInterval;
11
use Doctrine\ORM\EntityManager;
12
use Doctrine\ORM\EntityManagerInterface;
13
use Doctrine\ORM\EntityRepository;
14
use Exception;
15
use Symfony\Component\ExpressionLanguage\Node\ArrayNode;
16
use Symfony\Contracts\Cache\CacheInterface;
17
use Symfony\Contracts\Cache\ItemInterface;
18
use Symfony\Contracts\Cache\TagAwareCacheInterface;
19
20
class StatsService
21
{
22
    /** @var ImageRepository */
23
    private $imageRepository;
24
25
    /** @var WanderRepository */
26
    private $wanderRepository;
27
28
    /** @var TagAwareCacheInterface */
29
    private $cache;
30
31
    /** @var EntityManagerInterface */
32
    private $entityManager;
33
34
    public function __construct(
35
        ImageRepository $imageRepository,
36
        WanderRepository $wanderRepository,
37
        TagAwareCacheInterface $cache,
38
        EntityManagerInterface $entityManager)
39
    {
40
        $this->imageRepository = $imageRepository;
41
        $this->wanderRepository = $wanderRepository;
42
        $this->cache = $cache;
43
        $this->entityManager = $entityManager;
44
    }
45
46
    /**
47
     * @return array<mixed>
48
     */
49
    public function getImageStats(): array
50
    {
51
        $stats = $this->cache->get('image_stats', function(ItemInterface $item) {
52
            $item->tag('stats');
53
            $imageStats = $this->imageRepository
54
                ->createQueryBuilder('i')
55
                ->select('COUNT(i.id) as totalCount')
56
                ->addSelect('COUNT(i.latlng) as countWithCoords')
57
                ->addSelect('COUNT(i.title) as countWithTitle')
58
                ->addSelect('COUNT(i.description) as countWithDescription')
59
                ->getQuery()
60
                ->getSingleResult();
61
            return $imageStats;
62
        });
63
        return $stats;
64
    }
65
66
    /**
67
     * @return array<mixed>
68
     */
69
    public function getImageLocationStats(): array
70
    {
71
        return $this->cache->get('image_location_stats', function (ItemInterface $item) {
72
            $item->tag('stats');
73
            $stats = $this->imageRepository
74
                ->createQueryBuilder('i')
75
                ->select('i.location')
76
                ->addSelect('COUNT(i) AS locationCount')
77
                ->groupBy('i.location')
78
                ->Where('i.location IS NOT NULL')
79
                ->OrderBy('i.location')
80
                ->getQuery()
81
                ->getResult();
82
            return array_column($stats, 'locationCount', 'location');
0 ignored issues
show
Bug introduced by
It seems like $stats can also be of type integer; however, parameter $array of array_column() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

82
            return array_column(/** @scrutinizer ignore-type */ $stats, 'locationCount', 'location');
Loading history...
83
        });
84
    }
85
86
    /**
87
     * @return array<mixed>
88
     */
89
    public function getWanderStats(): array
90
    {
91
        $stats = $this->cache->get('wander_stats', function(ItemInterface $item) {
92
            $item->tag('stats');
93
94
            $wanderStats = $this->getGeneralWanderStats();
95
            $overallTimeStats = $this->getOverallTimeStats();
96
97
            $wanderStats += $overallTimeStats;
98
99
            $wanderStats['totalDurationForHumans'] = $wanderStats['totalDuration']
100
                ->forHumans(['short' => true, 'options' => 0]); // https://github.com/briannesbitt/Carbon/issues/2035
101
            $wanderStats['averageDurationForHumans'] = $wanderStats['averageDuration']
102
                ->forHumans(['short' => true, 'options' => 0]); // https://github.com/briannesbitt/Carbon/issues/2035
103
104
            // Distances
105
            $wanderStats['shortestWanderDistance'] = $this->wanderRepository->findShortest();
106
            $wanderStats['longestWanderDistance'] = $this->wanderRepository->findLongest();
107
            $wanderStats['averageWanderDistance'] = $this->wanderRepository->findAverageDistance();
108
109
            $wanderStats['monthlyStats'] = $this->getPeriodicStats(
110
                $overallTimeStats['firstWanderStartTime']->startOfMonth(),
111
                $overallTimeStats['latestWanderStartTime']->startOfMonth(),
112
                1,
113
                'MMM YYYY'
114
            );
115
116
            $wanderStats['yearlyStats'] = $this->getPeriodicStats(
117
                $overallTimeStats['firstWanderStartTime']->startOfYear(),
118
                $overallTimeStats['latestWanderStartTime']->startOfMonth(),
119
                12,
120
                'YYYY'
121
            );
122
123
            // Specialist stuff
124
            $qb = $this->wanderRepository
125
                ->createQueryBuilder('w');
126
            $wanderStats['imageProcessingBacklog'] = $this->wanderRepository
127
                ->addWhereHasImages($qb, false)
128
                ->select('COUNT(w.id)')
129
                ->getQuery()
130
                ->getSingleScalarResult();
131
132
            return $wanderStats;
133
        });
134
135
        return $stats;
136
    }
137
138
    /**
139
     * @return array<string, mixed>
140
     */
141
    private function getGeneralWanderStats(): array
142
    {
143
        // General statistics
144
        $wanderStats = $this->wanderRepository
145
            ->createQueryBuilder('w')
146
            ->select('COUNT(w.id) as totalCount')
147
            ->addSelect('COUNT(w.title) as countWithTitle')
148
            ->addSelect('COUNT(w.description) as countWithDescription')
149
            ->addSelect('COALESCE(SUM(w.distance), 0) as totalDistance')
150
            ->addSelect('COALESCE(SUM(w.cumulativeElevationGain), 0) as totalCumulativeElevationGain')
151
            ->getQuery()
152
            ->getSingleResult();
153
154
        $wanderStats['hasWanders'] = $wanderStats['totalCount'] > 0;
155
        return $wanderStats;
156
    }
157
    /**
158
     * @return array<string, mixed>
159
     */
160
    private function getOverallTimeStats(): array
161
    {
162
        // Doctrine doesn't support calculating a difference
163
        // in seconds from two datetime values via ORM. Let's
164
        // go raw. Seeing as we're aggregating over all wanders
165
        // and want to process the results into Carbon dates,
166
        // we might as well also get the earliest and latest
167
        // wanders, too. These will also be helpful for our monthly
168
        // chart where we don't want to skip missing months.
169
        $conn = $this->entityManager->getConnection();
170
        $sql = 'SELECT
171
                    COALESCE(SUM(TIME_TO_SEC(TIMEDIFF(end_time, start_time))), 0) AS totalDuration,
172
                    COALESCE(AVG(TIME_TO_SEC(TIMEDIFF(end_time, start_time))), 0) AS averageDuration,
173
                    MIN(start_time) AS firstWanderStartTime,
174
                    MAX(start_time) AS latestWanderStartTime
175
                FROM wander
176
            ';
177
        $stmt = $conn->prepare($sql);
178
        $result = $stmt->executeQuery();
179
        $row = $result->fetchAssociative();
180
181
        if ($row === false) {
182
            throw new Exception("Got no results when finding duration stats.");
183
        }
184
185
        $overallTimeStats = [
186
            'firstWanderStartTime' => Carbon::parse($row['firstWanderStartTime']),
187
            'latestWanderStartTime' => Carbon::parse($row['latestWanderStartTime']),
188
            'totalDuration' => CarbonInterval::seconds($row['totalDuration'])->cascade(),
189
            'averageDuration'=> CarbonInterval::seconds($row['averageDuration'])->cascade()
190
        ];
191
        return $overallTimeStats;
192
    }
193
194
    /**
195
     * @return array<int, array<string, mixed>>
196
     */
197
    private function getPeriodicStats(Carbon $startMonth, Carbon $endMonth, int $periodLengthMonths, string $periodLabelFormat): array
198
    {
199
        // Stats per month or year. It would be most efficient to write some complicated SQL query
200
        // that groups the lot together, including filling in months with missing data using some kind
201
        // of row generator or date dimension table, but frankly this is still fast enough,
202
        // especially as it's cached and invalidated quite sensibly.
203
        $sql = 'SELECT
204
                    COUNT(*) AS number_of_wanders,
205
                    COALESCE(SUM(w.distance), 0) AS total_distance_metres,
206
                    COALESCE(SUM(w.distance), 0) / 1000.0 AS total_distance_km,
207
                    COALESCE(SUM((SELECT COUNT(*) FROM image i WHERE i.wander_id = w.id)), 0) AS number_of_images,
208
                    COALESCE(SUM(TIME_TO_SEC(TIMEDIFF(w.end_time, w.start_time))), 0) AS total_duration_seconds,
209
                    COALESCE(AVG(TIME_TO_SEC(TIMEDIFF(w.end_time, w.start_time))), 0) AS average_duration_seconds
210
                FROM
211
                    wander w
212
                WHERE
213
                    w.start_time >= :start AND
214
                    w.start_time < :end';
215
216
        $stmt = $this->entityManager->getConnection()->prepare($sql);
217
218
        $periodicStats = [];
219
220
        for ($rangeStartMonth = $startMonth->copy(); $rangeStartMonth <= $endMonth; $rangeStartMonth->addMonths($periodLengthMonths)) {
221
            $rangeEndMonth = $rangeStartMonth->copy()->addMonths($periodLengthMonths);
222
            $result = $stmt->executeQuery([
223
                'start' => $rangeStartMonth,
224
                'end' => $rangeEndMonth
225
            ]);
226
            $row = $result->fetchAssociative();
227
            if ($row === false) {
228
                // It's entirely aggregated, so even if no rows match the WHERE there should always be a row
229
                // returned.
230
                throw new Exception("Expected to get a row back from the database no matter what with this query.");
231
            }
232
            $duration = CarbonInterval::seconds($row['total_duration_seconds'])->cascade();
233
            $periodicStats[] = [
234
                'periodStartDate' => $rangeStartMonth,
235
                'periodEndDate' => $rangeEndMonth,
236
                'periodLabel' => $rangeStartMonth->isoFormat($periodLabelFormat),
237
                'starYear' => $rangeStartMonth->year,
238
                'startMonth' => $rangeStartMonth->month,
239
                'numberOfWanders' => (int) $row['number_of_wanders'],
240
                'totalDistance' => (float) $row['total_distance_metres'],
241
                'numberOfImages' => (int) $row['number_of_images'],
242
                'totalDurationInterval' => $duration,
243
                'totalDurationForHumans' => $duration->forHumans(['short' => true, 'options' => 0]),
244
                'averageDurationInterval' => CarbonInterval::seconds($row['average_duration_seconds'])->cascade(),
245
            ];
246
        }
247
        return $periodicStats;
248
    }
249
}
250