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[&quot;id&quot;].">{$}</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">&#039;a&#039;</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