packages/sql-catalog/src/Core/Analysis/EntryFactory.php
1<?php
2
3declare(strict_types=1);
4
5namespace SqlCatalog\Core\Analysis;
6
7use SqlCatalog\Core\Catalog\CallSite;
8use SqlCatalog\Core\Catalog\CatalogEntry;
9use SqlCatalog\Core\Catalog\EntryIdentity;
10use SqlCatalog\Core\Catalog\Finding;
11use SqlCatalog\Core\Catalog\FindingRule;
12use SqlCatalog\Core\Catalog\Placeholder;
13use SqlCatalog\Core\Catalog\Resolution;
14use SqlCatalog\Core\Catalog\ValueDomain;
15use SqlCatalog\Core\Evaluation\Domain;
16use SqlCatalog\Core\Sql\PlaceholderScanner;
17use SqlCatalog\Core\Sql\StatementKind;
18use SqlCatalog\Core\Sql\StatementKindReader;
19use SqlCatalog\Core\Sql\TableReader;
20use SqlCatalog\Core\Text\Origin;
21use SqlCatalog\Core\Text\TextPattern;
22
23/**
24 * Turns the statements found while walking a file into catalog entries.
25 *
26 * @visibility root
27 */
28final class EntryFactory
29{
30 private StatementKindReader $kinds;
31
32 private TableReader $tables;
33
34 private PlaceholderScanner $placeholders;
35
36 private EntryIdentity $identity;
37
38 /**
39 * Wires the factory to the readers that describe a statement.
40 */
41 public function __construct(
42 ?StatementKindReader $kinds = null,
43 ?TableReader $tables = null,
44 ?PlaceholderScanner $placeholders = null,
45 ?EntryIdentity $identity = null,
46 ) {
47 $this->kinds = $kinds ?? new StatementKindReader();
48 $this->tables = $tables ?? new TableReader();
49 $this->placeholders = $placeholders ?? new PlaceholderScanner();
50 $this->identity = $identity ?? new EntryIdentity();
51 }
52
53 /**
54 * The catalog entries the records describe, without the ones a better record supersedes.
55 *
56 * @param list<QueryRecord> $records
57 * @return list<CatalogEntry>
58 */
59 public function build(array $records): array
60 {
61 $entries = [];
62 $occurrences = [];
63 foreach ($this->groupBySite($this->merge($records)) as $group) {
64 $closed = true;
65 foreach ($group as $record) {
66 $closed = $closed && !$record->isTruncated() && Resolution::of($record->pattern)->isClosed();
67 }
68 foreach ($group as $record) {
69 $entry = $this->buildOne($record, count($group) > 1, $closed);
70 $seen = $occurrences[$entry->id] ?? 0;
71 $occurrences[$entry->id] = $seen + 1;
72 $entries[] = $seen === 0 ? $entry : $this->renumber($entry, $record, $seen);
73 }
74 }
75
76 return $entries;
77 }
78
79 /**
80 * The same statement under an identifier that tells it apart from its twin.
81 */
82 public function renumber(CatalogEntry $entry, QueryRecord $record, int $occurrence): CatalogEntry
83 {
84 return new CatalogEntry(
85 $this->identity->compute($record->site, $record->pattern, $occurrence),
86 $entry->kind,
87 $entry->pattern,
88 $entry->tables,
89 $entry->placeholders,
90 $entry->site,
91 $entry->findings,
92 $entry->correlated,
93 $entry->through,
94 $entry->truncated,
95 $entry->siteClosed,
96 );
97 }
98
99 /**
100 * The records with every reading of the same statement folded into one.
101 *
102 * @param list<QueryRecord> $records
103 * @return list<QueryRecord>
104 */
105 public function merge(array $records): array
106 {
107 $merged = [];
108 foreach ($records as $record) {
109 $key = $record->siteKey . "\x1f" . $record->pattern->signature();
110 $held = $merged[$key] ?? null;
111 if ($held === null) {
112 $merged[$key] = $record;
113 continue;
114 }
115 $held->absorb($record);
116 }
117
118 return array_values($merged);
119 }
120
121 /**
122 * The records of each call site, keyed by the site.
123 *
124 * @param list<QueryRecord> $records
125 * @return array<string, list<QueryRecord>>
126 */
127 public function groupBySite(array $records): array
128 {
129 $groups = [];
130 foreach ($records as $record) {
131 $groups[$record->siteKey][] = $record;
132 }
133
134 return $groups;
135 }
136
137 /**
138 * The catalog entry one record describes.
139 *
140 * @param bool $alternatives Whether the call site produced more than one statement
141 */
142 public function buildOne(QueryRecord $record, bool $alternatives = false, bool $siteClosed = true): CatalogEntry
143 {
144 $pattern = $record->pattern;
145 $placeholders = $this->bindPlaceholders($pattern, $record);
146
147 return new CatalogEntry(
148 $this->identity->compute($record->site, $pattern),
149 $record->kind ?? $this->kinds->read($pattern),
150 $pattern,
151 $this->tables->read($pattern),
152 $placeholders,
153 $record->site,
154 $this->findings($pattern, $record, $placeholders, $alternatives),
155 !$record->combined,
156 $record->through,
157 $record->isTruncated(),
158 $siteClosed,
159 );
160 }
161
162 /**
163 * The statement's parameters, each carrying whatever was bound to it.
164 *
165 * @return list<Placeholder>
166 */
167 public function bindPlaceholders(TextPattern $pattern, QueryRecord $record): array
168 {
169 $positional = $record->positional();
170 $named = $record->named();
171 $index = 0;
172
173 $bound = [];
174 foreach ($this->placeholders->scan($pattern) as $reference) {
175 $value = $reference->isPositional()
176 ? ($positional[$index++] ?? null)
177 : ($named[$reference->name ?? ''] ?? $this->numberedValue($reference->name, $positional));
178 $bound[] = new Placeholder(
179 $reference->token,
180 $reference->position,
181 $reference->name,
182 $value === null ? null : ValueDomain::fromDomain($value),
183 );
184 }
185
186 return $bound;
187 }
188
189 /**
190 * The value a numbered parameter such as `$1` binds to.
191 *
192 * @param array<int, Domain> $positional
193 */
194 public function numberedValue(?string $name, array $positional): ?Domain
195 {
196 if ($name === null || !ctype_digit($name)) {
197 return null;
198 }
199
200 return $positional[((int) $name) - 1] ?? null;
201 }
202
203 /**
204 * What is worth reporting about a statement.
205 *
206 * @param list<Placeholder> $placeholders
207 * @param bool $alternatives Whether the call site produced more than one statement
208 * @return list<Finding>
209 */
210 public function findings(
211 TextPattern $pattern,
212 QueryRecord $record,
213 array $placeholders,
214 bool $alternatives = false,
215 ): array {
216 $findings = [];
217 $holes = $pattern->holes();
218 $resolution = Resolution::of($pattern);
219
220 if ($holes !== [] && $resolution !== Resolution::NotAnalyzed && $this->kinds->read($pattern) === StatementKind::Unknown) {
221 $findings[] = Finding::of(FindingRule::UnresolvedSql, 'The statement text did not resolve far enough to read what it does.');
222 }
223 if ($holes !== [] && $resolution->wasRead()) {
224 $findings[] = Finding::of(
225 FindingRule::DynamicSql,
226 sprintf('%d value(s) are spliced into the statement text rather than bound.', count($holes)),
227 );
228 }
229 foreach ($holes as $hole) {
230 if ($hole->origin === Origin::External) {
231 $findings[] = Finding::of(
232 FindingRule::ExternalInput,
233 sprintf('A value from %s reaches the statement text.', $hole->origin->describe()),
234 );
235 break;
236 }
237 }
238
239 if ($resolution === Resolution::Incomplete) {
240 $findings[] = Finding::of(
241 FindingRule::AnalysisIncomplete,
242 'The search stopped at a cycle or a budget, so the statements here may not be all of them.',
243 );
244 }
245 if ($resolution === Resolution::NotAnalyzed) {
246 $findings[] = Finding::of(FindingRule::CallNotAnalyzed, $this->notAnalyzedReason($record));
247 }
248 if ($record->isTruncated() && $resolution !== Resolution::Incomplete && $resolution !== Resolution::NotAnalyzed) {
249 $findings[] = Finding::of(
250 FindingRule::AnalysisIncomplete,
251 'A limit on loop passes or on callers cut the search short, so the statements here may not be all of them.',
252 );
253 }
254
255 $mismatch = $alternatives ? null : $this->countMismatch($record, $placeholders);
256
257 return $mismatch === null ? $findings : array_merge($findings, [$mismatch]);
258 }
259
260 /**
261 * Why no statement was read from a call that is written the way a database call is.
262 */
263 public function notAnalyzedReason(QueryRecord $record): string
264 {
265 $quoted = $record->pattern->holes()[0]->expression ?? null;
266 $call = $quoted === null ? 'The call' : '`' . $quoted . '`';
267
268 return $record->site->sink === CallSite::UNMATCHED
269 ? $call . ' is written the way a database call is written, but what it is called on could not be identified.'
270 : $call . ' is a database call, but the analysis stopped before it read what the call is given.';
271 }
272
273 /**
274 * The finding for a statement that binds a different number of values than it takes.
275 *
276 * The count is only asserted for a call site that resolved to exactly one
277 * statement. Where the analyzer had to enumerate alternatives, one of them
278 * having a different number of placeholders is the enumeration talking, not
279 * the code.
280 *
281 * @param list<Placeholder> $placeholders
282 */
283 public function countMismatch(QueryRecord $record, array $placeholders): ?Finding
284 {
285 if (!$record->isBound() || !$record->pattern->isExact()) {
286 return null;
287 }
288 $expected = count($placeholders);
289 $bound = count($record->positional()) + count($record->named());
290 if ($expected === $bound) {
291 return null;
292 }
293
294 return Finding::of(
295 FindingRule::PlaceholderCountMismatch,
296 sprintf('The statement has %d placeholder(s) but %d value(s) are bound.', $expected, $bound),
297 );
298 }
299
300}
301