packages/sql-catalog/tests/Unit/Facade/AnalyzerTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Facade;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use SqlCatalog\Core\Analysis\EvaluationBudget;
12use SqlCatalog\Core\Catalog\AnalysisProblem;
13use SqlCatalog\Core\Catalog\CallSite;
14use SqlCatalog\Core\Catalog\Catalog;
15use SqlCatalog\Core\Catalog\CatalogEntry;
16use SqlCatalog\Core\Catalog\FindingRule;
17use SqlCatalog\Core\Catalog\Resolution;
18use SqlCatalog\Core\Extension\ExtensionRegistry;
19use SqlCatalog\Core\Extension\UnknownExtensionException;
20use SqlCatalog\Core\Php\ParsedFile;
21use SqlCatalog\Core\Source\SourceFile;
22use SqlCatalog\Core\Source\SourceScanException;
23use SqlCatalog\Extension\Pdo\PdoExtension;
24use SqlCatalog\Facade\AnalysisOptions;
25use SqlCatalog\Facade\Analyzer;
26
27#[CoversClass(Analyzer::class)]
28#[UsesClass(AnalysisOptions::class)]
29#[UsesClass(AnalysisProblem::class)]
30#[UsesClass(Catalog::class)]
31#[UsesClass(CatalogEntry::class)]
32#[UsesClass(\SqlCatalog\Core\Catalog\StatementPart::class)]
33#[UsesClass(EvaluationBudget::class)]
34#[UsesClass(Resolution::class)]
35#[UsesClass(ExtensionRegistry::class)]
36#[UsesClass(PdoExtension::class)]
37#[UsesClass(UnknownExtensionException::class)]
38#[UsesClass(ParsedFile::class)]
39#[UsesClass(SourceFile::class)]
40#[UsesClass(SourceScanException::class)]
41#[UsesClass(\SqlCatalog\Core\Analysis\CallEvaluator::class)]
42#[UsesClass(\SqlCatalog\Core\Analysis\EntryFactory::class)]
43#[UsesClass(EvaluationBudget::class)]
44#[UsesClass(\SqlCatalog\Core\Analysis\ExpressionEvaluator::class)]
45#[UsesClass(\SqlCatalog\Core\Analysis\ExternalInput::class)]
46#[UsesClass(\SqlCatalog\Core\Analysis\FunctionScope::class)]
47#[UsesClass(\SqlCatalog\Core\Analysis\Interpreter::class)]
48#[UsesClass(\SqlCatalog\Core\Analysis\QueryRecord::class)]
49#[UsesClass(\SqlCatalog\Core\Analysis\ReferenceEvaluator::class)]
50#[UsesClass(\SqlCatalog\Core\Analysis\SinkMatcher::class)]
51#[UsesClass(\SqlCatalog\Core\Analysis\StatementRecorder::class)]
52#[UsesClass(\SqlCatalog\Core\Analysis\ValueBinder::class)]
53#[UsesClass(CallSite::class)]
54#[UsesClass(\SqlCatalog\Core\Catalog\EntryIdentity::class)]
55#[UsesClass(\SqlCatalog\Core\Catalog\Placeholder::class)]
56#[UsesClass(\SqlCatalog\Core\Catalog\ValueDomain::class)]
57#[UsesClass(\SqlCatalog\Core\Evaluation\ArrayEntry::class)]
58#[UsesClass(\SqlCatalog\Core\Evaluation\ArrayTerm::class)]
59#[UsesClass(\SqlCatalog\Core\Evaluation\Domain::class)]
60#[UsesClass(\SqlCatalog\Core\Evaluation\Environment::class)]
61#[UsesClass(\SqlCatalog\Core\Evaluation\LiteralTerm::class)]
62#[UsesClass(\SqlCatalog\Core\Evaluation\ObjectTerm::class)]
63#[UsesClass(\SqlCatalog\Core\Evaluation\OpaqueTerm::class)]
64#[UsesClass(\SqlCatalog\Extension\Doctrine\DoctrineExtension::class)]
65#[UsesClass(\SqlCatalog\Extension\Laravel\LaravelExtension::class)]
66#[UsesClass(\SqlCatalog\Extension\Mysqli\MysqliExtension::class)]
67#[UsesClass(\SqlCatalog\Core\Extension\SinkSpec::class)]
68#[UsesClass(\SqlCatalog\Core\Php\FunctionShape::class)]
69#[UsesClass(\SqlCatalog\Core\Php\NodeText::class)]
70#[UsesClass(\SqlCatalog\Core\Php\ParameterShape::class)]
71#[UsesClass(\SqlCatalog\Core\Php\ProgramIndex::class)]
72#[UsesClass(\SqlCatalog\Core\Php\ProgramIndexBuilder::class)]
73#[UsesClass(\SqlCatalog\Core\Php\SourceParser::class)]
74#[UsesClass(\SqlCatalog\Core\Php\SyntaxException::class)]
75#[UsesClass(\SqlCatalog\Core\Php\TypeReader::class)]
76#[UsesClass(\SqlCatalog\Core\Source\SourceScanner::class)]
77#[UsesClass(\SqlCatalog\Core\Sql\PlaceholderRef::class)]
78#[UsesClass(\SqlCatalog\Core\Sql\PlaceholderScanner::class)]
79#[UsesClass(\SqlCatalog\Core\Sql\SqlLexer::class)]
80#[UsesClass(\SqlCatalog\Core\Sql\SqlToken::class)]
81#[UsesClass(\SqlCatalog\Core\Sql\StatementKindReader::class)]
82#[UsesClass(\SqlCatalog\Core\Sql\TableReader::class)]
83#[UsesClass(\SqlCatalog\Core\Text\LiteralText::class)]
84#[UsesClass(\SqlCatalog\Core\Text\TextPattern::class)]
85#[UsesClass(\SqlCatalog\Core\Type\TypeShape::class)]
86#[UsesClass(\SqlCatalog\Core\Php\ClassShape::class)]
87#[UsesClass(\SqlCatalog\Core\Analysis\BuiltinCallModel::class)]
88#[UsesClass(\SqlCatalog\Core\Analysis\SinkFinder::class)]
89#[UsesClass(\SqlCatalog\Core\Catalog\Finding::class)]
90#[UsesClass(FindingRule::class)]
91#[UsesClass(\SqlCatalog\Core\Evaluation\CallResults::class)]
92#[UsesClass(\SqlCatalog\Core\Evaluation\PatternTerm::class)]
93#[UsesClass(\SqlCatalog\Extension\WordPress\WordPressExtension::class)]
94#[UsesClass(\SqlCatalog\Core\Text\Origin::class)]
95#[UsesClass(\SqlCatalog\Core\Text\TextHole::class)]
96#[UsesClass(\SqlCatalog\Core\Php\DeclaredGlobals::class)]
97#[UsesClass(\SqlCatalog\Core\Php\MethodShape::class)]
98#[UsesClass(\SqlCatalog\Core\Analysis\ConstantReader::class)]
99#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Binding::class)]
100#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CalleeReturns::class)]
101#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CallerIndex::class)]
102#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Callers::class)]
103#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Deriver::class)]
104#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\EntryBinder::class)]
105#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\FreeNames::class)]
106#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\ModifiedNames::class)]
107#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\PropertyWrites::class)]
108#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\SliceExecutor::class)]
109#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\Arrival::class)]
110#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\AssignmentSteps::class)]
111#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\BackwardSlicer::class)]
112#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\BranchArms::class)]
113#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\LoopPasses::class)]
114#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\Pending::class)]
115#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\SliceStep::class)]
116#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Solution::class)]
117#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\SourceTree::class)]
118#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CallerSet::class)]
119#[UsesClass(\SqlCatalog\Core\Analysis\FunctionModel\Registry::class)]
120#[UsesClass(\SqlCatalog\Facade\Configuration::class)]
121#[UsesClass(\SqlCatalog\Core\Analysis\FunctionModel\NamedModel::class)]
122#[UsesClass(\SqlCatalog\Facade\ConfigurationSchema::class)]
123#[UsesClass(\SqlCatalog\Core\Analysis\Effect\WriteEffects::class)]
124#[UsesClass(\SqlCatalog\Core\Analysis\Effect\ReferenceEffects::class)]
125#[CoversClass(\SqlCatalog\Core\Analysis\Derivation\Deriver::class)]
126#[CoversClass(\SqlCatalog\Core\Analysis\CallEvaluator::class)]
127#[CoversClass(\SqlCatalog\Core\Analysis\Derivation\CalleeReturns::class)]
128#[CoversClass(\SqlCatalog\Core\Analysis\Derivation\FreeNames::class)]
129#[CoversClass(\SqlCatalog\Core\Analysis\Derivation\ModifiedNames::class)]
130#[CoversClass(\SqlCatalog\Core\Analysis\Derivation\Objects\CallbackEffects::class)]
131#[CoversClass(\SqlCatalog\Core\Analysis\Derivation\Objects\ObjectEffects::class)]
132#[CoversClass(\SqlCatalog\Core\Analysis\Derivation\Slice\AssignmentSteps::class)]
133#[CoversClass(\SqlCatalog\Core\Analysis\Derivation\Slice\BackwardSlicer::class)]
134#[CoversClass(\SqlCatalog\Core\Analysis\Derivation\SliceExecutor::class)]
135#[CoversClass(\SqlCatalog\Core\Analysis\ExpressionEvaluator::class)]
136#[CoversClass(\SqlCatalog\Core\Analysis\Interpreter::class)]
137#[CoversClass(\SqlCatalog\Extension\Laravel\BuilderCalls::class)]
138#[CoversClass(\SqlCatalog\Extension\Laravel\BuilderQueries::class)]
139#[CoversClass(\SqlCatalog\Extension\Laravel\CallbackModel::class)]
140#[CoversClass(\SqlCatalog\Extension\Laravel\Clauses::class)]
141#[CoversClass(\SqlCatalog\Extension\Laravel\Grammar::class)]
142#[CoversClass(\SqlCatalog\Extension\Laravel\ModelMetadata::class)]
143#[CoversClass(\SqlCatalog\Extension\Laravel\Predicates::class)]
144#[CoversClass(\SqlCatalog\Extension\Laravel\QueryState::class)]
145#[CoversClass(\SqlCatalog\Extension\Laravel\SelectCompiler::class)]
146#[CoversClass(\SqlCatalog\Extension\Laravel\WriteCompiler::class)]
147#[CoversClass(\SqlCatalog\Core\Analysis\ReferenceEvaluator::class)]
148#[CoversClass(\SqlCatalog\Core\Analysis\SinkFinder::class)]
149#[CoversClass(\SqlCatalog\Core\Analysis\SinkMatcher::class)]
150#[CoversClass(\SqlCatalog\Core\Evaluation\Environment::class)]
151#[CoversClass(\SqlCatalog\Core\Evaluation\ObjectMemory::class)]
152#[CoversClass(\SqlCatalog\Core\Evaluation\ObjectTerm::class)]
153#[CoversClass(\SqlCatalog\Extension\Laravel\CallModel::class)]
154#[CoversClass(\SqlCatalog\Core\Extension\Model\ModelSet::class)]
155#[CoversClass(\SqlCatalog\Core\Analysis\Model\ModelQueries::class)]
156#[UsesClass(\SqlCatalog\Core\Extension\Model\CallContext::class)]
157#[UsesClass(\SqlCatalog\Core\Extension\Model\ModelContext::class)]
158#[UsesClass(\SqlCatalog\Core\Extension\Model\QueryOutput::class)]
159final class AnalyzerTest extends TestCase
160{
161    #[\PHPUnit\Framework\Attributes\RunInSeparateProcess]
162    #[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
163    public function testAnalyzeSourceBoundsDestructuredAlternativesBeforeTheyExhaustMemory(): void
164    {
165        $previousLimit = ini_set('memory_limit', '128M');
166        self::assertIsString($previousLimit);
167        try {
168            $variables = array_map(static fn (int $index): string => '$v' . $index, range(0, 19));
169            $source = '<?php function run(PDO $db, bool $flag) {'
170                . '[' . implode(', ', $variables) . '] = ['
171                . implode(', ', array_fill(0, 20, '$flag ? "1" : "2"')) . '];'
172                . '$db->query("SELECT " . ' . implode(' . ", " . ', $variables) . '); }';
173
174            $catalog = (new Analyzer())->analyzeSource(['query.php' => $source]);
175
176            self::assertSame([], $catalog->problems());
177            self::assertCount(12, $catalog->entries());
178            self::assertContains('SELECT ' . implode(', ', array_fill(0, 20, '1')), array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries()));
179            $joined = array_values(array_filter($catalog->entries(), static fn (CatalogEntry $entry): bool => !$entry->correlated));
180            self::assertCount(1, $joined);
181            self::assertFalse($joined[0]->isExact());
182            self::assertFalse($joined[0]->searchClosed());
183        } finally {
184            ini_set('memory_limit', $previousLimit);
185        }
186    }
187
188    public function testIssetGuardsAConditionallyAssignedSqlFragment(): void
189    {
190        $catalog = (new Analyzer())->analyzeSource([
191            'a.php' => <<<'PHP'
192                <?php
193                function findUsers(PDO $pdo, bool $active): void {
194                    if ($active) {
195                        $where = ' WHERE active = 1';
196                    }
197                    $pdo->prepare('SELECT * FROM users' . (isset($where) ? $where : '') . '');
198                }
199                PHP,
200        ]);
201
202        self::assertEqualsCanonicalizing(
203            ['SELECT * FROM users WHERE active = 1', 'SELECT * FROM users'],
204            array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries()),
205        );
206        self::assertNotContains(false, array_map(
207            static fn (CatalogEntry $entry): bool => $entry->resolution() === Resolution::Resolved && $entry->searchClosed(),
208            $catalog->entries(),
209        ));
210    }
211
212    /**
213     * @param list<string> $expected
214     */
215    #[DataProvider('providerIssetSqlFragments')]
216    public function testIssetSqlFragmentsKeepBothSyntacticBranches(string $source, array $expected): void
217    {
218        $catalog = (new Analyzer())->analyzeSource(['a.php' => '<?php ' . $source]);
219
220        self::assertEqualsCanonicalizing($expected, array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries()));
221        self::assertNotContains(false, array_map(
222            static fn (CatalogEntry $entry): bool => $entry->resolution() === Resolution::Resolved && $entry->searchClosed(),
223            $catalog->entries(),
224        ));
225    }
226
227    /**
228     * @return array<string, array{string, list<string>}>
229     */
230    public static function providerIssetSqlFragments(): array
231    {
232        return [
233            'method' => [
234                'class Q { public function run(PDO $pdo, bool $on): void { if ($on) { $tail = " WHERE active = 1"; }'
235                    . ' $pdo->prepare("SELECT * FROM users" . (isset($tail) ? $tail : "")); } }',
236                ['SELECT * FROM users', 'SELECT * FROM users WHERE active = 1'],
237            ],
238            'helper return' => [
239                'function tail(bool $on): string { if ($on) { $tail = " WHERE active = 1"; } return isset($tail) ? $tail : ""; }'
240                    . ' function run(PDO $pdo, bool $on): void { $pdo->prepare("SELECT * FROM users" . tail($on)); }',
241                ['SELECT * FROM users', 'SELECT * FROM users WHERE active = 1'],
242            ],
243            'null initialization' => [
244                'function run(PDO $pdo, bool $on): void { $tail = null; if ($on) { $tail = " WHERE active = 1"; }'
245                    . ' $pdo->prepare("SELECT * FROM users" . (isset($tail) ? $tail : "")); }',
246                ['SELECT * FROM users', 'SELECT * FROM users WHERE active = 1'],
247            ],
248            'unset' => [
249                'function run(PDO $pdo): void { $tail = " WHERE active = 1"; unset($tail);'
250                    . ' $pdo->prepare("SELECT * FROM users" . (isset($tail) ? $tail : "")); }',
251                ['SELECT * FROM users'],
252            ],
253            'empty and false values are set' => [
254                'function run(PDO $pdo, bool $on): void { $tail = $on ? "" : false;'
255                    . ' $pdo->prepare("SELECT * FROM users" . (isset($tail) ? $tail : " WHERE active = 1")); }',
256                ['SELECT * FROM users', 'SELECT * FROM users WHERE active = 1'],
257            ],
258            'two optional fragments' => [
259                'function run(PDO $pdo, bool $filter, bool $sort): void { if ($filter) { $where = " WHERE active = 1"; }'
260                    . ' if ($sort) { $order = " ORDER BY id"; }'
261                    . ' $pdo->prepare("SELECT * FROM users" . (isset($where) ? $where : "") . (isset($order) ? $order : "")); }',
262                ['SELECT * FROM users', 'SELECT * FROM users WHERE active = 1', 'SELECT * FROM users ORDER BY id', 'SELECT * FROM users WHERE active = 1 ORDER BY id'],
263            ],
264            'correlated variables' => [
265                'function run(PDO $pdo, bool $on): void { if ($on) { $table = "admins"; $tail = " WHERE admin = 1"; } else { $table = "users"; }'
266                    . ' $pdo->prepare("SELECT * FROM " . $table . (isset($tail) ? $tail : "")); }',
267                ['SELECT * FROM users', 'SELECT * FROM admins', 'SELECT * FROM admins WHERE admin = 1'],
268            ],
269            'nullable parameter supplied by callers' => [
270                'function run(PDO $pdo, ?string $tail): void { $pdo->prepare("SELECT * FROM users" . (isset($tail) ? $tail : "")); }'
271                    . ' function callers(PDO $pdo): void { run($pdo, null); run($pdo, " WHERE active = 1"); }',
272                ['SELECT * FROM users', 'SELECT * FROM users WHERE active = 1'],
273            ],
274        ];
275    }
276
277    #[DataProvider('providerUnknownIssetSqlFragment')]
278    public function testIssetDoesNotDiscardAnUnknownSqlFragment(string $body): void
279    {
280        $catalog = (new Analyzer())->analyzeSource([
281            'a.php' => '<?php function run(PDO $pdo, $input): void { ' . $body
282                . ' $pdo->prepare("SELECT * FROM users" . (isset($tail) ? $tail : "")); }',
283        ]);
284
285        self::assertEqualsCanonicalizing(['SELECT * FROM users', 'SELECT * FROM users{$}'], array_map(
286            static fn (CatalogEntry $entry): string => $entry->sql(),
287            $catalog->entries(),
288        ));
289    }
290
291    /**
292     * @return array<string, array{string}>
293     */
294    public static function providerUnknownIssetSqlFragment(): array
295    {
296        return [
297            'external input' => ['$tail = $_GET["tail"];'],
298            'unknown call' => ['$tail = unknown();'],
299            'parameter' => ['$tail = $input;'],
300            'declared global' => ['global $tail;'],
301            'unsupported expression' => ['$tail = $input + 1;'],
302        ];
303    }
304
305    public function testACallSiteSurvivesFailingToResolveItsStatement(): void
306    {
307        $catalog = (new Analyzer())->analyzeSource([
308            'a.php' => '<?php function f(PDO $d, string $sql): void { $d->query($sql); }',
309        ]);
310
311        self::assertCount(1, $catalog);
312        self::assertSame('pdo.query', $catalog->entries()[0]->site->sink);
313        self::assertFalse($catalog->entries()[0]->isExact());
314    }
315
316    public function testACallSiteSurvivesTheWalkNotReachingIt(): void
317    {
318        $options = new AnalysisOptions(['pdo'], new EvaluationBudget(2));
319        $catalog = (new Analyzer())->analyzeSource([
320            'a.php' => '<?php function f(PDO $d): void { $a = 1; $b = 2; $c = 3; $d->query("SELECT 1"); }',
321        ], $options);
322
323        self::assertCount(1, $catalog);
324        self::assertSame(Resolution::NotAnalyzed, $catalog->entries()[0]->resolution());
325        self::assertFalse($catalog->entries()[0]->resolution()->isClosed());
326        self::assertTrue($catalog->entries()[0]->hasFinding(FindingRule::CallNotAnalyzed));
327    }
328
329    public function testACallOnAClassTheExtensionsDoNotNameIsNotReportedAtAll(): void
330    {
331        $catalog = (new Analyzer())->analyzeSource([
332            'a.php' => '<?php class Q { public function query(string $s): void {} }'
333                . ' function f(Q $q): void { $q->query("SELECT 1"); }',
334        ], new AnalysisOptions(['pdo']));
335
336        self::assertCount(0, $catalog);
337    }
338
339    public function testACallOnSomethingThatCouldNotBeNamedIsReportedAsUnmatched(): void
340    {
341        $catalog = (new Analyzer())->analyzeSource([
342            'a.php' => '<?php function f($q): void { $q->query("SELECT 1"); }',
343        ], new AnalysisOptions(['pdo']));
344
345        self::assertCount(1, $catalog);
346        self::assertSame(CallSite::UNMATCHED, $catalog->entries()[0]->site->sink);
347        self::assertSame(Resolution::NotAnalyzed, $catalog->entries()[0]->resolution());
348    }
349
350    public function testAHandleReachedThroughAGlobalIsRecognisedWhenSomethingSaysWhatItIs(): void
351    {
352        $analyzer = new Analyzer();
353
354        $undocumented = $analyzer->analyzeSource([
355            'a.php' => '<?php function f(): void { global $db; $db->query("SELECT 1"); }',
356        ], new AnalysisOptions(['pdo']));
357
358        self::assertSame(CallSite::UNMATCHED, $undocumented->entries()[0]->site->sink);
359
360        $documented = $analyzer->analyzeSource([
361            'a.php' => '<?php' . "\n" . '/** @global PDO $db */' . "\n"
362                . 'function f(): void { global $db; $db->query("SELECT 1"); }',
363        ], new AnalysisOptions(['pdo']));
364
365        self::assertSame(['SELECT 1'], array_map(
366            static fn (CatalogEntry $entry): string => $entry->sql(),
367            $documented->entries(),
368        ));
369    }
370
371    public function testACallSiteSurvivesSittingInAnOperandNothingNeedsTheValueOf(): void
372    {
373        $catalog = (new Analyzer())->analyzeSource([
374            'a.php' => '<?php function f(PDO $d, bool $on): void { $on && $d->query("SELECT 1"); }',
375        ]);
376
377        self::assertSame(['SELECT 1'], array_map(
378            static fn (CatalogEntry $entry): string => $entry->sql(),
379            $catalog->entries(),
380        ));
381    }
382
383    public function testValuesDecidedByOneBranchStayTogether(): void
384    {
385        $catalog = (new Analyzer())->analyzeSource([
386            'a.php' => '<?php function f(PDO $d, bool $admin): void {'
387                . ' if ($admin) { $t = "admins"; $c = "admin_id"; } else { $t = "users"; $c = "user_id"; }'
388                . ' $d->query("SELECT $c FROM $t"); }',
389        ]);
390
391        $found = array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries());
392        sort($found);
393
394        self::assertSame(['SELECT admin_id FROM admins', 'SELECT user_id FROM users'], $found);
395    }
396
397    public function testAnElementReadUnderAnUnknownKeyGivesOneStatementPerElement(): void
398    {
399        $catalog = (new Analyzer())->analyzeSource([
400            'a.php' => '<?php class R { private const TABLES = ["u" => "users", "a" => "admins"];'
401                . ' public function f(PDO $d, string $kind): void { $d->query("SELECT * FROM " . self::TABLES[$kind]); } }',
402        ]);
403
404        $found = array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries());
405        sort($found);
406
407        self::assertSame(['SELECT * FROM admins', 'SELECT * FROM users'], $found);
408        self::assertSame(
409            ['resolved:closed', 'resolved:closed'],
410            array_map(
411                static fn (CatalogEntry $entry): string => $entry->resolution()->value . ($entry->searchClosed() ? ':closed' : ':open'),
412                $catalog->entries(),
413            ),
414        );
415    }
416
417    public function testAStaticPropertyNothingAssignsReadsLikeAConstant(): void
418    {
419        $catalog = (new Analyzer())->analyzeSource([
420            'a.php' => '<?php class R { private static array $tables = ["u" => "users", "a" => "admins"];'
421                . ' public function f(PDO $d, string $kind): void { $d->query("SELECT * FROM " . static::$tables[$kind]); } }',
422        ]);
423
424        $found = array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries());
425        sort($found);
426
427        self::assertSame(['SELECT * FROM admins', 'SELECT * FROM users'], $found);
428    }
429
430    public function testValuesDecidedByNestedBranchesStayTogether(): void
431    {
432        $catalog = (new Analyzer())->analyzeSource([
433            'a.php' => '<?php function f(PDO $d, bool $a, bool $b): void {'
434                . ' if ($a) { $t = "x"; } else { $t = "y"; }'
435                . ' if ($b) { $o = "ASC"; } else { $o = "DESC"; }'
436                . ' $d->query("SELECT * FROM $t ORDER BY id $o"); }',
437        ]);
438
439        $found = array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries());
440        sort($found);
441
442        self::assertSame([
443            'SELECT * FROM x ORDER BY id ASC',
444            'SELECT * FROM x ORDER BY id DESC',
445            'SELECT * FROM y ORDER BY id ASC',
446            'SELECT * FROM y ORDER BY id DESC',
447        ], $found);
448    }
449
450    public function testAResolvedCallerDoesNotDeleteAnUndeterminedOne(): void
451    {
452        $catalog = (new Analyzer())->analyzeSource([
453            'a.php' => '<?php function run(PDO $d, string $sql): void { $d->query($sql); }'
454                . ' function a(PDO $d): void { run($d, "SELECT 1"); }'
455                . ' function b(PDO $d, string $outside): void { run($d, $outside); }',
456        ]);
457
458        $found = array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries());
459        sort($found);
460
461        self::assertSame(['SELECT 1', '{$}'], $found);
462    }
463
464    public function testAnUndeterminedStatementSaysWhyItIsUndetermined(): void
465    {
466        $catalog = (new Analyzer())->analyzeSource([
467            'external.php' => '<?php function f(PDO $d): void { $d->query("SELECT " . $_GET["x"]); }',
468            'model.php' => '<?php function g(PDO $d, string $s): void { $d->query("SELECT " . $s); }',
469        ]);
470
471        $reasons = array_map(
472            static fn (CatalogEntry $entry): string => $entry->site->file . '=' . $entry->resolution()->value,
473            $catalog->entries(),
474        );
475
476        self::assertSame(['external.php=external-input', 'model.php=incomplete-model'], $reasons);
477    }
478
479    public function testAlternativesPairedAcrossIndependentPartsAreMarkedAsSuch(): void
480    {
481        $catalog = (new Analyzer())->analyzeSource([
482            'a.php' => '<?php function pick(bool $a): string { return $a ? "x" : "y"; }'
483                . ' function f(PDO $d, bool $p, bool $q): void { $d->query("SELECT " . pick($p) . " FROM " . pick($q)); }',
484        ]);
485
486        self::assertNotSame([], $catalog->entries());
487        self::assertFalse($catalog->entries()[0]->correlated);
488    }
489
490    public function testAValueBoundToOnePlaceholderStaysWithThatPlaceholder(): void
491    {
492        $catalog = (new Analyzer())->analyzeSource([
493            't.php' => '<?php function f(PDO $d): void { $s = $d->prepare("SELECT * FROM t WHERE a = ? AND b = ?"); $s->bindValue(2, "x"); $s->execute(); }',
494        ]);
495
496        self::assertNull($catalog->entries()[0]->placeholders[0]->value);
497        self::assertSame(['x'], $catalog->entries()[0]->placeholders[1]->value?->values);
498        self::assertTrue($catalog->entries()[0]->hasFinding(FindingRule::PlaceholderCountMismatch));
499    }
500
501    public function testExtensionsAreTheOnesTheRunCanAskFor(): void
502    {
503        self::assertSame(['doctrine', 'laravel', 'mysqli', 'pdo', 'wordpress'], (new Analyzer())->extensions()->names());
504        self::assertSame(['pdo'], (new Analyzer(new ExtensionRegistry([new PdoExtension()])))->extensions()->names());
505    }
506
507    public function testAnalyzePathsReadsTheFilesUnderThem(): void
508    {
509        $directory = sys_get_temp_dir() . '/sql-catalog-test-' . bin2hex(random_bytes(6));
510        mkdir($directory);
511        file_put_contents($directory . '/Repo.php', '<?php function f(PDO $d) { $d->query("SELECT 1"); }');
512
513        $catalog = (new Analyzer())->analyzePaths([$directory], null, $directory);
514
515        self::assertSame('Repo.php', $catalog->entries()[0]->site->file);
516        unlink($directory . '/Repo.php');
517        rmdir($directory);
518        self::assertSame('<?php function f(PDO $d) { $d->query("SELECT 1"); }', $catalog->source('Repo.php'));
519    }
520
521    public function testAnalyzePathsSkipsWhatIsExcluded(): void
522    {
523        $directory = sys_get_temp_dir() . '/sql-catalog-test-' . bin2hex(random_bytes(6));
524        mkdir($directory);
525        file_put_contents($directory . '/Repo.php', '<?php function f(PDO $d) { $d->query("SELECT 1"); }');
526
527        $catalog = (new Analyzer())->analyzePaths([$directory], null, $directory, ['Repo.php']);
528
529        self::assertCount(0, $catalog);
530        self::assertNull($catalog->source('Repo.php'));
531        unlink($directory . '/Repo.php');
532        rmdir($directory);
533    }
534
535    public function testAnalyzePathsRefusesAPathItCannotRead(): void
536    {
537        $this->expectException(SourceScanException::class);
538        (new Analyzer())->analyzePaths(['/definitely/not/here']);
539    }
540
541    public function testAnalyzeSourceCatalogsTheStatementsOfTheGivenSources(): void
542    {
543        $catalog = (new Analyzer())->analyzeSource([
544            'a.php' => '<?php function f(PDO $d): void { $d->query("SELECT id FROM users"); }',
545        ]);
546        self::assertSame('SELECT id FROM users', $catalog->entries()[0]->sql());
547    }
548
549    public function testAnalyzeSourceResolvesAcrossFiles(): void
550    {
551        $catalog = (new Analyzer())->analyzeSource([
552            'schema.php' => '<?php namespace App; class Schema { public const TABLE = "users"; }',
553            'repo.php' => '<?php namespace App; function f(\\PDO $d): void { $d->query("SELECT * FROM " . Schema::TABLE); }',
554        ]);
555        self::assertSame('SELECT * FROM users', $catalog->entries()[0]->sql());
556    }
557
558    public function testAnalyzeSourceReportsAFileItCannotParse(): void
559    {
560        $catalog = (new Analyzer())->analyzeSource(['broken.php' => '<?php function {']);
561        self::assertCount(0, $catalog);
562        self::assertSame('broken.php', $catalog->problems()[0]->file);
563        self::assertSame('<?php function {', $catalog->source('broken.php'));
564    }
565
566    public function testAnalyzeSourceRefusesAnExtensionThatIsNotRegistered(): void
567    {
568        $this->expectException(UnknownExtensionException::class);
569        (new Analyzer())->analyzeSource(['a.php' => '<?php'], new AnalysisOptions(['symfony']));
570    }
571
572    public function testParseAnswersWithTheProblemThatStoppedIt(): void
573    {
574        $analyzer = new Analyzer();
575        self::assertInstanceOf(ParsedFile::class, $analyzer->parse(new SourceFile('a.php', '<?php $a = 1;')));
576        self::assertInstanceOf(AnalysisProblem::class, $analyzer->parse(new SourceFile('a.php', '<?php function {')));
577    }
578
579    public function testEntriesOfRecognisesOnlyTheExtensionsTheOptionsName(): void
580    {
581        $analyzer = new Analyzer();
582        $catalog = $analyzer->analyzeSource(
583            ['a.php' => '<?php function f(mysqli $m): void { $m->query("SELECT 1"); }'],
584            new AnalysisOptions(['pdo']),
585        );
586        self::assertCount(0, $catalog);
587    }
588
589    public function testEntriesOfIsCalledWithTheParsedFiles(): void
590    {
591        $analyzer = new Analyzer();
592        $file = (new \SqlCatalog\Core\Php\SourceParser())->parse('a.php', '<?php function f(PDO $d) { $d->query("SELECT 1"); }');
593
594        $entries = $analyzer->entriesOf([$file], new AnalysisOptions(['pdo']));
595
596        self::assertCount(1, $entries);
597        self::assertSame('SELECT 1', $entries[0]->sql());
598    }
599
600    public function testSortRecordsIsCalledWithTheEntries(): void
601    {
602        $analyzer = new Analyzer();
603        $later = new CatalogEntry(
604            'a',
605            \SqlCatalog\Core\Sql\StatementKind::Select,
606            \SqlCatalog\Core\Text\TextPattern::fromText('SELECT 1'),
607            [],
608            [],
609            new CallSite('b.php', 1, 'f', 's'),
610            [],
611        );
612        $earlier = new CatalogEntry(
613            'b',
614            \SqlCatalog\Core\Sql\StatementKind::Select,
615            \SqlCatalog\Core\Text\TextPattern::fromText('SELECT 2'),
616            [],
617            [],
618            new CallSite('a.php', 1, 'f', 's'),
619            [],
620        );
621
622        self::assertSame([$earlier, $later], $analyzer->sortRecords([$later, $earlier]));
623    }
624
625    public function testSortRecordsOrdersByWhereTheStatementIsIssued(): void
626    {
627        $catalog = (new Analyzer())->analyzeSource([
628            'b.php' => '<?php function g(PDO $d): void { $d->query("SELECT 2"); }',
629            'a.php' => '<?php function f(PDO $d): void { $d->query("SELECT 1"); }',
630        ]);
631        self::assertSame(['a.php', 'b.php'], array_map(
632            static fn (CatalogEntry $entry): string => $entry->site->file,
633            $catalog->entries(),
634        ));
635    }
636    public function testAnalyzeSourceNormalizesPlaceholderListsThroughVariables(): void
637    {
638        $source = <<<'PHP'
639<?php
640function findUsers(PDO $db, array $ids) {
641    $size = count($ids);
642    $marker = '?';
643    $items = array_fill(0, $size, $marker);
644    $marks = implode(',', $items);
645    $db->prepare('SELECT * FROM users WHERE id IN (' . $marks . ')');
646}
647PHP;
648        $analyzer = new Analyzer();
649        self::assertSame('SELECT * FROM users WHERE id IN (?)', $analyzer->analyzeSource(['users.php' => $source])->entries()[0]->sql());
650    }
651
652    #[DataProvider('providerConfiguredPlaceholderLists')]
653    public function testAnalyzeSourceHandlesPlaceholderExpressions(string $expression, string $expected): void
654    {
655        $source = '<?php function f(PDO $db, array $ids) { $db->prepare("SELECT * FROM users WHERE id IN (" . ' . $expression . ' . ")"); }';
656        $catalog = (new Analyzer())->analyzeSource(['users.php' => $source]);
657        self::assertCount(1, $catalog->entries());
658        self::assertSame('SELECT * FROM users WHERE id IN (' . $expected . ')', $catalog->entries()[0]->sql());
659    }
660
661    /**
662     * @return array<string, array{string, string}>
663     */
664    public static function providerConfiguredPlaceholderLists(): array
665    {
666        return [
667            'inline' => ["implode(',', array_fill(0, count(\$ids), '?'))", '?'],
668            'join alias' => ["join(',', array_fill(0, count(\$ids), '?'))", '?'],
669            'known count' => ["implode(',', array_fill(0, 10, '?'))", '?'],
670            'zero count is deliberately normalized' => ["implode(',', array_fill(0, 0, '?'))", '?'],
671            'different value is not normalized' => ["implode(',', array_fill(0, count(\$ids), 'x'))", '{$}'],
672            'external value is not normalized' => ["implode(',', array_fill(0, count(\$ids), \$_GET['value']))", '{$}'],
673        ];
674    }
675
676    public function testAnalyzeSourceAppliesRegisteredFunctionsAndFallsBackToSource(): void
677    {
678        $models = \SqlCatalog\Core\Analysis\FunctionModel\Registry::withBuiltins();
679        $models->register('App\table', static fn (array $arguments): ?\SqlCatalog\Core\Evaluation\Domain => ($arguments[0] ?? \SqlCatalog\Core\Evaluation\Domain::unknown())->soleLiteral()?->value === 'override' ? \SqlCatalog\Core\Evaluation\Domain::literal('modeled') : null);
680        $source = <<<'PHP'
681<?php
682namespace App;
683function table($which) { return 'original'; }
684function run(\PDO $db) {
685    $db->query('SELECT * FROM ' . table('override'));
686    $db->query('SELECT * FROM ' . table('fallback'));
687}
688PHP;
689        $entries = (new Analyzer(functionModels: $models))->analyzeSource(['app.php' => $source])->entries();
690        self::assertSame(['SELECT * FROM modeled', 'SELECT * FROM original'], array_map(static fn ($entry): string => $entry->sql(), $entries));
691    }
692
693    public function testAnalyzeSourceDistinguishesNamespacedFunctionsAndGlobalBuiltins(): void
694    {
695        $source = <<<'PHP'
696<?php
697namespace App;
698function strtoupper($value) { return 'local'; }
699function run(\PDO $db) {
700    $db->query('SELECT ' . strtoupper('foo'));
701    $db->query('SELECT ' . \strtoupper('foo'));
702}
703PHP;
704        $entries = (new Analyzer())->analyzeSource(['app.php' => $source])->entries();
705        self::assertSame(['SELECT local', 'SELECT FOO'], array_map(static fn ($entry): string => $entry->sql(), $entries));
706    }
707
708    public function testWithConfigurationOverridesTheBuiltinWithoutChangingTheOriginalAnalyzer(): void
709    {
710        $analyzer = new Analyzer();
711        $configured = $analyzer->withConfiguration(new \SqlCatalog\Facade\Configuration(functionModels: ['array_fill' => \Tests\Fake\PairModel::class]));
712        $source = '<?php function f(PDO $db, array $ids) { $db->prepare("SELECT * FROM users WHERE id IN (" . implode(",", array_fill(0, count($ids), "?")) . ")"); }';
713        self::assertSame('SELECT * FROM users WHERE id IN (?,?)', $configured->analyzeSource(['users.php' => $source])->entries()[0]->sql());
714        self::assertSame('SELECT * FROM users WHERE id IN (?)', $analyzer->analyzeSource(['users.php' => $source])->entries()[0]->sql());
715    }
716
717    #[DataProvider('providerUncertainWrites')]
718    public function testUncertainWritesRemainOpenInsteadOfBecomingEmpty(string $body): void
719    {
720        $catalog = (new Analyzer())->analyzeSource([
721            'a.php' => '<?php function mutate(&$x) { $x = "tail"; } function fragment() { ' . $body
722                . ' return "SELECT * FROM users" . (isset($tail) ? $tail : ""); }'
723                . ' function run(PDO $pdo) { $pdo->query(fragment()); }',
724        ]);
725        self::assertEqualsCanonicalizing(['SELECT * FROM users', 'SELECT * FROM users{$}'], array_map(
726            static fn (CatalogEntry $entry): string => $entry->sql(),
727            $catalog->entries(),
728        ));
729        self::assertSame([false, false], array_map(static fn (CatalogEntry $entry): bool => $entry->searchClosed(), $catalog->entries()));
730    }
731
732    /**
733     * @return array<string, array{string}>
734     */
735    public static function providerUncertainWrites(): array
736    {
737        return [
738            'include' => ['include "fragment.php";'],
739            'require' => ['require "fragment.php";'],
740            'eval' => ['eval($code);'],
741            'dynamic assignment' => ['$$name = "tail";'],
742            'dynamic element assignment' => ['$$name[0] = "tail";'],
743            'dynamic reference argument' => ['mutate($$name);'],
744            'dynamic call' => ['$call($data);'],
745            'builtin reference output' => ['str_replace("a", "b", "c", $tail);'],
746            'extract' => ['extract($data);'],
747            'known reference call' => ['mutate($tail);'],
748            'named reference call' => ['mutate(x: $tail);'],
749            'unknown reference call' => ['unknown($tail);'],
750            'static reference call' => ['Unknown::mutate($tail);'],
751            'method reference call' => ['$object->mutate($tail);'],
752            'later alias write' => ['$alias =& $tail; $tail = "before"; $alias = "after";'],
753            'escaped closure' => ['$callback = function () use (&$tail) { $tail = "after"; }; $tail = "before"; $callback();'],
754            'global changed by call' => ['global $tail; $tail = "before"; unknown();'],
755        ];
756    }
757
758    /**
759     * @param list<string> $expected
760     */
761    #[DataProvider('providerUnconditionalCandidates')]
762    public function testUnconditionalCandidatesNormalizeOnlyWhenStringified(string $body, array $expected): void
763    {
764        $catalog = (new Analyzer())->analyzeSource([
765            'a.php' => '<?php function fragment() { ' . $body . ' } function run(PDO $pdo) { $pdo->query("SELECT " . fragment()); }',
766        ]);
767        self::assertEqualsCanonicalizing($expected, array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries()));
768    }
769
770    /**
771     * @return array<string, array{string, list<string>}>
772     */
773    public static function providerUnconditionalCandidates(): array
774    {
775        return [
776            'true ternary' => ['return true ? "1" : "2";', ['SELECT 1', 'SELECT 2']],
777            'false ternary' => ['return false ? "1" : "2";', ['SELECT 1', 'SELECT 2']],
778            'no reaching assignment' => ['return isset($tail) ? $tail : "";', ['SELECT ']],
779            'null and empty' => ['return true ? null : "";', ['SELECT ']],
780            'literal empty and unresolved' => ['return true ? "" : unknown();', ['SELECT ', 'SELECT {$}']],
781            'definite overwrite after include' => ['include "a.php"; $tail = "1"; return $tail;', ['SELECT 1']],
782            'unset after include' => ['include "a.php"; unset($tail); return $tail;', ['SELECT ']],
783            'ternary effects' => ['$tail = "0"; true ? ($tail = "1") : ($tail = "2"); return $tail;', ['SELECT 1', 'SELECT 2']],
784            'optional ternary effect' => ['$tail = "0"; true ? ($tail = "1") : "2"; return $tail;', ['SELECT 0', 'SELECT 1']],
785            'short circuit effect' => ['$tail = "0"; true && ($tail = "1"); return $tail;', ['SELECT 0', 'SELECT 1']],
786            'match effects' => ['match (1) { 1 => $tail = "1", default => $tail = "2" }; return $tail;', ['SELECT 1', 'SELECT 2']],
787        ];
788    }
789
790    public function testMixedExternalAndUnresolvedDependenciesDoNotCloseTheSearch(): void
791    {
792        $catalog = (new Analyzer())->analyzeSource([
793            'a.php' => '<?php function f(PDO $pdo) { $pdo->query("SELECT " . $_GET["x"] . unknown()); }',
794        ]);
795        self::assertSame(Resolution::IncompleteModel, $catalog->entries()[0]->resolution());
796        self::assertFalse($catalog->entries()[0]->searchClosed());
797    }
798
799    public function testFileScopeAliasesCannotHideLaterWrites(): void
800    {
801        $catalog = (new Analyzer())->analyzeSource([
802            'a.php' => '<?php $alias =& $tail; $tail = "before"; $alias = "after"; $pdo = new PDO("sqlite::memory:"); $pdo->query("SELECT " . $tail);',
803        ]);
804        self::assertSame('SELECT {$}', $catalog->entries()[0]->sql());
805        self::assertFalse($catalog->entries()[0]->searchClosed());
806    }
807
808    public function testAHelperReturnCutShortByTheLoopBudgetCannotCloseTheCaller(): void
809    {
810        $catalog = (new Analyzer())->analyzeSource([
811            'a.php' => '<?php function fragment() { $tail = ""; while (unknown()) { $tail .= "x"; } return $tail; } function run(PDO $pdo) { $pdo->query("SELECT " . fragment()); }',
812        ], new AnalysisOptions(budget: new EvaluationBudget(maxLoopPasses: 1)));
813        self::assertNotEmpty($catalog->entries());
814        self::assertNotContains(true, array_map(static fn (CatalogEntry $entry): bool => $entry->searchClosed(), $catalog->entries()));
815    }
816
817
818    public function testAnalyzeSourceKeepsDirectBuilderArrayWritesOpen(): void
819    {
820        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; function f() { $q = DB::table("users"); $q->wheres[] = []; $q->get(); }'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
821
822        self::assertCount(1, $catalog->entries());
823        self::assertFalse($catalog->entries()[0]->searchClosed());
824    }
825
826    public function testAnalyzeSourceKeepsReferenceRebindingOfBuildersOpen(): void
827    {
828        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; function f() { $q = DB::table("users"); $alias =& $q; $alias->where("id", 1); $q->get(); }'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
829
830        self::assertCount(1, $catalog->entries());
831        self::assertFalse($catalog->entries()[0]->searchClosed());
832    }
833
834    public function testAnalyzeSourceFollowsBuilderAliasesStoredInArrays(): void
835    {
836        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; function f() { $q = DB::table("users"); $aliases = [$q]; $aliases[0]->where("id", 1); $q->get(); }'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
837
838        self::assertCount(1, $catalog->entries());
839        self::assertSame('select * from "users" where "id" = ?', $catalog->entries()[0]->sql());
840        self::assertTrue($catalog->entries()[0]->searchClosed());
841    }
842
843    public function testAnalyzeSourceKeepsConditionalMutationsAsAlternatives(): void
844    {
845        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; function f(bool $active) { $q = DB::table("users"); $active ? $q->where("a", 1) : $q->where("b", 2); $q->get(); }'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
846        $sql = array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries());
847        sort($sql);
848        self::assertSame(['select * from "users" where "a" = ?', 'select * from "users" where "b" = ?'], $sql);
849    }
850
851    public function testAnalyzeSourceRetainsTheLimitAfterReusingAFirstQuery(): void
852    {
853        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; $q = DB::table("users"); $q->first(); $q->get();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
854        self::assertCount(2, $catalog->entries());
855        self::assertSame('select * from "users" limit 1', $catalog->entries()[1]->sql());
856    }
857
858    public function testAnalyzeSourceDoesNotMistakeCollectionMethodsForDatabaseExecutions(): void
859    {
860        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; DB::table("users")->get()->first();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
861        self::assertCount(1, $catalog->entries());
862        self::assertSame('select * from "users"', $catalog->entries()[0]->sql());
863    }
864
865    public function testAnalyzeSourceRecognizesAuthenticationModelsWithoutVendorFiles(): void
866    {
867        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php class User extends \\Illuminate\\Foundation\\Auth\\User {} User::where("id", 1)->get();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
868        self::assertCount(1, $catalog->entries());
869        self::assertSame('select * from "users" where "id" = ?', $catalog->entries()[0]->sql());
870        self::assertTrue($catalog->entries()[0]->searchClosed());
871    }
872
873    public function testAnalyzeSourceDoesNotLoseAlternativesWhenMutatingAJoinedBuilder(): void
874    {
875        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; function f(bool $active) { $q = DB::table("users"); $active ? $q->where("a", 1) : $q->where("b", 2); $q->limit(3); $q->get(); }'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
876        $sql = array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries());
877        sort($sql);
878        self::assertSame(['select * from "users" where "a" = ? limit 3', 'select * from "users" where "b" = ? limit 3'], $sql);
879    }
880
881    public function testAnalyzeSourceKeepsObjectsPassedInsideArraysToUnknownHelpersOpen(): void
882    {
883        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; $q = DB::table("users"); $box = [$q]; unknown($box); $q->get();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
884        self::assertCount(1, $catalog->entries());
885        self::assertFalse($catalog->entries()[0]->searchClosed());
886    }
887
888    public function testAnalyzeSourceDoesNotAssumeCustomModelOrderingUsesTheStandardBuilder(): void
889    {
890        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php class User extends \\Illuminate\\Database\\Eloquent\\Model { public function orderBy($column) { return $this->newQuery()->where("tenant", 7); } } User::orderBy("id")->get();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
891        self::assertCount(1, $catalog->entries());
892        self::assertFalse($catalog->entries()[0]->searchClosed());
893    }
894
895    public function testAnalyzeSourcePreservesShortCircuitAlternativesOnTrackedBuilders(): void
896    {
897        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; $q = DB::table("users"); $q->where("a", 1); false && $q->where("b", 2); $q->get();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
898        $sql = array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries());
899        sort($sql);
900        self::assertSame(['select * from "users" where "a" = ?', 'select * from "users" where "a" = ? and "b" = ?'], $sql);
901        self::assertTrue($catalog->entries()[0]->searchClosed());
902        self::assertTrue($catalog->entries()[1]->searchClosed());
903    }
904
905    public function testAnalyzeSourceKeepsCapturedScalarBindingsInNestedPredicates(): void
906    {
907        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; $id = 7; DB::table("users")->where("active", 1)->where(function ($q) use ($id) { $q->where("id", $id)->orWhereNull("email"); })->get();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
908        self::assertCount(1, $catalog->entries());
909        self::assertSame('select * from "users" where "active" = ? and ("id" = ? or "email" is null)', $catalog->entries()[0]->sql());
910        self::assertTrue($catalog->entries()[0]->searchClosed());
911    }
912
913    public function testAnalyzeSourcePassesArgumentsToTraditionalLocalScopes(): void
914    {
915        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php class User extends \\Illuminate\\Database\\Eloquent\\Model { public function scopeForTenant($q, int $tenant) { return $q->where("tenant_id", $tenant); } } User::forTenant(7)->get();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
916        self::assertCount(1, $catalog->entries());
917        self::assertSame('select * from "users" where "tenant_id" = ?', $catalog->entries()[0]->sql());
918        self::assertTrue($catalog->entries()[0]->searchClosed());
919    }
920
921    public function testAnalyzeSourceDoesNotLoseUnknownEffectsInsideNestedPredicates(): void
922    {
923        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; DB::table("users")->where(fn ($q) => $q->customFilter())->get();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
924        self::assertCount(1, $catalog->entries());
925        self::assertFalse($catalog->entries()[0]->searchClosed());
926    }
927
928    public function testAnalyzeSourceKeepsEarlyReturningScopesAndBooleanRegroupingOpen(): void
929    {
930        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php class User extends \\Illuminate\\Database\\Eloquent\\Model { public function scopeEarly($q) { return $q; $q->where("id", 1); } public function scopeEither($q) { return $q->where("a", 1)->orWhere("b", 2); } } User::early()->get(); User::either()->get();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
931        self::assertCount(2, $catalog->entries());
932        self::assertFalse($catalog->entries()[0]->searchClosed());
933        self::assertFalse($catalog->entries()[1]->searchClosed());
934    }
935
936    public function testAnalyzeSourceKeepsCapturedBuilderEffectsOpen(): void
937    {
938        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; $other = DB::table("posts"); DB::table("users")->where(function ($q) use ($other) { $other->where("id", 7); $q->where("id", 1); })->get(); $other->get();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
939        self::assertCount(2, $catalog->entries());
940        self::assertFalse($catalog->entries()[0]->searchClosed());
941        self::assertFalse($catalog->entries()[1]->searchClosed());
942    }
943
944    public function testAnalyzeSourceDoesNotLoseArgumentSideEffectsOnTheBuilderReceiver(): void
945    {
946        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; function customize($q) { $q->where("tenant_id", 7); return 1; } $q = DB::table("users"); $q->where("active", customize($q))->get();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
947        self::assertCount(1, $catalog->entries());
948        self::assertFalse($catalog->entries()[0]->searchClosed());
949    }
950
951    public function testAnalyzeSourceRefreshesTerminalReceiversAfterEvaluatingArguments(): void
952    {
953        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; function columns($q) { $q->where("tenant_id", 7); return ["id"]; } $q = DB::table("users"); $q->get(columns($q));'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
954        self::assertCount(1, $catalog->entries());
955        self::assertFalse($catalog->entries()[0]->searchClosed());
956    }
957
958    public function testAnalyzeSourceRecognizesBothRawAndBuilderCallsOnConcreteConnections(): void
959    {
960        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php function run(\\Illuminate\\Database\\MySqlConnection $db) { $db->select("SELECT 1"); $db->table("users")->get(); }'], new AnalysisOptions(['laravel']));
961        self::assertCount(2, $catalog->entries());
962        self::assertSame('SELECT 1', $catalog->entries()[0]->sql());
963        self::assertSame('select * from `users`', $catalog->entries()[1]->sql());
964    }
965
966    #[DataProvider('providerBuilderQueriesWithUnresolvedValues')]
967    public function testAnalyzeSourceKeepsTheBuilderStatementWhenOnlyAValueIsUnresolved(string $source, string $expected, bool $exact): void
968    {
969        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; '.$source], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
970        self::assertCount(1, $catalog->entries());
971        self::assertSame($expected, $catalog->entries()[0]->sql());
972        self::assertSame($exact, $catalog->entries()[0]->searchClosed());
973    }
974
975    /**
976     * @return iterable<array{string, string, bool}>
977     */
978    public static function providerBuilderQueriesWithUnresolvedValues(): iterable
979    {
980        yield ['function f(array $ids) { DB::table("users")->select("id", "name")->whereIn("id", $ids)->get()->all(); }', 'select "id", "name" from "users" where "id" in ({$})', false];
981        yield ['function f($group) { DB::connection()->table("users")->where("group_id", $group)->pluck("name")->all(); }', 'select "name" from "users" where "group_id" = ?', true];
982        yield ['function f(int $a, ?string $b, array $ids) { DB::table("users")->where("a", $a)->where("b", $b)->whereIn("id", $ids)->delete(); }', 'delete from "users" where "a" = ? and "b" = ? and "id" in ({$})', false];
983        yield ['function f(int $a) { DB::table("users")->where(["a" => $a, "b" => null])->get(); }', 'select * from "users" where ("a" = ? and "b" is null)', true];
984        yield ['function f(int $n) { DB::table("users")->limit($n)->get(); }', 'select * from "users" limit {$}', false];
985        yield ['function f(\\Illuminate\\Database\\ConnectionInterface $db, bool $active) { $sql = "SELECT 1"; $db->select($sql); }', 'SELECT 1', true];
986        yield ['function f(\\Illuminate\\Database\\ConnectionInterface $db) { $db->table("users")->where("id", 1)->get(); }', 'select * from "users" where "id" = ?', true];
987        yield ['function f(string $name) { DB::table("users")->insertGetId(["name" => $name]); }', 'insert into "users" ("name") values (?)', true];
988        yield ['function f() { DB::table("users")->whereJsonContains("tags", "x")->get(); }', '{$}', false];
989    }
990
991    public function testAnalyzeSourceReportsBothOutcomesOfAConditionalBuilderCallback(): void
992    {
993        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; function f($name) { DB::table("users")->when($name, fn ($q, $v) => $q->where("name", "like", $v))->orderBy("id")->get(); }'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
994        $statements = array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries());
995        sort($statements);
996        self::assertSame(['select * from "users" order by "id" asc', 'select * from "users" where "name" like ? order by "id" asc'], $statements);
997    }
998
999    public function testAnalyzeSourceListsTheCountAndPageStatementsOfPaginate(): void
1000    {
1001        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; DB::table("users")->where("active", 1)->paginate(20);'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
1002        $statements = array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries());
1003        sort($statements);
1004        self::assertSame(['select * from "users" where "active" = ? limit 20 offset {$}', 'select count(*) as "aggregate" from "users" where "active" = ?'], $statements);
1005        self::assertSame(Resolution::ExternalInput, $catalog->entries()[0]->resolution());
1006    }
1007
1008    #[DataProvider('providerUnmodelledBuilderEscapes')]
1009    public function testAnalyzeSourceKeepsEachUnmodelledEscapeOfABuilderOpen(string $call): void
1010    {
1011        $source = '<?php use Illuminate\\Support\\Facades\\DB; function f(callable $fn, object $other, string $class, string $method) { $q = DB::table("users"); '.$call.'; $q->get(); }';
1012        $catalog = (new Analyzer())->analyzeSource(['query.php' => $source], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
1013        self::assertCount(1, $catalog->entries());
1014        self::assertFalse($catalog->entries()[0]->searchClosed());
1015    }
1016
1017    /**
1018     * @return iterable<array{string}>
1019     */
1020    public static function providerUnmodelledBuilderEscapes(): iterable
1021    {
1022        yield ['unknown($q)'];
1023        yield ['$fn($q)'];
1024        yield ['$other->customize($q)'];
1025        yield ['$other->$method($q)'];
1026        yield ['Unknown::customize($q)'];
1027        yield ['$class::$method($q)'];
1028        yield ['new Unknown($q)'];
1029        yield ['unknown([$q])'];
1030        yield ['unknown(fn () => $q->where("id", 7))'];
1031        yield ['$q->$method()'];
1032    }
1033
1034    #[DataProvider('providerNamedBuilderArguments')]
1035    public function testAnalyzeSourceKeepsNamedBuilderArgumentsOpen(string $call): void
1036    {
1037        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; '.$call.';'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
1038        self::assertCount(1, $catalog->entries());
1039        self::assertFalse($catalog->entries()[0]->searchClosed());
1040    }
1041
1042    /**
1043     * @return iterable<array{string}>
1044     */
1045    public static function providerNamedBuilderArguments(): iterable
1046    {
1047        yield ['DB::table(table: "users")->get()'];
1048        yield ['DB::table("users")->where(column: "id", value: 7)->get()'];
1049        yield ['DB::table("users")->get(columns: ["id"])'];
1050    }
1051
1052    #[DataProvider('providerCustomSqlCalls')]
1053    public function testAnalyzeSourceAllowsExtensionsToModelSqlFragmentsFromAnyCallForm(string $expression): void
1054    {
1055        $extension = self::createStub(\SqlCatalog\Core\Extension\Model\ModelProviderInterface::class);
1056        $extension->method('name')->willReturn('example');
1057        $extension->method('sinks')->willReturn([]);
1058        $extension->method('globals')->willReturn([]);
1059        $extension->method('models')->willReturn(new \SqlCatalog\Core\Extension\Model\ModelSet(calls: [static fn (\SqlCatalog\Core\Extension\Model\CallContext $call): ?\SqlCatalog\Core\Evaluation\Domain => $call->name === 'fragment' || $call->className === 'SqlText' ? \SqlCatalog\Core\Evaluation\Domain::literal('SELECT * FROM ')->concat($call->arguments[0]) : null]));
1060        $analyzer = new Analyzer(new ExtensionRegistry([new PdoExtension(), $extension]));
1061        $source = ['query.php' => '<?php $table = "items"; $pdo = new PDO("sqlite::memory:"); $pdo->query('.$expression.');'];
1062        $catalog = $analyzer->analyzeSource($source, new AnalysisOptions(['pdo', 'example']));
1063        self::assertSame('SELECT * FROM items', $catalog->entries()[0]->sql());
1064        self::assertTrue($catalog->entries()[0]->searchClosed());
1065        self::assertFalse($analyzer->analyzeSource($source, new AnalysisOptions(['pdo']))->entries()[0]->searchClosed());
1066    }
1067
1068    /**
1069     * @return iterable<array{string}>
1070     */
1071    public static function providerCustomSqlCalls(): iterable
1072    {
1073        yield ['fragment($table)'];
1074        yield ['Demo::fragment($table)'];
1075        yield ['(new Demo())->fragment($table)'];
1076        yield ['new SqlText($table)'];
1077    }
1078
1079    public function testAnalyzeSourceSupportsRegisteredQueryModelsWithoutLaravel(): void
1080    {
1081        $model = new class () implements \SqlCatalog\Core\Extension\Model\QueryModelInterface {
1082            public function inputs(\PhpParser\Node\Expr\CallLike $call): array
1083            {
1084                return array_map(static fn (\PhpParser\Node\Arg $argument): \PhpParser\Node\Expr => $argument->value, array_values($call->getArgs()));
1085            }
1086
1087            public function statements(\PhpParser\Node\Expr\CallLike $call, array $values): array
1088            {
1089                return [new \SqlCatalog\Core\Extension\Model\QueryOutput(\SqlCatalog\Core\Evaluation\Domain::literal('SELECT * FROM ')->concat($values[0])->concat(\SqlCatalog\Core\Evaluation\Domain::literal(' WHERE id = ?')), \SqlCatalog\Core\Evaluation\Domain::of(new \SqlCatalog\Core\Evaluation\ArrayTerm([new \SqlCatalog\Core\Evaluation\ArrayEntry(null, $values[1])])))];
1090            }
1091        };
1092        $extension = self::createStub(\SqlCatalog\Core\Extension\Model\ModelProviderInterface::class);
1093        $extension->method('name')->willReturn('example');
1094        $extension->method('sinks')->willReturn([new \SqlCatalog\Core\Extension\SinkSpec('example.run', \SqlCatalog\Core\Extension\SinkCallKind::FunctionCall, null, 'run', \SqlCatalog\Core\Extension\SinkRole::Modelled, model: 'example.query')]);
1095        $extension->method('globals')->willReturn([]);
1096        $extension->method('models')->willReturn(new \SqlCatalog\Core\Extension\Model\ModelSet(queries: ['example.query' => $model]));
1097        $catalog = (new Analyzer(new ExtensionRegistry([$extension])))->analyzeSource(['query.php' => '<?php function readRows(string $table, int $id) { run($table, $id); } readRows("users", 7); readRows("posts", 9);'], new AnalysisOptions(['example']));
1098        self::assertCount(2, $catalog->entries());
1099        self::assertSame(['SELECT * FROM posts WHERE id = ?', 'SELECT * FROM users WHERE id = ?'], array_map(static fn (CatalogEntry $entry): string => $entry->sql(), $catalog->entries()));
1100        self::assertSame([[9], [7]], array_map(static fn (CatalogEntry $entry): array => $entry->placeholders[0]->value->values ?? [], $catalog->entries()));
1101        self::assertTrue($catalog->entries()[0]->searchClosed());
1102        self::assertTrue($catalog->entries()[1]->searchClosed());
1103    }
1104
1105    public function testAnalyzeSourceKeepsAModelledSinkWithoutARegisteredCompilerVisible(): void
1106    {
1107        $extension = self::createStub(\SqlCatalog\Core\Extension\ExtensionInterface::class);
1108        $extension->method('name')->willReturn('example');
1109        $extension->method('globals')->willReturn([]);
1110        $extension->method('sinks')->willReturn([new \SqlCatalog\Core\Extension\SinkSpec('example.run', \SqlCatalog\Core\Extension\SinkCallKind::FunctionCall, null, 'run', \SqlCatalog\Core\Extension\SinkRole::Modelled, model: 'missing')]);
1111        $catalog = (new Analyzer(new ExtensionRegistry([$extension])))->analyzeSource(['query.php' => '<?php run();'], new AnalysisOptions(['example']));
1112        self::assertCount(1, $catalog->entries());
1113        self::assertFalse($catalog->entries()[0]->searchClosed());
1114    }    public function testAnalyzeSourceCombinesNamedFunctionModelsWithLaravelRegistrations(): void
1115    {
1116        $functions = \SqlCatalog\Core\Analysis\FunctionModel\Registry::withBuiltins();
1117        $functions->register('table_name', static fn (array $arguments): \SqlCatalog\Core\Evaluation\Domain => \SqlCatalog\Core\Evaluation\Domain::literal('accounts'));
1118        $analyzer = new Analyzer(functionModels: $functions);
1119        $catalog = $analyzer->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; DB::table(table_name())->where("id", 7)->get();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
1120        self::assertCount(1, $catalog->entries());
1121        self::assertSame('select * from "accounts" where "id" = ?', $catalog->entries()[0]->sql());
1122        self::assertTrue($catalog->entries()[0]->searchClosed());
1123    }
1124    public function testAnalyzeSourceRetainsBindingsReusedByModelledBuilderCalls(): void
1125    {
1126        $catalog = (new Analyzer())->analyzeSource(['query.php' => '<?php use Illuminate\\Support\\Facades\\DB; $id = 7; DB::table("users")->where("a", $id)->where("b", $id)->get();'], new AnalysisOptions(['laravel'], dialect: 'sqlite'));
1127        self::assertCount(1, $catalog->entries());
1128        self::assertSame('select * from "users" where "a" = ? and "b" = ?', $catalog->entries()[0]->sql());
1129        self::assertSame([7], $catalog->entries()[0]->placeholders[0]->value?->values);
1130        self::assertSame([7], $catalog->entries()[0]->placeholders[1]->value?->values);
1131        self::assertTrue($catalog->entries()[0]->searchClosed());
1132    }
1133
1134    public function testAnalyzeSourceLetsRegisteredCallModelsOwnTheirArgumentEffects(): void
1135    {
1136        $extension = self::createStub(\SqlCatalog\Core\Extension\Model\ModelProviderInterface::class);
1137        $extension->method('name')->willReturn('example');
1138        $extension->method('sinks')->willReturn([]);
1139        $extension->method('globals')->willReturn([]);
1140        $extension->method('models')->willReturn(new \SqlCatalog\Core\Extension\Model\ModelSet(calls: [static fn (\SqlCatalog\Core\Extension\Model\CallContext $call): ?\SqlCatalog\Core\Evaluation\Domain => $call->name === 'fragment' ? $call->arguments[0] : null]));
1141        $analyzer = new Analyzer(new ExtensionRegistry([new PdoExtension(), $extension]));
1142        $catalog = $analyzer->analyzeSource(['query.php' => '<?php $table = "items"; $pdo = new PDO("sqlite::memory:"); $pdo->query("SELECT * FROM " . fragment($table) . " JOIN " . fragment($table));'], new AnalysisOptions(['pdo', 'example']));
1143        self::assertSame('SELECT * FROM items JOIN items', $catalog->entries()[0]->sql());
1144        self::assertTrue($catalog->entries()[0]->searchClosed());
1145    }
1146
1147    public function testAnalyzeSourceKeepsVariableNamesForUnresolvedSqlAndFragments(): void
1148    {
1149        $source = <<<'SOURCE'
1150<?php
1151function queries(PDO $pdo, string $input): void {
1152    $sql = buildSql();
1153    $pdo->prepare($sql);
1154    $pdo->prepare($input);
1155    $table = tableName();
1156    $pdo->query('SELECT id FROM ' . $table . ' WHERE active = 1');
1157    $pdo->prepare(buildSql());
1158}
1159SOURCE;
1160        $entries = (new Analyzer())->analyzeSource(['queries.php' => $source])->entries();
1161
1162        self::assertCount(4, $entries);
1163        self::assertSame('$sql', $entries[0]->firstGap()?->variable);
1164        self::assertSame('\\buildSql()', $entries[0]->firstGap()->expression);
1165        self::assertSame('$input', $entries[1]->firstGap()?->variable);
1166        self::assertSame('$table', $entries[2]->firstGap()?->variable);
1167        self::assertNull($entries[3]->firstGap()?->variable);
1168        self::assertSame('{$}', $entries[0]->sql());
1169    }
1170
1171    public function testAnalyzeSourceAcceptsAnApplicationSqlPolicy(): void
1172    {
1173        $policy = self::createStub(\SqlCatalog\Core\Sql\Dialect::class);
1174        $policy->method('identifierQuote')->willReturn('!');
1175        $dialects = new \SqlCatalog\Core\Sql\Dialects(['application' => $policy]);
1176        $extensions = new ExtensionRegistry([new \SqlCatalog\Extension\Laravel\LaravelExtension($dialects)]);
1177        $catalog = (new Analyzer($extensions))->analyzeSource([
1178            'app.php' => '<?php \Illuminate\Support\Facades\DB::table("items")->get();',
1179        ], new AnalysisOptions(['laravel'], dialect: 'application'));
1180        self::assertSame('select * from !items!', $catalog->entries()[0]->sql());
1181    }
1182}
1183