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->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>'a'|'b'</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["s"].">{$}</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["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->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><?php $db->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