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