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