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