packages/sql-catalog/tests/Unit/Core/Evaluation/DomainTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Core\Evaluation;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use SqlCatalog\Core\Evaluation\ArrayEntry;
11use SqlCatalog\Core\Evaluation\ArrayTerm;
12use SqlCatalog\Core\Evaluation\Domain;
13use SqlCatalog\Core\Evaluation\LiteralTerm;
14use SqlCatalog\Core\Evaluation\ObjectTerm;
15use SqlCatalog\Core\Evaluation\OpaqueTerm;
16use SqlCatalog\Core\Evaluation\PatternTerm;
17use SqlCatalog\Core\Text\LiteralText;
18use SqlCatalog\Core\Text\Origin;
19use SqlCatalog\Core\Text\TextGeneralization;
20use SqlCatalog\Core\Text\TextHole;
21use SqlCatalog\Core\Text\TextPattern;
22use SqlCatalog\Core\Type\TypeShape;
23
24#[CoversClass(Domain::class)]
25#[UsesClass(ArrayEntry::class)]
26#[UsesClass(ArrayTerm::class)]
27#[UsesClass(LiteralTerm::class)]
28#[UsesClass(ObjectTerm::class)]
29#[UsesClass(OpaqueTerm::class)]
30#[UsesClass(PatternTerm::class)]
31#[UsesClass(LiteralText::class)]
32#[UsesClass(TextGeneralization::class)]
33#[UsesClass(TextHole::class)]
34#[UsesClass(TextPattern::class)]
35#[UsesClass(TypeShape::class)]
36final class DomainTest extends TestCase
37{
38    public function testOfHoldsOneAlternative(): void
39    {
40        self::assertCount(1, Domain::of(new LiteralTerm('a'))->terms);
41    }
42
43    public function testLiteralHoldsOneResolvedScalar(): void
44    {
45        self::assertSame('a', Domain::literal('a')->soleLiteral()?->value);
46    }
47
48    public function testOpaqueCarriesTypeAndOrigin(): void
49    {
50        $domain = Domain::opaque(TypeShape::of(['int']), Origin::Parameter, '$id');
51        self::assertSame('int', $domain->type()->display());
52        self::assertFalse($domain->isExact());
53    }
54
55    public function testUnknownKnowsNothing(): void
56    {
57        self::assertSame('mixed', Domain::unknown()->type()->display());
58    }
59
60    public function testFromTermsDeduplicatesAlternatives(): void
61    {
62        $domain = Domain::fromTerms([new LiteralTerm('a'), new LiteralTerm('a'), new LiteralTerm('b')]);
63        self::assertCount(2, $domain->terms);
64    }
65
66    public function testFromTermsFallsBackToUnknownWhenGivenNothing(): void
67    {
68        self::assertSame('mixed', Domain::fromTerms([])->type()->display());
69    }
70
71    public function testFromTermsGeneralizesBeyondTheBound(): void
72    {
73        $terms = array_map(
74            static fn (int $index): LiteralTerm => new LiteralTerm('value' . $index),
75            range(0, Domain::MAX_TERMS),
76        );
77        $domain = Domain::fromTerms($terms);
78        self::assertCount(1, $domain->terms);
79        self::assertTrue($domain->widened);
80    }
81
82    public function testGeneralizeCoversEveryAlternative(): void
83    {
84        $term = Domain::generalize([new LiteralTerm('ORDER BY a'), new LiteralTerm('ORDER BY b')], Origin::Branch);
85        self::assertSame('ORDER BY {$}', $term->toPattern()->display());
86    }
87
88    public function testUnionKeepsBothSetsOfAlternatives(): void
89    {
90        $domain = Domain::literal('a')->union(Domain::literal('b'));
91        self::assertCount(2, $domain->terms);
92    }
93
94    public function testConcatBuildsEveryCombination(): void
95    {
96        $domain = Domain::literal('a')->union(Domain::literal('b'))->concat(Domain::literal('!'));
97        self::assertCount(2, $domain->terms);
98        self::assertSame(['a!', 'b!'], array_map(
99            static fn (TextPattern $pattern): ?string => $pattern->text(),
100            $domain->patterns(),
101        ));
102    }
103
104    public function testConcatKeepsAGapFromEitherSide(): void
105    {
106        $domain = Domain::literal('id = ')->concat(Domain::opaque(TypeShape::unknown(), Origin::External));
107        self::assertSame('id = {$}', $domain->patterns()[0]->display());
108    }
109
110    public function testSelectReadsTheElementUnderAResolvedKey(): void
111    {
112        $arrays = Domain::of(new ArrayTerm([
113            new ArrayEntry(Domain::literal('u'), Domain::literal('users')),
114            new ArrayEntry(Domain::literal('a'), Domain::literal('admins')),
115        ]));
116
117        self::assertSame('admins', $arrays->select(Domain::literal('a'))?->soleLiteral()?->value);
118    }
119
120    public function testSelectReadsEveryElementUnderAKeyThatDidNotResolve(): void
121    {
122        $arrays = Domain::of(new ArrayTerm([
123            new ArrayEntry(Domain::literal('u'), Domain::literal('users')),
124            new ArrayEntry(Domain::literal('a'), Domain::literal('admins')),
125        ]));
126        $key = Domain::opaque(TypeShape::of(['string']), Origin::External, '$_GET[\'kind\']');
127
128        self::assertSame('literal:string:admins|literal:string:users', $arrays->select($key)?->signature());
129    }
130
131    public function testSelectReadsEachElementAKeyWithSeveralValuesNames(): void
132    {
133        $arrays = Domain::of(new ArrayTerm([
134            new ArrayEntry(Domain::literal('u'), Domain::literal('users')),
135            new ArrayEntry(Domain::literal('a'), Domain::literal('admins')),
136            new ArrayEntry(Domain::literal('g'), Domain::literal('guests')),
137        ]));
138        $keys = Domain::literal('u')->union(Domain::literal('g'));
139
140        self::assertSame('literal:string:guests|literal:string:users', $arrays->select($keys)?->signature());
141    }
142
143    public function testSelectReadsAcrossEveryArrayAlternative(): void
144    {
145        $arrays = Domain::of(new ArrayTerm([new ArrayEntry(Domain::literal('t'), Domain::literal('users'))]))
146            ->union(Domain::of(new ArrayTerm([new ArrayEntry(Domain::literal('t'), Domain::literal('admins'))])));
147
148        self::assertSame('literal:string:admins|literal:string:users', $arrays->select(Domain::literal('t'))?->signature());
149    }
150
151    public function testSelectKeepsAGapForAnArrayKnownOnlyInPart(): void
152    {
153        $arrays = Domain::of(new ArrayTerm([new ArrayEntry(Domain::literal('u'), Domain::literal('users'))], false));
154
155        self::assertSame('users', $arrays->select(Domain::literal('u'))?->soleLiteral()?->value);
156        $any = $arrays->select(Domain::unknown(), '$parts[$k]');
157        self::assertSame('literal:string:users|opaque:mixed:unresolved', $any?->signature());
158        self::assertEquals(new OpaqueTerm(TypeShape::unknown(), Origin::Unresolved, '$parts[$k]'), $any->terms[1]);
159    }
160
161    public function testSelectKeepsTheElementsMarkedAsCombinedWhenTheKeysWere(): void
162    {
163        $arrays = Domain::of(new ArrayTerm([
164            new ArrayEntry(Domain::literal('ab'), Domain::literal('users')),
165            new ArrayEntry(Domain::literal('ac'), Domain::literal('admins')),
166            new ArrayEntry(Domain::literal('xb'), Domain::literal('guests')),
167            new ArrayEntry(Domain::literal('xc'), Domain::literal('bots')),
168        ]));
169        $keys = Domain::literal('a')->concat(Domain::literal('b')->union(Domain::literal('c')));
170        $paired = Domain::literal('a')->union(Domain::literal('x'))->concat(Domain::literal('b')->union(Domain::literal('c')));
171
172        $plain = $arrays->select($keys);
173        self::assertNotNull($plain);
174        self::assertFalse($plain->combined);
175        self::assertFalse($plain->widened);
176        self::assertTrue($arrays->select($paired)?->combined);
177        self::assertTrue(Domain::fromTerms($arrays->terms, false, true)->select(Domain::literal('ab'))?->combined);
178        self::assertTrue(Domain::fromTerms($arrays->terms, true)->select(Domain::literal('ab'))?->widened);
179    }
180
181    public function testSelectClaimsNothingItCannotRead(): void
182    {
183        $array = Domain::of(new ArrayTerm([new ArrayEntry(Domain::literal('u'), Domain::literal('users'))]));
184
185        self::assertNull($array->select(Domain::literal('missing')), 'a resolved key the array does not hold');
186        self::assertNull($array->select(Domain::literal('missing')->union(Domain::literal('u'))), 'one of several keys missing');
187        self::assertNull($array->union(Domain::literal('users'))->select(Domain::literal('u')), 'an alternative that is not an array');
188        self::assertNull(Domain::unknown()->select(Domain::literal('u')), 'no array at all');
189        self::assertNull(Domain::of(new ArrayTerm([]))->select(Domain::unknown()), 'no element to read under an unknown key');
190    }
191
192    public function testAsTermCollapsesAResolvedPatternToALiteral(): void
193    {
194        self::assertInstanceOf(LiteralTerm::class, Domain::asTerm(TextPattern::fromText('a')));
195    }
196
197    public function testAsTermKeepsAPatternThatStillHasGaps(): void
198    {
199        $pattern = TextPattern::fromHole(new TextHole(Origin::Loop, TypeShape::unknown()));
200        self::assertInstanceOf(PatternTerm::class, Domain::asTerm($pattern));
201    }
202
203    public function testCollapseFoldsAlternativesIntoOneShape(): void
204    {
205        $domain = Domain::literal('a')->union(Domain::literal('b'))->collapse(Origin::Loop);
206        self::assertCount(1, $domain->terms);
207        self::assertTrue($domain->widened);
208    }
209
210    public function testCollapseLeavesASingleAlternativeAlone(): void
211    {
212        $domain = Domain::literal('a')->collapse(Origin::Loop);
213        self::assertFalse($domain->widened);
214    }
215
216    public function testSoleLiteralOnlyAnswersForOneResolvedScalar(): void
217    {
218        self::assertNull(Domain::literal('a')->union(Domain::literal('b'))->soleLiteral());
219        self::assertNull(Domain::unknown()->soleLiteral());
220    }
221
222    public function testSoleArrayOnlyAnswersForOneArray(): void
223    {
224        self::assertNotNull(Domain::of(new ArrayTerm([new ArrayEntry(null, Domain::literal(1))]))->soleArray());
225        self::assertNull(Domain::literal('a')->soleArray());
226    }
227
228    public function testSoleObjectOnlyAnswersForOneObject(): void
229    {
230        self::assertSame('PDO', Domain::of(new ObjectTerm('PDO'))->soleObject()?->className);
231        self::assertNull(Domain::literal('a')->soleObject());
232    }
233
234    public function testPatternsWritesEveryAlternative(): void
235    {
236        self::assertCount(2, Domain::literal('a')->union(Domain::literal('b'))->patterns());
237    }
238
239    public function testIsExactOnlyWhenEveryAlternativeResolved(): void
240    {
241        self::assertTrue(Domain::literal('a')->union(Domain::literal('b'))->isExact());
242        self::assertFalse(Domain::literal('a')->union(Domain::unknown())->isExact());
243    }
244
245    public function testTypeCoversEveryAlternative(): void
246    {
247        self::assertSame('int|string', Domain::literal('a')->union(Domain::literal(1))->type()->display());
248    }
249
250    public function testSignatureIgnoresTheOrderOfAlternatives(): void
251    {
252        $left = Domain::literal('a')->union(Domain::literal('b'));
253        $right = Domain::literal('b')->union(Domain::literal('a'));
254        self::assertSame($left->signature(), $right->signature());
255    }
256
257    public function testEqualsComparesTheSetOfAlternatives(): void
258    {
259        self::assertTrue(Domain::literal('a')->equals(Domain::literal('a')));
260        self::assertFalse(Domain::literal('a')->equals(Domain::literal('b')));
261    }
262    public function testWithVariableNamesOnlyOpaqueAlternativesWithoutChangingTheirMeaning(): void
263    {
264        $value = Domain::fromTerms([
265            new OpaqueTerm(TypeShape::of(['string']), Origin::Call, 'buildSql()'),
266            new LiteralTerm('SELECT 1'),
267        ], true, true);
268        $named = $value->withVariable('$sql');
269        $hole = $named->patterns()[0]->holes()[0];
270
271        self::assertSame('$sql', $hole->variable);
272        self::assertSame('buildSql()', $hole->expression);
273        self::assertSame(Origin::Call, $hole->origin);
274        self::assertSame('string', $hole->type->display());
275        self::assertSame('SELECT 1', $named->patterns()[1]->text());
276        self::assertTrue($named->widened);
277        self::assertTrue($named->combined);
278        self::assertSame($value->signature(), $named->signature());
279        self::assertNull($value->patterns()[0]->holes()[0]->variable);
280    }
281
282    public function testWithVariablePreservesResolvedDomainsAndExistingFragmentNames(): void
283    {
284        $resolved = Domain::literal('SELECT 1');
285        $fragment = Domain::of(new PatternTerm(TextPattern::fromText('SELECT * FROM ')->concat(
286            TextPattern::fromHole(new TextHole(Origin::Parameter, TypeShape::of(['string']), '$table', '$table')),
287        )));
288
289        self::assertSame($resolved, $resolved->withVariable('$sql'));
290        self::assertSame($fragment, $fragment->withVariable('$sql'));
291    }
292
293}
294