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