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