packages/ztd-query-core/tests/Unit/SessionTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use Tests\Fake\FakeConnection;
11use Tests\Fake\FakeSqlRewriter;
12use Tests\Fake\FakeStatement;
13use Tests\Fake\SessionUnderTest;
14use ZtdQuery\Config\ZtdConfig;
15use ZtdQuery\Connection\Exception\DatabaseException;
16use ZtdQuery\Connection\ResultSet;
17use ZtdQuery\Exception\ForeignKeyViolationException;
18use ZtdQuery\Exception\MissingPrimaryKeyException;
19use ZtdQuery\Platform\CopySupport;
20use ZtdQuery\Platform\CopyTarget;
21use ZtdQuery\Platform\MissingResultColumnTypeResolver;
22use ZtdQuery\Platform\ParameterBindingCompiler;
23use ZtdQuery\Platform\ResultColumnTypeResolver;
24use ZtdQuery\ResultSelectRunner;
25use ZtdQuery\Rewrite\QueryKind;
26use ZtdQuery\Rewrite\RewritePlan;
27use ZtdQuery\RewriteRefusal;
28use ZtdQuery\Schema\Key\CandidateKeySet;
29use ZtdQuery\Schema\Key\ForeignKeyDefinition;
30use ZtdQuery\Schema\TableDefinition;
31use ZtdQuery\Schema\TableDefinitionRegistry;
32use ZtdQuery\Session;
33use ZtdQuery\Shadow\Mutation\MutationRowIdentity;
34use ZtdQuery\Shadow\Mutation\Row\InsertMutation;
35use ZtdQuery\Shadow\Mutation\Row\UpdateMutation;
36use ZtdQuery\Shadow\ReferentialIntegrityEnforcer;
37use ZtdQuery\Shadow\Row\RowPairing;
38use ZtdQuery\Shadow\ShadowStore;
39use ZtdQuery\Shadow\ShadowTransactions;
40use ZtdQuery\Sql\TransactionStatement;
41
42#[CoversClass(Session::class)]
43#[UsesClass(ZtdConfig::class)]
44#[UsesClass(ShadowStore::class)]
45#[UsesClass(ShadowTransactions::class)]
46#[UsesClass(TableDefinitionRegistry::class)]
47#[UsesClass(TableDefinition::class)]
48#[UsesClass(CandidateKeySet::class)]
49#[UsesClass(ResultSelectRunner::class)]
50#[UsesClass(ResultSet::class)]
51#[UsesClass(DatabaseException::class)]
52#[UsesClass(RewritePlan::class)]
53#[UsesClass(UpdateMutation::class)]
54#[UsesClass(InsertMutation::class)]
55#[UsesClass(MutationRowIdentity::class)]
56#[UsesClass(ForeignKeyDefinition::class)]
57#[UsesClass(ForeignKeyViolationException::class)]
58#[UsesClass(ReferentialIntegrityEnforcer::class)]
59#[UsesClass(MissingPrimaryKeyException::class)]
60#[UsesClass(CopyTarget::class)]
61#[UsesClass(MissingResultColumnTypeResolver::class)]
62#[UsesClass(TransactionStatement::class)]
63#[UsesClass(\ZtdQuery\Shadow\ShadowApplication::class)]
64#[UsesClass(\ZtdQuery\Shadow\Mutation\MutationImpact::class)]
65#[UsesClass(\ZtdQuery\GenericExecuteResult::class)]
66#[UsesClass(\ZtdQuery\Shadow\ShadowSavepoint::class)]
67#[UsesClass(\ZtdQuery\Shadow\Mutation\RowConstraints::class)]
68#[UsesClass(\ZtdQuery\Shadow\Mutation\ConflictSearch::class)]
69#[UsesClass(\ZtdQuery\Shadow\ForeignKeyCascade::class)]
70#[UsesClass(\ZtdQuery\Shadow\ForeignKeyEnds::class)]
71#[UsesClass(\ZtdQuery\Shadow\ForeignKeyIntegrity::class)]
72#[UsesClass(\ZtdQuery\Shadow\ParentKeyLookup::class)]
73#[UsesClass(\ZtdQuery\Shadow\Row\RowMatch::class)]
74#[UsesClass(\ZtdQuery\Shadow\Row\RowMultiset::class)]
75#[UsesClass(\ZtdQuery\Shadow\Row\TableTransition::class)]
76#[UsesClass(\ZtdQuery\Shadow\TableTransitions::class)]
77#[UsesClass(RewriteRefusal::class)]
78#[UsesClass(RowPairing::class)]
79#[UsesClass(\ZtdQuery\Schema\RowSet::class)]
80final class SessionTest extends TestCase
81{
82    public function testDisableEnableDisableEnableAndDisable(): void
83    {
84        $shadowStore = new ShadowStore();
85        $registry = new TableDefinitionRegistry();
86        $rewriter = new FakeSqlRewriter($shadowStore, $registry);
87        $connection = new FakeConnection();
88        $session = new Session(
89            $rewriter,
90            $shadowStore,
91            new ResultSelectRunner(),
92            ZtdConfig::default(),
93            $connection,
94        );
95
96        self::assertTrue($session->isEnabled());
97        self::assertNull($session->tableDefinition('users'));
98        self::assertNull($session->copySupport());
99        self::assertNull($session->copyTarget('users', null));
100        self::assertNull($session->parameterBindingCompiler());
101        self::assertInstanceOf(MissingResultColumnTypeResolver::class, $session->resultColumnTypeResolver());
102
103        $session->disable();
104        self::assertFalse($session->isEnabled());
105
106        $session->enable();
107        self::assertTrue($session->isEnabled());
108    }
109
110    public function testTableDefinitionReturnsRegisteredSchemaOrNull(): void
111    {
112        $shadowStore = new ShadowStore();
113        $registry = new TableDefinitionRegistry();
114        $definition = new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], ['id'], []);
115        $registry->register('users', $definition);
116        $session = new Session(
117            new FakeSqlRewriter($shadowStore, $registry),
118            $shadowStore,
119            new ResultSelectRunner(),
120            ZtdConfig::default(),
121            new FakeConnection(),
122            registry: $registry,
123        );
124
125        self::assertSame($definition, $session->tableDefinition('users'));
126        self::assertNull($session->tableDefinition('missing'));
127    }
128
129    public function testParameterBindingCompilerResultColumnTypeResolverParameterBindingCompilerDelegatesCopyTargetsToTheConfiguredPlatformSupport(): void
130    {
131        $shadowStore = new ShadowStore();
132        $registry = new TableDefinitionRegistry();
133        $definition = new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], ['id'], []);
134        $registry->register('users', $definition);
135        $target = new CopyTarget(['public', 'users'], ['id']);
136        $copy = self::createStub(CopySupport::class);
137        $copy->method('tableName')->willReturnMap([
138            ['public.users', 'users'],
139            ['missing', 'missing'],
140        ]);
141        $copy->method('target')->willReturn($target);
142        $compiler = self::createStub(ParameterBindingCompiler::class);
143        $typeResolver = self::createStub(ResultColumnTypeResolver::class);
144        $session = new Session(
145            new FakeSqlRewriter($shadowStore, $registry),
146            $shadowStore,
147            new ResultSelectRunner(),
148            ZtdConfig::default(),
149            new FakeConnection(),
150            registry: $registry,
151            copySupport: $copy,
152            parameterBindingCompiler: $compiler,
153            resultColumnTypeResolver: $typeResolver,
154        );
155
156        self::assertSame($copy, $session->copySupport());
157        self::assertSame($target, $session->copyTarget('public.users', 'id'));
158        self::assertNull($session->copyTarget('missing', null));
159        self::assertSame($compiler, $session->parameterBindingCompiler());
160        self::assertSame($typeResolver, $session->resultColumnTypeResolver());
161    }
162
163    public function testSplitStatementsUsesPlatformRewriter(): void
164    {
165        $shadowStore = new ShadowStore();
166        $rewriter = new FakeSqlRewriter($shadowStore, new TableDefinitionRegistry());
167        $session = new Session(
168            $rewriter,
169            $shadowStore,
170            new ResultSelectRunner(),
171            ZtdConfig::default(),
172            new FakeConnection(),
173        );
174
175        self::assertSame(
176            ['SELECT 1', 'SELECT 2'],
177            $session->splitStatements(' SELECT 1; SELECT 2 '),
178        );
179    }
180
181    public function testBeginTransactionRollBackTransactionBeginTransactionUsesProvidedTransactionManagerForSchemaRollback(): void
182    {
183        $shadowStore = new ShadowStore();
184        $registry = new TableDefinitionRegistry();
185        $definition = new TableDefinition(['id'], ['id' => 'INT'], ['id'], [], []);
186        $registry->register('users', $definition);
187        $session = new Session(
188            new FakeSqlRewriter($shadowStore, $registry),
189            $shadowStore,
190            new ResultSelectRunner(),
191            ZtdConfig::default(),
192            new FakeConnection(),
193            new ShadowTransactions($shadowStore, $registry),
194        );
195
196        $session->transactions()->begin();
197        $registry->unregister('users');
198        $session->transactions()->rollBack();
199
200        self::assertSame($definition, $registry->get('users'));
201    }
202
203    public function testMutationFailureIsConvertedToDatabaseException(): void
204    {
205        $shadowStore = new ShadowStore();
206        $shadowStore->set('users', [['id' => 1, 'name' => 'Alice']]);
207        $registry = new TableDefinitionRegistry();
208        $session = new Session(
209            new FakeSqlRewriter($shadowStore, $registry),
210            $shadowStore,
211            new ResultSelectRunner(),
212            ZtdConfig::default(),
213            new FakeConnection(),
214        );
215        $plan = new RewritePlan(
216            "SELECT 1 AS id, 'Bob' AS name",
217            QueryKind::WRITE_SIMULATED,
218            new UpdateMutation('users', []),
219        );
220
221        try {
222            $session->processExecutedStatement($plan, new FakeStatement([['id' => 1, 'name' => 'Bob']]));
223            self::fail('Expected a database exception.');
224        } catch (DatabaseException $exception) {
225            self::assertSame(0, $exception->getCode());
226            self::assertInstanceOf(MissingPrimaryKeyException::class, $exception->getPrevious());
227        }
228    }
229
230    public function testForeignKeyFailureRestoresShadowState(): void
231    {
232        $shadowStore = new ShadowStore();
233        $shadowStore->set('parents', []);
234        $shadowStore->set('children', []);
235        $registry = new TableDefinitionRegistry();
236        $registry->register('parents', new TableDefinition(['id'], ['id' => 'INT'], ['id'], ['id'], []));
237        $registry->register('children', new TableDefinition(
238            ['id', 'parent_id'],
239            ['id' => 'INT', 'parent_id' => 'INT'],
240            ['id'],
241            ['id'],
242            [],
243            foreignKeys: ['fk_parent' => new ForeignKeyDefinition(['parent_id'], 'parents', ['id'])],
244        ));
245        $session = new Session(
246            new FakeSqlRewriter($shadowStore, $registry),
247            $shadowStore,
248            new ResultSelectRunner(),
249            ZtdConfig::default(),
250            new FakeConnection(),
251            registry: $registry,
252        );
253        $plan = new RewritePlan(
254            'SELECT 1 AS id, 999 AS parent_id',
255            QueryKind::WRITE_SIMULATED,
256            new InsertMutation('children'),
257        );
258
259        try {
260            $session->processExecutedStatement($plan, new FakeStatement([['id' => 1, 'parent_id' => 999]]));
261            self::fail('Expected a database exception.');
262        } catch (DatabaseException $exception) {
263            self::assertInstanceOf(ForeignKeyViolationException::class, $exception->getPrevious());
264            self::assertSame([], $shadowStore->get('children'));
265        }
266    }
267
268    public function testIsEnabledFollowsWhatWasAskedFor(): void
269    {
270        $session = SessionUnderTest::plain();
271
272        self::assertTrue($session->isEnabled());
273
274        $session->disable();
275
276        self::assertFalse($session->isEnabled());
277    }
278
279    public function testDisableStopsZtdWithoutTouchingTheShadow(): void
280    {
281        $store = new ShadowStore();
282        $store->set('users', [['id' => 1]]);
283        $session = SessionUnderTest::over($store);
284
285        $session->disable();
286
287        self::assertSame([['id' => 1]], $store->get('users'));
288    }
289
290    public function testShouldExecuteIsFalseOnlyForAPlanNothingIsToBeRunFor(): void
291    {
292        $session = SessionUnderTest::plain();
293
294        self::assertTrue($session->shouldExecute(new RewritePlan('SELECT 1', QueryKind::READ)));
295        self::assertFalse($session->shouldExecute(new RewritePlan('SELECT 1', QueryKind::SKIPPED)));
296    }
297
298    public function testNeedsPostProcessingIsTrueForTheKindsThatChangeTheShadow(): void
299    {
300        $session = SessionUnderTest::plain();
301
302        self::assertTrue($session->needsPostProcessing(new RewritePlan('x', QueryKind::WRITE_SIMULATED)));
303        self::assertTrue($session->needsPostProcessing(new RewritePlan('x', QueryKind::DDL_SIMULATED)));
304        self::assertFalse($session->needsPostProcessing(new RewritePlan('x', QueryKind::READ)));
305    }
306
307    public function testCreateEmptyWriteResultAnswersASimulatedWriteWithNothingToFetch(): void
308    {
309        $result = SessionUnderTest::plain()->createEmptyWriteResult();
310
311        self::assertSame(QueryKind::WRITE_SIMULATED, $result->kind());
312        self::assertSame([], $result->fetchAll());
313    }
314
315    public function testLastInsertIdIsFalseUntilSomethingHasBeenInserted(): void
316    {
317        self::assertFalse(SessionUnderTest::plain()->lastInsertId());
318    }
319
320    public function testTransactionStatementIsNothingForAStatementThatIsNotOne(): void
321    {
322        self::assertNull(SessionUnderTest::plain()->transactionStatement('SELECT 1'));
323    }
324
325    public function testCommitTransactionKeepsWhatTheTransactionDid(): void
326    {
327        $store = new ShadowStore();
328        $store->set('users', [['id' => 1]]);
329        $session = SessionUnderTest::over($store);
330
331        $session->transactions()->begin();
332        $store->set('users', []);
333        $session->transactions()->commit();
334        $session->transactions()->rollBack();
335
336        self::assertSame([], $store->get('users'));
337    }
338
339    public function testApplyTransactionStatementDoesWhatTheStatementSays(): void
340    {
341        $store = new ShadowStore();
342        $store->set('users', [['id' => 1]]);
343        $session = SessionUnderTest::over($store);
344
345        TransactionStatement::begin()->apply($session->transactions());
346        $store->set('users', []);
347        TransactionStatement::rollback()->apply($session->transactions());
348
349        self::assertSame([['id' => 1]], $store->get('users'));
350    }
351
352    public function testCopySupportIsNothingWhereTheDialectHasNoCopy(): void
353    {
354        self::assertNull(SessionUnderTest::plain()->copySupport());
355    }
356
357    public function testCopyTargetIsNothingWhereTheDialectHasNoCopy(): void
358    {
359        self::assertNull(SessionUnderTest::plain()->copyTarget('users', null));
360    }
361
362    public function testParameterBindingCompilerIsNothingWhereTheDriverBindsThemItself(): void
363    {
364        self::assertNull(SessionUnderTest::plain()->parameterBindingCompiler());
365    }
366
367    public function testRewriteAnswersThePlanTheRewriterGives(): void
368    {
369        $plan = SessionUnderTest::plain()->rewrite('SELECT 1');
370
371        self::assertSame(QueryKind::READ, $plan->kind());
372    }
373
374    public function testProcessExecutedStatementReadsAReadStatementStraightBack(): void
375    {
376        $session = SessionUnderTest::plain();
377        $plan = new RewritePlan('SELECT 1', QueryKind::READ);
378
379        $result = $session->processExecutedStatement($plan, new FakeStatement([['id' => 1]]));
380
381        self::assertSame([['id' => 1]], $result->fetchAll());
382    }
383
384    public function testApplyShadowWritesTheMutationAndAnswersWhatItCameTo(): void
385    {
386        $store = new ShadowStore();
387        $store->set('users', []);
388        $session = SessionUnderTest::over($store);
389
390        $impact = $session->applyShadow(new InsertMutation('users'), new ResultSet([['id' => 1]], []), 'INSERT');
391
392        self::assertTrue($impact->isInsertLike());
393        self::assertSame([['id' => 1]], $store->get('users'));
394    }
395
396    public function testExecStatementAnswersHowManyRowsAReadStatementCameTo(): void
397    {
398        $session = SessionUnderTest::plain();
399
400        self::assertSame(0, $session->execStatement('SELECT 1'));
401    }
402
403    public function testRunResultSelectAndApplyShadowReadsBackWhatTheStatementWouldHaveWritten(): void
404    {
405        $store = new ShadowStore();
406        $store->set('users', []);
407        $session = SessionUnderTest::over($store);
408        $plan = new RewritePlan('SELECT 1', QueryKind::WRITE_SIMULATED, new InsertMutation('users'));
409
410        $rows = $session->runResultSelectAndApplyShadow(
411            $plan,
412            static fn (string $sql): FakeStatement => new FakeStatement([['id' => 1]]),
413        );
414
415        self::assertSame([['id' => 1]], $rows);
416    }
417
418    public function testEnableTurnsZtdBackOn(): void
419    {
420        $session = SessionUnderTest::plain();
421        $session->disable();
422
423        $session->enable();
424
425        self::assertTrue($session->isEnabled());
426    }
427
428    public function testRollBackTransactionPutsTheShadowBackToWhereItBegan(): void
429    {
430        $store = new ShadowStore();
431        $store->set('users', [['id' => 1]]);
432        $session = SessionUnderTest::over($store);
433
434        $session->transactions()->begin();
435        $store->set('users', []);
436        $session->transactions()->rollBack();
437
438        self::assertSame([['id' => 1]], $store->get('users'));
439    }
440
441    public function testResultColumnTypeResolverAnswersTheOneTheSessionWasBuiltWith(): void
442    {
443        $session = SessionUnderTest::plain();
444
445        self::assertInstanceOf(MissingResultColumnTypeResolver::class, $session->resultColumnTypeResolver());
446    }
447
448    public function testTransactionsAnswersWhatATransactionStatementIsAppliedTo(): void
449    {
450        $store = new ShadowStore();
451        $store->set('users', [['id' => 1]]);
452        $session = SessionUnderTest::over($store);
453
454        TransactionStatement::begin()->apply($session->transactions());
455        $store->set('users', []);
456        TransactionStatement::rollback()->apply($session->transactions());
457
458        self::assertSame([['id' => 1]], $store->get('users'));
459    }
460}
461