packages/sql-catalog/tests/Unit/Core/Analysis/BuiltinCallModelTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Core\Analysis;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use SqlCatalog\Core\Analysis\BuiltinCallModel;
12use SqlCatalog\Core\Evaluation\ArrayEntry;
13use SqlCatalog\Core\Evaluation\ArrayTerm;
14use SqlCatalog\Core\Evaluation\Domain;
15use SqlCatalog\Core\Evaluation\LiteralTerm;
16use SqlCatalog\Core\Evaluation\OpaqueTerm;
17use SqlCatalog\Core\Evaluation\PatternTerm;
18use SqlCatalog\Core\Text\LiteralText;
19use SqlCatalog\Core\Text\Origin;
20use SqlCatalog\Core\Text\TextGeneralization;
21use SqlCatalog\Core\Text\TextHole;
22use SqlCatalog\Core\Text\TextPattern;
23use SqlCatalog\Core\Type\TypeShape;
24
25#[CoversClass(BuiltinCallModel::class)]
26#[UsesClass(ArrayEntry::class)]
27#[UsesClass(ArrayTerm::class)]
28#[UsesClass(Domain::class)]
29#[UsesClass(LiteralTerm::class)]
30#[UsesClass(OpaqueTerm::class)]
31#[UsesClass(PatternTerm::class)]
32#[UsesClass(LiteralText::class)]
33#[UsesClass(TextGeneralization::class)]
34#[UsesClass(TextHole::class)]
35#[UsesClass(TextPattern::class)]
36#[UsesClass(TypeShape::class)]
37#[UsesClass(\SqlCatalog\Core\Analysis\FunctionModel\Registry::class)]
38final class BuiltinCallModelTest extends TestCase
39{
40    /**
41     * @param list<Domain> $arguments
42     */
43    #[DataProvider('providerEvaluate')]
44    public function testEvaluateOfEveryModelledFunction(string $function, array $arguments, string $expected): void
45    {
46        self::assertSame($expected, (new BuiltinCallModel())->evaluate($function, $arguments)->patterns()[0]->display());
47    }
48
49    /**
50     * @return list<array{string, list<Domain>, string}>
51     */
52    public static function providerEvaluate(): array
53    {
54        return [
55            ['sprintf', [Domain::literal('a%s'), Domain::literal('b')], 'ab'],
56            ['vsprintf', [Domain::literal('a%s'), Domain::of(new ArrayTerm([new ArrayEntry(null, Domain::literal('b'))]))], 'ab'],
57            ['implode', [Domain::literal('-'), Domain::of(new ArrayTerm([new ArrayEntry(null, Domain::literal('a')), new ArrayEntry(null, Domain::literal('b'))]))], 'a-b'],
58            ['join', [Domain::literal('-'), Domain::of(new ArrayTerm([new ArrayEntry(null, Domain::literal('a'))]))], 'a'],
59            ['str_repeat', [Domain::literal('?'), Domain::literal(3)], '???'],
60            ['strtolower', [Domain::literal('AB')], 'ab'],
61            ['strtoupper', [Domain::literal('ab')], 'AB'],
62            ['ucfirst', [Domain::literal('ab')], 'Ab'],
63            ['lcfirst', [Domain::literal('AB')], 'aB'],
64            ['trim', [Domain::literal(' a ')], 'a'],
65            ['ltrim', [Domain::literal(' a')], 'a'],
66            ['rtrim', [Domain::literal('a ')], 'a'],
67            ['str_replace', [Domain::literal('a'), Domain::literal('b'), Domain::literal('a')], 'b'],
68            ['strval', [Domain::literal('a')], 'a'],
69            ['intval', [Domain::literal('1')], '{$}'],
70            ['count', [Domain::literal('1')], '{$}'],
71            ['strlen', [Domain::literal('1')], '{$}'],
72            ['json_encode', [Domain::literal('1')], '{$}'],
73        ];
74    }
75
76    #[DataProvider('providerSupports')]
77    public function testSupportsEveryModelledFunction(string $function): void
78    {
79        self::assertTrue((new BuiltinCallModel())->supports($function));
80    }
81
82    /**
83     * @return list<array{string}>
84     */
85    public static function providerSupports(): array
86    {
87        return [
88            ['sprintf'], ['vsprintf'], ['implode'], ['join'], ['str_repeat'], ['array_fill'],
89            ['strtolower'], ['strtoupper'], ['ucfirst'], ['lcfirst'],
90            ['trim'], ['ltrim'], ['rtrim'], ['str_replace'], ['strval'],
91            ['intval'], ['count'], ['strlen'],
92            ['json_encode'], ['addslashes'], ['htmlspecialchars'], ['str_pad'],
93            ['substr'], ['number_format'], ['date'], ['ucwords'], ['nl2br'], ['serialize'],
94        ];
95    }
96
97    public function testEvaluateOfAFunctionItDoesNotModelFallsBackToAString(): void
98    {
99        self::assertSame('string', (new BuiltinCallModel())->evaluate('addslashes', [Domain::literal('a')])->type()->display());
100    }
101
102    public function testSupportsTheFunctionsThatShapeQueryText(): void
103    {
104        $model = new BuiltinCallModel();
105        self::assertTrue($model->supports('sprintf'));
106        self::assertTrue($model->supports('\\implode'));
107        self::assertTrue($model->supports('json_encode'));
108        self::assertFalse($model->supports('array_map'));
109    }
110
111    public function testEvaluateResolvesSprintf(): void
112    {
113        $result = (new BuiltinCallModel())->evaluate('sprintf', [Domain::literal('FROM %s'), Domain::literal('users')]);
114        self::assertSame('FROM users', $result->soleLiteral()?->value);
115    }
116
117    public function testEvaluateResolvesTheStringTransformations(): void
118    {
119        $model = new BuiltinCallModel();
120        self::assertSame('ABC', $model->evaluate('strtoupper', [Domain::literal('abc')])->soleLiteral()?->value);
121        self::assertSame('abc', $model->evaluate('trim', [Domain::literal(' abc ')])->soleLiteral()?->value);
122    }
123
124    public function testEvaluateOfAStringReturningFunctionKnowsOnlyTheType(): void
125    {
126        self::assertSame('string', (new BuiltinCallModel())->evaluate('json_encode', [Domain::literal('x')])->type()->display());
127    }
128
129    public function testEvaluateOfACountingFunctionKnowsOnlyTheType(): void
130    {
131        self::assertSame('int', (new BuiltinCallModel())->evaluate('strlen', [Domain::literal('x')])->type()->display());
132    }
133
134    public function testEvaluateOfStrvalPassesTheValueThrough(): void
135    {
136        self::assertSame('x', (new BuiltinCallModel())->evaluate('strval', [Domain::literal('x')])->soleLiteral()?->value);
137    }
138
139    /**
140     * @param list<Domain> $arguments
141     */
142    #[DataProvider('providerEvaluateUnresolved')]
143    public function testEvaluateKnowsTheTypeOfAResultItCouldNotResolve(string $function, array $arguments, string $expected): void
144    {
145        self::assertSame($expected, (new BuiltinCallModel())->evaluate($function, $arguments)->type()->display());
146    }
147
148    /**
149     * @return array<string, array{string, list<Domain>, string}>
150     */
151    public static function providerEvaluateUnresolved(): array
152    {
153        return [
154            'intval' => ['intval', [Domain::unknown()], 'int'],
155            'count' => ['count', [Domain::unknown()], 'int'],
156            'strlen' => ['strlen', [Domain::unknown()], 'int'],
157            'sprintf of an unknown format' => ['sprintf', [Domain::unknown()], 'string'],
158            'vsprintf without an array' => ['vsprintf', [Domain::literal('%s'), Domain::unknown()], 'string'],
159            'implode without an array' => ['implode', [Domain::literal(','), Domain::unknown()], 'string'],
160            'str_repeat of an unknown count' => ['str_repeat', [Domain::literal('?'), Domain::unknown()], 'string'],
161            'strtolower of an unknown subject' => ['strtolower', [Domain::unknown()], 'string'],
162            'str_replace of an unknown subject' => ['str_replace', [Domain::literal('a'), Domain::literal('b'), Domain::unknown()], 'string'],
163        ];
164    }
165
166    #[DataProvider('providerNormalize')]
167    public function testNormalize(string $written, string $expected): void
168    {
169        self::assertSame($expected, (new BuiltinCallModel())->normalize($written));
170    }
171
172    /**
173     * @return list<array{string, string}>
174     */
175    public static function providerNormalize(): array
176    {
177        return [
178            ['SPRINTF', 'sprintf'],
179            ['\\App\\implode', 'app\\implode'],
180            ['\\sprintf', 'sprintf'],
181        ];
182    }
183
184    public function testSprintfLeavesAGapForAnArgumentThatDidNotResolve(): void
185    {
186        $result = (new BuiltinCallModel())->sprintf([Domain::unknown()], Domain::literal('FROM %s'));
187        self::assertSame('FROM {$}', $result->patterns()[0]->display());
188    }
189
190    public function testSprintfGivesUpWhenTheFormatDidNotResolve(): void
191    {
192        self::assertFalse((new BuiltinCallModel())->sprintf([], Domain::unknown())->isExact());
193    }
194
195    public function testSprintfOfAFormatThatIsNotTextKeepsItsOriginButNotItsExpression(): void
196    {
197        $result = (new BuiltinCallModel())->sprintf([Domain::literal('a')], Domain::opaque(TypeShape::unknown(), Origin::External, '$format'));
198        $hole = $result->patterns()[0]->holes()[0];
199
200        self::assertSame(Origin::External, $hole->origin);
201        self::assertSame('sprintf', $hole->expression);
202        self::assertSame('string', $hole->type->display());
203    }
204
205    public function testSprintfFillsAPartlyKnownFormat(): void
206    {
207        $format = Domain::of(new PatternTerm(
208            TextPattern::fromText('SELECT * FROM ')
209                ->concat(TextPattern::fromHole(new TextHole(Origin::Parameter, TypeShape::of(['string']), '$table')))
210                ->concat(TextPattern::fromText(' WHERE id = %d')),
211        ));
212
213        $result = (new BuiltinCallModel())->sprintf([Domain::literal(7)], $format);
214
215        self::assertSame('SELECT * FROM {$} WHERE id = 7', $result->patterns()[0]->display());
216    }
217
218    public function testSprintfKeepsALiteralPercentSign(): void
219    {
220        self::assertSame('100%', (new BuiltinCallModel())->sprintf([], Domain::literal('100%%'))->soleLiteral()?->value);
221    }
222
223    /**
224     * @param list<Domain> $arguments
225     */
226    #[DataProvider('providerFormatPattern')]
227    public function testFormatPatternFillsEachConversionWithTheNextArgument(string $format, array $arguments, string $expected): void
228    {
229        self::assertSame($expected, (new BuiltinCallModel())->formatPattern(TextPattern::fromText($format), $arguments)->soleLiteral()?->value);
230    }
231
232    /**
233     * @return list<array{string, list<Domain>, string}>
234     */
235    public static function providerFormatPattern(): array
236    {
237        return [
238            ['SELECT %s FROM %s', [Domain::literal('id'), Domain::literal('users')], 'SELECT id FROM users'],
239            ['LIMIT %d', [Domain::literal(10)], 'LIMIT 10'],
240            ['100%%', [], '100%'],
241            ['%%%s%%', [Domain::literal('a')], '%a%'],
242            ['%05.2f|%-3s|%x', [Domain::literal('1'), Domain::literal('2'), Domain::literal('3')], '1|2|3'],
243            ['no conversions', [Domain::literal('unused')], 'no conversions'],
244            ['', [Domain::literal('unused')], ''],
245        ];
246    }
247
248    public function testFormatPatternLeavesAGapForAConversionWithoutAnArgument(): void
249    {
250        $pattern = (new BuiltinCallModel())->formatPattern(TextPattern::fromText('%s-%s'), [Domain::literal('only')])->patterns()[0];
251
252        self::assertSame('only-{$}', $pattern->display());
253        self::assertSame(Origin::Call, $pattern->holes()[0]->origin);
254        self::assertSame('mixed', $pattern->holes()[0]->type->display());
255        self::assertSame('sprintf', $pattern->holes()[0]->expression);
256    }
257
258    public function testFormatPatternCarriesAGapInTheFormatAndKeepsCountingArgumentsPastIt(): void
259    {
260        $format = TextPattern::fromSegments([
261            new LiteralText('SELECT %s FROM '),
262            new TextHole(Origin::Parameter, TypeShape::of(['string']), '$table'),
263            new LiteralText(' WHERE id = %d'),
264        ]);
265
266        $pattern = (new BuiltinCallModel())->formatPattern($format, [Domain::literal('name'), Domain::literal(7)])->patterns()[0];
267
268        self::assertSame('SELECT name FROM {$} WHERE id = 7', $pattern->display());
269        self::assertCount(1, $pattern->holes());
270        self::assertSame(Origin::Parameter, $pattern->holes()[0]->origin);
271        self::assertSame('$table', $pattern->holes()[0]->expression);
272    }
273
274    public function testFormatPatternKeepsEveryAlternativeOfAnArgument(): void
275    {
276        $result = (new BuiltinCallModel())->formatPattern(
277            TextPattern::fromText('id = %s'),
278            [Domain::literal(1)->union(Domain::literal(2))],
279        );
280
281        self::assertSame(
282            ['id = 1', 'id = 2'],
283            array_map(static fn (TextPattern $pattern): string => $pattern->display(), $result->patterns()),
284        );
285    }
286
287    public function testOriginOfIsTheOriginOfTheFirstOpaqueAlternative(): void
288    {
289        $domain = Domain::literal('a')
290            ->union(Domain::opaque(TypeShape::of(['string']), Origin::Parameter))
291            ->union(Domain::opaque(TypeShape::of(['int']), Origin::External));
292
293        self::assertSame(Origin::Parameter, (new BuiltinCallModel())->originOf($domain, Origin::Call));
294    }
295
296    public function testOriginOfFallsBackWhenNothingInTheValueIsOpaque(): void
297    {
298        $model = new BuiltinCallModel();
299
300        self::assertSame(Origin::Loop, $model->originOf(Domain::literal('a'), Origin::Loop));
301        self::assertSame(Origin::Call, $model->originOf(Domain::literal('a'), Origin::Call));
302        self::assertSame(Origin::External, $model->originOf(Domain::opaque(TypeShape::unknown(), Origin::External), Origin::Loop));
303    }
304
305    public function testVsprintfTakesItsArgumentsFromAnArray(): void
306    {
307        $values = Domain::of(new ArrayTerm([new ArrayEntry(null, Domain::literal('users'))]));
308        $result = (new BuiltinCallModel())->vsprintf([Domain::literal('FROM %s'), $values]);
309        self::assertSame('FROM users', $result->soleLiteral()?->value);
310    }
311
312    public function testVsprintfGivesUpWithoutAKnownArray(): void
313    {
314        self::assertFalse((new BuiltinCallModel())->vsprintf([Domain::literal('%s'), Domain::unknown()])->isExact());
315    }
316
317    public function testSplitFormatSeparatesLiteralsFromConversions(): void
318    {
319        self::assertSame(['SELECT ', '%s', ' FROM ', '%s'], (new BuiltinCallModel())->splitFormat('SELECT %s FROM %s'));
320    }
321
322    public function testSplitFormatListsThePiecesOfAFormatStartingWithAConversion(): void
323    {
324        self::assertSame(['%s', ' = ', '%d'], (new BuiltinCallModel())->splitFormat('%s = %d'));
325    }
326
327    public function testImplodeWithoutGlueJoinsTheElementsDirectly(): void
328    {
329        $array = Domain::of(new ArrayTerm([
330            new ArrayEntry(null, Domain::literal('a')),
331            new ArrayEntry(null, Domain::literal('b')),
332        ]));
333
334        self::assertSame('ab', (new BuiltinCallModel())->implode([$array])->soleLiteral()?->value);
335    }
336
337    public function testImplodeWithoutAKnownArrayKeepsTheOriginOfThePieces(): void
338    {
339        $result = (new BuiltinCallModel())->implode([Domain::literal(','), Domain::opaque(TypeShape::unknown(), Origin::External)]);
340        $hole = $result->patterns()[0]->holes()[0];
341
342        self::assertSame(Origin::External, $hole->origin);
343        self::assertSame('implode', $hole->expression);
344    }
345
346    public function testImplodeOfAnIncompleteArrayEndsInAStringGap(): void
347    {
348        $array = Domain::of(new ArrayTerm([new ArrayEntry(null, Domain::literal('id'))], false));
349        $holes = (new BuiltinCallModel())->implode([Domain::literal(','), $array])->patterns()[0]->holes();
350
351        self::assertCount(1, $holes);
352        self::assertSame('string', $holes[0]->type->display());
353        self::assertSame('implode', $holes[0]->expression);
354    }
355
356    public function testImplodeJoinsTheElementsOfAKnownArray(): void
357    {
358        $array = Domain::of(new ArrayTerm([
359            new ArrayEntry(null, Domain::literal('id')),
360            new ArrayEntry(null, Domain::literal('name')),
361        ]));
362        $result = (new BuiltinCallModel())->implode([Domain::literal(', '), $array]);
363        self::assertSame('id, name', $result->soleLiteral()?->value);
364    }
365
366    public function testImplodeLeavesAGapWhenTheArrayIsIncomplete(): void
367    {
368        $array = Domain::of(new ArrayTerm([new ArrayEntry(null, Domain::literal('id'))], false));
369        self::assertFalse((new BuiltinCallModel())->implode([Domain::literal(','), $array])->isExact());
370    }
371
372    public function testImplodeGivesUpWithoutAKnownArray(): void
373    {
374        self::assertFalse((new BuiltinCallModel())->implode([Domain::literal(','), Domain::unknown()])->isExact());
375    }
376
377    public function testArrayArgumentFindsTheArrayInAnyPosition(): void
378    {
379        $array = Domain::of(new ArrayTerm([]));
380        $model = new BuiltinCallModel();
381        self::assertNotNull($model->arrayArgument([Domain::literal(','), $array]));
382        self::assertNull($model->arrayArgument([Domain::literal(',')]));
383    }
384
385    public function testRepeatResolvesAKnownRepetition(): void
386    {
387        self::assertSame('??', (new BuiltinCallModel())->repeat([Domain::literal('?'), Domain::literal(2)])->soleLiteral()?->value);
388    }
389
390    public function testRepeatGivesUpOnAnUnreasonableCount(): void
391    {
392        $model = new BuiltinCallModel();
393        self::assertFalse($model->repeat([Domain::literal('?'), Domain::literal(-1)])->isExact());
394        self::assertFalse($model->repeat([Domain::literal('?'), Domain::literal(10000)])->isExact());
395        self::assertFalse($model->repeat([Domain::literal('?'), Domain::unknown()])->isExact());
396    }
397
398    public function testRepeatResolvesTheBoundsOfAReasonableCount(): void
399    {
400        $model = new BuiltinCallModel();
401
402        self::assertSame('', $model->repeat([Domain::literal('?'), Domain::literal(0)])->soleLiteral()?->value);
403        self::assertSame(str_repeat('?', 1000), $model->repeat([Domain::literal('?'), Domain::literal(1000)])->soleLiteral()?->value);
404        self::assertFalse($model->repeat([Domain::literal('?'), Domain::literal(1001)])->isExact());
405    }
406
407    public function testTransformGivesUpWithoutAKnownSubject(): void
408    {
409        self::assertFalse((new BuiltinCallModel())->transform('strtolower', [Domain::unknown()])->isExact());
410    }
411
412    public function testTransformCoversEachOneArgumentFunction(): void
413    {
414        $model = new BuiltinCallModel();
415        self::assertSame('abc', $model->transform('strtolower', [Domain::literal('ABC')])->soleLiteral()?->value);
416        self::assertSame('Abc', $model->transform('ucfirst', [Domain::literal('abc')])->soleLiteral()?->value);
417        self::assertSame('aBC', $model->transform('lcfirst', [Domain::literal('ABC')])->soleLiteral()?->value);
418        self::assertSame('a ', $model->transform('ltrim', [Domain::literal(' a ')])->soleLiteral()?->value);
419        self::assertSame(' a', $model->transform('rtrim', [Domain::literal(' a ')])->soleLiteral()?->value);
420    }
421
422    public function testReplaceResolvesWhenEveryArgumentIsKnown(): void
423    {
424        $result = (new BuiltinCallModel())->replace([Domain::literal('a'), Domain::literal('b'), Domain::literal('aa')]);
425        self::assertSame('bb', $result->soleLiteral()?->value);
426    }
427
428    public function testReplaceGivesUpWhenAnArgumentIsNotKnown(): void
429    {
430        self::assertFalse((new BuiltinCallModel())->replace([Domain::unknown(), Domain::literal('b'), Domain::literal('a')])->isExact());
431        self::assertFalse((new BuiltinCallModel())->replace([Domain::literal('a'), Domain::unknown(), Domain::literal('a')])->isExact());
432        self::assertFalse((new BuiltinCallModel())->replace([Domain::literal('a'), Domain::literal('b'), Domain::unknown()])->isExact());
433    }
434    public function testRegisterInstallsModelsIntoAnEmptyRegistry(): void
435    {
436        $models = new \SqlCatalog\Core\Analysis\FunctionModel\Registry();
437        (new BuiltinCallModel())->register($models);
438        self::assertTrue($models->supports('implode'));
439        self::assertSame('x', $models->evaluate('strval', [Domain::literal('x')])?->soleLiteral()?->value);
440    }
441
442    #[DataProvider('providerFillCounts')]
443    public function testFillKeepsOnlyARepresentativePlaceholder(Domain $count): void
444    {
445        $result = (new BuiltinCallModel())->fill([Domain::literal(0), $count, Domain::literal('?')]);
446        self::assertCount(1, $result?->soleArray()->entries ?? []);
447        self::assertSame('?', $result?->soleArray()?->entries[0]->value->soleLiteral()?->value);
448    }
449
450    /**
451     * @return list<array{Domain}>
452     */
453    public static function providerFillCounts(): array
454    {
455        return [[Domain::unknown()], [Domain::literal(0)], [Domain::literal(100)]];
456    }
457
458    public function testFillDeclinesOtherOrMissingValues(): void
459    {
460        $model = new BuiltinCallModel();
461        self::assertNull($model->fill([Domain::literal(0), Domain::unknown(), Domain::literal('x')]));
462        self::assertNull($model->fill([]));
463    }
464}
465