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