packages/sql-catalog/tests/Unit/Reporter/Html/SqlHighlighterTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Reporter\Html;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use SqlCatalog\Core\Catalog\StatementPart;
12use SqlCatalog\Core\Text\LiteralText;
13use SqlCatalog\Core\Text\Origin;
14use SqlCatalog\Core\Text\TextHole;
15use SqlCatalog\Core\Text\TextPattern;
16use SqlCatalog\Core\Type\TypeShape;
17use SqlCatalog\Reporter\Html\HtmlText;
18use SqlCatalog\Reporter\Html\SqlHighlighter;
19
20#[CoversClass(SqlHighlighter::class)]
21#[UsesClass(HtmlText::class)]
22#[UsesClass(StatementPart::class)]
23#[UsesClass(LiteralText::class)]
24#[UsesClass(Origin::class)]
25#[UsesClass(TextHole::class)]
26#[UsesClass(TextPattern::class)]
27#[UsesClass(TypeShape::class)]
28final class SqlHighlighterTest extends TestCase
29{
30 public function testRenderMarksTheResolvedRunsAndTheGapsBetweenThem(): void
31 {
32 $pattern = TextPattern::fromSegments([
33 new LiteralText('SELECT * FROM t WHERE id = '),
34 new TextHole(Origin::External, TypeShape::unknown(), '$_GET["id"]'),
35 ]);
36
37 self::assertSame(
38 '<span class="tok-kw">SELECT</span> * <span class="tok-kw">FROM</span> t <span class="tok-kw">WHERE</span> id = <span class="hole tone-danger" title="T'
39 . 'his is a gap: external input fills it. Written as $_GET["id"].">{$}</span>',
40 (new SqlHighlighter())->render(StatementPart::of($pattern)),
41 );
42 }
43
44 public function testInlineWritesTheStatementOnOneLine(): void
45 {
46 $parts = StatementPart::of(TextPattern::fromSegments([
47 new LiteralText("SELECT\n\t1 FROM "),
48 new TextHole(Origin::Property, TypeShape::unknown()),
49 ]));
50
51 self::assertStringStartsWith('<span class="tok-kw">SELECT</span> <span class="tok-num">1</span> <span class="tok-kw">FROM</span> <span class="hole', (new SqlHighlighter())->inline($parts));
52 }
53
54 public function testRenderSaysSoWhenThereIsNothingToShow(): void
55 {
56 self::assertSame('<span class="none">(empty)</span>', (new SqlHighlighter())->render(StatementPart::of(TextPattern::empty())));
57 }
58
59 public function testHighlightEscapesWithoutBreakingWhatItEscaped(): void
60 {
61 self::assertSame(
62 '<span class="tok-kw">VALUES</span> (<span class="tok-str">'a'</span>)',
63 (new SqlHighlighter())->highlight("VALUES ('a')"),
64 );
65 }
66
67 public function testHighlightMarksCommentsNumbersAndPlaceholders(): void
68 {
69 self::assertSame(
70 '<span class="tok-com">-- note</span>' . "\n" . '<span class="tok-num">42</span> <span class="tok-var">:id</span> <span class="tok-id">`t`</span>',
71 (new SqlHighlighter())->highlight('-- note' . "\n" . '42 :id `t`'),
72 );
73 }
74
75 public function testCapturedKeepsOnlyTheNamedCaptures(): void
76 {
77 self::assertSame(
78 ['word' => 'SELECT'],
79 (new SqlHighlighter())->captured([0 => ['SELECT', 0], 'word' => ['SELECT', 0], 1 => ['SELECT', 0]]),
80 );
81 }
82
83 public function testTokenWritesAnUnknownWordAsPlainText(): void
84 {
85 self::assertSame('users', (new SqlHighlighter())->token(['word' => 'users']));
86 }
87
88 public function testTokenWritesAKeywordAsOne(): void
89 {
90 self::assertSame('<span class="tok-kw">FROM</span>', (new SqlHighlighter())->token(['word' => 'FROM']));
91 }
92
93 /**
94 * @return list<array{string, bool}>
95 */
96 public static function providerIsKeyword(): array
97 {
98 return [['select', true], ['WHERE', true], ['users', false], ['', false]];
99 }
100
101 #[DataProvider('providerIsKeyword')]
102 public function testIsKeywordRecognisesTheWordsSqlWritesAsKeywords(string $word, bool $expected): void
103 {
104 self::assertSame($expected, (new SqlHighlighter())->isKeyword($word));
105 }
106
107 public function testHoleSaysWhereTheValueThatFillsItComesFrom(): void
108 {
109 self::assertSame(
110 '<span class="hole tone-warn" title="This is a gap: an object property fills it.">{$}</span>',
111 (new SqlHighlighter())->hole(new StatementPart('', true, 'property', 'an object property')),
112 );
113 }
114
115 /**
116 * @return list<array{string|null, string|null, string}>
117 */
118 public static function providerHoleLabel(): array
119 {
120 return [
121 ['$sql', null, '{$sql}'],
122 ['$query_2', null, '{$query_2}'],
123 ['$クエリ', null, '{$クエリ}'],
124 ['buildSql()', '$sql', '{$sql}'],
125 [null, null, '{$}'],
126 ['buildSql($sql)', null, '{$}'],
127 ['$$name', null, '{$}'],
128 ['$sql + $other', null, '{$}'],
129 ['$sql<script>', null, '{$}'],
130 ];
131 }
132
133 #[DataProvider('providerHoleLabel')]
134 public function testHoleLabelUsesOnlyAnIdentifiedVariableName(?string $expression, ?string $variable, string $expected): void
135 {
136 $gap = new StatementPart('', true, 'call', 'a call result', $expression, $variable);
137 $highlighter = new SqlHighlighter();
138
139 self::assertSame($expected, $highlighter->holeLabel($gap));
140 self::assertStringEndsWith('>' . $expected . '</span>', $highlighter->hole($gap));
141 self::assertStringNotContainsString('<script>', $highlighter->hole($gap));
142 }
143
144 /**
145 * @return list<array{Origin, string}>
146 */
147 public static function providerHoleRole(): array
148 {
149 return [
150 [Origin::External, 'tone-danger'],
151 [Origin::Unreached, 'tone-neutral'],
152 [Origin::Budget, 'tone-warn'],
153 [Origin::Parameter, 'tone-warn'],
154 ];
155 }
156
157 #[DataProvider('providerHoleRole')]
158 public function testHoleRoleTintsAGapByWhereItCameFrom(Origin $origin, string $expected): void
159 {
160 self::assertSame($expected, (new SqlHighlighter())->holeRole($origin->value));
161 }
162 public function testPlainUsesNamedGapsWithoutEscapingKnownText(): void
163 {
164 self::assertSame('SELECT * FROM {$table} WHERE id < 3 AND {$}', (new SqlHighlighter())->plain([
165 new StatementPart('SELECT * FROM '),
166 new StatementPart('', true, 'parameter', 'a parameter', '$table'),
167 new StatementPart(' WHERE id < 3 AND '),
168 new StatementPart('', true, 'call', 'a call result', 'condition()'),
169 ]));
170 }
171
172}
173