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