packages/ztd-query-core/tests/Unit/Shadow/Mutation/MutationImpactTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Shadow\Mutation;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use ZtdQuery\Rewrite\AffectedRowsMode;
11use ZtdQuery\Schema\Key\CandidateKeyConflict;
12use ZtdQuery\Schema\Key\CandidateKeySet;
13use ZtdQuery\Schema\TableDefinition;
14use ZtdQuery\Shadow\Mutation\MutationImpact;
15use ZtdQuery\Shadow\Mutation\MutationRowIdentity;
16use ZtdQuery\Shadow\Mutation\Row\DeleteMutation;
17use ZtdQuery\Shadow\Mutation\Row\InsertMutation;
18use ZtdQuery\Shadow\Mutation\Row\ReplaceMutation;
19use ZtdQuery\Shadow\Mutation\Row\UpdateMutation;
20use ZtdQuery\Shadow\Mutation\Table\SynchronizeMutation;
21use ZtdQuery\Shadow\Mutation\Upsert\UpsertOperator;
22use ZtdQuery\Shadow\Mutation\UpsertColumnSource;
23use ZtdQuery\Shadow\Mutation\UpsertExpression;
24use ZtdQuery\Shadow\Mutation\UpsertExpressionKind;
25use ZtdQuery\Shadow\Mutation\UpsertMutation;
26use ZtdQuery\Shadow\Mutation\UpsertUpdate;
27use ZtdQuery\Shadow\ShadowStore;
28use ZtdQuery\Sql\SqlToken;
29use ZtdQuery\Sql\SqlTokenStream;
30
31#[CoversClass(MutationImpact::class)]
32#[UsesClass(CandidateKeyConflict::class)]
33#[UsesClass(CandidateKeySet::class)]
34#[UsesClass(DeleteMutation::class)]
35#[UsesClass(InsertMutation::class)]
36#[UsesClass(MutationRowIdentity::class)]
37#[UsesClass(ReplaceMutation::class)]
38#[UsesClass(SynchronizeMutation::class)]
39#[UsesClass(TableDefinition::class)]
40#[UsesClass(UpsertExpression::class)]
41#[UsesClass(UpsertMutation::class)]
42#[UsesClass(UpdateMutation::class)]
43#[UsesClass(ShadowStore::class)]
44#[UsesClass(SqlToken::class)]
45#[UsesClass(SqlTokenStream::class)]
46#[UsesClass(\ZtdQuery\Schema\Key\CandidateKeyMatch::class)]
47#[UsesClass(\ZtdQuery\Shadow\Mutation\ConflictSearch::class)]
48#[UsesClass(\ZtdQuery\Shadow\Mutation\RowConstraints::class)]
49#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertColumn::class)]
50#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertComparison::class)]
51#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertNumber::class)]
52#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertTruth::class)]
53#[UsesClass(\ZtdQuery\Shadow\Row\RowMatch::class)]
54#[UsesClass(\ZtdQuery\Shadow\Row\RowMultiset::class)]
55#[UsesClass(UpsertUpdate::class)]
56#[UsesClass(UpsertOperator::class)]
57#[UsesClass(\ZtdQuery\Schema\RowSet::class)]
58#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertLiteral::class)]
59final class MutationImpactTest extends TestCase
60{
61    public function testInsertImpactContainsOnlyRowsActuallyAdded(): void
62    {
63        $before = [['id' => 1]];
64        $after = [['id' => 1], ['id' => 2]];
65        $impact = new MutationImpact(new InsertMutation('items'), $before, [['id' => 2]], $after);
66
67        self::assertSame(1, $impact->affectedRowCount(AffectedRowsMode::Changed));
68        self::assertSame([['id' => 2]], $impact->returningRows());
69        self::assertTrue($impact->isInsertLike());
70    }
71
72    public function testNoOpUpdateUsesPlatformRowCountMode(): void
73    {
74        $row = ['id' => 1, 'name' => 'same'];
75        $reordered = ['name' => 'same', 'id' => 1];
76        $impact = new MutationImpact(new UpdateMutation('items', ['id']), [$row], [$reordered], [$reordered]);
77
78        self::assertSame(0, $impact->affectedRowCount(AffectedRowsMode::Changed));
79        self::assertSame(1, $impact->affectedRowCount(AffectedRowsMode::Matched));
80        self::assertFalse($impact->isInsertLike());
81    }
82
83    public function testNoneModeHidesInternalMigrationRows(): void
84    {
85        $impact = new MutationImpact(
86            new UpdateMutation('items', ['id']),
87            [['id' => 1]],
88            [['id' => 1, 'age' => 7]],
89            [['id' => 1, 'age' => 7]],
90        );
91
92        self::assertSame(0, $impact->affectedRowCount(AffectedRowsMode::None));
93    }
94
95    public function testReturningUpdateRowsExcludeInternalIdentityMetadata(): void
96    {
97        $impact = new MutationImpact(
98            new UpdateMutation('items', ['id']),
99            [['id' => 1, 'name' => 'before']],
100            [['id' => 2, 'name' => 'after', '__ztd_original_id' => 1]],
101            [['id' => 2, 'name' => 'after']],
102        );
103
104        self::assertSame([['id' => 2, 'name' => 'after']], $impact->returningRows());
105    }
106
107    public function testReturningDeleteRowsComeFromMutationInput(): void
108    {
109        $deleted = ['id' => 2, 'name' => 'deleted', '__ztd_original_id' => 2];
110        $impact = new MutationImpact(
111            new DeleteMutation('items', ['id']),
112            [['id' => 1], ['id' => 2, 'name' => 'deleted']],
113            [$deleted],
114            [['id' => 1]],
115        );
116
117        self::assertSame([['id' => 2, 'name' => 'deleted']], $impact->returningRows());
118        self::assertFalse($impact->isInsertLike());
119    }
120
121    public function testReturningRowsPreserveSparseInputKeys(): void
122    {
123        $impact = new MutationImpact(
124            new UpdateMutation('items', ['id']),
125            [['id' => 1]],
126            [9 => ['id' => 2, '__ztd_original_id' => 1]],
127            [['id' => 2]],
128        );
129
130        self::assertSame([9 => ['id' => 2]], $impact->returningRows());
131    }
132
133    public function testIgnoredInsertHasNoReturningRows(): void
134    {
135        $row = ['id' => 1];
136        $impact = new MutationImpact(new InsertMutation('items'), [$row], [$row], [$row]);
137
138        self::assertSame([], $impact->returningRows());
139    }
140
141    public function testAppliedUpsertReturnsResultRowsAndIsInsertLike(): void
142    {
143        $before = ['id' => 1, 'name' => 'same'];
144        $input = $before;
145        $mutation = new UpsertMutation('items', ['id']);
146        $store = new ShadowStore();
147        $store->set('items', [$before]);
148        $mutation->apply($store, [$input]);
149        $impact = new MutationImpact($mutation, [$before], [$input], $store->get('items'));
150
151        self::assertSame([$input], $impact->returningRows());
152        self::assertSame(1, $impact->affectedRowCount(AffectedRowsMode::Matched));
153        self::assertSame(0, $impact->affectedRowCount(AffectedRowsMode::Changed));
154        self::assertTrue($impact->isInsertLike());
155    }
156
157    public function testReplaceIsInsertLike(): void
158    {
159        $before = [['id' => 1, 'name' => 'before']];
160        $after = [['id' => 1]];
161        $impact = new MutationImpact(new ReplaceMutation('items', ['id']), $before, $after, $after);
162
163        self::assertTrue($impact->isInsertLike());
164        self::assertSame($after, $impact->returningRows());
165    }
166
167    public function testImpactPreservesEveryChangedRowAndDuplicateMultiplicity(): void
168    {
169        $first = ['id' => 1];
170        $second = ['id' => 2];
171        $impact = new MutationImpact(new InsertMutation('items'), [], [$first, $second], [$first, $second]);
172        self::assertSame(2, $impact->affectedRowCount(AffectedRowsMode::Changed));
173        self::assertSame([$first, $second], $impact->returningRows());
174
175        $duplicateImpact = new MutationImpact(
176            new DeleteMutation('items', ['id']),
177            [$first, $first],
178            [$first],
179            [$first],
180        );
181        self::assertSame(1, $duplicateImpact->affectedRowCount(AffectedRowsMode::Changed));
182    }
183
184    public function testRowsWithDifferentColumnsOrValuesAreChanged(): void
185    {
186        $impact = new MutationImpact(
187            new UpdateMutation('items', ['id']),
188            [['id' => 1, 'name' => 'before'], ['id' => 2], ['id' => 3]],
189            [],
190            [['id' => 1, 'other' => 'before'], ['id' => 2, 'name' => null], ['id' => 4]],
191        );
192
193        self::assertSame(3, $impact->affectedRowCount(AffectedRowsMode::Changed));
194    }
195
196    public function testSynchronizationCountsMixedOperationsOnceEach(): void
197    {
198        $definition = new TableDefinition(
199            ['id', 'name'],
200            ['id' => 'INTEGER', 'name' => 'TEXT'],
201            ['id'],
202            ['id'],
203            [],
204        );
205        $mutation = new SynchronizeMutation('items', $definition);
206        $impact = new MutationImpact(
207            $mutation,
208            [['id' => 1, 'name' => 'old'], ['id' => 3, 'name' => 'deleted']],
209            [['id' => 1, 'name' => 'updated'], ['id' => 2, 'name' => 'inserted']],
210            [['id' => 1, 'name' => 'updated'], ['id' => 2, 'name' => 'inserted']],
211        );
212
213        self::assertSame(3, $impact->affectedRowCount(AffectedRowsMode::Changed));
214        self::assertTrue($impact->isInsertLike());
215    }
216
217    public function testSkippedConditionalUpsertHasNoAffectedOrReturningRows(): void
218    {
219        $row = ['id' => 1, 'name' => 'original', 'score' => 50];
220        $mutation = new UpsertMutation(
221            'items',
222            ['id'],
223            ['name'],
224            ['name' => UpsertExpression::column(UpsertColumnSource::Incoming, 'name')],
225            updatePredicate: UpsertExpression::binary(
226                UpsertExpressionKind::GreaterOrEqual,
227                UpsertExpression::column(UpsertColumnSource::Existing, 'score'),
228                UpsertExpression::literal(80),
229            ),
230        );
231        $store = new ShadowStore();
232        $store->set('items', [$row]);
233        $input = [['id' => 1, 'name' => 'skipped', 'score' => 95]];
234        $mutation->apply($store, $input);
235        $impact = new MutationImpact($mutation, [$row], $input, $store->get('items'));
236
237        self::assertSame(0, $impact->affectedRowCount(AffectedRowsMode::Matched));
238        self::assertSame([], $impact->returningRows());
239    }
240
241    public function testAffectedRowCountIsNothingWhereTheStatementReportsNone(): void
242    {
243        $impact = new MutationImpact(new InsertMutation('users'), [], [['id' => 1]], [['id' => 1]]);
244
245        self::assertSame(0, $impact->affectedRowCount(AffectedRowsMode::None));
246    }
247
248    public function testReturningRowsAnswersTheRowsAStatementWouldReadBack(): void
249    {
250        $impact = new MutationImpact(new InsertMutation('users'), [], [['id' => 1]], [['id' => 1]]);
251
252        self::assertSame([['id' => 1]], $impact->returningRows());
253    }
254
255    public function testIsInsertLikeIsFalseForAStatementThatOnlyRemovesRows(): void
256    {
257        $impact = new MutationImpact(new DeleteMutation('users', ['id']), [['id' => 1]], [['id' => 1]], []);
258
259        self::assertFalse($impact->isInsertLike());
260    }
261}
262