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

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Shadow\Mutation\Row;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use ZtdQuery\Exception\DuplicateKeyException;
11use ZtdQuery\Exception\NotNullViolationException;
12use ZtdQuery\Schema\Key\CandidateKeyConflict;
13use ZtdQuery\Schema\Key\CandidateKeySet;
14use ZtdQuery\Schema\TableDefinition;
15use ZtdQuery\Shadow\Mutation\Row\InsertMutation;
16use ZtdQuery\Shadow\Mutation\Upsert\UpsertOperator;
17use ZtdQuery\Shadow\Mutation\UpsertColumnSource;
18use ZtdQuery\Shadow\Mutation\UpsertExpression;
19use ZtdQuery\Shadow\Mutation\UpsertExpressionKind;
20use ZtdQuery\Shadow\ShadowStore;
21
22#[UsesClass(DuplicateKeyException::class)]
23#[UsesClass(NotNullViolationException::class)]
24#[UsesClass(TableDefinition::class)]
25#[UsesClass(ShadowStore::class)]
26#[UsesClass(CandidateKeyConflict::class)]
27#[UsesClass(CandidateKeySet::class)]
28#[UsesClass(UpsertExpression::class)]
29#[CoversClass(InsertMutation::class)]
30#[UsesClass(\ZtdQuery\Schema\Key\CandidateKeyMatch::class)]
31#[UsesClass(\ZtdQuery\Shadow\Mutation\ConflictSearch::class)]
32#[UsesClass(\ZtdQuery\Shadow\Mutation\RowConstraints::class)]
33#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertColumn::class)]
34#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertComparison::class)]
35#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertNumber::class)]
36#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertTruth::class)]
37#[UsesClass(UpsertOperator::class)]
38#[UsesClass(\ZtdQuery\Schema\RowSet::class)]
39#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertLiteral::class)]
40final class InsertMutationTest extends TestCase
41{
42    public function testTableNameApplyAppendsRows(): void
43    {
44        $store = new ShadowStore();
45        $store->set('users', [['id' => 1]]);
46
47        $mutation = new InsertMutation('users');
48        $mutation->apply($store, [['id' => 2]]);
49
50        self::assertSame([['id' => 1], ['id' => 2]], $store->get('users'));
51        self::assertSame('users', $mutation->tableName());
52    }
53
54    public function testInsertIgnoreSkipsDuplicates(): void
55    {
56        $store = new ShadowStore();
57        $store->set('users', [['id' => 1, 'name' => 'Alice']]);
58
59        $mutation = new InsertMutation('users', ['id'], true);
60        $mutation->apply($store, [['id' => 1, 'name' => 'Bob'], ['id' => 2, 'name' => 'Carol']]);
61
62        self::assertCount(2, $store->get('users'));
63        self::assertSame('Alice', $store->get('users')[0]['name']);
64        self::assertSame('Carol', $store->get('users')[1]['name']);
65    }
66
67    public function testInsertIgnoreSkipsUniqueKeyConflict(): void
68    {
69        $definition = new TableDefinition(
70            ['id', 'email'],
71            ['id' => 'INT', 'email' => 'VARCHAR(255)'],
72            ['id'],
73            ['id'],
74            ['users_email' => ['email']],
75        );
76        $store = new ShadowStore();
77        $store->set('users', [['id' => 1, 'email' => 'alice@example.com']]);
78
79        $mutation = new InsertMutation(
80            'users',
81            ['id'],
82            true,
83            candidateKeys: $definition->candidateKeys(),
84        );
85        $mutation->apply($store, [
86            ['id' => 2, 'email' => 'alice@example.com'],
87            ['id' => 3, 'email' => 'bob@example.com'],
88        ]);
89
90        self::assertSame([
91            ['id' => 1, 'email' => 'alice@example.com'],
92            ['id' => 3, 'email' => 'bob@example.com'],
93        ], $store->get('users'));
94    }
95
96    public function testValidatePrimaryKeyDuplicateThrowsException(): void
97    {
98        $tableDefinition = new TableDefinition(
99            ['id', 'name'],
100            ['id' => 'INT', 'name' => 'VARCHAR(255)'],
101            ['id'],
102            ['id'],
103            [],
104        );
105
106        $store = new ShadowStore();
107        $store->set('users', [['id' => 1, 'name' => 'Alice']]);
108
109        $mutation = new InsertMutation(
110            'users',
111            ['id'],
112            false,
113            $tableDefinition,
114            'INSERT INTO users (id, name) VALUES (1, "Bob")',
115            true
116        );
117
118        $this->expectException(DuplicateKeyException::class);
119        $this->expectExceptionMessage("Duplicate entry '1' for key 'PRIMARY'");
120
121        $mutation->apply($store, [['id' => 1, 'name' => 'Bob']]);
122    }
123
124    public function testValidateNotNullThrowsException(): void
125    {
126        $tableDefinition = new TableDefinition(
127            ['id', 'name'],
128            ['id' => 'INT', 'name' => 'VARCHAR(255)'],
129            ['id'],
130            ['id', 'name'],
131            [],
132        );
133
134        $store = new ShadowStore();
135
136        $mutation = new InsertMutation(
137            'users',
138            ['id'],
139            false,
140            $tableDefinition,
141            'INSERT INTO users (id, name) VALUES (1, NULL)',
142            true
143        );
144
145        $this->expectException(NotNullViolationException::class);
146        $this->expectExceptionMessage("Column 'name' in table 'users' cannot be NULL");
147
148        $mutation->apply($store, [['id' => 1, 'name' => null]]);
149    }
150
151    public function testValidateUniqueThrowsException(): void
152    {
153        $tableDefinition = new TableDefinition(
154            ['id', 'email'],
155            ['id' => 'INT', 'email' => 'VARCHAR(255)'],
156            ['id'],
157            ['id'],
158            ['email_UNIQUE' => ['email']],
159        );
160
161        $store = new ShadowStore();
162        $store->set('users', [['id' => 1, 'email' => 'alice@example.com']]);
163
164        $mutation = new InsertMutation(
165            'users',
166            ['id'],
167            false,
168            $tableDefinition,
169            'INSERT INTO users (id, email) VALUES (2, "alice@example.com")',
170            true
171        );
172
173        $this->expectException(DuplicateKeyException::class);
174        $this->expectExceptionMessageMatches("/Duplicate entry.*alice@example.com.*for key 'email_UNIQUE'/");
175
176        $mutation->apply($store, [['id' => 2, 'email' => 'alice@example.com']]);
177    }
178
179    public function testValidationDisabledByDefaultAllowsDuplicates(): void
180    {
181        $tableDefinition = new TableDefinition(
182            ['id', 'name'],
183            ['id' => 'INT', 'name' => 'VARCHAR(255)'],
184            ['id'],
185            ['id', 'name'],
186            [],
187        );
188
189        $store = new ShadowStore();
190        $store->set('users', [['id' => 1, 'name' => 'Alice']]);
191
192        $mutation = new InsertMutation('users', ['id'], false, $tableDefinition);
193        $mutation->apply($store, [['id' => 1, 'name' => null]]);
194
195        self::assertCount(2, $store->get('users'));
196    }
197
198    public function testUniqueConstraintAllowsNull(): void
199    {
200        $tableDefinition = new TableDefinition(
201            ['id', 'email'],
202            ['id' => 'INT', 'email' => 'VARCHAR(255)'],
203            ['id'],
204            ['id'],
205            ['email_UNIQUE' => ['email']],
206        );
207
208        $store = new ShadowStore();
209        $store->set('users', [['id' => 1, 'email' => null]]);
210
211        $mutation = new InsertMutation(
212            'users',
213            ['id'],
214            false,
215            $tableDefinition,
216            'INSERT INTO users (id, email) VALUES (2, NULL)',
217            true
218        );
219
220        $mutation->apply($store, [['id' => 2, 'email' => null]]);
221
222        self::assertCount(2, $store->get('users'));
223    }
224
225    public function testApplyInsertsMultipleRows(): void
226    {
227        $store = new ShadowStore();
228        $store->set('users', []);
229
230        $mutation = new InsertMutation('users');
231        $mutation->apply($store, [
232            ['id' => 1, 'name' => 'Alice'],
233            ['id' => 2, 'name' => 'Bob'],
234            ['id' => 3, 'name' => 'Carol'],
235        ]);
236
237        self::assertCount(3, $store->get('users'));
238    }
239
240    public function testApplyWithCompositePrimaryKey(): void
241    {
242        $store = new ShadowStore();
243        $store->set('order_items', [
244            ['order_id' => 1, 'product_id' => 100, 'quantity' => 1],
245        ]);
246
247        $mutation = new InsertMutation('order_items', ['order_id', 'product_id'], true);
248        $mutation->apply($store, [
249            ['order_id' => 1, 'product_id' => 100, 'quantity' => 5],
250            ['order_id' => 1, 'product_id' => 200, 'quantity' => 2],
251        ]);
252
253        self::assertCount(2, $store->get('order_items'));
254        self::assertSame(1, $store->get('order_items')[0]['quantity']);
255    }
256
257    public function testApplyInsertsToEmptyTable(): void
258    {
259        $store = new ShadowStore();
260        $store->ensure('users');
261
262        $mutation = new InsertMutation('users');
263        $mutation->apply($store, [['id' => 1, 'name' => 'Alice']]);
264
265        self::assertCount(1, $store->get('users'));
266        self::assertSame('Alice', $store->get('users')[0]['name']);
267    }
268
269    public function testInsertIgnoreUsesPartialConflictPredicate(): void
270    {
271        $store = new ShadowStore();
272        $store->set('users', [[
273            'email' => 'alice@example.com',
274            'status' => 'inactive',
275        ]]);
276        $mutation = new InsertMutation(
277            'users',
278            ignore: true,
279            candidateKeys: CandidateKeySet::fromSchema([], ['users_active_email' => ['email']]),
280            conflictPredicate: UpsertExpression::binary(
281                UpsertExpressionKind::Equal,
282                UpsertExpression::column(UpsertColumnSource::Existing, 'status'),
283                UpsertExpression::literal('active'),
284            ),
285        );
286
287        $mutation->apply($store, [
288            ['email' => 'alice@example.com', 'status' => 'active'],
289            ['email' => 'alice@example.com', 'status' => 'active'],
290            ['email' => 'alice@example.com', 'status' => 'inactive'],
291        ]);
292
293        self::assertSame([
294            ['email' => 'alice@example.com', 'status' => 'inactive'],
295            ['email' => 'alice@example.com', 'status' => 'active'],
296            ['email' => 'alice@example.com', 'status' => 'inactive'],
297        ], $store->get('users'));
298    }
299}
300