packages/sql-catalog/src/Reporter/Html/CatalogIndex.php

1<?php
2
3declare(strict_types=1);
4
5namespace SqlCatalog\Reporter\Html;
6
7use SqlCatalog\Core\Catalog\Catalog;
8use SqlCatalog\Core\Catalog\CatalogEntry;
9use SqlCatalog\Core\Catalog\Severity;
10
11/**
12 * The catalog grouped along every route a reader can take to a statement.
13 *
14 * A reader rarely wants the whole catalog. They want the statements on one
15 * table, the ones a class issues, the ones in a file, or the ones a function
16 * writes with values it does not control. Each of those is a grouping of the
17 * same entries, and the groupings are built once here so every page addresses
18 * the same statement through the same keys.
19 *
20 * @visibility root
21 */
22final class CatalogIndex
23{
24    /**
25     * @var list<CatalogEntry>
26     */
27    private array $entries;
28
29    /**
30     * @var array<string, list<CatalogEntry>>
31     */
32    private array $unreadFiles = [];
33
34    /**
35     * Groups a catalog, in the order the report lists statements.
36     */
37    public function __construct(Catalog $catalog)
38    {
39        $this->entries = $catalog->sorted()->entries();
40        foreach ($catalog->problems() as $problem) {
41            if ($catalog->source($problem->file) !== null) {
42                $this->unreadFiles[$problem->file] = [];
43            }
44        }
45    }
46
47    /**
48     * The statements, in reporting order.
49     *
50     * @return list<CatalogEntry>
51     */
52    public function entries(): array
53    {
54        return $this->entries;
55    }
56
57    /**
58     * The statements naming each table, most named first.
59     *
60     * @return array<string, list<CatalogEntry>>
61     */
62    public function byTable(): array
63    {
64        $grouped = [];
65        foreach ($this->entries as $entry) {
66            foreach ($entry->tables as $table) {
67                $grouped[$table][] = $entry;
68            }
69        }
70        $counts = array_map('count', $grouped);
71        uksort($grouped, static fn (string $left, string $right): int
72            => [$counts[$right] ?? 0, $left] <=> [$counts[$left] ?? 0, $right]);
73
74        return $grouped;
75    }
76
77    /**
78     * The statements each function issues, in the order the functions are met.
79     *
80     * @return array<string, list<CatalogEntry>>
81     */
82    public function byFunction(): array
83    {
84        $grouped = [];
85        foreach ($this->entries as $entry) {
86            $grouped[$entry->site->function][] = $entry;
87        }
88
89        return $grouped;
90    }
91
92    /**
93     * The statements each class issues through its methods, in name order.
94     *
95     * @return array<string, list<CatalogEntry>>
96     */
97    public function byClass(): array
98    {
99        $grouped = [];
100        foreach ($this->entries as $entry) {
101            $class = Scope::of($entry->site->function)->class;
102            if ($class !== null) {
103                $grouped[$class][] = $entry;
104            }
105        }
106        ksort($grouped, SORT_STRING | SORT_FLAG_CASE);
107
108        return $grouped;
109    }
110
111    /**
112     * The statements issued under each namespace, the global one first.
113     *
114     * @return array<string, list<CatalogEntry>>
115     */
116    public function byNamespace(): array
117    {
118        $grouped = [];
119        foreach ($this->entries as $entry) {
120            $grouped[Scope::of($entry->site->function)->namespace][] = $entry;
121        }
122        ksort($grouped, SORT_STRING | SORT_FLAG_CASE);
123
124        return $grouped;
125    }
126
127    /**
128     * The statements written in each file, in path order.
129     *
130     * @return array<string, list<CatalogEntry>>
131     */
132    public function byFile(): array
133    {
134        $grouped = $this->unreadFiles;
135        foreach ($this->entries as $entry) {
136            $grouped[$entry->site->file][] = $entry;
137        }
138        ksort($grouped, SORT_STRING);
139
140        return $grouped;
141    }
142
143    /**
144     * The files under each directory, in path order.
145     *
146     * @return array<string, list<string>>
147     */
148    public function byDirectory(): array
149    {
150        $grouped = [];
151        foreach (array_keys($this->byFile()) as $file) {
152            $slash = strrpos($file, '/');
153            $grouped[$slash === false ? '' : substr($file, 0, $slash)][] = $file;
154        }
155
156        return $grouped;
157    }
158
159    /**
160     * The statements each rule reported on, most reported first.
161     *
162     * @return array<string, list<CatalogEntry>>
163     */
164    public function byRule(): array
165    {
166        $grouped = [];
167        foreach ($this->entries as $entry) {
168            foreach ($entry->findings as $finding) {
169                $grouped[$finding->rule->value][] = $entry;
170            }
171        }
172        $counts = array_map('count', $grouped);
173        uksort($grouped, static fn (string $left, string $right): int
174            => [$counts[$right] ?? 0, $left] <=> [$counts[$left] ?? 0, $right]);
175
176        return $grouped;
177    }
178
179    /**
180     * How a set of statements uses what it names.
181     *
182     * @param list<CatalogEntry> $entries
183     * @return array{reads: int, writes: int, schema: int, other: int, attention: int}
184     */
185    public function usage(array $entries): array
186    {
187        $usage = ['reads' => 0, 'writes' => 0, 'schema' => 0, 'other' => 0, 'attention' => 0];
188        foreach ($entries as $entry) {
189            $usage[$this->usageOf($entry)]++;
190            $usage['attention'] += $entry->severity()->atLeast(Severity::Medium) && $entry->findings !== [] ? 1 : 0;
191        }
192
193        return $usage;
194    }
195
196    /**
197     * Which use a statement is counted as.
198     *
199     * @return 'reads'|'writes'|'schema'|'other'
200     */
201    public function usageOf(CatalogEntry $entry): string
202    {
203        if ($entry->kind->isWrite() && !$entry->kind->isSchema()) {
204            return 'writes';
205        }
206        if ($entry->kind->isSchema()) {
207            return 'schema';
208        }
209
210        return $entry->kind->value === 'select' ? 'reads' : 'other';
211    }
212
213    /**
214     * How often each table is named by a set of statements, most named first.
215     *
216     * @param list<CatalogEntry> $entries
217     * @return array<string, int>
218     */
219    public function tablesOf(array $entries): array
220    {
221        $counts = [];
222        foreach ($entries as $entry) {
223            foreach ($entry->tables as $table) {
224                $counts[$table] = ($counts[$table] ?? 0) + 1;
225            }
226        }
227
228        return $this->mostFirst($counts);
229    }
230
231    /**
232     * How many statements each function issues among a set, most first.
233     *
234     * @param list<CatalogEntry> $entries
235     * @return array<string, int>
236     */
237    public function functionsOf(array $entries): array
238    {
239        $counts = [];
240        foreach ($entries as $entry) {
241            $counts[$entry->site->function] = ($counts[$entry->site->function] ?? 0) + 1;
242        }
243
244        return $this->mostFirst($counts);
245    }
246
247    /**
248     * The tables named alongside one table, most often first.
249     *
250     * @return array<string, int>
251     */
252    public function alongside(string $table): array
253    {
254        $counts = [];
255        foreach ($this->byTable()[$table] ?? [] as $entry) {
256            foreach ($entry->tables as $other) {
257                if ($other !== $table) {
258                    $counts[$other] = ($counts[$other] ?? 0) + 1;
259                }
260            }
261        }
262
263        return $this->mostFirst($counts);
264    }
265
266    /**
267     * The functions issuing statements that need attention, worst first.
268     *
269     * @return list<array{function: string, file: string, high: int, medium: int}>
270     */
271    public function hotspots(): array
272    {
273        $spots = [];
274        foreach ($this->entries as $entry) {
275            $severity = $entry->severity();
276            if ($entry->findings === [] || !$severity->atLeast(Severity::Medium)) {
277                continue;
278            }
279            $spot = $spots[$entry->site->function] ?? ['function' => $entry->site->function, 'file' => $entry->site->file, 'high' => 0, 'medium' => 0];
280            $spot[$severity === Severity::High ? 'high' : 'medium']++;
281            $spots[$entry->site->function] = $spot;
282        }
283        usort($spots, static fn (array $left, array $right): int
284            => [$right['high'], $right['medium'], $left['function']] <=> [$left['high'], $left['medium'], $right['function']]);
285
286        return $spots;
287    }
288
289    /**
290     * Counts sorted highest first, ties in name order.
291     *
292     * @param array<string, int> $counts
293     * @return array<string, int>
294     */
295    public function mostFirst(array $counts): array
296    {
297        uksort($counts, static fn (string $left, string $right): int
298            => [$counts[$right], $left] <=> [$counts[$left], $right]);
299
300        return $counts;
301    }
302}
303