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