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