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