packages/sql-catalog/tests/Unit/Reporter/Html/Page/StatementPageTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Reporter\Html\Page;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use SqlCatalog\Core\Catalog\AnalysisProblem;
11use SqlCatalog\Core\Catalog\CallSite;
12use SqlCatalog\Core\Catalog\Catalog;
13use SqlCatalog\Core\Catalog\CatalogEntry;
14use SqlCatalog\Core\Catalog\Finding;
15use SqlCatalog\Core\Catalog\FindingRule;
16use SqlCatalog\Core\Catalog\Placeholder;
17use SqlCatalog\Core\Catalog\Resolution;
18use SqlCatalog\Core\Catalog\Severity;
19use SqlCatalog\Core\Catalog\StatementPart;
20use SqlCatalog\Core\Catalog\ValueDomain;
21use SqlCatalog\Core\Sql\StatementKind;
22use SqlCatalog\Core\Text\LiteralText;
23use SqlCatalog\Core\Text\Origin;
24use SqlCatalog\Core\Text\TextHole;
25use SqlCatalog\Core\Text\TextPattern;
26use SqlCatalog\Core\Type\TypeShape;
27use SqlCatalog\Reporter\Html\CatalogIndex;
28use SqlCatalog\Reporter\Html\CatalogStatistics;
29use SqlCatalog\Reporter\Html\HtmlText;
30use SqlCatalog\Reporter\Html\Page\StatementPage;
31use SqlCatalog\Reporter\Html\Palette;
32use SqlCatalog\Reporter\Html\ReportSite;
33use SqlCatalog\Reporter\Html\Scope;
34use SqlCatalog\Reporter\Html\Source\SourceCode;
35use SqlCatalog\Reporter\Html\SqlFormatter;
36use SqlCatalog\Reporter\Html\SqlHighlighter;
37use SqlCatalog\Reporter\Html\StatementList;
38use SqlCatalog\Reporter\Html\StatementRow;
39use SqlCatalog\Reporter\Html\TableName;
40
41#[CoversClass(StatementPage::class)]
42#[UsesClass(CallSite::class)]
43#[UsesClass(Catalog::class)]
44#[UsesClass(CatalogEntry::class)]
45#[UsesClass(CatalogIndex::class)]
46#[UsesClass(CatalogStatistics::class)]
47#[UsesClass(Finding::class)]
48#[UsesClass(FindingRule::class)]
49#[UsesClass(HtmlText::class)]
50#[UsesClass(Palette::class)]
51#[UsesClass(Placeholder::class)]
52#[UsesClass(ReportSite::class)]
53#[UsesClass(Resolution::class)]
54#[UsesClass(Scope::class)]
55#[UsesClass(SourceCode::class)]
56#[UsesClass(Severity::class)]
57#[UsesClass(SqlFormatter::class)]
58#[UsesClass(SqlHighlighter::class)]
59#[UsesClass(StatementKind::class)]
60#[UsesClass(StatementList::class)]
61#[UsesClass(StatementPart::class)]
62#[UsesClass(StatementRow::class)]
63#[UsesClass(TableName::class)]
64#[UsesClass(ValueDomain::class)]
65#[UsesClass(LiteralText::class)]
66#[UsesClass(Origin::class)]
67#[UsesClass(TextHole::class)]
68#[UsesClass(TextPattern::class)]
69#[UsesClass(TypeShape::class)]
70#[UsesClass(AnalysisProblem::class)]
71final class StatementPageTest extends TestCase
72{
73    public function testRenderCarriesEverythingKnownAboutTheStatement(): void
74    {
75        $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT id FROM users WHERE id = ?'), ['users'], [
76            new Placeholder('?', 0, null, new ValueDomain('int', [7], true, [])),
77        ], new CallSite('src/a.php', 12, 'App\\R::find', 'pdo.prepare'), [Finding::of(FindingRule::DynamicSql, 'spliced')]);
78        $other = new CatalogEntry('a2', StatementKind::Delete, TextPattern::fromText('DELETE FROM users'), ['users'], [], new CallSite('src/a.php', 20, 'App\\R::find', 'pdo.query'), []);
79        $page = (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->render(new ReportSite(new Catalog([$entry, $other]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), $entry);
80
81        self::assertStringContainsString('<h1><span class="chip tone-blue">SELECT</span><span>on users</span></h1>', $page);
82        self::assertStringContainsString('<pre class="code code-lead"><span class="tok-kw">SELECT</span>' . "\n    " . 'id' . "\n" . '<span class="tok-kw">FROM</span>' . "\n    " . 'users', $page);
83        self::assertStringContainsString('<div class="split"><section><h2 id="facts">About this statement</h2>', $page);
84        self::assertStringContainsString('<h2 id="values">Bound values</h2>', $page);
85        self::assertStringContainsString('<h2 id="findings">Findings</h2>', $page);
86        self::assertStringContainsString('<h2 id="same-function">Also issued by <code>R::find</code><span class="count">1</span></h2>', $page);
87        self::assertStringContainsString('<h2 id="same-table-users">Also on <a class="chip chip-ghost" href="../tables/users.html">users</a><span class="count">1</span></h2>', $page);
88    }
89
90    public function testRenderLeavesOutTheValuesWhenTheStatementTakesNone(): void
91    {
92        $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, '{main}', 'pdo.query'), []);
93        $page = (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->render(new ReportSite(new Catalog([$entry]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), $entry);
94
95        self::assertStringNotContainsString('class="split"', $page);
96        self::assertStringNotContainsString('Bound values', $page);
97        self::assertStringNotContainsString('Also issued by', $page);
98    }
99
100    public function testTitleNamesTheTablesOrSaysWhyThereAreNone(): void
101    {
102        $page = new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
103        $site = new CallSite('a.php', 1, 'f', 'pdo.query');
104
105        self::assertSame('on {$}posts, users', $page->title(new CatalogEntry('a', StatementKind::Select, TextPattern::fromText('SELECT 1'), ['{$}posts', 'users'], [], $site, [])));
106        self::assertSame('no table named', $page->title(new CatalogEntry('a', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], $site, [])));
107        self::assertSame('a call nothing was read from', $page->title(new CatalogEntry('a', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown())), [], [], $site, [])));
108    }
109
110    public function testWhereLinksEveryPlaceAndNamesThePathTaken(): void
111    {
112        $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('src/a.php', 12, 'App\\R::find', 'pdo.prepare'), [], true, ['App\\R::run', 'App\\R::find']);
113
114        self::assertSame(
115            'Issued at <a class="mono" href="../files/src-a-php.html">src/a.php:12</a> in <a class="mono" href="../classes/app-r.html#fn-app-r-find">App\\R::find</'
116                . 'a> through <span class="chip chip-sm chip-ghost" title="The database call that was matched">pdo.prepare</span>, reached by way of <code>App\\R::run → '
117                . 'App\\R::find</code>.',
118            (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->where(new ReportSite(new Catalog([$entry]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), $entry),
119        );
120    }
121
122    public function testBodyLaysTheStatementOutAndKeepsTheSourceForm(): void
123    {
124        $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText("SELECT 1\n  FROM t"), [], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []);
125        $body = (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->body($entry);
126
127        self::assertStringContainsString('<button type="button" class="btn copy" data-dd-copy title="Copy the statement">Copy</button>', $body);
128        self::assertStringContainsString('<pre class="code code-lead"><span class="tok-kw">SELECT</span>' . "\n    " . '<span class="tok-num">1</span>' . "\n" . '<span class="tok-kw">FROM</span>' . "\n    " . 't</pre>', $body);
129        self::assertStringContainsString(
130            '<details class="as-written"><summary>As written in the source</summary><pre class="code"><span class="tok-kw">SELECT</span> <span class="tok-num">1</span>' . "\n" . '  <span class="tok-kw">FROM</span> t</pre></details>',
131            $body,
132        );
133        self::assertStringNotContainsString('<textarea', $body);
134    }
135
136    public function testBodyDoesNotDressUpACallNoStatementWasReadFrom(): void
137    {
138        $entry = new CatalogEntry('a1', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown(), '$db->query($sql)')), [], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []);
139
140        self::assertSame(
141            '<pre class="code code-lead"><span class="tok-com">-- no statement was read from this call</span>' . "\n" . '$db-&gt;query($sql)</pre>',
142            (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->body($entry),
143        );
144    }
145
146    public function testCaveatsWarnAboutWhatTheStatementDoesNotSay(): void
147    {
148        $page = new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
149        $site = new CallSite('a.php', 1, 'f', 'pdo.query');
150        $open = new CatalogEntry('a', StatementKind::Select, TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())), [], [], $site, [], false);
151        $cut = new CatalogEntry('b', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], $site, [], true, [], true);
152        $unread = new CatalogEntry('c', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown())), [], [], $site, []);
153
154        self::assertStringContainsString('may not be all of them.</li><li>Assembled from parts that vary independently', $page->caveats($open));
155        self::assertStringContainsString('A limit on loop passes or on callers cut the search short.', $page->caveats($cut));
156        self::assertStringContainsString('never examined', $page->caveats($unread));
157        self::assertSame('', $page->caveats(new CatalogEntry('d', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], $site, [])));
158    }
159
160    public function testFactsLinkTheTablesAndExplainTheReading(): void
161    {
162        $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())), ['users'], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []);
163        $facts = (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->facts(new ReportSite(new Catalog([$entry]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), $entry);
164
165        self::assertStringContainsString('<dt>Resolution</dt><dd><span class="chip chip-ghost">incomplete</span>', $facts);
166        self::assertStringContainsString('<dt>Search</dt><dd><span class="muted">left open: candidates or dependencies remain unknown</span></dd>', $facts);
167        self::assertStringContainsString('<dt>Tables</dt><dd><a class="chip chip-ghost" href="../tables/users.html">users</a> </dd>', $facts);
168        self::assertStringContainsString('<dt>Identifier</dt><dd><code>a1</code>', $facts);
169    }
170
171    public function testValuesListTheBindParameters(): void
172    {
173        $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [
174            new Placeholder(':id', 0, 'id', new ValueDomain('string', ['a', 'b'], true, [])),
175        ], new CallSite('a.php', 3, 'f', 'pdo.query'), []);
176
177        self::assertStringContainsString('<code>&#039;a&#039;|&#039;b&#039;</code>', (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->values($entry));
178        self::assertSame('', (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->values(new CatalogEntry('b', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 3, 'f', 'pdo.query'), [])));
179    }
180
181    public function testValueRowSaysWhenNothingWasBoundOrPinnedDown(): void
182    {
183        $page = new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
184
185        self::assertSame(
186            '<tr><td class="tight"><code>?</code></td><td class="tight"><code>?</code></td><td><span class="none">unbound</span></td></tr>',
187            $page->valueRow(new Placeholder('?', 0, null, null)),
188        );
189        self::assertSame(
190            '<tr><td class="tight"><code>?</code></td><td class="tight"><code>string</code></td><td><span class="muted">not pinned down: external</span></td></tr>',
191            $page->valueRow(new Placeholder('?', 0, null, new ValueDomain('string', [], false, ['external']))),
192        );
193    }
194
195    public function testOpenValueSaysSoWhenNothingIsKnownAboutWhereItCameFrom(): void
196    {
197        self::assertSame('not pinned down', (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->openValue([]));
198    }
199
200    public function testFindingsCarryTheirRuleAndSeverity(): void
201    {
202        $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 3, 'f', 'pdo.query'), [Finding::of(FindingRule::DynamicSql, 'spliced')]);
203
204        self::assertSame(
205            '<h2 id="findings">Findings</h2><ul class="finding-list"><li><span class="chip tone-warn">medium</span><span><code>dynamic-sql</code> spliced</span></li></ul>',
206            (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->findings($entry),
207        );
208        self::assertSame('', (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->findings(new CatalogEntry('b', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 3, 'f', 'pdo.query'), [])));
209    }
210
211    public function testRelatedPointsAtTheRestWhenThereIsMoreThanItLists(): void
212    {
213        $entries = array_map(
214            static fn (int $n): CatalogEntry => new CatalogEntry('s' . $n, StatementKind::Select, TextPattern::fromText('SELECT ' . $n), ['users'], [], new CallSite('a.php', $n, 'f', 'pdo.query'), []),
215            range(1, 10),
216        );
217        $related = (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->related(new ReportSite(new Catalog($entries), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), 'statements/s1.html', $entries[0]);
218
219        self::assertStringContainsString('<a href="../files/a-php.html#fn-f">Every statement of this function</a>', $related);
220        self::assertStringContainsString('<a href="../tables/users.html">Every statement on this table</a>', $related);
221        self::assertSame(2, substr_count($related, '<ol class="rows">'));
222    }
223
224
225
226    public function testRelatedListsTheFirstOfTheRestAndOnlyTheFirstTwoTables(): void
227    {
228        $entries = array_map(
229            static fn (int $n): CatalogEntry => new CatalogEntry('s' . $n, StatementKind::Select, TextPattern::fromText('SELECT ' . $n), ['users', 'posts', 'meta'], [], new CallSite('a.php', $n, 'f', 'pdo.query'), []),
230            range(1, 10),
231        );
232        $related = (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->related(new ReportSite(new Catalog($entries), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), 'statements/s1.html', $entries[0]);
233
234        self::assertStringContainsString('href="../statements/s2.html"', $related);
235        self::assertStringNotContainsString('href="../statements/s10.html"', $related);
236        self::assertStringContainsString('id="same-table-users"', $related);
237        self::assertStringContainsString('id="same-table-posts"', $related);
238        self::assertStringNotContainsString('id="same-table-meta"', $related);
239    }
240
241    public function testWhereSaysNothingAboutThePathWhenTheStatementIsReadWhereItIsWritten(): void
242    {
243        $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'f', 'pdo.query'), [], true, ['f']);
244
245        self::assertStringEndsWith('pdo.query</span>.', (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->where(new ReportSite(new Catalog([$entry]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), $entry));
246    }
247
248    public function testContextLeadsToEveryPlaceTheStatementBelongsToAndTheRestOfItsFunction(): void
249    {
250        $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), ['users'], [], new CallSite('src/a.php', 4, 'App\\R::find', 'pdo.query'), []);
251        $other = new CatalogEntry('a2', StatementKind::Delete, TextPattern::fromText('DELETE'), [], [], new CallSite('src/a.php', 9, 'App\\R::find', 'pdo.query'), []);
252
253        self::assertSame(
254            [
255                ['Belongs to', [
256                    ['Table users', 'tables/users.html', 1, false],
257                    ['Class R', 'classes/app-r.html', 2, false],
258                    ['R::find()', 'classes/app-r.html#fn-app-r-find', 2, false],
259                    ['File src/a.php', 'files/src-a-php.html', 2, false],
260                ], null],
261                ['Statements of R::find', [
262                    ['SELECT 1', 'statements/a1.html', null, true],
263                    ['DELETE', 'statements/a2.html', null, false],
264                ], 'classes/app-r.html#fn-app-r-find'],
265            ],
266            (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->context(new ReportSite(new Catalog([$entry, $other]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), $entry),
267        );
268    }
269
270    public function testContextOfTopLevelCodeLeadsOnlyToItsFile(): void
271    {
272        $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('index.php', 4, '{main}', 'pdo.query'), []);
273
274        self::assertSame(
275            ['Belongs to', [['File index.php', 'files/index-php.html', 1, false]], null],
276            (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->context(new ReportSite(new Catalog([$entry]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), $entry)[0],
277        );
278    }
279
280    public function testRenderIsWrittenExactly(): void
281    {
282        $entries = [
283            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromSegments([
284                new LiteralText('SELECT id FROM posts WHERE slug = '),
285                new TextHole(Origin::External, TypeShape::unknown(), '$_GET["s"]'),
286            ]), ['posts'], [new Placeholder('?', 0, null, new ValueDomain('int', [7], true, []))], new CallSite('src/a.php', 4, 'App\\R::find', 'pdo.query'), [
287                Finding::of(FindingRule::ExternalInput, 'spliced'),
288            ], false, ['App\\R::run', 'App\\R::find']),
289            new CatalogEntry('a2', StatementKind::Insert, TextPattern::fromText('INSERT INTO posts (id) VALUES (1)'), ['posts'], [], new CallSite('src/a.php', 9, 'App\\R::add', 'pdo.query'), []),
290            new CatalogEntry('a3', StatementKind::Update, TextPattern::fromText('UPDATE posts SET title = ? WHERE id = ?'), ['posts'], [], new CallSite('src/a.php', 14, 'App\\R::add', 'pdo.prepare'), [
291                Finding::of(FindingRule::PlaceholderCountMismatch, 'one bound'),
292            ]),
293            new CatalogEntry('b1', StatementKind::Delete, TextPattern::fromText('DELETE FROM users WHERE id = 1'), ['users'], [], new CallSite('src/b.php', 3, 'App\\Admin\\U::drop', 'pdo.query'), []),
294            new CatalogEntry('b2', StatementKind::Alter, TextPattern::fromText('ALTER TABLE users ADD x INT'), ['users'], [], new CallSite('src/b.php', 8, 'App\\Admin\\U::migrate', 'pdo.exec'), []),
295            new CatalogEntry('c1', StatementKind::Select, TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())), [], [], new CallSite('lib/c.php', 2, 'helper', 'mysqli.query'), [
296                Finding::of(FindingRule::AnalysisIncomplete, 'stopped'),
297            ], true, [], true),
298            new CatalogEntry('c2', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown(), '$db->query($sql)')), [], [], new CallSite('lib/c.php', 6, '{main}', 'unmatched'), [
299                Finding::of(FindingRule::CallNotAnalyzed, 'unseen'),
300            ]),
301            new CatalogEntry('d1', StatementKind::Select, TextPattern::fromText('SELECT * FROM posts p JOIN users u ON u.id = p.author'), ['posts', 'users'], [], new CallSite('src/d.php', 1, 'App\\R::find', 'pdo.query'), [
302                Finding::of(FindingRule::DynamicSql, 'dyn'),
303            ]),
304            new CatalogEntry('e1', StatementKind::Show, TextPattern::fromText('SHOW TABLES'), [], [], new CallSite('src/e.php', 1, 'App\\R::find', 'pdo.query'), []),
305            new CatalogEntry('f1', StatementKind::Select, TextPattern::fromText('SELECT 1 FROM t1'), ['t1'], [], new CallSite('src/f.php', 1, 'App\\F::a', 'pdo.query'), []),
306            new CatalogEntry('f2', StatementKind::Select, TextPattern::fromText('SELECT 2 FROM t2'), ['t2'], [], new CallSite('src/f.php', 2, 'App\\F::b', 'pdo.query'), []),
307            new CatalogEntry('f3', StatementKind::Select, TextPattern::fromText('SELECT 3 FROM t3'), ['t3'], [], new CallSite('src/f.php', 3, 'App\\G::a', 'pdo.query'), []),
308            new CatalogEntry('f4', StatementKind::Select, TextPattern::fromText('SELECT 4 FROM t4'), ['t4'], [], new CallSite('src/f.php', 4, 'App\\H::a', 'pdo.query'), []),
309            new CatalogEntry('f5', StatementKind::Select, TextPattern::fromText('SELECT 5 FROM t5'), ['t5'], [], new CallSite('src/g.php', 1, 'App\\I::a', 'pdo.query'), []),
310            new CatalogEntry('f6', StatementKind::Select, TextPattern::fromText('SELECT 6 FROM t6'), ['t6'], [], new CallSite('src/h.php', 1, 'App\\J::a', 'pdo.query'), []),
311            new CatalogEntry('f7', StatementKind::Select, TextPattern::fromText('SELECT 7 FROM t7'), ['t7'], [], new CallSite('src/i.php', 1, 'App\\K::a', 'pdo.query'), [
312                Finding::of(FindingRule::DynamicSql, 'dyn'),
313            ]),
314            new CatalogEntry('f8', StatementKind::Select, TextPattern::fromText('SELECT 8 FROM t8'), ['t8'], [], new CallSite('src/j.php', 1, 'App\\L::a', 'pdo.query'), [
315                Finding::of(FindingRule::DynamicSql, 'dyn'),
316            ]),
317            new CatalogEntry('f9', StatementKind::Select, TextPattern::fromText('SELECT 9 FROM t9'), ['t9'], [], new CallSite('src/k.php', 1, 'App\\M::a', 'pdo.query'), [
318                Finding::of(FindingRule::DynamicSql, 'dyn'),
319            ]),
320        ];
321        $catalog = new Catalog($entries, [new AnalysisProblem('src/broken.php', 'broken')]);
322        $site = new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
323
324        self::assertSame(
325            '<h1><span class="chip tone-blue">SELECT</span><span>on posts</span></h1><p class="lede">Issued at <a class="mono" href="../files/src-a-php.h'
326                . 'tml">src/a.php:4</a> in <a class="mono" href="../classes/app-r.html#fn-app-r-find">App\\R::find</a> through <span class="chip chip-sm chip-gh'
327                . 'ost" title="The database call that was matched">pdo.query</span>, reached by way of <code>App\\R::run → App\\R::find</code>.</p><div class="'
328                . 'code-block"><button type="button" class="btn copy" data-dd-copy title="Copy the statement">Copy</button><pre class="code code-lead"><span cl'
329                . 'ass="tok-kw">SELECT</span>
330    id
331<span class="tok-kw">FROM</span>
332    posts
333<span class="tok-kw">WHERE</span>
334    slug = <span class="hole '
335                . 'tone-danger" title="This is a gap: external input fills it. Written as $_GET[&quot;s&quot;].">{$}</span></pre><details class="as-written"><s'
336                . 'ummary>As written in the source</summary><pre class="code"><span class="tok-kw">SELECT</span> id <span class="tok-kw">FROM</span> posts <spa'
337                . 'n class="tok-kw">WHERE</span> slug = <span class="hole tone-danger" title="This is a gap: external input fills it. Written as $_GET[&quot;s&'
338                . 'quot;].">{$}</span></pre></details></div><div class="notice tone-warn"><ul><li>Assembled from parts that vary independently, so some of the '
339                . 'alternatives at this call may be unreachable.</li></ul></div><div class="split"><section><h2 id="facts">About this statement</h2><dl class="'
340                . 'facts"><div><dt>Resolution</dt><dd><span class="chip tone-danger">external-input</span> <span class="muted">The values were followed to runt'
341                . 'ime input, so the text cannot be fixed.</span></dd></div><div><dt>Search</dt><dd><span class="muted">closed: every dependency was followed t'
342                . 'o its end</span></dd></div><div><dt>Reachability</dt><dd><span class="muted">not assessed: both branches are retained, including constant co'
343                . 'nditions</span></dd></div><div><dt>Tables</dt><dd><a class="chip chip-ghost" href="../tables/posts.html">posts</a> </dd></div><div><dt>Kind<'
344                . '/dt><dd><span class="chip tone-blue">SELECT</span></dd></div><div><dt>Identifier</dt><dd><code>a1</code> <span class="muted">stable across r'
345                . 'uns while the statement is unchanged</span></dd></div></dl></section><section><h2 id="values">Bound values</h2><div class="table-wrap"><tabl'
346                . 'e><thead><tr><th scope="col" class="tight">Parameter</th><th scope="col" class="tight">Type</th><th scope="col">Bound to</th></tr></thead><t'
347                . 'body><tr><td class="tight"><code>?</code></td><td class="tight"><code>int</code></td><td><code>7</code></td></tr></tbody></table></div></sec'
348                . 'tion></div><h2 id="findings">Findings</h2><ul class="finding-list"><li><span class="chip tone-danger">high</span><span><code>external-input<'
349                . '/code> spliced</span></li></ul><section><h2 id="same-function">Also issued by <code>R::find</code><span class="count">2</span></h2><ol class'
350                . '="rows"><li class="row" data-kind="select" data-resolution="resolved" data-severity="medium" data-rule="dynamic-sql" data-sink="pdo.query" d'
351                . 'ata-open="" data-table="posts users" data-namespace="App" data-class="App\\R" data-function="App\\R::find" data-file="src/d.php"><a class="row'
352                . '-main" href="../statements/d1.html"><span class="chip tone-blue">SELECT</span><pre class="code"><span class="tok-kw">SELECT</span>
353    *
354<sp'
355                . 'an class="tok-kw">FROM</span>
356    posts p
357<span class="tok-kw">JOIN</span>
358    users u
359<span class="tok-kw">ON</span>
360    u.id = p.author</p'
361                . 're></a><p class="row-meta"><a href="../files/src-d-php.html">src/d.php:1</a><a class="chip chip-ghost" href="../tables/posts.html">posts</a>'
362                . '<a class="chip chip-ghost" href="../tables/users.html">users</a><span class="chip tone-warn" title="The most serious finding on this stateme'
363                . 'nt">medium</span></p></li><li class="row" data-kind="show" data-resolution="resolved" data-severity="" data-rule="" data-sink="pdo.query" da'
364                . 'ta-open="" data-table="" data-namespace="App" data-class="App\\R" data-function="App\\R::find" data-file="src/e.php"><a class="row-main" href='
365                . '"../statements/e1.html"><span class="chip tone-slate">SHOW</span><pre class="code"><span class="tok-kw">SHOW</span> TABLES</pre></a><p class'
366                . '="row-meta"><a href="../files/src-e-php.html">src/e.php:1</a></p></li></ol></section><section><h2 id="same-table-posts">Also on <a class="ch'
367                . 'ip chip-ghost" href="../tables/posts.html">posts</a><span class="count">3</span></h2><ol class="rows"><li class="row" data-kind="insert" dat'
368                . 'a-resolution="resolved" data-severity="" data-rule="" data-sink="pdo.query" data-open="" data-table="posts" data-namespace="App" data-class='
369                . '"App\\R" data-function="App\\R::add" data-file="src/a.php"><a class="row-main" href="../statements/a2.html"><span class="chip tone-teal">INSER'
370                . 'T</span><pre class="code"><span class="tok-kw">INSERT</span> <span class="tok-kw">INTO</span>
371    posts (id)
372<span class="tok-kw">VALUES</sp'
373                . 'an>
374    (<span class="tok-num">1</span>)</pre></a><p class="row-meta"><a href="../files/src-a-php.html">src/a.php:9</a><a href="../classes/a'
375                . 'pp-r.html#fn-app-r-add">R::add</a><a class="chip chip-ghost" href="../tables/posts.html">posts</a></p></li><li class="row" data-kind="update'
376                . '" data-resolution="resolved" data-severity="medium" data-rule="placeholder-count-mismatch" data-sink="pdo.prepare" data-open="" data-table="'
377                . 'posts" data-namespace="App" data-class="App\\R" data-function="App\\R::add" data-file="src/a.php"><a class="row-main" href="../statements/a3.h'
378                . 'tml"><span class="chip tone-violet">UPDATE</span><pre class="code"><span class="tok-kw">UPDATE</span>
379    posts
380<span class="tok-kw">SET</sp'
381                . 'an>
382    title = <span class="tok-var">?</span>
383<span class="tok-kw">WHERE</span>
384    id = <span class="tok-var">?</span></pre></a><p class="'
385                . 'row-meta"><a href="../files/src-a-php.html">src/a.php:14</a><a href="../classes/app-r.html#fn-app-r-add">R::add</a><a class="chip chip-ghost'
386                . '" href="../tables/posts.html">posts</a><span class="chip tone-warn" title="The most serious finding on this statement">medium</span></p></li'
387                . '><li class="row" data-kind="select" data-resolution="resolved" data-severity="medium" data-rule="dynamic-sql" data-sink="pdo.query" data-ope'
388                . 'n="" data-table="posts users" data-namespace="App" data-class="App\\R" data-function="App\\R::find" data-file="src/d.php"><a class="row-main" '
389                . 'href="../statements/d1.html"><span class="chip tone-blue">SELECT</span><pre class="code"><span class="tok-kw">SELECT</span>
390    *
391<span clas'
392                . 's="tok-kw">FROM</span>
393    posts p
394<span class="tok-kw">JOIN</span>
395    users u
396<span class="tok-kw">ON</span>
397    u.id = p.author</pre></a>'
398                . '<p class="row-meta"><a href="../files/src-d-php.html">src/d.php:1</a><a href="../classes/app-r.html#fn-app-r-find">R::find</a><a class="chip'
399                . ' chip-ghost" href="../tables/posts.html">posts</a><a class="chip chip-ghost" href="../tables/users.html">users</a><span class="chip tone-war'
400                . 'n" title="The most serious finding on this statement">medium</span></p></li></ol></section>',
401            (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->render($site, $entries[0]),
402        );
403    }
404
405    public function testRenderOfACallNothingWasReadFromIsWrittenExactly(): void
406    {
407        $entries = [
408            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromSegments([
409                new LiteralText('SELECT id FROM posts WHERE slug = '),
410                new TextHole(Origin::External, TypeShape::unknown(), '$_GET["s"]'),
411            ]), ['posts'], [new Placeholder('?', 0, null, new ValueDomain('int', [7], true, []))], new CallSite('src/a.php', 4, 'App\\R::find', 'pdo.query'), [
412                Finding::of(FindingRule::ExternalInput, 'spliced'),
413            ], false, ['App\\R::run', 'App\\R::find']),
414            new CatalogEntry('a2', StatementKind::Insert, TextPattern::fromText('INSERT INTO posts (id) VALUES (1)'), ['posts'], [], new CallSite('src/a.php', 9, 'App\\R::add', 'pdo.query'), []),
415            new CatalogEntry('a3', StatementKind::Update, TextPattern::fromText('UPDATE posts SET title = ? WHERE id = ?'), ['posts'], [], new CallSite('src/a.php', 14, 'App\\R::add', 'pdo.prepare'), [
416                Finding::of(FindingRule::PlaceholderCountMismatch, 'one bound'),
417            ]),
418            new CatalogEntry('b1', StatementKind::Delete, TextPattern::fromText('DELETE FROM users WHERE id = 1'), ['users'], [], new CallSite('src/b.php', 3, 'App\\Admin\\U::drop', 'pdo.query'), []),
419            new CatalogEntry('b2', StatementKind::Alter, TextPattern::fromText('ALTER TABLE users ADD x INT'), ['users'], [], new CallSite('src/b.php', 8, 'App\\Admin\\U::migrate', 'pdo.exec'), []),
420            new CatalogEntry('c1', StatementKind::Select, TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())), [], [], new CallSite('lib/c.php', 2, 'helper', 'mysqli.query'), [
421                Finding::of(FindingRule::AnalysisIncomplete, 'stopped'),
422            ], true, [], true),
423            new CatalogEntry('c2', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown(), '$db->query($sql)')), [], [], new CallSite('lib/c.php', 6, '{main}', 'unmatched'), [
424                Finding::of(FindingRule::CallNotAnalyzed, 'unseen'),
425            ]),
426            new CatalogEntry('d1', StatementKind::Select, TextPattern::fromText('SELECT * FROM posts p JOIN users u ON u.id = p.author'), ['posts', 'users'], [], new CallSite('src/d.php', 1, 'App\\R::find', 'pdo.query'), [
427                Finding::of(FindingRule::DynamicSql, 'dyn'),
428            ]),
429            new CatalogEntry('e1', StatementKind::Show, TextPattern::fromText('SHOW TABLES'), [], [], new CallSite('src/e.php', 1, 'App\\R::find', 'pdo.query'), []),
430            new CatalogEntry('f1', StatementKind::Select, TextPattern::fromText('SELECT 1 FROM t1'), ['t1'], [], new CallSite('src/f.php', 1, 'App\\F::a', 'pdo.query'), []),
431            new CatalogEntry('f2', StatementKind::Select, TextPattern::fromText('SELECT 2 FROM t2'), ['t2'], [], new CallSite('src/f.php', 2, 'App\\F::b', 'pdo.query'), []),
432            new CatalogEntry('f3', StatementKind::Select, TextPattern::fromText('SELECT 3 FROM t3'), ['t3'], [], new CallSite('src/f.php', 3, 'App\\G::a', 'pdo.query'), []),
433            new CatalogEntry('f4', StatementKind::Select, TextPattern::fromText('SELECT 4 FROM t4'), ['t4'], [], new CallSite('src/f.php', 4, 'App\\H::a', 'pdo.query'), []),
434            new CatalogEntry('f5', StatementKind::Select, TextPattern::fromText('SELECT 5 FROM t5'), ['t5'], [], new CallSite('src/g.php', 1, 'App\\I::a', 'pdo.query'), []),
435            new CatalogEntry('f6', StatementKind::Select, TextPattern::fromText('SELECT 6 FROM t6'), ['t6'], [], new CallSite('src/h.php', 1, 'App\\J::a', 'pdo.query'), []),
436            new CatalogEntry('f7', StatementKind::Select, TextPattern::fromText('SELECT 7 FROM t7'), ['t7'], [], new CallSite('src/i.php', 1, 'App\\K::a', 'pdo.query'), [
437                Finding::of(FindingRule::DynamicSql, 'dyn'),
438            ]),
439            new CatalogEntry('f8', StatementKind::Select, TextPattern::fromText('SELECT 8 FROM t8'), ['t8'], [], new CallSite('src/j.php', 1, 'App\\L::a', 'pdo.query'), [
440                Finding::of(FindingRule::DynamicSql, 'dyn'),
441            ]),
442            new CatalogEntry('f9', StatementKind::Select, TextPattern::fromText('SELECT 9 FROM t9'), ['t9'], [], new CallSite('src/k.php', 1, 'App\\M::a', 'pdo.query'), [
443                Finding::of(FindingRule::DynamicSql, 'dyn'),
444            ]),
445        ];
446        $catalog = new Catalog($entries, [new AnalysisProblem('src/broken.php', 'broken')]);
447        $site = new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
448
449        self::assertSame(
450            '<h1><span class="chip tone-slate">UNKNOWN</span><span>a call nothing was read from</span></h1><p class="lede">Issued at <a class="mono" href="../files'
451                . '/lib-c-php.html">lib/c.php:6</a> through <span class="chip chip-sm chip-ghost" title="The database call that was matched">unmatched</span>.</p><pre cl'
452                . 'ass="code code-lead"><span class="tok-com">-- no statement was read from this call</span>
453$db-&gt;query($sql)</pre><div class="notice tone-warn"><ul><'
454                . 'li>The call was found but never examined, so nothing was read from it.</li></ul></div><section><h2 id="facts">About this statement</h2><dl class="fact'
455                . 's"><div><dt>Resolution</dt><dd><span class="chip tone-neutral">not-analyzed</span> <span class="muted">The call was found but never examined, so nothi'
456                . 'ng was read from it.</span></dd></div><div><dt>Search</dt><dd><span class="muted">left open: candidates or dependencies remain unknown</span></dd></d'
457                . 'iv><div><dt>Reachability</dt><dd><span class="muted">not assessed: both branches are retained, including constant conditions</span></dd></div><div><dt>Tables</dt><dd><span class="none">none named</span></dd></div><div><dt>Kind</dt><dd><span class="chip tone-slate">UNKNOWN</span></dd></div'
458                . '><div><dt>Identifier</dt><dd><code>c2</code> <span class="muted">stable across runs while the statement is unchanged</span></dd></div></dl></section><'
459                . 'h2 id="findings">Findings</h2><ul class="finding-list"><li><span class="chip tone-neutral">low</span><span><code>call-not-analyzed</code> unseen</span'
460                . '></li></ul>',
461            (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->render($site, $entries[6]),
462        );
463    }
464
465    public function testRenderShowsUnanalyzedSourceBetweenTheCaveatAndStatementFacts(): void
466    {
467        $entry = new CatalogEntry('unknown', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown(), '$db->query($sql)')), [], [], new CallSite('a.php', 1, 'f', 'unmatched'), []);
468        $site = new ReportSite(new Catalog([$entry], [], ['a.php' => '<?php $db->query($sql);']), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
469        $page = (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->render($site, $entry);
470
471        self::assertStringContainsString('examined, so nothing was read from it.</li></ul></div><section><h2 id="source">Source code</h2>', $page);
472        self::assertStringContainsString('</a>&lt;?php $db-&gt;query($sql);</span>', $page);
473        self::assertStringContainsString('</code></pre></section><section><h2 id="facts">About this statement</h2>', $page);
474        self::assertSame(['Source code', '#source', null, false], (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->context($site, $entry)[0][1][0]);
475    }
476    public function testBodyUsesTheSameNamedGapInTheCopyableAndSourceBlocks(): void
477    {
478        $entry = new CatalogEntry('q', StatementKind::Unknown, TextPattern::fromHole(
479            new TextHole(Origin::Parameter, TypeShape::unknown(), '$sql'),
480        ), [], [], new CallSite('query.php', 4, 'f', 'pdo.prepare'), []);
481        $body = (new StatementPage(formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()))->body($entry);
482
483        self::assertSame(2, substr_count($body, '>{$sql}</span>'));
484        self::assertStringContainsString('data-dd-copy', $body);
485    }
486
487}
488