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 ZtdQuery\Config\ZtdConfig;
14use ZtdQuery\Connection\Exception\DatabaseException;
15use ZtdQuery\Connection\ResultSet;
16use ZtdQuery\Exception\ForeignKeyViolationException;
17use ZtdQuery\Exception\MissingPrimaryKeyException;
18use ZtdQuery\Platform\CopySupport;
19use ZtdQuery\Platform\CopyTarget;
20use ZtdQuery\Platform\MissingResultColumnTypeResolver;
21use ZtdQuery\Platform\ParameterBindingCompiler;
22use ZtdQuery\Platform\ResultColumnTypeResolver;
23use ZtdQuery\ResultSelectRunner;
24use ZtdQuery\Rewrite\QueryKind;
25use ZtdQuery\Rewrite\RewritePlan;
26use ZtdQuery\RewriteRefusal;
27use ZtdQuery\Schema\Key\CandidateKeySet;
28use ZtdQuery\Schema\Key\ForeignKeyDefinition;
29use ZtdQuery\Schema\TableDefinition;
30use ZtdQuery\Schema\TableDefinitionRegistry;
31use ZtdQuery\Session;
32use ZtdQuery\Shadow\Mutation\MutationRowIdentity;
33use ZtdQuery\Shadow\Mutation\Row\InsertMutation;
34use ZtdQuery\Shadow\Mutation\Row\UpdateMutation;
35use ZtdQuery\Shadow\ReferentialIntegrityEnforcer;
36use ZtdQuery\Shadow\Row\RowPairing;
37use ZtdQuery\Shadow\ShadowStore;
38use ZtdQuery\Shadow\ShadowTransactions;
39use ZtdQuery\Sql\TransactionStatement;
40
41#[CoversClass(Session::class)]
42#[UsesClass(ZtdConfig::class)]
43#[UsesClass(ShadowStore::class)]
44#[UsesClass(ShadowTransactions::class)]
45#[UsesClass(TableDefinitionRegistry::class)]
46#[UsesClass(TableDefinition::class)]
47#[UsesClass(CandidateKeySet::class)]
48#[UsesClass(ResultSelectRunner::class)]
49#[UsesClass(ResultSet::class)]
50#[UsesClass(DatabaseException::class)]
51#[UsesClass(RewritePlan::class)]
52#[UsesClass(UpdateMutation::class)]
53#[UsesClass(InsertMutation::class)]
54#[UsesClass(MutationRowIdentity::class)]
55#[UsesClass(ForeignKeyDefinition::class)]
56#[UsesClass(ForeignKeyViolationException::class)]
57#[UsesClass(ReferentialIntegrityEnforcer::class)]
58#[UsesClass(MissingPrimaryKeyException::class)]
59#[UsesClass(CopyTarget::class)]
60#[UsesClass(MissingResultColumnTypeResolver::class)]
61#[UsesClass(TransactionStatement::class)]
62#[UsesClass(\ZtdQuery\Shadow\ShadowApplication::class)]
63#[UsesClass(\ZtdQuery\Shadow\Mutation\MutationImpact::class)]
64#[UsesClass(\ZtdQuery\GenericExecuteResult::class)]
65#[UsesClass(\ZtdQuery\Shadow\ShadowSavepoint::class)]
66#[UsesClass(\ZtdQuery\Shadow\Mutation\RowConstraints::class)]
67#[UsesClass(\ZtdQuery\Shadow\Mutation\ConflictSearch::class)]
68#[UsesClass(\ZtdQuery\Shadow\ForeignKeyCascade::class)]
69#[UsesClass(\ZtdQuery\Shadow\ForeignKeyEnds::class)]
70#[UsesClass(\ZtdQuery\Shadow\ForeignKeyIntegrity::class)]
71#[UsesClass(\ZtdQuery\Shadow\ParentKeyLookup::class)]
72#[UsesClass(\ZtdQuery\Shadow\Row\RowMatch::class)]
73#[UsesClass(\ZtdQuery\Shadow\Row\RowMultiset::class)]
74#[UsesClass(\ZtdQuery\Shadow\Row\TableTransition::class)]
75#[UsesClass(\ZtdQuery\Shadow\TableTransitions::class)]
76#[UsesClass(RewriteRefusal::class)]
77#[UsesClass(RowPairing::class)]
78#[UsesClass(\ZtdQuery\Schema\RowSet::class)]
79final class SessionTest extends TestCase
80{
81    public function testDisableEnableDisableEnableAndDisable(): void
82    {
83        $shadowStore = new ShadowStore();
84        $registry = new TableDefinitionRegistry();
85        $rewriter = new FakeSqlRewriter($shadowStore, $registry);
86        $connection = new FakeConnection();
87        $session = new Session(
88            $rewriter,
89            $shadowStore,
90            new ResultSelectRunner(),
91            ZtdConfig::default(),
92            $connection,
93        );
94
95        self::assertTrue($session->isEnabled());
96        self::assertNull($session->tableDefinition('users'));
97        self::assertNull($session->copySupport());
98        self::assertNull($session->copyTarget('users', null));
99        self::assertNull($session->parameterBindingCompiler());
100        self::assertInstanceOf(MissingResultColumnTypeResolver::class, $session->resultColumnTypeResolver());
101
102        $session->disable();
103        self::assertFalse($session->isEnabled());
104
105        $session->enable();
106        self::assertTrue($session->isEnabled());
107    }
108
109    public function testTableDefinitionReturnsRegisteredSchemaOrNull(): void
110    {
111        $shadowStore = new ShadowStore();
112        $registry = new TableDefinitionRegistry();
113        $definition = new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], ['id'], []);
114        $registry->register('users', $definition);
115        $session = new Session(
116            new FakeSqlRewriter($shadowStore, $registry),
117            $shadowStore,
118            new ResultSelectRunner(),
119            ZtdConfig::default(),
120            new FakeConnection(),
121            registry: $registry,
122        );
123
124        self::assertSame($definition, $session->tableDefinition('users'));
125        self::assertNull($session->tableDefinition('missing'));
126    }
127
128    public function testParameterBindingCompilerResultColumnTypeResolverParameterBindingCompilerDelegatesCopyTargetsToTheConfiguredPlatformSupport(): void
129    {
130        $shadowStore = new ShadowStore();
131        $registry = new TableDefinitionRegistry();
132        $definition = new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], ['id'], []);
133        $registry->register('users', $definition);
134        $target = new CopyTarget(['public', 'users'], ['id']);
135        $copy = self::createStub(CopySupport::class);
136        $copy->method('tableName')->willReturnMap([
137            ['public.users', 'users'],
138            ['missing', 'missing'],
139        ]);
140        $copy->method('target')->willReturn($target);
141        $compiler = self::createStub(ParameterBindingCompiler::class);
142        $typeResolver = self::createStub(ResultColumnTypeResolver::class);
143        $session = new Session(
144            new FakeSqlRewriter($shadowStore, $registry),
145            $shadowStore,
146            new ResultSelectRunner(),
147            ZtdConfig::default(),
148            new FakeConnection(),
149            registry: $registry,
150            copySupport: $copy,
151            parameterBindingCompiler: $compiler,
152            resultColumnTypeResolver: $typeResolver,
153        );
154
155        self::assertSame($copy, $session->copySupport());
156        self::assertSame($target, $session->copyTarget('public.users', 'id'));
157        self::assertNull($session->copyTarget('missing', null));
158        self::assertSame($compiler, $session->parameterBindingCompiler());
159        self::assertSame($typeResolver, $session->resultColumnTypeResolver());
160    }
161
162    public function testSplitStatementsUsesPlatformRewriter(): void
163    {
164        $shadowStore = new ShadowStore();
165        $rewriter = new FakeSqlRewriter($shadowStore, new TableDefinitionRegistry());
166        $session = new Session(
167            $rewriter,
168            $shadowStore,
169            new ResultSelectRunner(),
170            ZtdConfig::default(),
171            new FakeConnection(),
172        );
173
174        self::assertSame(
175            ['SELECT 1', 'SELECT 2'],
176            $session->splitStatements(' SELECT 1; SELECT 2 '),
177        );
178    }
179
180    public function testBeginTransactionRollBackTransactionBeginTransactionUsesProvidedTransactionManagerForSchemaRollback(): void
181    {
182        $shadowStore = new ShadowStore();
183        $registry = new TableDefinitionRegistry();
184        $definition = new TableDefinition(['id'], ['id' => 'INT'], ['id'], [], []);
185        $registry->register('users', $definition);
186        $session = new Session(
187            new FakeSqlRewriter($shadowStore, $registry),
188            $shadowStore,
189            new ResultSelectRunner(),
190            ZtdConfig::default(),
191            new FakeConnection(),
192            new ShadowTransactions($shadowStore, $registry),
193        );
194
195        $session->transactions()->begin();
196        $registry->unregister('users');
197        $session->transactions()->rollBack();
198
199        self::assertSame($definition, $registry->get('users'));
200    }
201
202    public function testMutationFailureIsConvertedToDatabaseException(): void
203    {
204        $shadowStore = new ShadowStore();
205        $shadowStore->set('users', [['id' => 1, 'name' => 'Alice']]);
206        $registry = new TableDefinitionRegistry();
207        $session = new Session(
208            new FakeSqlRewriter($shadowStore, $registry),
209            $shadowStore,
210            new ResultSelectRunner(),
211            ZtdConfig::default(),
212            new FakeConnection(),
213        );
214        $plan = new RewritePlan(
215            "SELECT 1 AS id, 'Bob' AS name",
216            QueryKind::WRITE_SIMULATED,
217            new UpdateMutation('users', []),
218        );
219
220        try {
221            $session->processExecutedStatement($plan, new FakeStatement([['id' => 1, 'name' => 'Bob']]));
222            self::fail('Expected a database exception.');
223        } catch (DatabaseException $exception) {
224            self::assertSame(0, $exception->getCode());
225            self::assertInstanceOf(MissingPrimaryKeyException::class, $exception->getPrevious());
226        }
227    }
228
229    public function testForeignKeyFailureRestoresShadowState(): void
230    {
231        $shadowStore = new ShadowStore();
232        $shadowStore->set('parents', []);
233        $shadowStore->set('children', []);
234        $registry = new TableDefinitionRegistry();
235        $registry->register('parents', new TableDefinition(['id'], ['id' => 'INT'], ['id'], ['id'], []));
236        $registry->register('children', new TableDefinition(
237            ['id', 'parent_id'],
238            ['id' => 'INT', 'parent_id' => 'INT'],
239            ['id'],
240            ['id'],
241            [],
242            foreignKeys: ['fk_parent' => new ForeignKeyDefinition(['parent_id'], 'parents', ['id'])],
243        ));
244        $session = new Session(
245            new FakeSqlRewriter($shadowStore, $registry),
246            $shadowStore,
247            new ResultSelectRunner(),
248            ZtdConfig::default(),
249            new FakeConnection(),
250            registry: $registry,
251        );
252        $plan = new RewritePlan(
253            'SELECT 1 AS id, 999 AS parent_id',
254            QueryKind::WRITE_SIMULATED,
255            new InsertMutation('children'),
256        );
257
258        try {
259            $session->processExecutedStatement($plan, new FakeStatement([['id' => 1, 'parent_id' => 999]]));
260            self::fail('Expected a database exception.');
261        } catch (DatabaseException $exception) {
262            self::assertInstanceOf(ForeignKeyViolationException::class, $exception->getPrevious());
263            self::assertSame([], $shadowStore->get('children'));
264        }
265    }
266
267    public function testIsEnabledFollowsWhatWasAskedFor(): void
268    {
269        $store = new ShadowStore();
270        $registry = new TableDefinitionRegistry();
271        $session = new Session(
272            new FakeSqlRewriter($store, $registry),
273            $store,
274            new ResultSelectRunner(),
275            ZtdConfig::default(),
276            new FakeConnection(),
277            registry: $registry,
278        );
279
280        self::assertTrue($session->isEnabled());
281
282        $session->disable();
283
284        self::assertFalse($session->isEnabled());
285    }
286
287    public function testDisableStopsZtdWithoutTouchingTheShadow(): void
288    {
289        $store = new ShadowStore();
290        $store->set('users', [['id' => 1]]);
291        $registry = new TableDefinitionRegistry();
292        $session = new Session(
293            new FakeSqlRewriter($store, $registry),
294            $store,
295            new ResultSelectRunner(),
296            ZtdConfig::default(),
297            new FakeConnection(),
298            registry: $registry,
299        );
300
301        $session->disable();
302
303        self::assertSame([['id' => 1]], $store->get('users'));
304    }
305
306    public function testShouldExecuteIsFalseOnlyForAPlanNothingIsToBeRunFor(): void
307    {
308        $store = new ShadowStore();
309        $registry = new TableDefinitionRegistry();
310        $session = new Session(
311            new FakeSqlRewriter($store, $registry),
312            $store,
313            new ResultSelectRunner(),
314            ZtdConfig::default(),
315            new FakeConnection(),
316            registry: $registry,
317        );
318
319        self::assertTrue($session->shouldExecute(new RewritePlan('SELECT 1', QueryKind::READ)));
320        self::assertFalse($session->shouldExecute(new RewritePlan('SELECT 1', QueryKind::SKIPPED)));
321    }
322
323    public function testNeedsPostProcessingIsTrueForTheKindsThatChangeTheShadow(): void
324    {
325        $store = new ShadowStore();
326        $registry = new TableDefinitionRegistry();
327        $session = new Session(
328            new FakeSqlRewriter($store, $registry),
329            $store,
330            new ResultSelectRunner(),
331            ZtdConfig::default(),
332            new FakeConnection(),
333            registry: $registry,
334        );
335
336        self::assertTrue($session->needsPostProcessing(new RewritePlan('x', QueryKind::WRITE_SIMULATED)));
337        self::assertTrue($session->needsPostProcessing(new RewritePlan('x', QueryKind::DDL_SIMULATED)));
338        self::assertFalse($session->needsPostProcessing(new RewritePlan('x', QueryKind::READ)));
339    }
340
341    public function testCreateEmptyWriteResultAnswersASimulatedWriteWithNothingToFetch(): void
342    {
343        $store = new ShadowStore();
344        $registry = new TableDefinitionRegistry();
345        $session = new Session(
346            new FakeSqlRewriter($store, $registry),
347            $store,
348            new ResultSelectRunner(),
349            ZtdConfig::default(),
350            new FakeConnection(),
351            registry: $registry,
352        );
353
354        $result = $session->createEmptyWriteResult();
355
356        self::assertSame(QueryKind::WRITE_SIMULATED, $result->kind());
357        self::assertSame([], $result->fetchAll());
358    }
359
360    public function testLastInsertIdIsFalseUntilSomethingHasBeenInserted(): void
361    {
362        $store = new ShadowStore();
363        $registry = new TableDefinitionRegistry();
364        $session = new Session(
365            new FakeSqlRewriter($store, $registry),
366            $store,
367            new ResultSelectRunner(),
368            ZtdConfig::default(),
369            new FakeConnection(),
370            registry: $registry,
371        );
372
373        self::assertFalse($session->lastInsertId());
374    }
375
376    public function testTransactionStatementIsNothingForAStatementThatIsNotOne(): void
377    {
378        $store = new ShadowStore();
379        $registry = new TableDefinitionRegistry();
380        $session = new Session(
381            new FakeSqlRewriter($store, $registry),
382            $store,
383            new ResultSelectRunner(),
384            ZtdConfig::default(),
385            new FakeConnection(),
386            registry: $registry,
387        );
388
389        self::assertNull($session->transactionStatement('SELECT 1'));
390    }
391
392    public function testCommitTransactionKeepsWhatTheTransactionDid(): void
393    {
394        $store = new ShadowStore();
395        $store->set('users', [['id' => 1]]);
396        $registry = new TableDefinitionRegistry();
397        $session = new Session(
398            new FakeSqlRewriter($store, $registry),
399            $store,
400            new ResultSelectRunner(),
401            ZtdConfig::default(),
402            new FakeConnection(),
403            registry: $registry,
404        );
405
406        $session->transactions()->begin();
407        $store->set('users', []);
408        $session->transactions()->commit();
409        $session->transactions()->rollBack();
410
411        self::assertSame([], $store->get('users'));
412    }
413
414    public function testApplyTransactionStatementDoesWhatTheStatementSays(): void
415    {
416        $store = new ShadowStore();
417        $store->set('users', [['id' => 1]]);
418        $registry = new TableDefinitionRegistry();
419        $session = new Session(
420            new FakeSqlRewriter($store, $registry),
421            $store,
422            new ResultSelectRunner(),
423            ZtdConfig::default(),
424            new FakeConnection(),
425            registry: $registry,
426        );
427
428        TransactionStatement::begin()->apply($session->transactions());
429        $store->set('users', []);
430        TransactionStatement::rollback()->apply($session->transactions());
431
432        self::assertSame([['id' => 1]], $store->get('users'));
433    }
434
435    public function testCopySupportIsNothingWhereTheDialectHasNoCopy(): void
436    {
437        $store = new ShadowStore();
438        $registry = new TableDefinitionRegistry();
439        $session = new Session(
440            new FakeSqlRewriter($store, $registry),
441            $store,
442            new ResultSelectRunner(),
443            ZtdConfig::default(),
444            new FakeConnection(),
445            registry: $registry,
446        );
447
448        self::assertNull($session->copySupport());
449    }
450
451    public function testCopyTargetIsNothingWhereTheDialectHasNoCopy(): void
452    {
453        $store = new ShadowStore();
454        $registry = new TableDefinitionRegistry();
455        $session = new Session(
456            new FakeSqlRewriter($store, $registry),
457            $store,
458            new ResultSelectRunner(),
459            ZtdConfig::default(),
460            new FakeConnection(),
461            registry: $registry,
462        );
463
464        self::assertNull($session->copyTarget('users', null));
465    }
466
467    public function testParameterBindingCompilerIsNothingWhereTheDriverBindsThemItself(): void
468    {
469        $store = new ShadowStore();
470        $registry = new TableDefinitionRegistry();
471        $session = new Session(
472            new FakeSqlRewriter($store, $registry),
473            $store,
474            new ResultSelectRunner(),
475            ZtdConfig::default(),
476            new FakeConnection(),
477            registry: $registry,
478        );
479
480        self::assertNull($session->parameterBindingCompiler());
481    }
482
483    public function testRewriteAnswersThePlanTheRewriterGives(): void
484    {
485        $store = new ShadowStore();
486        $registry = new TableDefinitionRegistry();
487        $session = new Session(
488            new FakeSqlRewriter($store, $registry),
489            $store,
490            new ResultSelectRunner(),
491            ZtdConfig::default(),
492            new FakeConnection(),
493            registry: $registry,
494        );
495
496        $plan = $session->rewrite('SELECT 1');
497
498        self::assertSame(QueryKind::READ, $plan->kind());
499    }
500
501    public function testProcessExecutedStatementReadsAReadStatementStraightBack(): void
502    {
503        $store = new ShadowStore();
504        $registry = new TableDefinitionRegistry();
505        $session = new Session(
506            new FakeSqlRewriter($store, $registry),
507            $store,
508            new ResultSelectRunner(),
509            ZtdConfig::default(),
510            new FakeConnection(),
511            registry: $registry,
512        );
513        $plan = new RewritePlan('SELECT 1', QueryKind::READ);
514
515        $result = $session->processExecutedStatement($plan, new FakeStatement([['id' => 1]]));
516
517        self::assertSame([['id' => 1]], $result->fetchAll());
518    }
519
520    public function testApplyShadowWritesTheMutationAndAnswersWhatItCameTo(): void
521    {
522        $store = new ShadowStore();
523        $store->set('users', []);
524        $registry = new TableDefinitionRegistry();
525        $session = new Session(
526            new FakeSqlRewriter($store, $registry),
527            $store,
528            new ResultSelectRunner(),
529            ZtdConfig::default(),
530            new FakeConnection(),
531            registry: $registry,
532        );
533
534        $impact = $session->applyShadow(new InsertMutation('users'), new ResultSet([['id' => 1]], []), 'INSERT');
535
536        self::assertTrue($impact->isInsertLike());
537        self::assertSame([['id' => 1]], $store->get('users'));
538    }
539
540    public function testExecStatementAnswersHowManyRowsAReadStatementCameTo(): void
541    {
542        $store = new ShadowStore();
543        $registry = new TableDefinitionRegistry();
544        $session = new Session(
545            new FakeSqlRewriter($store, $registry),
546            $store,
547            new ResultSelectRunner(),
548            ZtdConfig::default(),
549            new FakeConnection(),
550            registry: $registry,
551        );
552
553        self::assertSame(0, $session->execStatement('SELECT 1'));
554    }
555
556    public function testRunResultSelectAndApplyShadowReadsBackWhatTheStatementWouldHaveWritten(): void
557    {
558        $store = new ShadowStore();
559        $store->set('users', []);
560        $registry = new TableDefinitionRegistry();
561        $session = new Session(
562            new FakeSqlRewriter($store, $registry),
563            $store,
564            new ResultSelectRunner(),
565            ZtdConfig::default(),
566            new FakeConnection(),
567            registry: $registry,
568        );
569        $plan = new RewritePlan('SELECT 1', QueryKind::WRITE_SIMULATED, new InsertMutation('users'));
570
571        $rows = $session->runResultSelectAndApplyShadow(
572            $plan,
573            static fn (string $sql): FakeStatement => new FakeStatement([['id' => 1]]),
574        );
575
576        self::assertSame([['id' => 1]], $rows);
577    }
578
579    public function testEnableTurnsZtdBackOn(): void
580    {
581        $store = new ShadowStore();
582        $registry = new TableDefinitionRegistry();
583        $session = new Session(
584            new FakeSqlRewriter($store, $registry),
585            $store,
586            new ResultSelectRunner(),
587            ZtdConfig::default(),
588            new FakeConnection(),
589            registry: $registry,
590        );
591        $session->disable();
592
593        $session->enable();
594
595        self::assertTrue($session->isEnabled());
596    }
597
598    public function testRollBackTransactionPutsTheShadowBackToWhereItBegan(): void
599    {
600        $store = new ShadowStore();
601        $store->set('users', [['id' => 1]]);
602        $registry = new TableDefinitionRegistry();
603        $session = new Session(
604            new FakeSqlRewriter($store, $registry),
605            $store,
606            new ResultSelectRunner(),
607            ZtdConfig::default(),
608            new FakeConnection(),
609            registry: $registry,
610        );
611
612        $session->transactions()->begin();
613        $store->set('users', []);
614        $session->transactions()->rollBack();
615
616        self::assertSame([['id' => 1]], $store->get('users'));
617    }
618
619    public function testResultColumnTypeResolverAnswersTheOneTheSessionWasBuiltWith(): void
620    {
621        $store = new ShadowStore();
622        $registry = new TableDefinitionRegistry();
623        $session = new Session(
624            new FakeSqlRewriter($store, $registry),
625            $store,
626            new ResultSelectRunner(),
627            ZtdConfig::default(),
628            new FakeConnection(),
629            registry: $registry,
630        );
631
632        self::assertInstanceOf(MissingResultColumnTypeResolver::class, $session->resultColumnTypeResolver());
633    }
634
635    public function testTransactionsAnswersWhatATransactionStatementIsAppliedTo(): void
636    {
637        $store = new ShadowStore();
638        $store->set('users', [['id' => 1]]);
639        $registry = new TableDefinitionRegistry();
640        $session = new Session(
641            new FakeSqlRewriter($store, $registry),
642            $store,
643            new ResultSelectRunner(),
644            ZtdConfig::default(),
645            new FakeConnection(),
646            registry: $registry,
647        );
648
649        TransactionStatement::begin()->apply($session->transactions());
650        $store->set('users', []);
651        TransactionStatement::rollback()->apply($session->transactions());
652
653        self::assertSame([['id' => 1]], $store->get('users'));
654    }
655}
656