packages/ztd-query-core/tests/Contract/MutationContractTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Contract;
6
7use PHPUnit\Framework\TestCase;
8use ZtdQuery\Schema\TableDefinition;
9use ZtdQuery\Shadow\Mutation\Row\DeleteMutation;
10use ZtdQuery\Shadow\Mutation\Row\InsertMutation;
11use ZtdQuery\Shadow\Mutation\Row\UpdateMutation;
12use ZtdQuery\Shadow\Mutation\ShadowMutation;
13use ZtdQuery\Shadow\Mutation\Table\TruncateMutation;
14use ZtdQuery\Shadow\ShadowStore;
15
16/**
17 * Abstract contract test for ShadowMutation implementations.
18 *
19 * Tests universal mutation properties that must hold for any platform.
20 * Enforces contracts defined in quality-standards.md Section 1.2 and properties P-SM-1 through P-SM-6.
21 *
22 * @phpstan-import-type Row from TableDefinition
23 */
24#[\PHPUnit\Framework\Attributes\UsesClass(\ZtdQuery\Schema\RowSet::class)]
25abstract class MutationContractTest extends TestCase
26{
27 /**
28 * Create initial rows to seed the shadow store for testing.
29 *
30 * @return list<Row>
31 */
32 abstract protected function initialRows(): array;
33
34 /**
35 * Create rows to insert for testing.
36 *
37 * @return list<Row>
38 */
39 abstract protected function insertRows(): array;
40
41 /**
42 * Create rows representing a delete result set (rows that were deleted).
43 *
44 * @return list<Row>
45 */
46 abstract protected function deleteRows(): array;
47
48 /**
49 * Create rows representing an update result set (rows after update).
50 *
51 * @return list<Row>
52 */
53 abstract protected function updateRows(): array;
54
55 /**
56 * Return the primary key column names for the test table.
57 *
58 * @return array<int, string>
59 */
60 abstract protected function primaryKeys(): array;
61
62 /**
63 * Return the table name used in tests.
64 */
65 protected function tableName(): string
66 {
67 return 'users';
68 }
69
70 /**
71 * Return a different table name for isolation tests.
72 */
73 protected function otherTableName(): string
74 {
75 return 'orders';
76 }
77
78 /**
79 * InsertMutation increases row count by at most count(rows) (P-SM-1).
80 */
81 public function testInsertIncreasesRowCount(): void
82 {
83 $store = new ShadowStore();
84 $store->set($this->tableName(), $this->initialRows());
85 $countBefore = count($store->get($this->tableName()));
86
87 $insertRows = $this->insertRows();
88 $mutation = new InsertMutation($this->tableName());
89 $mutation->apply($store, $insertRows);
90
91 $countAfter = count($store->get($this->tableName()));
92
93 self::assertGreaterThanOrEqual($countBefore, $countAfter);
94 self::assertLessThanOrEqual($countBefore + count($insertRows), $countAfter);
95 }
96
97 /**
98 * DeleteMutation decreases row count by at most count(rows) (P-SM-2).
99 */
100 public function testDeleteDecreasesRowCount(): void
101 {
102 $store = new ShadowStore();
103 $store->set($this->tableName(), $this->initialRows());
104 $countBefore = count($store->get($this->tableName()));
105
106 $deleteRows = $this->deleteRows();
107 $mutation = new DeleteMutation($this->tableName(), $this->primaryKeys());
108 $mutation->apply($store, $deleteRows);
109
110 $countAfter = count($store->get($this->tableName()));
111
112 self::assertLessThanOrEqual($countBefore, $countAfter + count($deleteRows));
113 self::assertLessThanOrEqual($countBefore, $countAfter + count($deleteRows));
114 self::assertGreaterThanOrEqual(0, $countAfter);
115 }
116
117 /**
118 * UpdateMutation preserves row count (P-SM-3).
119 */
120 public function testUpdatePreservesRowCount(): void
121 {
122 $store = new ShadowStore();
123 $store->set($this->tableName(), $this->initialRows());
124 $countBefore = count($store->get($this->tableName()));
125
126 $updateRows = $this->updateRows();
127 $mutation = new UpdateMutation($this->tableName(), array_values($this->primaryKeys()));
128 $mutation->apply($store, $updateRows);
129
130 $countAfter = count($store->get($this->tableName()));
131
132 self::assertSame($countBefore, $countAfter);
133 }
134
135 /**
136 * TruncateMutation empties the table (P-SM-4).
137 */
138 public function testTruncateEmptiesTable(): void
139 {
140 $store = new ShadowStore();
141 $store->set($this->tableName(), $this->initialRows());
142
143 self::assertNotEmpty($store->get($this->tableName()));
144
145 $mutation = new TruncateMutation($this->tableName());
146 $mutation->apply($store, []);
147
148 self::assertSame([], $store->get($this->tableName()));
149 }
150
151 /**
152 * Any mutation on table T must not modify store.get(T') for T' != T (P-SM-5).
153 */
154 public function testMutationTableIsolation(): void
155 {
156 $store = new ShadowStore();
157 $store->set($this->tableName(), $this->initialRows());
158
159 $otherRows = [['id' => 100, 'product' => 'Widget']];
160 $store->set($this->otherTableName(), $otherRows);
161
162 $mutation = new InsertMutation($this->tableName());
163 $mutation->apply($store, $this->insertRows());
164
165 self::assertSame($otherRows, $store->get($this->otherTableName()));
166
167 $deleteMutation = new DeleteMutation($this->tableName(), $this->primaryKeys());
168 $deleteMutation->apply($store, $this->deleteRows());
169
170 self::assertSame($otherRows, $store->get($this->otherTableName()));
171
172 $truncateMutation = new TruncateMutation($this->tableName());
173 $truncateMutation->apply($store, []);
174
175 self::assertSame($otherRows, $store->get($this->otherTableName()));
176 }
177
178 /**
179 * tableName() must return the same value across multiple calls.
180 */
181 public function testTableNameIsConsistent(): void
182 {
183 $mutation = new InsertMutation($this->tableName());
184
185 $name1 = $mutation->tableName();
186 $name2 = $mutation->tableName();
187 $name3 = $mutation->tableName();
188
189 self::assertSame($name1, $name2);
190 self::assertSame($name2, $name3);
191 }
192
193 /**
194 * Applying TruncateMutation twice yields the same result as once (P-SM-6).
195 */
196 public function testIdempotentTruncate(): void
197 {
198 $store = new ShadowStore();
199 $store->set($this->tableName(), $this->initialRows());
200
201 $mutation = new TruncateMutation($this->tableName());
202 $mutation->apply($store, []);
203
204 self::assertSame([], $store->get($this->tableName()));
205
206 $mutation->apply($store, []);
207
208 self::assertSame([], $store->get($this->tableName()));
209 }
210
211 /**
212 * InsertMutation with NULL values in rows must not crash.
213 */
214 public function testInsertWithNullValues(): void
215 {
216 $store = new ShadowStore();
217 $store->set($this->tableName(), []);
218
219 $rowsWithNull = [
220 ['id' => 10, 'name' => null, 'email' => 'test@example.com'],
221 ];
222
223 $mutation = new InsertMutation($this->tableName());
224 $mutation->apply($store, $rowsWithNull);
225
226 $stored = $store->get($this->tableName());
227 self::assertCount(1, $stored);
228 self::assertNull($stored[0]['name']);
229 }
230
231 /**
232 * InsertMutation with empty string values must preserve them.
233 */
234 public function testInsertWithEmptyStringValues(): void
235 {
236 $store = new ShadowStore();
237 $store->set($this->tableName(), []);
238
239 $rowsWithEmpty = [
240 ['id' => 10, 'name' => '', 'email' => 'test@example.com'],
241 ];
242
243 $mutation = new InsertMutation($this->tableName());
244 $mutation->apply($store, $rowsWithEmpty);
245
246 $stored = $store->get($this->tableName());
247 self::assertCount(1, $stored);
248 self::assertSame('', $stored[0]['name']);
249 }
250
251 /**
252 * InsertMutation into empty table must work.
253 */
254 public function testInsertIntoEmptyTable(): void
255 {
256 $store = new ShadowStore();
257 $store->set($this->tableName(), []);
258
259 $mutation = new InsertMutation($this->tableName());
260 $mutation->apply($store, $this->insertRows());
261
262 $stored = $store->get($this->tableName());
263 self::assertCount(count($this->insertRows()), $stored);
264 }
265
266 /**
267 * DeleteMutation on empty table must not crash.
268 */
269 public function testDeleteOnEmptyTable(): void
270 {
271 $store = new ShadowStore();
272 $store->set($this->tableName(), []);
273
274 $mutation = new DeleteMutation($this->tableName(), $this->primaryKeys());
275 $mutation->apply($store, $this->deleteRows());
276
277 self::assertSame([], $store->get($this->tableName()));
278 }
279
280 /**
281 * UpdateMutation on empty table must not crash.
282 */
283 public function testUpdateOnEmptyTable(): void
284 {
285 $store = new ShadowStore();
286 $store->set($this->tableName(), []);
287
288 $mutation = new UpdateMutation($this->tableName(), array_values($this->primaryKeys()));
289 $mutation->apply($store, $this->updateRows());
290
291 self::assertSame(0, count($store->get($this->tableName())));
292 }
293}
294