packages/sql-catalog/tests/Unit/Core/Evaluation/EnvironmentTest.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\Domain;
11use SqlCatalog\Core\Evaluation\Environment;
12use SqlCatalog\Core\Evaluation\LiteralTerm;
13use SqlCatalog\Core\Evaluation\OpaqueTerm;
14use SqlCatalog\Core\Text\TextHole;
15use SqlCatalog\Core\Text\TextPattern;
16use SqlCatalog\Core\Type\TypeShape;
17
18#[CoversClass(Environment::class)]
19#[UsesClass(Domain::class)]
20#[UsesClass(LiteralTerm::class)]
21#[UsesClass(OpaqueTerm::class)]
22#[UsesClass(TextHole::class)]
23#[UsesClass(TextPattern::class)]
24#[UsesClass(TypeShape::class)]
25#[UsesClass(\SqlCatalog\Core\Evaluation\PatternTerm::class)]
26#[UsesClass(\SqlCatalog\Core\Evaluation\ArrayTerm::class)]
27#[UsesClass(\SqlCatalog\Core\Evaluation\ObjectMemory::class)]
28#[UsesClass(\SqlCatalog\Core\Evaluation\ObjectTerm::class)]
29final class EnvironmentTest extends TestCase
30{
31    public function testReadFallsBackToAnUnresolvedValue(): void
32    {
33        self::assertSame('mixed', (new Environment())->read('missing')->type()->display());
34    }
35
36    public function testWriteBindsAVariable(): void
37    {
38        $environment = new Environment();
39        $environment->write('sql', Domain::literal('SELECT 1'));
40        self::assertSame('SELECT 1', $environment->read('sql')->soleLiteral()?->value);
41    }
42
43    public function testHasReportsWhetherAVariableIsBound(): void
44    {
45        $environment = new Environment(['sql' => Domain::literal('x')]);
46        self::assertTrue($environment->has('sql'));
47        self::assertFalse($environment->has('other'));
48    }
49
50    public function testForgetDropsABinding(): void
51    {
52        $environment = new Environment(['sql' => Domain::literal('x')]);
53        $environment->forget('sql');
54        self::assertFalse($environment->has('sql'));
55    }
56
57    public function testNamesListsTheBoundVariables(): void
58    {
59        self::assertSame(['a', 'b'], (new Environment([
60            'a' => Domain::literal(1),
61            'b' => Domain::literal(2),
62        ]))->names());
63    }
64
65    public function testCopyIsIndependentOfTheOriginal(): void
66    {
67        $environment = new Environment(['a' => Domain::literal(1)]);
68        $copy = $environment->copy();
69        $copy->write('a', Domain::literal(2));
70        self::assertSame(1, $environment->read('a')->soleLiteral()?->value);
71    }
72
73    public function testJoinKeepsWhatEitherSideMayHold(): void
74    {
75        $left = new Environment(['a' => Domain::literal(1)]);
76        $right = new Environment(['a' => Domain::literal(2), 'b' => Domain::literal(3)]);
77        $joined = $left->join($right);
78        self::assertCount(2, $joined->read('a')->terms);
79        self::assertCount(2, $joined->read('b')->terms);
80    }
81
82    public function testJoinKeepsABindingOnlyOneSideHas(): void
83    {
84        $joined = (new Environment(['a' => Domain::literal(1)]))->join(new Environment());
85        self::assertCount(2, $joined->read('a')->terms);
86    }
87
88    public function testSignatureWritesEachBindingSortedByName(): void
89    {
90        $environment = new Environment([
91            'b' => Domain::literal(2),
92            'a' => Domain::literal('x')->union(Domain::literal('w')),
93        ]);
94
95        self::assertSame(
96            'a=literal:string:w|literal:string:x;b=literal:int:2',
97            $environment->signature(),
98        );
99    }
100
101    public function testSignatureIsEmptyWithoutBindings(): void
102    {
103        self::assertSame('', (new Environment())->signature());
104    }
105
106    public function testSignatureIsTheSameForTheSameBindingsInAnyOrder(): void
107    {
108        $left = new Environment(['a' => Domain::literal(1), 'b' => Domain::literal('x')]);
109        $right = new Environment(['b' => Domain::literal('x'), 'a' => Domain::literal(1)]);
110
111        self::assertSame($left->signature(), $right->signature());
112    }
113
114    public function testSignatureDiffersForDifferentBindings(): void
115    {
116        $environment = new Environment(['a' => Domain::literal(1)]);
117
118        self::assertNotSame($environment->signature(), (new Environment(['a' => Domain::literal('1')]))->signature());
119        self::assertNotSame($environment->signature(), (new Environment(['b' => Domain::literal(1)]))->signature());
120        self::assertNotSame($environment->signature(), (new Environment(['a' => Domain::literal(1), 'b' => Domain::literal(1)]))->signature());
121    }
122
123    public function testEqualsComparesBindings(): void
124    {
125        $left = new Environment(['a' => Domain::literal(1)]);
126        self::assertTrue($left->equals(new Environment(['a' => Domain::literal(1)])));
127        self::assertFalse($left->equals(new Environment(['a' => Domain::literal(2)])));
128        self::assertFalse($left->equals(new Environment()));
129    }
130    public function testPresenceSeparatesUndefinedNullAndUnknown(): void
131    {
132        $environment = new Environment(['null' => Domain::literal(null)]);
133        $environment->markAbsent('missing');
134        self::assertSame(\SqlCatalog\Core\Evaluation\Presence::Present, $environment->presence('null'));
135        self::assertSame(\SqlCatalog\Core\Evaluation\Presence::Absent, $environment->presence('missing'));
136        self::assertSame(\SqlCatalog\Core\Evaluation\Presence::Maybe, $environment->presence('unknown'));
137        self::assertSame('', $environment->read('missing')->concat(Domain::literal(''))->soleLiteral()?->value);
138        self::assertNotSame($environment->signature(), (new Environment(['null' => Domain::literal(null), 'missing' => Domain::literal(null)]))->signature());
139    }
140
141    public function testMarkAbsentSurvivesCopyAndJoin(): void
142    {
143        $absent = new Environment();
144        $absent->markAbsent('part');
145        $joined = $absent->copy()->join(new Environment(['part' => Domain::literal('tail')]));
146        self::assertSame(\SqlCatalog\Core\Evaluation\Presence::Maybe, $joined->presence('part'));
147        self::assertSame('literal:null:|literal:string:tail', $joined->read('part')->signature());
148        self::assertSame(\SqlCatalog\Core\Evaluation\Presence::Absent, $absent->presence('part'));
149    }
150
151    public function testInvalidateDoesNotTurnUnknownWritesIntoEmptyStrings(): void
152    {
153        $environment = new Environment();
154        $environment->markAbsent('part');
155        $environment->invalidate('part');
156        self::assertSame(\SqlCatalog\Core\Evaluation\Presence::Maybe, $environment->presence('part'));
157        self::assertNull($environment->read('part')->concat(Domain::literal(''))->soleLiteral());
158        $environment->write('part', Domain::literal('known'));
159        self::assertSame(\SqlCatalog\Core\Evaluation\Presence::Present, $environment->presence('part'));
160    }
161
162    public function testReplaceRetainsPresenceAndCombinationMetadata(): void
163    {
164        $environment = new Environment(['old' => Domain::literal('old')]);
165        $other = new Environment();
166        $other->markAbsent('part');
167        $other->combined = true;
168        $environment->replace($other);
169        self::assertTrue($environment->equals($other));
170        self::assertTrue($environment->combined);
171        self::assertFalse($environment->has('old'));
172    }
173
174    public function testNarrowDoesNotTurnPossibleAbsenceIntoPresentNull(): void
175    {
176        $environment = new Environment();
177        $environment->markAbsent('tail');
178        $joined = $environment->join(new Environment(['tail' => Domain::literal('suffix')]));
179        $joined->narrow('tail', Domain::literal(null));
180        self::assertSame(\SqlCatalog\Core\Evaluation\Presence::Maybe, $joined->presence('tail'));
181        $joined->narrow('tail', Domain::literal('suffix'));
182        self::assertSame(\SqlCatalog\Core\Evaluation\Presence::Present, $joined->presence('tail'));
183        $environment->narrow('tail', Domain::literal(null));
184        self::assertSame(\SqlCatalog\Core\Evaluation\Presence::Absent, $environment->presence('tail'));
185    }
186
187
188    public function testJoinPreservesCombinationUncertaintyAndPresenceWithoutInventingIt(): void
189    {
190        $left = new Environment(['a' => Domain::literal('a')]);
191        self::assertFalse($left->join($left)->combined);
192        $right = $left->copy();
193        $right->combined = true;
194        self::assertTrue($left->join($right)->combined);
195        self::assertTrue($right->join($left)->combined);
196        self::assertTrue($left->join(new Environment(['a' => Domain::literal('b')]))->combined);
197        self::assertNotSame($left->signature(), $right->signature());
198        $left->invalidate('a');
199        $right->invalidate('b');
200        self::assertSame('$a', $left->read('a')->terms[0]->toPattern()->holes()[0]->expression);
201        self::assertSame('$b', $right->read('b')->terms[0]->toPattern()->holes()[0]->expression);
202        $absent = new Environment();
203        $absent->markAbsent('a');
204        self::assertNotSame($absent->signature(), $left->signature());
205        self::assertSame(\SqlCatalog\Core\Evaluation\Presence::Absent, $absent->join($absent)->presence('a'));
206    }
207
208
209    public function testObjectsSharesSnapshotsAcrossAliasesButNotCopies(): void
210    {
211        $object = new \SqlCatalog\Core\Evaluation\ObjectTerm('Builder', identity: 'a');
212        $env = new Environment(['q' => Domain::of($object), 'alias' => Domain::of($object)]);
213        $copy = $env->copy();
214        $changed = new \SqlCatalog\Core\Evaluation\ObjectTerm('Builder', identity: 'a', state: new \SqlCatalog\Core\Evaluation\ArrayTerm([]));
215        $env->objects()->remember($changed);
216        self::assertSame($changed, $env->read('alias')->soleObject());
217        self::assertSame($object, $copy->read('alias')->soleObject());
218        self::assertFalse($env->equals($copy));
219    }
220
221    public function testReplaceKeepsJoinedAlternativeObjectStates(): void
222    {
223        $object = new \SqlCatalog\Core\Evaluation\ObjectTerm('Builder', identity: 'a');
224        $env = new Environment(['q' => Domain::of($object)]);
225        $left = $env->copy();
226        $right = $env->copy();
227        $right->objects()->remember(new \SqlCatalog\Core\Evaluation\ObjectTerm('Builder', identity: 'a', state: new \SqlCatalog\Core\Evaluation\ArrayTerm([])));
228        $env->replace($left->join($right));
229        self::assertCount(2, $env->read('q')->terms);
230        self::assertSame($object, $left->read('q')->soleObject());
231    }
232
233    public function testRefreshObservesLaterMutationsThroughAnAlreadyEvaluatedReference(): void
234    {
235        $object = new \SqlCatalog\Core\Evaluation\ObjectTerm('Builder', identity: 'a');
236        $value = Domain::of($object);
237        $environment = new Environment(['q' => $value]);
238        $updated = new \SqlCatalog\Core\Evaluation\ObjectTerm('Builder', identity: 'a', state: new \SqlCatalog\Core\Evaluation\ArrayTerm([]));
239        $environment->objects()->remember($updated);
240        self::assertSame($updated, $environment->refresh($value)->soleObject());
241        self::assertSame($value, (new Environment())->refresh($value));
242    }
243}
244