packages/sql-catalog/tests/Unit/Reporter/Html/StatementListTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Reporter\Html;
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\Palette;
31use SqlCatalog\Reporter\Html\ReportSite;
32use SqlCatalog\Reporter\Html\Scope;
33use SqlCatalog\Reporter\Html\SqlFormatter;
34use SqlCatalog\Reporter\Html\SqlHighlighter;
35use SqlCatalog\Reporter\Html\StatementList;
36use SqlCatalog\Reporter\Html\StatementRow;
37use SqlCatalog\Reporter\Html\TableName;
38
39#[CoversClass(StatementList::class)]
40#[UsesClass(CallSite::class)]
41#[UsesClass(Catalog::class)]
42#[UsesClass(CatalogEntry::class)]
43#[UsesClass(CatalogIndex::class)]
44#[UsesClass(CatalogStatistics::class)]
45#[UsesClass(Finding::class)]
46#[UsesClass(FindingRule::class)]
47#[UsesClass(HtmlText::class)]
48#[UsesClass(Palette::class)]
49#[UsesClass(ReportSite::class)]
50#[UsesClass(Resolution::class)]
51#[UsesClass(Scope::class)]
52#[UsesClass(Severity::class)]
53#[UsesClass(SqlHighlighter::class)]
54#[UsesClass(StatementKind::class)]
55#[UsesClass(StatementPart::class)]
56#[UsesClass(StatementRow::class)]
57#[UsesClass(LiteralText::class)]
58#[UsesClass(TextPattern::class)]
59#[UsesClass(AnalysisProblem::class)]
60#[UsesClass(Placeholder::class)]
61#[UsesClass(ValueDomain::class)]
62#[UsesClass(SqlFormatter::class)]
63#[UsesClass(TableName::class)]
64#[UsesClass(Origin::class)]
65#[UsesClass(TextHole::class)]
66#[UsesClass(TypeShape::class)]
67final class StatementListTest extends TestCase
68{
69 public function testRowsListEveryStatementAndSaySoWhenThereIsNone(): void
70 {
71 $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 4, 'f', 'pdo.query'), []);
72 $site = new ReportSite(new Catalog([$entry]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
73 $list = new StatementList();
74
75 self::assertStringStartsWith('<ol class="rows"><li class="row" data-kind="select"', $list->rows($site, 'index.html', [$entry]));
76 self::assertSame('<p class="empty-inline">No statement here.</p>', $list->rows($site, 'index.html', []));
77 }
78
79 public function testFacetsOfferOnlyWhatWouldNarrowTheListing(): void
80 {
81 $entries = [
82 new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []),
83 new CatalogEntry('a2', StatementKind::Insert, TextPattern::fromText('INSERT'), [], [], new CallSite('a.php', 2, 'f', 'pdo.query'), [Finding::of(FindingRule::DynamicSql, 'x')]),
84 ];
85
86 self::assertSame(
87 '<div class="facets" role="group" aria-label="Narrow the listing"><input type="search" name="narrow" class="input facet-search" placeholder="Narrow by '
88 . 'text…" aria-label="Narrow by text" autocomplete="off" spellcheck="false"><span class="facet-group"><button type="button" class="chip facet tone-blue" '
89 . 'data-facet="kind" data-value="select" aria-pressed="false">SELECT<span class="facet-count">1</span></button><button type="button" class="chip facet to'
90 . 'ne-teal" data-facet="kind" data-value="insert" aria-pressed="false">INSERT<span class="facet-count">1</span></button></span><span class="facet-shown" '
91 . 'aria-live="polite"></span><button type="button" class="btn facet-clear" hidden>Clear</button></div>',
92 (new StatementList())->facets($entries),
93 );
94 }
95
96 public function testGroupIsSilentWhenEveryRowSharesTheValue(): void
97 {
98 self::assertSame('', (new StatementList())->group('kind', ['select' => 3], static fn (string $value): string => 'k-' . $value));
99 }
100
101 public function testFacetsIsWrittenExactly(): void
102 {
103 $entries = [
104 new CatalogEntry('a1', StatementKind::Select, TextPattern::fromSegments([
105 new LiteralText('SELECT id FROM posts WHERE slug = '),
106 new TextHole(Origin::External, TypeShape::unknown(), '$_GET["s"]'),
107 ]), ['posts'], [new Placeholder('?', 0, null, new ValueDomain('int', [7], true, []))], new CallSite('src/a.php', 4, 'App\\R::find', 'pdo.query'), [
108 Finding::of(FindingRule::ExternalInput, 'spliced'),
109 ], false, ['App\\R::run', 'App\\R::find']),
110 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'), []),
111 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'), [
112 Finding::of(FindingRule::PlaceholderCountMismatch, 'one bound'),
113 ]),
114 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'), []),
115 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'), []),
116 new CatalogEntry('c1', StatementKind::Select, TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())), [], [], new CallSite('lib/c.php', 2, 'helper', 'mysqli.query'), [
117 Finding::of(FindingRule::AnalysisIncomplete, 'stopped'),
118 ], true, [], true),
119 new CatalogEntry('c2', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown(), '$db->query($sql)')), [], [], new CallSite('lib/c.php', 6, '{main}', 'unmatched'), [
120 Finding::of(FindingRule::CallNotAnalyzed, 'unseen'),
121 ]),
122 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'), [
123 Finding::of(FindingRule::DynamicSql, 'dyn'),
124 ]),
125 new CatalogEntry('e1', StatementKind::Show, TextPattern::fromText('SHOW TABLES'), [], [], new CallSite('src/e.php', 1, 'App\\R::find', 'pdo.query'), []),
126 new CatalogEntry('f1', StatementKind::Select, TextPattern::fromText('SELECT 1 FROM t1'), ['t1'], [], new CallSite('src/f.php', 1, 'App\\F::a', 'pdo.query'), []),
127 new CatalogEntry('f2', StatementKind::Select, TextPattern::fromText('SELECT 2 FROM t2'), ['t2'], [], new CallSite('src/f.php', 2, 'App\\F::b', 'pdo.query'), []),
128 new CatalogEntry('f3', StatementKind::Select, TextPattern::fromText('SELECT 3 FROM t3'), ['t3'], [], new CallSite('src/f.php', 3, 'App\\G::a', 'pdo.query'), []),
129 new CatalogEntry('f4', StatementKind::Select, TextPattern::fromText('SELECT 4 FROM t4'), ['t4'], [], new CallSite('src/f.php', 4, 'App\\H::a', 'pdo.query'), []),
130 new CatalogEntry('f5', StatementKind::Select, TextPattern::fromText('SELECT 5 FROM t5'), ['t5'], [], new CallSite('src/g.php', 1, 'App\\I::a', 'pdo.query'), []),
131 new CatalogEntry('f6', StatementKind::Select, TextPattern::fromText('SELECT 6 FROM t6'), ['t6'], [], new CallSite('src/h.php', 1, 'App\\J::a', 'pdo.query'), []),
132 new CatalogEntry('f7', StatementKind::Select, TextPattern::fromText('SELECT 7 FROM t7'), ['t7'], [], new CallSite('src/i.php', 1, 'App\\K::a', 'pdo.query'), [
133 Finding::of(FindingRule::DynamicSql, 'dyn'),
134 ]),
135 new CatalogEntry('f8', StatementKind::Select, TextPattern::fromText('SELECT 8 FROM t8'), ['t8'], [], new CallSite('src/j.php', 1, 'App\\L::a', 'pdo.query'), [
136 Finding::of(FindingRule::DynamicSql, 'dyn'),
137 ]),
138 new CatalogEntry('f9', StatementKind::Select, TextPattern::fromText('SELECT 9 FROM t9'), ['t9'], [], new CallSite('src/k.php', 1, 'App\\M::a', 'pdo.query'), [
139 Finding::of(FindingRule::DynamicSql, 'dyn'),
140 ]),
141 ];
142 $catalog = new Catalog($entries, [new AnalysisProblem('src/broken.php', 'broken')]);
143 $site = new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
144
145 self::assertSame(
146 '<div class="facets" role="group" aria-label="Narrow the listing"><input type="search" name="narrow" class="input facet-search" placeholder="Narrow by '
147 . 'text…" aria-label="Narrow by text" autocomplete="off" spellcheck="false"><span class="facet-group"><button type="button" class="chip facet tone-blue" '
148 . 'data-facet="kind" data-value="select" aria-pressed="false">SELECT<span class="facet-count">12</span></button><button type="button" class="chip facet t'
149 . 'one-teal" data-facet="kind" data-value="insert" aria-pressed="false">INSERT<span class="facet-count">1</span></button><button type="button" class="chi'
150 . 'p facet tone-violet" data-facet="kind" data-value="update" aria-pressed="false">UPDATE<span class="facet-count">1</span></button><button type="button"'
151 . ' class="chip facet tone-pink" data-facet="kind" data-value="delete" aria-pressed="false">DELETE<span class="facet-count">1</span></button><button type'
152 . '="button" class="chip facet tone-indigo" data-facet="kind" data-value="alter" aria-pressed="false">ALTER<span class="facet-count">1</span></button><bu'
153 . 'tton type="button" class="chip facet tone-slate" data-facet="kind" data-value="unknown" aria-pressed="false">UNKNOWN<span class="facet-count">1</span>'
154 . '</button><button type="button" class="chip facet tone-slate" data-facet="kind" data-value="show" aria-pressed="false">SHOW<span class="facet-count">1<'
155 . '/span></button></span><span class="facet-group"><button type="button" class="chip facet tone-ok" data-facet="resolution" data-value="resolved" aria-pr'
156 . 'essed="false">resolved<span class="facet-count">15</span></button><button type="button" class="chip facet tone-danger" data-facet="resolution" data-va'
157 . 'lue="external-input" aria-pressed="false">external-input<span class="facet-count">1</span></button><button type="button" class="chip facet chip-ghost"'
158 . ' data-facet="resolution" data-value="incomplete" aria-pressed="false">incomplete<span class="facet-count">1</span></button><button type="button" class'
159 . '="chip facet tone-neutral" data-facet="resolution" data-value="not-analyzed" aria-pressed="false">not-analyzed<span class="facet-count">1</span></butt'
160 . 'on></span><span class="facet-group"><button type="button" class="chip facet tone-warn" data-facet="severity" data-value="medium" aria-pressed="false">'
161 . 'medium<span class="facet-count">5</span></button><button type="button" class="chip facet tone-neutral" data-facet="severity" data-value="low" aria-pre'
162 . 'ssed="false">low<span class="facet-count">2</span></button><button type="button" class="chip facet tone-danger" data-facet="severity" data-value="high'
163 . '" aria-pressed="false">high<span class="facet-count">1</span></button></span><span class="facet-shown" aria-live="polite"></span><button type="button"'
164 . ' class="btn facet-clear" hidden>Clear</button></div>',
165 (new StatementList())->facets($entries),
166 );
167 }
168}
169