packages/ztd-query-sqlite/tests/Unit/Shadow/SqliteMutationResolverTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Shadow;
6
7use Generator;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\DataProvider;
10use PHPUnit\Framework\Attributes\UsesClass;
11use PHPUnit\Framework\TestCase;
12use ZtdQuery\Exception\ColumnAlreadyExistsException;
13use ZtdQuery\Exception\ColumnNotFoundException;
14use ZtdQuery\Exception\UnknownSchemaException;
15use ZtdQuery\Exception\UnsupportedSqlException;
16use ZtdQuery\Platform\SchemaParser;
17use ZtdQuery\Platform\Sqlite\Schema\SqliteSchemaParser;
18use ZtdQuery\Platform\Sqlite\Shadow\Mutation\Table\AlterTableMutation;
19use ZtdQuery\Platform\Sqlite\Shadow\SqliteMutationResolver;
20use ZtdQuery\Platform\Sqlite\Sql\SqliteIdentifierQuoter;
21use ZtdQuery\Platform\Sqlite\Sql\SqliteLexicalMasker;
22use ZtdQuery\Platform\Sqlite\Sql\SqliteParser;
23use ZtdQuery\Rewrite\QueryKind;
24use ZtdQuery\Schema\ColumnDeclaration;
25use ZtdQuery\Schema\ColumnTypeFamily;
26use ZtdQuery\Schema\Key\ForeignKeyDefinition;
27use ZtdQuery\Schema\Key\IdentityGenerationStrategy;
28use ZtdQuery\Schema\TableDefinition;
29use ZtdQuery\Schema\TableDefinitionRegistry;
30use ZtdQuery\Shadow\Mutation\Row\DeleteMutation;
31use ZtdQuery\Shadow\Mutation\Row\InsertMutation;
32use ZtdQuery\Shadow\Mutation\Row\ReplaceMutation;
33use ZtdQuery\Shadow\Mutation\Row\UpdateMutation;
34use ZtdQuery\Shadow\Mutation\Table\CreateTableMutation;
35use ZtdQuery\Shadow\Mutation\Table\DropTableMutation;
36use ZtdQuery\Shadow\Mutation\UpsertMutation;
37use ZtdQuery\Shadow\ShadowStore;
38use ZtdQuery\Shadow\ShadowTableState;
39
40#[CoversClass(SqliteMutationResolver::class)]
41#[UsesClass(AlterTableMutation::class)]
42#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Mutation\Table\Alter\AddColumnResolver::class)]
43#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Mutation\Table\Alter\AlteredTableProjection::class)]
44#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Mutation\Table\Alter\ColumnDefinitionEditor::class)]
45#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Mutation\Table\Alter\DropColumnResolver::class)]
46#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Mutation\Table\Alter\RenameResolver::class)]
47#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Resolution\InsertMutationResolver::class)]
48#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Resolution\MutationTableLookup::class)]
49#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Resolution\RowMutationResolver::class)]
50#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Resolution\TableMutationResolver::class)]
51#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Alter\AlterOperationParser::class)]
52#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Dml\Expression\AssignmentColumnParser::class)]
53#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Dml\Expression\AssignmentParser::class)]
54#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Dml\Expression\ValueListParser::class)]
55#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Dml\Insert\InsertClauseParser::class)]
56#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Lexing\ExpressionSpan::class)]
57#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Lexing\IdentifierDecoder::class)]
58#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Lexing\LiteralMasker::class)]
59#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Lexing\OpaqueSqlSpan::class)]
60#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Lexing\QuotedSpan::class)]
61#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Lexing\TopLevelKeywordScanner::class)]
62#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Relation\RelationSourceParser::class)]
63#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Statement\StatementClassifier::class)]
64#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Statement\StatementStructure::class)]
65#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Statement\TargetTableParser::class)]
66#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Dml\Update\UpdateClauseParser::class)]
67#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Mutation\Upsert\ArithmeticExpressionParser::class)]
68#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Mutation\Upsert\ComparisonExpressionParser::class)]
69#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Mutation\Upsert\ExpressionCursor::class)]
70#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Mutation\Upsert\ExpressionTokenDecoder::class)]
71#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Mutation\Upsert\LogicalExpressionParser::class)]
72#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Mutation\Upsert\PrimaryExpressionParser::class)]
73#[UsesClass(\ZtdQuery\Platform\Sqlite\Schema\Create\ColumnDefinitionParser::class)]
74#[UsesClass(\ZtdQuery\Platform\Sqlite\Schema\Create\TableBodyParser::class)]
75#[UsesClass(\ZtdQuery\Platform\Sqlite\Schema\Create\TableDefinitionBuilder::class)]
76#[UsesClass(\ZtdQuery\Platform\Sqlite\Schema\Create\VirtualTableParser::class)]
77#[UsesClass(\ZtdQuery\Platform\Sqlite\Schema\Key\ForeignKeyEntryParser::class)]
78#[UsesClass(\ZtdQuery\Platform\Sqlite\Schema\Key\ForeignKeyTokens::class)]
79#[UsesClass(\ZtdQuery\Platform\Sqlite\Schema\SqliteColumnTypeMapper::class)]
80#[UsesClass(\ZtdQuery\Platform\Sqlite\Schema\Key\SqliteForeignKeyDefinitionParser::class)]
81#[UsesClass(SqliteIdentifierQuoter::class)]
82#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\SqliteLexerProfile::class)]
83#[UsesClass(SqliteLexicalMasker::class)]
84#[UsesClass(SqliteParser::class)]
85#[UsesClass(SqliteSchemaParser::class)]
86#[UsesClass(\ZtdQuery\Platform\Sqlite\Sql\Relation\SqliteSelectRelationParser::class)]
87#[UsesClass(\ZtdQuery\Platform\Sqlite\Shadow\Mutation\Upsert\SqliteUpsertExpressionParser::class)]
88final class SqliteMutationResolverTest extends TestCase
89{
90 public function testResolveInsert(): void
91 {
92 $store = new ShadowStore();
93 $registry = new TableDefinitionRegistry();
94 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
95 $mutation = $resolver->resolve("INSERT INTO users (id, name) VALUES (1, 'Alice')", QueryKind::WRITE_SIMULATED);
96
97 self::assertInstanceOf(InsertMutation::class, $mutation);
98 }
99
100 public function testResolveInsertIgnore(): void
101 {
102 $registry = new TableDefinitionRegistry();
103 $registry->register('users', new TableDefinition(
104 ['id', 'name', 'email'],
105 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
106 ['id'],
107 ['id', 'name'],
108 [],
109 ));
110 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
111 $mutation = $resolver->resolve("INSERT OR IGNORE INTO users (id, name) VALUES (1, 'Alice')", QueryKind::WRITE_SIMULATED);
112
113 self::assertInstanceOf(InsertMutation::class, $mutation);
114 }
115
116 public function testResolveReplace(): void
117 {
118 $store = new ShadowStore();
119 $registry = new TableDefinitionRegistry();
120 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
121 $mutation = $resolver->resolve("REPLACE INTO users (id, name) VALUES (1, 'Alice')", QueryKind::WRITE_SIMULATED);
122
123 self::assertInstanceOf(ReplaceMutation::class, $mutation);
124 }
125
126 public function testResolveInsertOrReplace(): void
127 {
128 $store = new ShadowStore();
129 $registry = new TableDefinitionRegistry();
130 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
131 $mutation = $resolver->resolve("INSERT OR REPLACE INTO users (id, name) VALUES (1, 'Alice')", QueryKind::WRITE_SIMULATED);
132
133 self::assertInstanceOf(ReplaceMutation::class, $mutation);
134 }
135
136 public function testResolveUpsert(): void
137 {
138 $registry = new TableDefinitionRegistry();
139 $registry->register('users', new TableDefinition(
140 ['id', 'name', 'email'],
141 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
142 ['id'],
143 ['id', 'name'],
144 [],
145 ));
146 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
147 $mutation = $resolver->resolve(
148 "INSERT INTO users (id, name) VALUES (1, 'Alice') ON CONFLICT (id) DO UPDATE SET name = upper(users.name)",
149 QueryKind::WRITE_SIMULATED
150 );
151
152 self::assertInstanceOf(UpsertMutation::class, $mutation);
153 }
154
155 public function testResolveUpdate(): void
156 {
157 $store = new ShadowStore();
158 $store->ensure('users');
159 $resolver = new SqliteMutationResolver($store, new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
160 $mutation = $resolver->resolve("UPDATE users SET name = 'Bob' WHERE id = 1", QueryKind::WRITE_SIMULATED);
161
162 self::assertInstanceOf(UpdateMutation::class, $mutation);
163 }
164
165 public function testResolveDelete(): void
166 {
167 $registry = new TableDefinitionRegistry();
168 $registry->register('users', new TableDefinition(
169 ['id', 'name', 'email'],
170 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
171 ['id'],
172 ['id', 'name'],
173 [],
174 ));
175 $store = new ShadowStore();
176 $store->ensure('users');
177 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
178 $mutation = $resolver->resolve('DELETE FROM users WHERE id = 1', QueryKind::WRITE_SIMULATED);
179
180 self::assertInstanceOf(DeleteMutation::class, $mutation);
181 }
182
183 public function testResolveDeleteUnknownTableThrows(): void
184 {
185 $store = new ShadowStore();
186 $resolver = new SqliteMutationResolver($store, new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
187
188 $this->expectException(UnknownSchemaException::class);
189 $resolver->resolve('DELETE FROM unknown_table WHERE id = 1', QueryKind::WRITE_SIMULATED);
190 }
191
192 public function testResolveCreateTable(): void
193 {
194 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
195 $mutation = $resolver->resolve('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)', QueryKind::DDL_SIMULATED);
196
197 self::assertInstanceOf(CreateTableMutation::class, $mutation);
198 }
199
200 public function testResolveCreateTableAlreadyExistsThrows(): void
201 {
202 $registry = new TableDefinitionRegistry();
203 $registry->register('users', new TableDefinition(
204 ['id', 'name', 'email'],
205 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
206 ['id'],
207 ['id', 'name'],
208 [],
209 ));
210 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
211
212 $this->expectException(UnsupportedSqlException::class);
213 $resolver->resolve('CREATE TABLE users (id INTEGER PRIMARY KEY)', QueryKind::DDL_SIMULATED);
214 }
215
216 public function testResolveCreateTableIfNotExists(): void
217 {
218 $registry = new TableDefinitionRegistry();
219 $registry->register('users', new TableDefinition(
220 ['id', 'name', 'email'],
221 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
222 ['id'],
223 ['id', 'name'],
224 [],
225 ));
226 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
227 $mutation = $resolver->resolve('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY)', QueryKind::DDL_SIMULATED);
228
229 self::assertInstanceOf(CreateTableMutation::class, $mutation);
230 }
231
232 public function testResolveDropTable(): void
233 {
234 $registry = new TableDefinitionRegistry();
235 $registry->register('users', new TableDefinition(
236 ['id', 'name', 'email'],
237 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
238 ['id'],
239 ['id', 'name'],
240 [],
241 ));
242 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
243 $mutation = $resolver->resolve('DROP TABLE users', QueryKind::DDL_SIMULATED);
244
245 self::assertInstanceOf(DropTableMutation::class, $mutation);
246 }
247
248 public function testResolveDropTableUnknownThrows(): void
249 {
250 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
251
252 $this->expectException(UnknownSchemaException::class);
253 $resolver->resolve('DROP TABLE unknown_table', QueryKind::DDL_SIMULATED);
254 }
255
256 public function testResolveDropTableIfExists(): void
257 {
258 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
259 $mutation = $resolver->resolve('DROP TABLE IF EXISTS unknown_table', QueryKind::DDL_SIMULATED);
260
261 self::assertInstanceOf(DropTableMutation::class, $mutation);
262 }
263
264 public function testResolveAlterTableAddColumn(): void
265 {
266 $registry = new TableDefinitionRegistry();
267 $registry->register('users', new TableDefinition(
268 ['id', 'name', 'email'],
269 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
270 ['id'],
271 ['id', 'name'],
272 [],
273 ));
274 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
275 $mutation = $resolver->resolve('ALTER TABLE users ADD COLUMN phone TEXT', QueryKind::DDL_SIMULATED);
276
277 self::assertInstanceOf(AlterTableMutation::class, $mutation);
278 }
279
280 public function testAlterAddGeneratedColumnPreservesExistingAndAddedExpressions(): void
281 {
282 $store = new ShadowStore();
283 $registry = new TableDefinitionRegistry();
284 $registry->register('metrics', new TableDefinition(
285 ['source', 'doubled'],
286 ['source' => 'INTEGER', 'doubled' => 'INTEGER'],
287 [],
288 [],
289 [],
290 [],
291 [],
292 [],
293 ['doubled' => '(source * 2)'],
294 ));
295 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
296 $mutation = $resolver->resolve(
297 'ALTER TABLE metrics ADD COLUMN tripled INTEGER GENERATED ALWAYS AS (source * 3) STORED',
298 QueryKind::DDL_SIMULATED,
299 );
300
301 self::assertInstanceOf(AlterTableMutation::class, $mutation);
302 $mutation->apply($store, []);
303 self::assertSame([
304 'doubled' => '(source * 2)',
305 'tripled' => '(source * 3)',
306 ], $registry->get('metrics')?->generatedExpressions);
307 }
308
309 public function testAlterColumnMutationsPreserveRenameAndRemoveForeignKeys(): void
310 {
311 $store = new ShadowStore();
312 $registry = new TableDefinitionRegistry();
313 $registry->register('children', new TableDefinition(
314 ['id', 'old_parent'],
315 ['id' => 'INTEGER', 'old_parent' => 'INTEGER'],
316 ['id'],
317 [],
318 [],
319 foreignKeys: ['fk_parent' => new ForeignKeyDefinition(['old_parent'], 'parents', ['id'])],
320 ));
321 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
322 $rename = $resolver->resolve(
323 'ALTER TABLE children RENAME COLUMN old_parent TO parent_id',
324 QueryKind::DDL_SIMULATED,
325 );
326
327 self::assertInstanceOf(AlterTableMutation::class, $rename);
328 $rename->apply($store, []);
329 $definition = $registry->get('children');
330 self::assertNotNull($definition);
331 self::assertSame(['parent_id'], $definition->foreignKeys['fk_parent']->columns);
332
333 $add = $resolver->resolve('ALTER TABLE children ADD COLUMN label TEXT', QueryKind::DDL_SIMULATED);
334 self::assertInstanceOf(AlterTableMutation::class, $add);
335 $add->apply($store, []);
336 $definition = $registry->get('children');
337 self::assertNotNull($definition);
338 self::assertArrayHasKey('fk_parent', $definition->foreignKeys);
339
340 $drop = $resolver->resolve('ALTER TABLE children DROP COLUMN parent_id', QueryKind::DDL_SIMULATED);
341 self::assertInstanceOf(AlterTableMutation::class, $drop);
342 $drop->apply($store, []);
343 $definition = $registry->get('children');
344 self::assertNotNull($definition);
345 self::assertSame([], $definition->foreignKeys);
346 }
347
348 public function testAlterAddColumnPreservesExistingAndAddedForeignKeys(): void
349 {
350 $store = new ShadowStore();
351 $registry = new TableDefinitionRegistry();
352 $registry->register('children', new TableDefinition(
353 ['parent_id'],
354 ['parent_id' => 'INTEGER'],
355 [],
356 [],
357 [],
358 foreignKeys: ['foreign_0' => new ForeignKeyDefinition(['parent_id'], 'parents', ['id'])],
359 ));
360 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
361 $mutation = $resolver->resolve(
362 'ALTER TABLE children ADD COLUMN owner_id INTEGER REFERENCES owners(id) ON DELETE CASCADE',
363 QueryKind::DDL_SIMULATED,
364 );
365
366 self::assertInstanceOf(AlterTableMutation::class, $mutation);
367 $mutation->apply($store, []);
368 $definition = $registry->get('children');
369 self::assertNotNull($definition);
370 $foreignKeys = $definition->foreignKeys;
371 self::assertCount(2, $foreignKeys);
372 self::assertSame(['foreign_0', 'foreign_0_added'], array_keys($foreignKeys));
373 self::assertSame(['parents', 'owners'], array_map(
374 static fn (ForeignKeyDefinition $foreignKey): string => $foreignKey->referencedTable,
375 array_values($foreignKeys),
376 ));
377 }
378
379 public function testResolveAlterTableDropColumn(): void
380 {
381 $registry = new TableDefinitionRegistry();
382 $registry->register('users', new TableDefinition(
383 ['id', 'name', 'email'],
384 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
385 ['id'],
386 ['id', 'name'],
387 [],
388 ));
389 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
390 $mutation = $resolver->resolve('ALTER TABLE users DROP COLUMN email', QueryKind::DDL_SIMULATED);
391
392 self::assertInstanceOf(AlterTableMutation::class, $mutation);
393 }
394
395 public function testResolveAlterTableRenameColumn(): void
396 {
397 $registry = new TableDefinitionRegistry();
398 $registry->register('users', new TableDefinition(
399 ['id', 'name', 'email'],
400 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
401 ['id'],
402 ['id', 'name'],
403 [],
404 ));
405 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
406 $mutation = $resolver->resolve('ALTER TABLE users RENAME COLUMN name TO full_name', QueryKind::DDL_SIMULATED);
407
408 self::assertInstanceOf(AlterTableMutation::class, $mutation);
409 }
410
411 public function testResolveAlterTableUnknownThrows(): void
412 {
413 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
414
415 $this->expectException(UnknownSchemaException::class);
416 $resolver->resolve('ALTER TABLE unknown_table ADD COLUMN x TEXT', QueryKind::DDL_SIMULATED);
417 }
418
419 public function testResolveSelectReturnsNull(): void
420 {
421 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
422 $mutation = $resolver->resolve('SELECT * FROM users', QueryKind::READ);
423 self::assertNull($mutation);
424 }
425
426 public function testResolveEmptyReturnsNull(): void
427 {
428 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
429 $mutation = $resolver->resolve('', QueryKind::READ);
430 self::assertNull($mutation);
431 }
432
433 public function testResolveUnsupportedReturnsNull(): void
434 {
435 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
436 $mutation = $resolver->resolve('CREATE INDEX idx ON t (col)', QueryKind::READ);
437 self::assertNull($mutation);
438 }
439
440 public function testResolveUpdateWithoutTargetThrows(): void
441 {
442 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
443 $this->expectException(UnsupportedSqlException::class);
444 $resolver->resolve('UPDATE', QueryKind::WRITE_SIMULATED);
445 }
446
447 public function testResolveDeleteWithoutTargetThrows(): void
448 {
449 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
450 $this->expectException(UnsupportedSqlException::class);
451 $resolver->resolve('DELETE', QueryKind::WRITE_SIMULATED);
452 }
453
454 public function testResolveInsertWithoutTargetThrows(): void
455 {
456 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
457 $this->expectException(UnsupportedSqlException::class);
458 $resolver->resolve('INSERT', QueryKind::WRITE_SIMULATED);
459 }
460
461 public function testResolveCreateTableWithoutTargetThrows(): void
462 {
463 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
464 $this->expectException(UnsupportedSqlException::class);
465 $resolver->resolve('CREATE TABLE', QueryKind::DDL_SIMULATED);
466 }
467
468 public function testResolveDropTableWithoutTargetThrows(): void
469 {
470 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
471 $this->expectException(UnsupportedSqlException::class);
472 $resolver->resolve('DROP TABLE', QueryKind::DDL_SIMULATED);
473 }
474
475 public function testResolveAlterTableRenameTable(): void
476 {
477 $registry = new TableDefinitionRegistry();
478 $registry->register('users', new TableDefinition(
479 ['id', 'name'],
480 ['id' => 'INTEGER', 'name' => 'TEXT'],
481 ['id'],
482 ['id'],
483 [],
484 ));
485 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
486 $mutation = $resolver->resolve('ALTER TABLE users RENAME TO people', QueryKind::DDL_SIMULATED);
487 self::assertInstanceOf(AlterTableMutation::class, $mutation);
488 }
489
490 public function testResolveAlterTableUnsupportedOperationThrows(): void
491 {
492 $registry = new TableDefinitionRegistry();
493 $registry->register('users', new TableDefinition(
494 ['id', 'name'],
495 ['id' => 'INTEGER', 'name' => 'TEXT'],
496 ['id'],
497 ['id'],
498 [],
499 ));
500 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
501 $this->expectException(UnsupportedSqlException::class);
502 $resolver->resolve('ALTER TABLE users SOMETHING WEIRD', QueryKind::DDL_SIMULATED);
503 }
504
505 public function testResolveAlterTableAddColumnWithType(): void
506 {
507 $registry = new TableDefinitionRegistry();
508 $registry->register('users', new TableDefinition(
509 ['id', 'name'],
510 ['id' => 'INTEGER', 'name' => 'TEXT'],
511 ['id'],
512 ['id'],
513 [],
514 ));
515 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
516 $mutation = $resolver->resolve('ALTER TABLE users ADD COLUMN age INTEGER', QueryKind::DDL_SIMULATED);
517 self::assertInstanceOf(AlterTableMutation::class, $mutation);
518 }
519
520 public function testResolveAlterTableAddColumnWithoutType(): void
521 {
522 $registry = new TableDefinitionRegistry();
523 $registry->register('users', new TableDefinition(
524 ['id', 'name'],
525 ['id' => 'INTEGER', 'name' => 'TEXT'],
526 ['id'],
527 ['id'],
528 [],
529 ));
530 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
531 $mutation = $resolver->resolve('ALTER TABLE users ADD COLUMN notes', QueryKind::DDL_SIMULATED);
532 self::assertInstanceOf(AlterTableMutation::class, $mutation);
533 }
534
535 public function testResolveAlterTableDropColumnUpdatesSchema(): void
536 {
537 $registry = new TableDefinitionRegistry();
538 $registry->register('users', new TableDefinition(
539 ['id', 'name', 'email'],
540 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
541 ['id'],
542 ['id', 'name'],
543 ['email_UNIQUE' => ['email']],
544 ));
545 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
546 $mutation = $resolver->resolve('ALTER TABLE users DROP COLUMN email', QueryKind::DDL_SIMULATED);
547 self::assertInstanceOf(AlterTableMutation::class, $mutation);
548 }
549
550 public function testResolveAlterTableRenameColumnUpdatesSchema(): void
551 {
552 $registry = new TableDefinitionRegistry();
553 $registry->register('users', new TableDefinition(
554 ['id', 'name'],
555 ['id' => 'INTEGER', 'name' => 'TEXT'],
556 ['id'],
557 ['id', 'name'],
558 ['name_UNIQUE' => ['name']],
559 ));
560 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
561 $mutation = $resolver->resolve('ALTER TABLE users RENAME COLUMN name TO full_name', QueryKind::DDL_SIMULATED);
562 self::assertInstanceOf(AlterTableMutation::class, $mutation);
563 }
564
565 public function testResolveAlterTableWithoutTargetThrows(): void
566 {
567 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
568 $this->expectException(UnsupportedSqlException::class);
569 $resolver->resolve('ALTER TABLE', QueryKind::DDL_SIMULATED);
570 }
571
572 public function testResolveDeleteWithExistingRows(): void
573 {
574 $store = new ShadowStore();
575 $store->ensure('users');
576 $store->set('users', [['id' => 1, 'name' => 'Alice']]);
577 $registry = new TableDefinitionRegistry();
578 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
579 $mutation = $resolver->resolve('DELETE FROM users WHERE id = 1', QueryKind::WRITE_SIMULATED);
580 self::assertInstanceOf(DeleteMutation::class, $mutation);
581 }
582
583 public function testResolveUpdateWithPrimaryKeys(): void
584 {
585 $registry = new TableDefinitionRegistry();
586 $registry->register('users', new TableDefinition(
587 ['id', 'name'],
588 ['id' => 'INTEGER', 'name' => 'TEXT'],
589 ['id'],
590 [],
591 [],
592 ));
593 $store = new ShadowStore();
594 $store->ensure('users');
595 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
596 $mutation = $resolver->resolve("UPDATE users SET name = 'Bob' WHERE id = 1", QueryKind::WRITE_SIMULATED);
597 self::assertInstanceOf(UpdateMutation::class, $mutation);
598 }
599
600 public function testResolveOnConflictWithEmptyUpdatesReturnsInsert(): void
601 {
602 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
603 $mutation = $resolver->resolve(
604 'INSERT INTO t (id) VALUES (1) ON CONFLICT (id) DO NOTHING',
605 QueryKind::WRITE_SIMULATED
606 );
607 self::assertInstanceOf(InsertMutation::class, $mutation);
608 }
609
610 public function testResolveAlterTableAddWithoutColumnKeyword(): void
611 {
612 $registry = new TableDefinitionRegistry();
613 $registry->register('users', new TableDefinition(
614 ['id', 'name'],
615 ['id' => 'INTEGER', 'name' => 'TEXT'],
616 ['id'],
617 ['id'],
618 [],
619 ));
620 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
621 $mutation = $resolver->resolve('ALTER TABLE users ADD phone TEXT', QueryKind::DDL_SIMULATED);
622 self::assertInstanceOf(AlterTableMutation::class, $mutation);
623 }
624
625 public function testResolveAlterTableRenameWithoutColumnKeyword(): void
626 {
627 $registry = new TableDefinitionRegistry();
628 $registry->register('users', new TableDefinition(
629 ['id', 'name'],
630 ['id' => 'INTEGER', 'name' => 'TEXT'],
631 ['id'],
632 ['id'],
633 [],
634 ));
635 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
636 $mutation = $resolver->resolve('ALTER TABLE users RENAME name TO full_name', QueryKind::DDL_SIMULATED);
637 self::assertInstanceOf(AlterTableMutation::class, $mutation);
638 }
639
640 public function testResolveAlterTableAddColumnUnknownTableThrows(): void
641 {
642 $registry = new TableDefinitionRegistry();
643 $registry->register('users', new TableDefinition(
644 ['id'],
645 [],
646 [],
647 [],
648 [],
649 ));
650 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
651 $mutation = $resolver->resolve('ALTER TABLE users ADD COLUMN x', QueryKind::DDL_SIMULATED);
652 self::assertInstanceOf(AlterTableMutation::class, $mutation);
653 }
654
655 public function testResolveAlterTableDropColumnUnknownThrows(): void
656 {
657 $registry = new TableDefinitionRegistry();
658 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
659 $this->expectException(UnknownSchemaException::class);
660 $resolver->resolve('ALTER TABLE missing DROP COLUMN x', QueryKind::DDL_SIMULATED);
661 }
662
663 public function testResolveAlterTableRenameColumnUnknownThrows(): void
664 {
665 $registry = new TableDefinitionRegistry();
666 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
667 $this->expectException(UnknownSchemaException::class);
668 $resolver->resolve('ALTER TABLE missing RENAME COLUMN x TO y', QueryKind::DDL_SIMULATED);
669 }
670
671 public function testResolveCreateTableIfNotExistsLowercase(): void
672 {
673 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
674 $mutation = $resolver->resolve('create table if not exists t (id integer primary key)', QueryKind::DDL_SIMULATED);
675 self::assertInstanceOf(CreateTableMutation::class, $mutation);
676 }
677
678 public function testResolveDropTableIfExistsLowercase(): void
679 {
680 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
681 $mutation = $resolver->resolve('drop table if exists nonexistent', QueryKind::DDL_SIMULATED);
682 self::assertInstanceOf(DropTableMutation::class, $mutation);
683 }
684
685 public function testResolveAlterTableAddColumnLowercase(): void
686 {
687 $registry = new TableDefinitionRegistry();
688 $registry->register('users', new TableDefinition(
689 ['id'],
690 ['id' => 'INTEGER'],
691 ['id'],
692 [],
693 [],
694 ));
695 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
696 $mutation = $resolver->resolve('alter table users add column email text', QueryKind::DDL_SIMULATED);
697 self::assertInstanceOf(AlterTableMutation::class, $mutation);
698 }
699
700 public function testResolveAlterTableDropColumnLowercase(): void
701 {
702 $registry = new TableDefinitionRegistry();
703 $registry->register('users', new TableDefinition(
704 ['id', 'email'],
705 ['id' => 'INTEGER', 'email' => 'TEXT'],
706 ['id'],
707 [],
708 [],
709 ));
710 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
711 $mutation = $resolver->resolve('alter table users drop column email', QueryKind::DDL_SIMULATED);
712 self::assertInstanceOf(AlterTableMutation::class, $mutation);
713 }
714
715 public function testResolveAlterTableRenameToLowercase(): void
716 {
717 $registry = new TableDefinitionRegistry();
718 $registry->register('users', new TableDefinition(
719 ['id'],
720 ['id' => 'INTEGER'],
721 ['id'],
722 [],
723 [],
724 ));
725 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
726 $mutation = $resolver->resolve('alter table users rename to people', QueryKind::DDL_SIMULATED);
727 self::assertInstanceOf(AlterTableMutation::class, $mutation);
728 }
729
730 public function testResolveAlterTableRenameColumnLowercase(): void
731 {
732 $registry = new TableDefinitionRegistry();
733 $registry->register('users', new TableDefinition(
734 ['id', 'name'],
735 ['id' => 'INTEGER', 'name' => 'TEXT'],
736 ['id'],
737 [],
738 [],
739 ));
740 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
741 $mutation = $resolver->resolve('alter table users rename column name to full_name', QueryKind::DDL_SIMULATED);
742 self::assertInstanceOf(AlterTableMutation::class, $mutation);
743 }
744
745 public function testResolveDeleteFullTableWithoutWhere(): void
746 {
747 $registry = new TableDefinitionRegistry();
748 $registry->register('users', new TableDefinition(
749 ['id', 'name'],
750 ['id' => 'INTEGER', 'name' => 'TEXT'],
751 ['id'],
752 [],
753 [],
754 ));
755 $store = new ShadowStore();
756 $store->ensure('users');
757 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
758 $mutation = $resolver->resolve('DELETE FROM users', QueryKind::WRITE_SIMULATED);
759 self::assertInstanceOf(DeleteMutation::class, $mutation);
760 }
761
762 public function testResolveAlterTableAddColumnNoKeyword(): void
763 {
764 $registry = new TableDefinitionRegistry();
765 $registry->register('t', new TableDefinition(
766 ['id'],
767 ['id' => 'INTEGER'],
768 ['id'],
769 [],
770 [],
771 ));
772 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
773 $mutation = $resolver->resolve('ALTER TABLE t ADD email TEXT', QueryKind::DDL_SIMULATED);
774 self::assertInstanceOf(AlterTableMutation::class, $mutation);
775 }
776
777 public function testResolveAlterTableAddColumnWithParenthesizedType(): void
778 {
779 $registry = new TableDefinitionRegistry();
780 $registry->register('t', new TableDefinition(
781 ['id'],
782 ['id' => 'INTEGER'],
783 ['id'],
784 [],
785 [],
786 ));
787 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
788 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN price DECIMAL(10,2)', QueryKind::DDL_SIMULATED);
789 self::assertInstanceOf(AlterTableMutation::class, $mutation);
790 }
791
792 public function testResolveAlterTableAddColumnWithPrimaryKeyword(): void
793 {
794 $registry = new TableDefinitionRegistry();
795 $registry->register('t', new TableDefinition(
796 ['id'],
797 ['id' => 'INTEGER'],
798 ['id'],
799 [],
800 [],
801 ));
802 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
803 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN note PRIMARY KEY', QueryKind::DDL_SIMULATED);
804 self::assertInstanceOf(AlterTableMutation::class, $mutation);
805 }
806
807 public function testResolveAlterTableAddColumnVerifyNewColumns(): void
808 {
809 $registry = new TableDefinitionRegistry();
810 $registry->register('users', new TableDefinition(
811 ['id', 'name'],
812 ['id' => 'INTEGER', 'name' => 'TEXT'],
813 ['id'],
814 ['id'],
815 [],
816 ));
817 $store = new ShadowStore();
818 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
819 $mutation = $resolver->resolve('ALTER TABLE users ADD COLUMN phone TEXT', QueryKind::DDL_SIMULATED);
820 self::assertInstanceOf(AlterTableMutation::class, $mutation);
821 self::assertSame('users', $mutation->tableName());
822 $registry->unregister('users');
823 $mutation->apply($store, []);
824 $def = $registry->get('users');
825 self::assertNotNull($def);
826 self::assertContains('phone', $def->columns);
827 self::assertContains('id', $def->columns);
828 self::assertContains('name', $def->columns);
829 self::assertSame('TEXT', $def->columnTypes['phone']);
830 }
831
832 public function testResolveAlterTableDropColumnVerifyRemovedFromAllLists(): void
833 {
834 $registry = new TableDefinitionRegistry();
835 $registry->register('users', new TableDefinition(
836 ['id', 'name', 'email'],
837 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
838 ['id'],
839 ['id', 'name'],
840 ['email_unique' => ['email']],
841 ));
842 $store = new ShadowStore();
843 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
844 $mutation = $resolver->resolve('ALTER TABLE users DROP COLUMN email', QueryKind::DDL_SIMULATED);
845 self::assertInstanceOf(AlterTableMutation::class, $mutation);
846 self::assertSame('users', $mutation->tableName());
847 $registry->unregister('users');
848 $mutation->apply($store, []);
849 $def = $registry->get('users');
850 self::assertNotNull($def);
851 self::assertNotContains('email', $def->columns);
852 self::assertContains('id', $def->columns);
853 self::assertContains('name', $def->columns);
854 self::assertArrayNotHasKey('email', $def->columnTypes);
855 self::assertNotContains('email', $def->notNullColumns);
856 }
857
858 public function testResolveAlterTableRenameColumnVerifyAllUpdated(): void
859 {
860 $registry = new TableDefinitionRegistry();
861 $registry->register('users', new TableDefinition(
862 ['id', 'name'],
863 ['id' => 'INTEGER', 'name' => 'TEXT'],
864 ['id'],
865 ['id', 'name'],
866 ['name_unique' => ['name']],
867 ));
868 $store = new ShadowStore();
869 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
870 $mutation = $resolver->resolve('ALTER TABLE users RENAME COLUMN name TO full_name', QueryKind::DDL_SIMULATED);
871 self::assertInstanceOf(AlterTableMutation::class, $mutation);
872 self::assertSame('users', $mutation->tableName());
873 $registry->unregister('users');
874 $mutation->apply($store, []);
875 $def = $registry->get('users');
876 self::assertNotNull($def);
877 self::assertContains('full_name', $def->columns);
878 self::assertNotContains('name', $def->columns);
879 self::assertArrayHasKey('full_name', $def->columnTypes);
880 self::assertArrayNotHasKey('name', $def->columnTypes);
881 self::assertSame('TEXT', $def->columnTypes['full_name']);
882 self::assertContains('full_name', $def->notNullColumns);
883 self::assertNotContains('name', $def->notNullColumns);
884 }
885
886 public function testResolveAlterTableRenameColumnPrimaryKeysUpdated(): void
887 {
888 $registry = new TableDefinitionRegistry();
889 $registry->register('t', new TableDefinition(
890 ['id', 'code'],
891 ['id' => 'INTEGER', 'code' => 'TEXT'],
892 ['id', 'code'],
893 [],
894 [],
895 ));
896 $store = new ShadowStore();
897 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
898 $mutation = $resolver->resolve('ALTER TABLE t RENAME COLUMN code TO code_new', QueryKind::DDL_SIMULATED);
899 self::assertInstanceOf(AlterTableMutation::class, $mutation);
900 $registry->unregister('t');
901 $mutation->apply($store, []);
902 $def = $registry->get('t');
903 self::assertNotNull($def);
904 self::assertContains('code_new', $def->primaryKeys);
905 self::assertNotContains('code', $def->primaryKeys);
906 }
907
908 public function testResolveAlterTableRenameColumnUniqueConstraintsUpdated(): void
909 {
910 $registry = new TableDefinitionRegistry();
911 $registry->register('t', new TableDefinition(
912 ['id', 'email'],
913 ['id' => 'INTEGER', 'email' => 'TEXT'],
914 ['id'],
915 [],
916 ['email_uq' => ['email']],
917 ));
918 $store = new ShadowStore();
919 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
920 $mutation = $resolver->resolve('ALTER TABLE t RENAME COLUMN email TO mail', QueryKind::DDL_SIMULATED);
921 self::assertNotNull($mutation);
922 $registry->unregister('t');
923 $mutation->apply($store, []);
924 $def = $registry->get('t');
925 self::assertNotNull($def);
926 self::assertSame(['mail'], $def->uniqueConstraints['email_uq']);
927 }
928
929 public function testResolveAlterTableRenameColumnTypedColumnsUpdated(): void
930 {
931 $registry = new TableDefinitionRegistry();
932 $registry->register('t', new TableDefinition(
933 ['id', 'val'],
934 ['id' => 'INTEGER', 'val' => 'REAL'],
935 ['id'],
936 [],
937 [],
938 ['id' => new ColumnDeclaration(ColumnTypeFamily::INTEGER, 'INTEGER'), 'val' => new ColumnDeclaration(ColumnTypeFamily::FLOAT, 'REAL')],
939 ));
940 $store = new ShadowStore();
941 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
942 $mutation = $resolver->resolve('ALTER TABLE t RENAME COLUMN val TO value', QueryKind::DDL_SIMULATED);
943 self::assertNotNull($mutation);
944 $registry->unregister('t');
945 $mutation->apply($store, []);
946 $def = $registry->get('t');
947 self::assertNotNull($def);
948 self::assertArrayHasKey('value', $def->typedColumns);
949 self::assertArrayNotHasKey('val', $def->typedColumns);
950 }
951
952 public function testResolveAlterTableDropColumnPreservesPrimaryKeys(): void
953 {
954 $registry = new TableDefinitionRegistry();
955 $registry->register('t', new TableDefinition(
956 ['id', 'name', 'email'],
957 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
958 ['id'],
959 ['id'],
960 [],
961 ));
962 $store = new ShadowStore();
963 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
964 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN email', QueryKind::DDL_SIMULATED);
965 self::assertNotNull($mutation);
966 $registry->unregister('t');
967 $mutation->apply($store, []);
968 $def = $registry->get('t');
969 self::assertNotNull($def);
970 self::assertSame(['id'], $def->primaryKeys);
971 }
972
973 public function testResolveAlterTableDropColumnRemovesPrimaryKeyIfDropped(): void
974 {
975 $registry = new TableDefinitionRegistry();
976 $registry->register('t', new TableDefinition(
977 ['id', 'code'],
978 ['id' => 'INTEGER', 'code' => 'TEXT'],
979 ['id', 'code'],
980 [],
981 [],
982 ));
983 $store = new ShadowStore();
984 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
985 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN code', QueryKind::DDL_SIMULATED);
986 self::assertNotNull($mutation);
987 $registry->unregister('t');
988 $mutation->apply($store, []);
989 $def = $registry->get('t');
990 self::assertNotNull($def);
991 self::assertSame(['id'], $def->primaryKeys);
992 self::assertNotContains('code', $def->primaryKeys);
993 }
994
995 public function testResolveAlterTableDropColumnRemovesUniqueConstraintIfEmpty(): void
996 {
997 $registry = new TableDefinitionRegistry();
998 $registry->register('t', new TableDefinition(
999 ['id', 'email'],
1000 ['id' => 'INTEGER', 'email' => 'TEXT'],
1001 ['id'],
1002 [],
1003 ['email_uq' => ['email']],
1004 ));
1005 $store = new ShadowStore();
1006 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1007 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN email', QueryKind::DDL_SIMULATED);
1008 self::assertNotNull($mutation);
1009 $registry->unregister('t');
1010 $mutation->apply($store, []);
1011 $def = $registry->get('t');
1012 self::assertNotNull($def);
1013 self::assertArrayNotHasKey('email_uq', $def->uniqueConstraints);
1014 }
1015
1016 public function testResolveAlterTableDropColumnPreservesPartialUniqueConstraint(): void
1017 {
1018 $registry = new TableDefinitionRegistry();
1019 $registry->register('t', new TableDefinition(
1020 ['id', 'name', 'email'],
1021 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
1022 ['id'],
1023 [],
1024 ['name_email_uq' => ['name', 'email']],
1025 ));
1026 $store = new ShadowStore();
1027 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1028 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN email', QueryKind::DDL_SIMULATED);
1029 self::assertNotNull($mutation);
1030 $registry->unregister('t');
1031 $mutation->apply($store, []);
1032 $def = $registry->get('t');
1033 self::assertNotNull($def);
1034 self::assertArrayHasKey('name_email_uq', $def->uniqueConstraints);
1035 self::assertSame(['name'], $def->uniqueConstraints['name_email_uq']);
1036 }
1037
1038 public function testResolveAlterTableAddColumnVerifyParenthesizedType(): void
1039 {
1040 $registry = new TableDefinitionRegistry();
1041 $registry->register('t', new TableDefinition(
1042 ['id'],
1043 ['id' => 'INTEGER'],
1044 ['id'],
1045 [],
1046 [],
1047 ));
1048 $store = new ShadowStore();
1049 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1050 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN price DECIMAL(10,2)', QueryKind::DDL_SIMULATED);
1051 self::assertNotNull($mutation);
1052 $registry->unregister('t');
1053 $mutation->apply($store, []);
1054 $def = $registry->get('t');
1055 self::assertNotNull($def);
1056 self::assertContains('price', $def->columns);
1057 self::assertSame('DECIMAL(10,2)', $def->columnTypes['price']);
1058 }
1059
1060 public function testResolveInsertTableName(): void
1061 {
1062 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
1063 $mutation = $resolver->resolve('INSERT INTO orders (id) VALUES (1)', QueryKind::WRITE_SIMULATED);
1064 self::assertInstanceOf(InsertMutation::class, $mutation);
1065 self::assertSame('orders', $mutation->tableName());
1066 }
1067
1068 public function testResolveUpdateWithoutContextThrowsUnknownSchema(): void
1069 {
1070 $store = new ShadowStore();
1071 $registry = new TableDefinitionRegistry();
1072 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1073 $this->expectException(UnknownSchemaException::class);
1074 $resolver->resolve('UPDATE t SET x = 1', QueryKind::WRITE_SIMULATED);
1075 }
1076
1077 public function testResolveDeleteTableName(): void
1078 {
1079 $store = new ShadowStore();
1080 $store->ensure('orders');
1081 $store->set('orders', [['id' => 1]]);
1082 $registry = new TableDefinitionRegistry();
1083 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1084 $mutation = $resolver->resolve('DELETE FROM orders WHERE id = 1', QueryKind::WRITE_SIMULATED);
1085 self::assertInstanceOf(DeleteMutation::class, $mutation);
1086 self::assertSame('orders', $mutation->tableName());
1087 }
1088
1089 public function testResolveDeleteLowercaseSql(): void
1090 {
1091 $registry = new TableDefinitionRegistry();
1092 $registry->register('users', new TableDefinition(
1093 ['id', 'name'],
1094 ['id' => 'INTEGER', 'name' => 'TEXT'],
1095 ['id'],
1096 [],
1097 [],
1098 ));
1099 $store = new ShadowStore();
1100 $store->ensure('users');
1101 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1102 $mutation = $resolver->resolve('delete from users where id = 1', QueryKind::WRITE_SIMULATED);
1103 self::assertInstanceOf(DeleteMutation::class, $mutation);
1104 }
1105
1106 public function testResolveUpsertTableNameAndUpdateColumns(): void
1107 {
1108 $registry = new TableDefinitionRegistry();
1109 $registry->register('users', new TableDefinition(
1110 ['id', 'name'],
1111 ['id' => 'INTEGER', 'name' => 'TEXT'],
1112 ['id'],
1113 [],
1114 [],
1115 ));
1116 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
1117 $mutation = $resolver->resolve(
1118 "INSERT INTO users (id, name) VALUES (1, 'Alice') ON CONFLICT (id) DO UPDATE SET name = excluded.name",
1119 QueryKind::WRITE_SIMULATED
1120 );
1121 self::assertInstanceOf(UpsertMutation::class, $mutation);
1122 self::assertSame('users', $mutation->tableName());
1123 }
1124
1125 public function testResolveReplaceTableName(): void
1126 {
1127 $registry = new TableDefinitionRegistry();
1128 $registry->register('users', new TableDefinition(
1129 ['id', 'name'],
1130 ['id' => 'INTEGER', 'name' => 'TEXT'],
1131 ['id'],
1132 [],
1133 [],
1134 ));
1135 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
1136 $mutation = $resolver->resolve("REPLACE INTO users (id, name) VALUES (1, 'Alice')", QueryKind::WRITE_SIMULATED);
1137 self::assertInstanceOf(ReplaceMutation::class, $mutation);
1138 self::assertSame('users', $mutation->tableName());
1139 }
1140
1141 public function testResolveCreateTableTableName(): void
1142 {
1143 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
1144 $mutation = $resolver->resolve('CREATE TABLE orders (id INTEGER PRIMARY KEY)', QueryKind::DDL_SIMULATED);
1145 self::assertInstanceOf(CreateTableMutation::class, $mutation);
1146 self::assertSame('orders', $mutation->tableName());
1147 }
1148
1149 public function testResolveDropTableTableName(): void
1150 {
1151 $registry = new TableDefinitionRegistry();
1152 $registry->register('users', new TableDefinition(
1153 ['id'],
1154 ['id' => 'INTEGER'],
1155 ['id'],
1156 [],
1157 [],
1158 ));
1159 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
1160 $mutation = $resolver->resolve('DROP TABLE users', QueryKind::DDL_SIMULATED);
1161 self::assertInstanceOf(DropTableMutation::class, $mutation);
1162 self::assertSame('users', $mutation->tableName());
1163 }
1164
1165 public function testResolveInsertIgnoreWithPrimaryKeys(): void
1166 {
1167 $registry = new TableDefinitionRegistry();
1168 $registry->register('users', new TableDefinition(
1169 ['id', 'name'],
1170 ['id' => 'INTEGER', 'name' => 'TEXT'],
1171 ['id'],
1172 [],
1173 [],
1174 ));
1175 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
1176 $mutation = $resolver->resolve("INSERT OR IGNORE INTO users (id, name) VALUES (1, 'x')", QueryKind::WRITE_SIMULATED);
1177 self::assertInstanceOf(InsertMutation::class, $mutation);
1178 self::assertSame('users', $mutation->tableName());
1179 }
1180
1181 public function testResolveAlterTableRenameToDropsOldTable(): void
1182 {
1183 $registry = new TableDefinitionRegistry();
1184 $registry->register('old_t', new TableDefinition(
1185 ['id'],
1186 ['id' => 'INTEGER'],
1187 ['id'],
1188 [],
1189 [],
1190 ));
1191 $store = new ShadowStore();
1192 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1193 $mutation = $resolver->resolve('ALTER TABLE old_t RENAME TO new_t', QueryKind::DDL_SIMULATED);
1194 self::assertInstanceOf(AlterTableMutation::class, $mutation);
1195 self::assertSame('old_t', $mutation->tableName());
1196 }
1197
1198 public function testResolveAlterTableDropColumnTypedColumnsUpdated(): void
1199 {
1200 $registry = new TableDefinitionRegistry();
1201 $registry->register('t', new TableDefinition(
1202 ['id', 'val'],
1203 ['id' => 'INTEGER', 'val' => 'TEXT'],
1204 ['id'],
1205 [],
1206 [],
1207 ['id' => new ColumnDeclaration(ColumnTypeFamily::INTEGER, 'INTEGER'), 'val' => new ColumnDeclaration(ColumnTypeFamily::TEXT, 'TEXT')],
1208 ));
1209 $store = new ShadowStore();
1210 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1211 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN val', QueryKind::DDL_SIMULATED);
1212 self::assertNotNull($mutation);
1213 $registry->unregister('t');
1214 $mutation->apply($store, []);
1215 $def = $registry->get('t');
1216 self::assertNotNull($def);
1217 self::assertArrayNotHasKey('val', $def->typedColumns);
1218 self::assertArrayHasKey('id', $def->typedColumns);
1219 }
1220
1221 public function testResolveAlterTableAddColumnWithTypedColumns(): void
1222 {
1223 $registry = new TableDefinitionRegistry();
1224 $registry->register('t', new TableDefinition(
1225 ['id'],
1226 ['id' => 'INTEGER'],
1227 ['id'],
1228 [],
1229 [],
1230 ['id' => new ColumnDeclaration(ColumnTypeFamily::INTEGER, 'INTEGER')],
1231 ));
1232 $store = new ShadowStore();
1233 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1234 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN val TEXT', QueryKind::DDL_SIMULATED);
1235 self::assertNotNull($mutation);
1236 $registry->unregister('t');
1237 $mutation->apply($store, []);
1238 $def = $registry->get('t');
1239 self::assertNotNull($def);
1240 self::assertArrayHasKey('val', $def->typedColumns);
1241 }
1242
1243 public function testResolveAlterTableDropColumnNotNullUpdated(): void
1244 {
1245 $registry = new TableDefinitionRegistry();
1246 $registry->register('t', new TableDefinition(
1247 ['id', 'name', 'email'],
1248 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
1249 ['id'],
1250 ['id', 'name', 'email'],
1251 [],
1252 ));
1253 $store = new ShadowStore();
1254 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1255 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN name', QueryKind::DDL_SIMULATED);
1256 self::assertNotNull($mutation);
1257 $registry->unregister('t');
1258 $mutation->apply($store, []);
1259 $def = $registry->get('t');
1260 self::assertNotNull($def);
1261 self::assertNotContains('name', $def->notNullColumns);
1262 self::assertContains('id', $def->notNullColumns);
1263 self::assertContains('email', $def->notNullColumns);
1264 }
1265
1266 public function testResolveAlterTableRenameColumnNotNullUpdated(): void
1267 {
1268 $registry = new TableDefinitionRegistry();
1269 $registry->register('t', new TableDefinition(
1270 ['id', 'name'],
1271 ['id' => 'INTEGER', 'name' => 'TEXT'],
1272 ['id'],
1273 ['id', 'name'],
1274 [],
1275 ));
1276 $store = new ShadowStore();
1277 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1278 $mutation = $resolver->resolve('ALTER TABLE t RENAME COLUMN name TO full_name', QueryKind::DDL_SIMULATED);
1279 self::assertNotNull($mutation);
1280 $registry->unregister('t');
1281 $mutation->apply($store, []);
1282 $def = $registry->get('t');
1283 self::assertNotNull($def);
1284 self::assertContains('full_name', $def->notNullColumns);
1285 self::assertNotContains('name', $def->notNullColumns);
1286 }
1287
1288 public function testResolveReturnsNullForUnsupportedClassification(): void
1289 {
1290 $store = new ShadowStore();
1291 $registry = new TableDefinitionRegistry();
1292 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1293 $result = $resolver->resolve('CREATE INDEX idx ON t (a)', QueryKind::DDL_SIMULATED);
1294 self::assertNull($result);
1295 }
1296
1297 public function testResolveUpdateEnsuresShadowStoreWithDefinition(): void
1298 {
1299 $store = new ShadowStore();
1300 $registry = new TableDefinitionRegistry();
1301 $registry->register('users', new TableDefinition(
1302 ['id', 'name'],
1303 ['id' => 'INTEGER', 'name' => 'TEXT'],
1304 ['id'],
1305 [],
1306 [],
1307 ));
1308 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1309 $mutation = $resolver->resolve("UPDATE users SET name = 'Bob' WHERE id = 1", QueryKind::WRITE_SIMULATED);
1310 self::assertNotNull($mutation);
1311 self::assertInstanceOf(UpdateMutation::class, $mutation);
1312 self::assertSame(ShadowTableState::Initialized, $store->state('users'));
1313 }
1314
1315 public function testResolveUpdatePrimaryKeysFromDefinition(): void
1316 {
1317 $store = new ShadowStore();
1318 $registry = new TableDefinitionRegistry();
1319 $registry->register('users', new TableDefinition(
1320 ['id', 'name'],
1321 ['id' => 'INTEGER', 'name' => 'TEXT'],
1322 ['id'],
1323 [],
1324 [],
1325 ));
1326 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1327 $mutation = $resolver->resolve("UPDATE users SET name = 'Bob' WHERE id = 1", QueryKind::WRITE_SIMULATED);
1328 self::assertInstanceOf(UpdateMutation::class, $mutation);
1329 }
1330
1331 public function testResolveUpdateWithoutDefinitionHasEmptyPrimaryKeys(): void
1332 {
1333 $store = new ShadowStore();
1334 $store->ensure('users');
1335 $store->set('users', [['id' => 1, 'name' => 'x']]);
1336 $registry = new TableDefinitionRegistry();
1337 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1338 $mutation = $resolver->resolve("UPDATE users SET name = 'Bob' WHERE id = 1", QueryKind::WRITE_SIMULATED);
1339 self::assertInstanceOf(UpdateMutation::class, $mutation);
1340 }
1341
1342 public function testResolveDeleteEnsuresShadowStore(): void
1343 {
1344 $store = new ShadowStore();
1345 $registry = new TableDefinitionRegistry();
1346 $registry->register('users', new TableDefinition(
1347 ['id', 'name'],
1348 ['id' => 'INTEGER', 'name' => 'TEXT'],
1349 ['id'],
1350 [],
1351 [],
1352 ));
1353 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1354 $mutation = $resolver->resolve('DELETE FROM users WHERE id = 1', QueryKind::WRITE_SIMULATED);
1355 self::assertInstanceOf(DeleteMutation::class, $mutation);
1356 self::assertSame([], $store->get('users'));
1357 }
1358
1359 public function testResolveDeleteWithoutDefinitionAndNoRowsThrows(): void
1360 {
1361 $store = new ShadowStore();
1362 $registry = new TableDefinitionRegistry();
1363 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1364 $this->expectException(UnknownSchemaException::class);
1365 $resolver->resolve('DELETE FROM unknown_table WHERE id = 1', QueryKind::WRITE_SIMULATED);
1366 }
1367
1368 public function testResolveUpsertWithDefinitionPrimaryKeys(): void
1369 {
1370 $store = new ShadowStore();
1371 $registry = new TableDefinitionRegistry();
1372 $registry->register('users', new TableDefinition(
1373 ['id', 'name'],
1374 ['id' => 'INTEGER', 'name' => 'TEXT'],
1375 ['id'],
1376 [],
1377 [],
1378 ));
1379 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1380 $mutation = $resolver->resolve("INSERT INTO users (id, name) VALUES (1, 'a') ON CONFLICT (id) DO UPDATE SET name = excluded.name", QueryKind::WRITE_SIMULATED);
1381 self::assertInstanceOf(UpsertMutation::class, $mutation);
1382 }
1383
1384 public function testResolveInsertIgnoreWithPrimaryKeysReturnsInsertMutation(): void
1385 {
1386 $store = new ShadowStore();
1387 $registry = new TableDefinitionRegistry();
1388 $registry->register('users', new TableDefinition(
1389 ['id', 'name'],
1390 ['id' => 'INTEGER', 'name' => 'TEXT'],
1391 ['id'],
1392 [],
1393 [],
1394 ));
1395 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1396 $mutation = $resolver->resolve("INSERT OR IGNORE INTO users (id, name) VALUES (1, 'a')", QueryKind::WRITE_SIMULATED);
1397 self::assertInstanceOf(InsertMutation::class, $mutation);
1398 }
1399
1400 public function testResolveCreateTableIfNotExistsLowercaseDoesNotThrow(): void
1401 {
1402 $store = new ShadowStore();
1403 $registry = new TableDefinitionRegistry();
1404 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1405 $mutation = $resolver->resolve('CREATE TABLE if not exists t (id INTEGER)', QueryKind::DDL_SIMULATED);
1406 self::assertInstanceOf(CreateTableMutation::class, $mutation);
1407 }
1408
1409 public function testResolveCreateTableAlreadyExistsNoIfNotExistsThrows(): void
1410 {
1411 $store = new ShadowStore();
1412 $registry = new TableDefinitionRegistry();
1413 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
1414 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1415 $this->expectException(UnsupportedSqlException::class);
1416 $resolver->resolve('CREATE TABLE t (id INTEGER)', QueryKind::DDL_SIMULATED);
1417 }
1418
1419 public function testResolveAlterTableOnNonExistentTableThrows(): void
1420 {
1421 $store = new ShadowStore();
1422 $registry = new TableDefinitionRegistry();
1423 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1424 $this->expectException(UnknownSchemaException::class);
1425 $resolver->resolve('ALTER TABLE nonexistent ADD COLUMN a INTEGER', QueryKind::DDL_SIMULATED);
1426 }
1427
1428 public function testResolveAlterTableUnsupportedOperationThrowsForUnknownAction(): void
1429 {
1430 $store = new ShadowStore();
1431 $registry = new TableDefinitionRegistry();
1432 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
1433 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1434 $this->expectException(UnsupportedSqlException::class);
1435 $resolver->resolve('ALTER TABLE t WHATEVER', QueryKind::DDL_SIMULATED);
1436 }
1437
1438 public function testResolveAlterTableAddColumnWithoutColumnKeyword(): void
1439 {
1440 $store = new ShadowStore();
1441 $registry = new TableDefinitionRegistry();
1442 $registry->register('t', new TableDefinition(
1443 ['id'],
1444 ['id' => 'INTEGER'],
1445 ['id'],
1446 [],
1447 [],
1448 ));
1449 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1450 $mutation = $resolver->resolve('ALTER TABLE t ADD name TEXT', QueryKind::DDL_SIMULATED);
1451 self::assertNotNull($mutation);
1452 $registry->unregister('t');
1453 $mutation->apply($store, []);
1454 $def = $registry->get('t');
1455 self::assertNotNull($def);
1456 self::assertContains('name', $def->columns);
1457 }
1458
1459 public function testResolveAlterTableRenameColumnWithoutColumnKeyword(): void
1460 {
1461 $store = new ShadowStore();
1462 $registry = new TableDefinitionRegistry();
1463 $registry->register('t', new TableDefinition(
1464 ['id', 'old_name'],
1465 ['id' => 'INTEGER', 'old_name' => 'TEXT'],
1466 ['id'],
1467 [],
1468 [],
1469 ));
1470 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1471 $mutation = $resolver->resolve('ALTER TABLE t RENAME old_name TO new_name', QueryKind::DDL_SIMULATED);
1472 self::assertNotNull($mutation);
1473 $registry->unregister('t');
1474 $mutation->apply($store, []);
1475 $def = $registry->get('t');
1476 self::assertNotNull($def);
1477 self::assertContains('new_name', $def->columns);
1478 self::assertNotContains('old_name', $def->columns);
1479 }
1480
1481 public function testResolveAlterTableAddColumnAppliesNewColumnToDefinition(): void
1482 {
1483 $store = new ShadowStore();
1484 $registry = new TableDefinitionRegistry();
1485 $registry->register('t', new TableDefinition(
1486 ['id'],
1487 ['id' => 'INTEGER'],
1488 ['id'],
1489 [],
1490 [],
1491 ));
1492 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1493 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN status TEXT', QueryKind::DDL_SIMULATED);
1494 self::assertNotNull($mutation);
1495 $registry->unregister('t');
1496 $mutation->apply($store, []);
1497 $def = $registry->get('t');
1498 self::assertNotNull($def);
1499 self::assertContains('status', $def->columns);
1500 }
1501
1502 public function testResolveAlterTableAddColumnAppliesParenthesizedType(): void
1503 {
1504 $store = new ShadowStore();
1505 $registry = new TableDefinitionRegistry();
1506 $registry->register('t', new TableDefinition(
1507 ['id'],
1508 ['id' => 'INTEGER'],
1509 ['id'],
1510 [],
1511 [],
1512 ));
1513 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1514 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN price DECIMAL(10,2)', QueryKind::DDL_SIMULATED);
1515 self::assertNotNull($mutation);
1516 $registry->unregister('t');
1517 $mutation->apply($store, []);
1518 $def = $registry->get('t');
1519 self::assertNotNull($def);
1520 self::assertContains('price', $def->columns);
1521 self::assertSame('DECIMAL(10,2)', $def->columnTypes['price']);
1522 }
1523
1524 public function testResolveDeleteWithOnlyTableNameNoWhereNoTrailing(): void
1525 {
1526 $store = new ShadowStore();
1527 $registry = new TableDefinitionRegistry();
1528 $registry->register('users', new TableDefinition(
1529 ['id'],
1530 ['id' => 'INTEGER'],
1531 ['id'],
1532 [],
1533 [],
1534 ));
1535 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1536 $mutation = $resolver->resolve('DELETE FROM users', QueryKind::WRITE_SIMULATED);
1537 self::assertInstanceOf(DeleteMutation::class, $mutation);
1538 }
1539
1540 public function testResolveDropTableIfExistsOnNonExistentTableDoesNotThrow(): void
1541 {
1542 $store = new ShadowStore();
1543 $registry = new TableDefinitionRegistry();
1544 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1545 $mutation = $resolver->resolve('DROP TABLE IF EXISTS nonexistent', QueryKind::DDL_SIMULATED);
1546 self::assertInstanceOf(DropTableMutation::class, $mutation);
1547 }
1548
1549 public function testResolveDropTableNonExistentWithoutIfExistsThrows(): void
1550 {
1551 $store = new ShadowStore();
1552 $registry = new TableDefinitionRegistry();
1553 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1554 $this->expectException(UnknownSchemaException::class);
1555 $resolver->resolve('DROP TABLE nonexistent', QueryKind::DDL_SIMULATED);
1556 }
1557
1558 public function testResolveAlterDropColumnRemovesFromColumns(): void
1559 {
1560 $store = new ShadowStore();
1561 $registry = new TableDefinitionRegistry();
1562 $registry->register('t', new TableDefinition(
1563 ['id', 'name', 'email'],
1564 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
1565 ['id'],
1566 ['id', 'name'],
1567 ['unique_email' => ['email']],
1568 ));
1569 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1570 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN email', QueryKind::DDL_SIMULATED);
1571 self::assertNotNull($mutation);
1572 $registry->unregister('t');
1573 $mutation->apply($store, []);
1574 $def = $registry->get('t');
1575 self::assertNotNull($def);
1576 self::assertSame(['id', 'name'], $def->columns);
1577 self::assertArrayNotHasKey('email', $def->columnTypes);
1578 self::assertNotContains('email', $def->notNullColumns);
1579 }
1580
1581 public function testResolveAlterDropColumnRemovesFromPrimaryKeys(): void
1582 {
1583 $store = new ShadowStore();
1584 $registry = new TableDefinitionRegistry();
1585 $registry->register('t', new TableDefinition(
1586 ['id', 'name'],
1587 ['id' => 'INTEGER', 'name' => 'TEXT'],
1588 ['id', 'name'],
1589 [],
1590 [],
1591 ));
1592 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1593 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN name', QueryKind::DDL_SIMULATED);
1594 self::assertNotNull($mutation);
1595 $registry->unregister('t');
1596 $mutation->apply($store, []);
1597 $def = $registry->get('t');
1598 self::assertNotNull($def);
1599 self::assertSame(['id'], $def->primaryKeys);
1600 }
1601
1602 public function testResolveAlterDropColumnRemovesFromUniqueConstraints(): void
1603 {
1604 $store = new ShadowStore();
1605 $registry = new TableDefinitionRegistry();
1606 $registry->register('t', new TableDefinition(
1607 ['id', 'email', 'name'],
1608 ['id' => 'INTEGER', 'email' => 'TEXT', 'name' => 'TEXT'],
1609 ['id'],
1610 [],
1611 ['unique_email' => ['email'], 'unique_name' => ['name']],
1612 ));
1613 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1614 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN email', QueryKind::DDL_SIMULATED);
1615 self::assertNotNull($mutation);
1616 $registry->unregister('t');
1617 $mutation->apply($store, []);
1618 $def = $registry->get('t');
1619 self::assertNotNull($def);
1620 self::assertArrayNotHasKey('unique_email', $def->uniqueConstraints);
1621 self::assertArrayHasKey('unique_name', $def->uniqueConstraints);
1622 }
1623
1624 public function testResolveAlterRenameColumnRenamesInColumns(): void
1625 {
1626 $store = new ShadowStore();
1627 $registry = new TableDefinitionRegistry();
1628 $registry->register('t', new TableDefinition(
1629 ['id', 'old_col'],
1630 ['id' => 'INTEGER', 'old_col' => 'TEXT'],
1631 ['id'],
1632 ['old_col'],
1633 ['unique_old' => ['old_col']],
1634 ));
1635 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1636 $mutation = $resolver->resolve('ALTER TABLE t RENAME COLUMN old_col TO new_col', QueryKind::DDL_SIMULATED);
1637 self::assertNotNull($mutation);
1638 $registry->unregister('t');
1639 $mutation->apply($store, []);
1640 $def = $registry->get('t');
1641 self::assertNotNull($def);
1642 self::assertContains('new_col', $def->columns);
1643 self::assertNotContains('old_col', $def->columns);
1644 self::assertArrayHasKey('new_col', $def->columnTypes);
1645 self::assertArrayNotHasKey('old_col', $def->columnTypes);
1646 self::assertContains('new_col', $def->notNullColumns);
1647 }
1648
1649 public function testResolveAlterRenameColumnRenamesInPrimaryKeys(): void
1650 {
1651 $store = new ShadowStore();
1652 $registry = new TableDefinitionRegistry();
1653 $registry->register('t', new TableDefinition(
1654 ['old_pk', 'val'],
1655 ['old_pk' => 'INTEGER', 'val' => 'TEXT'],
1656 ['old_pk'],
1657 [],
1658 [],
1659 ));
1660 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1661 $mutation = $resolver->resolve('ALTER TABLE t RENAME COLUMN old_pk TO new_pk', QueryKind::DDL_SIMULATED);
1662 self::assertNotNull($mutation);
1663 $registry->unregister('t');
1664 $mutation->apply($store, []);
1665 $def = $registry->get('t');
1666 self::assertNotNull($def);
1667 self::assertContains('new_pk', $def->primaryKeys);
1668 self::assertNotContains('old_pk', $def->primaryKeys);
1669 }
1670
1671 public function testResolveAlterRenameColumnRenamesInUniqueConstraints(): void
1672 {
1673 $store = new ShadowStore();
1674 $registry = new TableDefinitionRegistry();
1675 $registry->register('t', new TableDefinition(
1676 ['id', 'old_name'],
1677 ['id' => 'INTEGER', 'old_name' => 'TEXT'],
1678 ['id'],
1679 [],
1680 ['uq_name' => ['old_name']],
1681 ));
1682 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1683 $mutation = $resolver->resolve('ALTER TABLE t RENAME COLUMN old_name TO new_name', QueryKind::DDL_SIMULATED);
1684 self::assertNotNull($mutation);
1685 $registry->unregister('t');
1686 $mutation->apply($store, []);
1687 $def = $registry->get('t');
1688 self::assertNotNull($def);
1689 self::assertContains('new_name', $def->uniqueConstraints['uq_name']);
1690 self::assertNotContains('old_name', $def->uniqueConstraints['uq_name']);
1691 }
1692
1693 public function testResolveAlterRenameTableProducesDropMutation(): void
1694 {
1695 $store = new ShadowStore();
1696 $registry = new TableDefinitionRegistry();
1697 $registry->register('old_t', new TableDefinition(
1698 ['id'],
1699 ['id' => 'INTEGER'],
1700 ['id'],
1701 [],
1702 [],
1703 ));
1704 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1705 $mutation = $resolver->resolve('ALTER TABLE old_t RENAME TO new_t', QueryKind::DDL_SIMULATED);
1706 self::assertInstanceOf(AlterTableMutation::class, $mutation);
1707 }
1708
1709 public function testResolveDeleteWithStrippedCommentsMatchesRegex(): void
1710 {
1711 $store = new ShadowStore();
1712 $registry = new TableDefinitionRegistry();
1713 $registry->register('users', new TableDefinition(
1714 ['id'],
1715 ['id' => 'INTEGER'],
1716 ['id'],
1717 [],
1718 [],
1719 ));
1720 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1721 $mutation = $resolver->resolve('DELETE FROM users', QueryKind::WRITE_SIMULATED);
1722 self::assertInstanceOf(DeleteMutation::class, $mutation);
1723 }
1724
1725 public function testResolveInsertOnConflictDoNothingIsIgnore(): void
1726 {
1727 $store = new ShadowStore();
1728 $store->set('users', [['id' => 1, 'name' => 'existing']]);
1729 $registry = new TableDefinitionRegistry();
1730 $registry->register('users', new TableDefinition(
1731 ['id', 'name'],
1732 ['id' => 'INTEGER', 'name' => 'TEXT'],
1733 ['id'],
1734 [],
1735 [],
1736 ));
1737 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1738 $mutation = $resolver->resolve("INSERT INTO users (id, name) VALUES (1, 'a') ON CONFLICT DO NOTHING", QueryKind::WRITE_SIMULATED);
1739 self::assertInstanceOf(InsertMutation::class, $mutation);
1740 $mutation->apply($store, [['id' => 1, 'name' => 'ignored']]);
1741 self::assertSame([['id' => 1, 'name' => 'existing']], $store->get('users'));
1742 }
1743
1744 public function testResolveAlterAddColumnTypeDefaultKeywordExcluded(): void
1745 {
1746 $store = new ShadowStore();
1747 $registry = new TableDefinitionRegistry();
1748 $registry->register('t', new TableDefinition(
1749 ['id'],
1750 ['id' => 'INTEGER'],
1751 ['id'],
1752 [],
1753 [],
1754 ));
1755 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1756 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN active DEFAULT 1', QueryKind::DDL_SIMULATED);
1757 self::assertNotNull($mutation);
1758 $registry->unregister('t');
1759 $mutation->apply($store, []);
1760 $def = $registry->get('t');
1761 self::assertNotNull($def);
1762 self::assertContains('active', $def->columns);
1763 self::assertArrayNotHasKey('active', $def->columnTypes);
1764 }
1765
1766 public function testResolveAlterAddColumnTypeNotKeywordExcluded(): void
1767 {
1768 $store = new ShadowStore();
1769 $registry = new TableDefinitionRegistry();
1770 $registry->register('t', new TableDefinition(
1771 ['id'],
1772 ['id' => 'INTEGER'],
1773 ['id'],
1774 [],
1775 [],
1776 ));
1777 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1778 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN active NOT NULL', QueryKind::DDL_SIMULATED);
1779 self::assertNotNull($mutation);
1780 $registry->unregister('t');
1781 $mutation->apply($store, []);
1782 $def = $registry->get('t');
1783 self::assertNotNull($def);
1784 self::assertContains('active', $def->columns);
1785 self::assertArrayNotHasKey('active', $def->columnTypes);
1786 }
1787
1788 public function testResolveAlterAddColumnWithTextType(): void
1789 {
1790 $store = new ShadowStore();
1791 $registry = new TableDefinitionRegistry();
1792 $registry->register('t', new TableDefinition(
1793 ['id'],
1794 ['id' => 'INTEGER'],
1795 ['id'],
1796 [],
1797 [],
1798 ));
1799 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1800 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN name TEXT', QueryKind::DDL_SIMULATED);
1801 self::assertNotNull($mutation);
1802 $registry->unregister('t');
1803 $mutation->apply($store, []);
1804 $def = $registry->get('t');
1805 self::assertNotNull($def);
1806 self::assertSame('TEXT', $def->columnTypes['name']);
1807 }
1808
1809 public function testResolveAlterAddColumnWithNoRest(): void
1810 {
1811 $store = new ShadowStore();
1812 $registry = new TableDefinitionRegistry();
1813 $registry->register('t', new TableDefinition(
1814 ['id'],
1815 ['id' => 'INTEGER'],
1816 ['id'],
1817 [],
1818 [],
1819 ));
1820 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1821 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN val', QueryKind::DDL_SIMULATED);
1822 self::assertNotNull($mutation);
1823 $registry->unregister('t');
1824 $mutation->apply($store, []);
1825 $def = $registry->get('t');
1826 self::assertNotNull($def);
1827 self::assertContains('val', $def->columns);
1828 self::assertArrayNotHasKey('val', $def->columnTypes);
1829 }
1830
1831 public function testResolveUpdateWithoutTargetContextThrowsUnknownSchema(): void
1832 {
1833 $store = new ShadowStore();
1834 $registry = new TableDefinitionRegistry();
1835 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1836 $this->expectException(UnknownSchemaException::class);
1837 $resolver->resolve('UPDATE users SET x = 1', QueryKind::WRITE_SIMULATED);
1838 }
1839
1840 public function testResolveInsertIsReplaceReturnsPrimaryKeys(): void
1841 {
1842 $store = new ShadowStore();
1843 $registry = new TableDefinitionRegistry();
1844 $registry->register('users', new TableDefinition(
1845 ['id', 'name'],
1846 ['id' => 'INTEGER', 'name' => 'TEXT'],
1847 ['id'],
1848 [],
1849 [],
1850 ));
1851 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1852 $mutation = $resolver->resolve("REPLACE INTO users (id, name) VALUES (1, 'a')", QueryKind::WRITE_SIMULATED);
1853 self::assertInstanceOf(ReplaceMutation::class, $mutation);
1854 self::assertSame('users', $mutation->tableName());
1855 }
1856
1857 public function testResolveCreateTableIfNotExistsExistingTableNoError(): void
1858 {
1859 $store = new ShadowStore();
1860 $registry = new TableDefinitionRegistry();
1861 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
1862 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1863 $mutation = $resolver->resolve('CREATE TABLE IF NOT EXISTS t (id INTEGER)', QueryKind::DDL_SIMULATED);
1864 self::assertInstanceOf(CreateTableMutation::class, $mutation);
1865 }
1866
1867 public function testResolveDropTableExistingReturnsDropMutation(): void
1868 {
1869 $store = new ShadowStore();
1870 $registry = new TableDefinitionRegistry();
1871 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
1872 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1873 $mutation = $resolver->resolve('DROP TABLE t', QueryKind::DDL_SIMULATED);
1874 self::assertInstanceOf(DropTableMutation::class, $mutation);
1875 self::assertSame('t', $mutation->tableName());
1876 }
1877
1878 public function testResolveAlterTableWithAddWithoutColumnKeywordDetected(): void
1879 {
1880 $store = new ShadowStore();
1881 $registry = new TableDefinitionRegistry();
1882 $registry->register('t', new TableDefinition(
1883 ['id'],
1884 ['id' => 'INTEGER'],
1885 ['id'],
1886 [],
1887 [],
1888 ));
1889 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1890 $mutation = $resolver->resolve('ALTER TABLE t ADD email TEXT', QueryKind::DDL_SIMULATED);
1891 self::assertNotNull($mutation);
1892 $registry->unregister('t');
1893 $mutation->apply($store, []);
1894 $def = $registry->get('t');
1895 self::assertNotNull($def);
1896 self::assertContains('email', $def->columns);
1897 self::assertSame('TEXT', $def->columnTypes['email']);
1898 }
1899
1900 public function testResolveReturnsNullForUnclassifiableStatement(): void
1901 {
1902 $store = new ShadowStore();
1903 $registry = new TableDefinitionRegistry();
1904 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1905 self::assertNull($resolver->resolve('PRAGMA table_info(users)', QueryKind::READ));
1906 }
1907
1908 public function testResolveDeleteWithCommentsMatchesRegex(): void
1909 {
1910 $store = new ShadowStore();
1911 $registry = new TableDefinitionRegistry();
1912 $registry->register('users', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
1913 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1914 $mutation = $resolver->resolve('/* comment */ DELETE FROM users WHERE id = 1', QueryKind::WRITE_SIMULATED);
1915 self::assertInstanceOf(DeleteMutation::class, $mutation);
1916 }
1917
1918 public function testResolveDeleteCaseInsensitiveRegex(): void
1919 {
1920 $store = new ShadowStore();
1921 $registry = new TableDefinitionRegistry();
1922 $registry->register('users', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
1923 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1924 $mutation = $resolver->resolve('delete from users where id = 1', QueryKind::WRITE_SIMULATED);
1925 self::assertInstanceOf(DeleteMutation::class, $mutation);
1926 }
1927
1928 public function testResolveUpsertWithDefinitionReturnsPrimaryKeys(): void
1929 {
1930 $store = new ShadowStore();
1931 $registry = new TableDefinitionRegistry();
1932 $registry->register('t', new TableDefinition(['id', 'name'], ['id' => 'INTEGER', 'name' => 'TEXT'], ['id'], [], []));
1933 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1934 $mutation = $resolver->resolve("INSERT INTO t (id, name) VALUES (1, 'a') ON CONFLICT (id) DO UPDATE SET name = excluded.name", QueryKind::WRITE_SIMULATED);
1935 self::assertInstanceOf(UpsertMutation::class, $mutation);
1936 }
1937
1938 public function testResolveUpsertWithoutDefinitionReturnsEmptyPrimaryKeys(): void
1939 {
1940 $store = new ShadowStore();
1941 $registry = new TableDefinitionRegistry();
1942 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1943 $mutation = $resolver->resolve("INSERT INTO t (id, name) VALUES (1, 'a') ON CONFLICT (id) DO UPDATE SET name = excluded.name", QueryKind::WRITE_SIMULATED);
1944 self::assertInstanceOf(UpsertMutation::class, $mutation);
1945 }
1946
1947 public function testResolveInsertIgnoreWithDefinitionReturnsPrimaryKeys(): void
1948 {
1949 $store = new ShadowStore();
1950 $registry = new TableDefinitionRegistry();
1951 $registry->register('t', new TableDefinition(['id', 'name'], ['id' => 'INTEGER', 'name' => 'TEXT'], ['id'], [], []));
1952 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1953 $mutation = $resolver->resolve("INSERT OR IGNORE INTO t (id, name) VALUES (1, 'a')", QueryKind::WRITE_SIMULATED);
1954 self::assertInstanceOf(InsertMutation::class, $mutation);
1955 }
1956
1957 public function testResolveInsertIgnoreWithoutDefinitionReturnsEmptyPrimaryKeys(): void
1958 {
1959 $store = new ShadowStore();
1960 $registry = new TableDefinitionRegistry();
1961 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1962 $mutation = $resolver->resolve("INSERT OR IGNORE INTO t (id, name) VALUES (1, 'a')", QueryKind::WRITE_SIMULATED);
1963 self::assertInstanceOf(InsertMutation::class, $mutation);
1964 }
1965
1966 public function testResolveCreateTableCaseInsensitiveIfNotExists(): void
1967 {
1968 $store = new ShadowStore();
1969 $registry = new TableDefinitionRegistry();
1970 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1971 $mutation = $resolver->resolve('create table if not exists t (id integer primary key)', QueryKind::DDL_SIMULATED);
1972 self::assertInstanceOf(CreateTableMutation::class, $mutation);
1973 }
1974
1975 public function testResolveAlterTableCaseInsensitiveAddColumn(): void
1976 {
1977 $store = new ShadowStore();
1978 $registry = new TableDefinitionRegistry();
1979 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
1980 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1981 $mutation = $resolver->resolve('alter table t add column name text', QueryKind::DDL_SIMULATED);
1982 self::assertNotNull($mutation);
1983 $registry->unregister('t');
1984 $mutation->apply($store, []);
1985 $def = $registry->get('t');
1986 self::assertNotNull($def);
1987 self::assertContains('name', $def->columns);
1988 }
1989
1990 public function testResolveAlterTableCaseInsensitiveDropColumn(): void
1991 {
1992 $store = new ShadowStore();
1993 $registry = new TableDefinitionRegistry();
1994 $registry->register('t', new TableDefinition(['id', 'name'], ['id' => 'INTEGER', 'name' => 'TEXT'], ['id'], ['id'], ['idx_name' => ['name']]));
1995 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
1996 $mutation = $resolver->resolve('alter table t drop column name', QueryKind::DDL_SIMULATED);
1997 self::assertNotNull($mutation);
1998 $registry->unregister('t');
1999 $mutation->apply($store, []);
2000 $def = $registry->get('t');
2001 self::assertNotNull($def);
2002 self::assertNotContains('name', $def->columns);
2003 }
2004
2005 public function testResolveAlterTableCaseInsensitiveRenameColumn(): void
2006 {
2007 $store = new ShadowStore();
2008 $registry = new TableDefinitionRegistry();
2009 $registry->register('t', new TableDefinition(['id', 'name'], ['id' => 'INTEGER', 'name' => 'TEXT'], ['id'], ['name'], ['idx_name' => ['name']]));
2010 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2011 $mutation = $resolver->resolve('alter table t rename column name to full_name', QueryKind::DDL_SIMULATED);
2012 self::assertNotNull($mutation);
2013 $registry->unregister('t');
2014 $mutation->apply($store, []);
2015 $def = $registry->get('t');
2016 self::assertNotNull($def);
2017 self::assertContains('full_name', $def->columns);
2018 self::assertNotContains('name', $def->columns);
2019 }
2020
2021 public function testResolveAlterRenameColumnRenamesInTypedColumns(): void
2022 {
2023 $store = new ShadowStore();
2024 $registry = new TableDefinitionRegistry();
2025 $registry->register('t', new TableDefinition(
2026 ['id', 'name'],
2027 ['id' => 'INTEGER', 'name' => 'TEXT'],
2028 ['id'],
2029 [],
2030 [],
2031 ));
2032 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2033 $mutation = $resolver->resolve('ALTER TABLE t RENAME COLUMN name TO full_name', QueryKind::DDL_SIMULATED);
2034 self::assertNotNull($mutation);
2035 $registry->unregister('t');
2036 $mutation->apply($store, []);
2037 $def = $registry->get('t');
2038 self::assertNotNull($def);
2039 self::assertArrayHasKey('full_name', $def->columnTypes);
2040 self::assertArrayNotHasKey('name', $def->columnTypes);
2041 }
2042
2043 public function testResolveAlterTableCaseInsensitiveRenameTo(): void
2044 {
2045 $store = new ShadowStore();
2046 $registry = new TableDefinitionRegistry();
2047 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2048 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2049 $mutation = $resolver->resolve('alter table t rename to t2', QueryKind::DDL_SIMULATED);
2050 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2051 }
2052
2053 public function testResolveAlterRenameWithoutColumnKeyword(): void
2054 {
2055 $store = new ShadowStore();
2056 $registry = new TableDefinitionRegistry();
2057 $registry->register('t', new TableDefinition(['id', 'name'], ['id' => 'INTEGER', 'name' => 'TEXT'], ['id'], [], []));
2058 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2059 $mutation = $resolver->resolve('ALTER TABLE t RENAME name TO full_name', QueryKind::DDL_SIMULATED);
2060 self::assertNotNull($mutation);
2061 $registry->unregister('t');
2062 $mutation->apply($store, []);
2063 $def = $registry->get('t');
2064 self::assertNotNull($def);
2065 self::assertContains('full_name', $def->columns);
2066 }
2067
2068 public function testResolveAlterAddColumnWithTrimmedRest(): void
2069 {
2070 $store = new ShadowStore();
2071 $registry = new TableDefinitionRegistry();
2072 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2073 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2074 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN name TEXT ', QueryKind::DDL_SIMULATED);
2075 self::assertNotNull($mutation);
2076 $registry->unregister('t');
2077 $mutation->apply($store, []);
2078 $def = $registry->get('t');
2079 self::assertNotNull($def);
2080 self::assertSame('TEXT', $def->columnTypes['name']);
2081 }
2082
2083 public function testResolveAlterAddColumnNonTypeKeywordNotStored(): void
2084 {
2085 $store = new ShadowStore();
2086 $registry = new TableDefinitionRegistry();
2087 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2088 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2089 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN name PRIMARY KEY', QueryKind::DDL_SIMULATED);
2090 self::assertNotNull($mutation);
2091 $registry->unregister('t');
2092 $mutation->apply($store, []);
2093 $def = $registry->get('t');
2094 self::assertNotNull($def);
2095 self::assertContains('name', $def->columns);
2096 self::assertArrayNotHasKey('name', $def->columnTypes);
2097 }
2098
2099 public function testResolveAlterAddColumnWithParensInType(): void
2100 {
2101 $store = new ShadowStore();
2102 $registry = new TableDefinitionRegistry();
2103 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2104 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2105 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN amount DECIMAL(10,2)', QueryKind::DDL_SIMULATED);
2106 self::assertNotNull($mutation);
2107 $registry->unregister('t');
2108 $mutation->apply($store, []);
2109 $def = $registry->get('t');
2110 self::assertNotNull($def);
2111 self::assertSame('DECIMAL(10,2)', $def->columnTypes['amount']);
2112 }
2113
2114 public function testResolveAlterDropColumnRemovesTypedColumns(): void
2115 {
2116 $store = new ShadowStore();
2117 $registry = new TableDefinitionRegistry();
2118 $registry->register('t', new TableDefinition(
2119 ['id', 'name'],
2120 ['id' => 'INTEGER', 'name' => 'TEXT'],
2121 ['id'],
2122 ['id'],
2123 [],
2124 ));
2125 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2126 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN name', QueryKind::DDL_SIMULATED);
2127 self::assertNotNull($mutation);
2128 $registry->unregister('t');
2129 $mutation->apply($store, []);
2130 $def = $registry->get('t');
2131 self::assertNotNull($def);
2132 self::assertArrayNotHasKey('name', $def->columnTypes);
2133 }
2134
2135 public function testResolveAlterDropColumnPreservesRemainingUniqueConstraints(): void
2136 {
2137 $store = new ShadowStore();
2138 $registry = new TableDefinitionRegistry();
2139 $registry->register('t', new TableDefinition(
2140 ['id', 'name', 'email'],
2141 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
2142 ['id'],
2143 [],
2144 ['idx_name' => ['name'], 'idx_email' => ['email']],
2145 ));
2146 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2147 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN name', QueryKind::DDL_SIMULATED);
2148 self::assertNotNull($mutation);
2149 $registry->unregister('t');
2150 $mutation->apply($store, []);
2151 $def = $registry->get('t');
2152 self::assertNotNull($def);
2153 self::assertArrayHasKey('idx_email', $def->uniqueConstraints);
2154 self::assertArrayNotHasKey('idx_name', $def->uniqueConstraints);
2155 }
2156
2157 public function testResolveAlterDropColumnFiltersMultiColumnConstraint(): void
2158 {
2159 $store = new ShadowStore();
2160 $registry = new TableDefinitionRegistry();
2161 $registry->register('t', new TableDefinition(
2162 ['id', 'name', 'email'],
2163 ['id' => 'INTEGER', 'name' => 'TEXT', 'email' => 'TEXT'],
2164 ['id'],
2165 [],
2166 ['idx_combo' => ['name', 'email']],
2167 ));
2168 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2169 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN name', QueryKind::DDL_SIMULATED);
2170 self::assertNotNull($mutation);
2171 $registry->unregister('t');
2172 $mutation->apply($store, []);
2173 $def = $registry->get('t');
2174 self::assertNotNull($def);
2175 self::assertArrayHasKey('idx_combo', $def->uniqueConstraints);
2176 self::assertSame(['email'], $def->uniqueConstraints['idx_combo']);
2177 }
2178
2179 public function testResolveAlterRenameColumnUpdatesUniqueConstraints(): void
2180 {
2181 $store = new ShadowStore();
2182 $registry = new TableDefinitionRegistry();
2183 $registry->register('t', new TableDefinition(
2184 ['id', 'name'],
2185 ['id' => 'INTEGER', 'name' => 'TEXT'],
2186 ['id'],
2187 [],
2188 ['idx_name' => ['name']],
2189 ));
2190 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2191 $mutation = $resolver->resolve('ALTER TABLE t RENAME COLUMN name TO full_name', QueryKind::DDL_SIMULATED);
2192 self::assertNotNull($mutation);
2193 $registry->unregister('t');
2194 $mutation->apply($store, []);
2195 $def = $registry->get('t');
2196 self::assertNotNull($def);
2197 self::assertSame(['full_name'], $def->uniqueConstraints['idx_name']);
2198 }
2199
2200 public function testResolveAlterRenameColumnUpdatesPrimaryKeys(): void
2201 {
2202 $store = new ShadowStore();
2203 $registry = new TableDefinitionRegistry();
2204 $registry->register('t', new TableDefinition(
2205 ['id', 'name'],
2206 ['id' => 'INTEGER', 'name' => 'TEXT'],
2207 ['id', 'name'],
2208 [],
2209 [],
2210 ));
2211 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2212 $mutation = $resolver->resolve('ALTER TABLE t RENAME COLUMN name TO full_name', QueryKind::DDL_SIMULATED);
2213 self::assertNotNull($mutation);
2214 $registry->unregister('t');
2215 $mutation->apply($store, []);
2216 $def = $registry->get('t');
2217 self::assertNotNull($def);
2218 self::assertContains('full_name', $def->primaryKeys);
2219 self::assertNotContains('name', $def->primaryKeys);
2220 }
2221
2222 public function testResolveAlterRenameColumnUpdatesNotNullColumns(): void
2223 {
2224 $store = new ShadowStore();
2225 $registry = new TableDefinitionRegistry();
2226 $registry->register('t', new TableDefinition(
2227 ['id', 'name'],
2228 ['id' => 'INTEGER', 'name' => 'TEXT'],
2229 ['id'],
2230 ['name'],
2231 [],
2232 ));
2233 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2234 $mutation = $resolver->resolve('ALTER TABLE t RENAME COLUMN name TO full_name', QueryKind::DDL_SIMULATED);
2235 self::assertNotNull($mutation);
2236 $registry->unregister('t');
2237 $mutation->apply($store, []);
2238 $def = $registry->get('t');
2239 self::assertNotNull($def);
2240 self::assertContains('full_name', $def->notNullColumns);
2241 self::assertNotContains('name', $def->notNullColumns);
2242 }
2243
2244 public function testResolveDropTableCaseInsensitiveIfExists(): void
2245 {
2246 $store = new ShadowStore();
2247 $registry = new TableDefinitionRegistry();
2248 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2249 $mutation = $resolver->resolve('drop table if exists nonexistent', QueryKind::DDL_SIMULATED);
2250 self::assertInstanceOf(DropTableMutation::class, $mutation);
2251 }
2252
2253 public function testResolveUpdateWithDefinitionReturnsPrimaryKeys(): void
2254 {
2255 $store = new ShadowStore();
2256 $registry = new TableDefinitionRegistry();
2257 $registry->register('t', new TableDefinition(['id', 'name'], ['id' => 'INTEGER', 'name' => 'TEXT'], ['id'], [], []));
2258 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2259 $mutation = $resolver->resolve("UPDATE t SET name = 'Bob' WHERE id = 1", QueryKind::WRITE_SIMULATED);
2260 self::assertInstanceOf(UpdateMutation::class, $mutation);
2261 }
2262
2263 public function testResolveUpdateWithoutDefinitionReturnsEmptyPrimaryKeys(): void
2264 {
2265 $store = new ShadowStore();
2266 $registry = new TableDefinitionRegistry();
2267 $store->ensure('t');
2268 $store->set('t', [['id' => 1, 'name' => 'Alice']]);
2269 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2270 $mutation = $resolver->resolve("UPDATE t SET name = 'Bob' WHERE id = 1", QueryKind::WRITE_SIMULATED);
2271 self::assertInstanceOf(UpdateMutation::class, $mutation);
2272 }
2273
2274 public function testResolveDeleteWithDefinitionReturnsPrimaryKeys(): void
2275 {
2276 $store = new ShadowStore();
2277 $registry = new TableDefinitionRegistry();
2278 $registry->register('t', new TableDefinition(['id', 'name'], ['id' => 'INTEGER', 'name' => 'TEXT'], ['id'], [], []));
2279 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2280 $mutation = $resolver->resolve('DELETE FROM t WHERE id = 1', QueryKind::WRITE_SIMULATED);
2281 self::assertInstanceOf(DeleteMutation::class, $mutation);
2282 }
2283
2284 public function testResolveDeleteWithoutDefinitionButWithShadowRows(): void
2285 {
2286 $store = new ShadowStore();
2287 $registry = new TableDefinitionRegistry();
2288 $store->ensure('t');
2289 $store->set('t', [['id' => 1]]);
2290 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2291 $mutation = $resolver->resolve('DELETE FROM t WHERE id = 1', QueryKind::WRITE_SIMULATED);
2292 self::assertInstanceOf(DeleteMutation::class, $mutation);
2293 }
2294
2295 public function testResolveOnConflictDoNothingIsNotUpsert(): void
2296 {
2297 $store = new ShadowStore();
2298 $registry = new TableDefinitionRegistry();
2299 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2300 $mutation = $resolver->resolve('INSERT INTO t (id) VALUES (1) ON CONFLICT DO NOTHING', QueryKind::WRITE_SIMULATED);
2301 self::assertInstanceOf(InsertMutation::class, $mutation);
2302 }
2303
2304 public function testResolveAlterAddColumnWithEmptyRestNoType(): void
2305 {
2306 $store = new ShadowStore();
2307 $registry = new TableDefinitionRegistry();
2308 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2309 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2310 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN name', QueryKind::DDL_SIMULATED);
2311 self::assertNotNull($mutation);
2312 $registry->unregister('t');
2313 $mutation->apply($store, []);
2314 $def = $registry->get('t');
2315 self::assertNotNull($def);
2316 self::assertContains('name', $def->columns);
2317 self::assertArrayNotHasKey('name', $def->columnTypes);
2318 }
2319
2320 public function testResolveAlterAddColumnUpperCasesTypeName(): void
2321 {
2322 $store = new ShadowStore();
2323 $registry = new TableDefinitionRegistry();
2324 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2325 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2326 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN name text', QueryKind::DDL_SIMULATED);
2327 self::assertNotNull($mutation);
2328 $registry->unregister('t');
2329 $mutation->apply($store, []);
2330 $def = $registry->get('t');
2331 self::assertNotNull($def);
2332 self::assertSame('TEXT', $def->columnTypes['name']);
2333 }
2334
2335 public function testResolveAlterAddWithoutColumnKeywordLowercase(): void
2336 {
2337 $registry = new TableDefinitionRegistry();
2338 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2339 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
2340 $mutation = $resolver->resolve('alter table t add email text', QueryKind::DDL_SIMULATED);
2341 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2342 }
2343
2344 public function testResolveCreateTableIfNotExistsExistingTableLowercase(): void
2345 {
2346 $registry = new TableDefinitionRegistry();
2347 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2348 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
2349 $mutation = $resolver->resolve('create table if not exists t (id integer primary key)', QueryKind::DDL_SIMULATED);
2350 self::assertInstanceOf(CreateTableMutation::class, $mutation);
2351 }
2352
2353 public function testResolveAlterRenameColumnLowercaseRegex(): void
2354 {
2355 $registry = new TableDefinitionRegistry();
2356 $registry->register('t', new TableDefinition(
2357 ['id', 'name'],
2358 ['id' => 'INTEGER', 'name' => 'TEXT'],
2359 ['id'],
2360 [],
2361 [],
2362 ));
2363 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
2364 $mutation = $resolver->resolve('alter table t rename column name to fullname', QueryKind::DDL_SIMULATED);
2365 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2366 $registry->unregister('t');
2367 $store = new ShadowStore();
2368 $mutation->apply($store, []);
2369 $def = $registry->get('t');
2370 self::assertNotNull($def);
2371 self::assertContains('fullname', $def->columns);
2372 self::assertNotContains('name', $def->columns);
2373 }
2374
2375 public function testResolveAlterRenameWithoutColumnKeywordLowercase(): void
2376 {
2377 $registry = new TableDefinitionRegistry();
2378 $registry->register('t', new TableDefinition(
2379 ['id', 'name'],
2380 ['id' => 'INTEGER', 'name' => 'TEXT'],
2381 ['id'],
2382 [],
2383 [],
2384 ));
2385 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
2386 $mutation = $resolver->resolve('alter table t rename name to fullname', QueryKind::DDL_SIMULATED);
2387 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2388 }
2389
2390 public function testResolveAlterDropColumnLowercaseRegex(): void
2391 {
2392 $registry = new TableDefinitionRegistry();
2393 $registry->register('t', new TableDefinition(
2394 ['id', 'name'],
2395 ['id' => 'INTEGER', 'name' => 'TEXT'],
2396 ['id'],
2397 [],
2398 [],
2399 ));
2400 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
2401 $mutation = $resolver->resolve('alter table t drop column name', QueryKind::DDL_SIMULATED);
2402 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2403 $registry->unregister('t');
2404 $store = new ShadowStore();
2405 $mutation->apply($store, []);
2406 $def = $registry->get('t');
2407 self::assertNotNull($def);
2408 self::assertNotContains('name', $def->columns);
2409 }
2410
2411 public function testResolveAlterAddColumnLowercaseTypeExtraction(): void
2412 {
2413 $registry = new TableDefinitionRegistry();
2414 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2415 $store = new ShadowStore();
2416 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2417 $mutation = $resolver->resolve('alter table t add column age integer', QueryKind::DDL_SIMULATED);
2418 self::assertNotNull($mutation);
2419 $registry->unregister('t');
2420 $mutation->apply($store, []);
2421 $def = $registry->get('t');
2422 self::assertNotNull($def);
2423 self::assertContains('age', $def->columns);
2424 self::assertSame('INTEGER', $def->columnTypes['age']);
2425 }
2426
2427 public function testResolveAlterRenameToLowercaseRegex(): void
2428 {
2429 $registry = new TableDefinitionRegistry();
2430 $registry->register('t', new TableDefinition(
2431 ['id'],
2432 ['id' => 'INTEGER'],
2433 ['id'],
2434 [],
2435 [],
2436 ));
2437 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
2438 $mutation = $resolver->resolve('alter table t rename to t2', QueryKind::DDL_SIMULATED);
2439 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2440 }
2441
2442 public function testResolveDropTableIfExistsLowercaseRegex(): void
2443 {
2444 $registry = new TableDefinitionRegistry();
2445 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
2446 $mutation = $resolver->resolve('drop table if exists nonexistent', QueryKind::DDL_SIMULATED);
2447 self::assertInstanceOf(DropTableMutation::class, $mutation);
2448 }
2449
2450 public function testResolveDeleteFullTableLowercaseMatchesRegex(): void
2451 {
2452 $registry = new TableDefinitionRegistry();
2453 $registry->register('t', new TableDefinition(
2454 ['id'],
2455 ['id' => 'INTEGER'],
2456 ['id'],
2457 [],
2458 [],
2459 ));
2460 $store = new ShadowStore();
2461 $store->ensure('t');
2462 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2463 $mutation = $resolver->resolve('delete from t', QueryKind::WRITE_SIMULATED);
2464 self::assertInstanceOf(DeleteMutation::class, $mutation);
2465 }
2466
2467 public function testResolveAlterAddQuotedColumnLowercaseAdd(): void
2468 {
2469 $registry = new TableDefinitionRegistry();
2470 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2471 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
2472 $mutation = $resolver->resolve('alter table t add "email" text', QueryKind::DDL_SIMULATED);
2473 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2474 }
2475
2476 public function testResolveCreateTableIfNotExistsWithoutExistingTable(): void
2477 {
2478 $resolver = new SqliteMutationResolver(new ShadowStore(), new TableDefinitionRegistry(), new SqliteSchemaParser(), new SqliteParser());
2479 $mutation = $resolver->resolve('CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY)', QueryKind::DDL_SIMULATED);
2480 self::assertInstanceOf(CreateTableMutation::class, $mutation);
2481 self::assertSame('t', $mutation->tableName());
2482 }
2483
2484 public function testResolveCreateTableWithoutIfNotExistsAlreadyExistsThrows(): void
2485 {
2486 $registry = new TableDefinitionRegistry();
2487 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2488 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, new SqliteSchemaParser(), new SqliteParser());
2489 $this->expectException(UnsupportedSqlException::class);
2490 $resolver->resolve('CREATE TABLE t (id INTEGER PRIMARY KEY)', QueryKind::DDL_SIMULATED);
2491 }
2492
2493 public function testResolveAlterAddColumnMultiWordTypeLowercase(): void
2494 {
2495 $registry = new TableDefinitionRegistry();
2496 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2497 $store = new ShadowStore();
2498 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2499 $mutation = $resolver->resolve('alter table t add column descr varying character(100)', QueryKind::DDL_SIMULATED);
2500 self::assertNotNull($mutation);
2501 $registry->unregister('t');
2502 $mutation->apply($store, []);
2503 $def = $registry->get('t');
2504 self::assertNotNull($def);
2505 self::assertContains('descr', $def->columns);
2506 self::assertSame('VARYING CHARACTER(100)', $def->columnTypes['descr']);
2507 }
2508
2509 public function testResolveAlterAddColumnIfNotExistsDoesNotThrow(): void
2510 {
2511 $registry = new TableDefinitionRegistry();
2512 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2513 $store = new ShadowStore();
2514 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2515 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN email TEXT', QueryKind::DDL_SIMULATED);
2516 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2517 $mutation->apply($store, []);
2518 self::assertTrue($registry->has('t'));
2519 }
2520
2521 public function testResolveAlterDropColumnIfNotExistsDoesNotThrow(): void
2522 {
2523 $registry = new TableDefinitionRegistry();
2524 $registry->register('t', new TableDefinition(
2525 ['id', 'name'],
2526 ['id' => 'INTEGER', 'name' => 'TEXT'],
2527 ['id'],
2528 [],
2529 [],
2530 ));
2531 $store = new ShadowStore();
2532 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2533 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN name', QueryKind::DDL_SIMULATED);
2534 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2535 $mutation->apply($store, []);
2536 self::assertTrue($registry->has('t'));
2537 }
2538
2539 public function testResolveAlterRenameTableIfExistsDoesNotThrow(): void
2540 {
2541 $registry = new TableDefinitionRegistry();
2542 $registry->register('t', new TableDefinition(
2543 ['id'],
2544 ['id' => 'INTEGER'],
2545 ['id'],
2546 [],
2547 [],
2548 ));
2549 $store = new ShadowStore();
2550 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2551 $mutation = $resolver->resolve('ALTER TABLE t RENAME TO t2', QueryKind::DDL_SIMULATED);
2552 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2553 $mutation->apply($store, []);
2554 self::assertFalse($registry->has('t'));
2555 }
2556
2557 public function testResolveAlterRenameColumnIfNotExistsDoesNotThrow(): void
2558 {
2559 $registry = new TableDefinitionRegistry();
2560 $registry->register('t', new TableDefinition(
2561 ['id', 'name'],
2562 ['id' => 'INTEGER', 'name' => 'TEXT'],
2563 ['id'],
2564 [],
2565 [],
2566 ));
2567 $store = new ShadowStore();
2568 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2569 $mutation = $resolver->resolve('ALTER TABLE t RENAME COLUMN name TO fullname', QueryKind::DDL_SIMULATED);
2570 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2571 $mutation->apply($store, []);
2572 self::assertTrue($registry->has('t'));
2573 }
2574
2575 public function testResolveAlterAddColumnMultilineType(): void
2576 {
2577 $registry = new TableDefinitionRegistry();
2578 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2579 $store = new ShadowStore();
2580 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2581 $mutation = $resolver->resolve("ALTER TABLE t ADD COLUMN email\nTEXT", QueryKind::DDL_SIMULATED);
2582 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2583 $registry->unregister('t');
2584 $mutation->apply($store, []);
2585 $def = $registry->get('t');
2586 self::assertNotNull($def);
2587 self::assertContains('email', $def->columns);
2588 self::assertSame('TEXT', $def->columnTypes['email']);
2589 }
2590
2591 public function testResolveAlterDropColumnFiltersByColumn(): void
2592 {
2593 $registry = new TableDefinitionRegistry();
2594 $registry->register('t', new TableDefinition(
2595 ['id', 'name', 'age'],
2596 ['id' => 'INTEGER', 'name' => 'TEXT', 'age' => 'INTEGER'],
2597 ['id'],
2598 ['id', 'name'],
2599 [],
2600 ));
2601 $store = new ShadowStore();
2602 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2603 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN name', QueryKind::DDL_SIMULATED);
2604 self::assertNotNull($mutation);
2605 $registry->unregister('t');
2606 $mutation->apply($store, []);
2607 $def = $registry->get('t');
2608 self::assertNotNull($def);
2609 self::assertSame(['id', 'age'], $def->columns);
2610 self::assertSame(['id'], $def->notNullColumns);
2611 }
2612
2613 public function testResolveUnknownUpdateDoesNotCreateShadowStoreEntry(): void
2614 {
2615 $store = new ShadowStore();
2616 $registry = new TableDefinitionRegistry();
2617 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2618 try {
2619 $resolver->resolve('UPDATE t SET x = 1', QueryKind::WRITE_SIMULATED);
2620 self::fail('Expected an unknown schema exception.');
2621 } catch (UnknownSchemaException) {
2622 self::assertSame([], $store->getAll());
2623 }
2624 }
2625
2626 public function testResolveDeleteEnsuresShadowStoreEntryCreated(): void
2627 {
2628 $store = new ShadowStore();
2629 $registry = new TableDefinitionRegistry();
2630 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2631 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2632 self::assertSame([], $store->getAll());
2633 $resolver->resolve('DELETE FROM t WHERE id = 1', QueryKind::WRITE_SIMULATED);
2634 self::assertArrayHasKey('t', $store->getAll());
2635 }
2636
2637 public function testResolveDeleteWithLeadingWhitespaceLowercase(): void
2638 {
2639 $store = new ShadowStore();
2640 $registry = new TableDefinitionRegistry();
2641 $registry->register('users', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2642 $store->ensure('users');
2643 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2644 $mutation = $resolver->resolve(' delete from users where id = 1 ', QueryKind::WRITE_SIMULATED);
2645 self::assertInstanceOf(DeleteMutation::class, $mutation);
2646 }
2647
2648 public function testResolveAlterAddColumnDoesNotThrowWhenApplied(): void
2649 {
2650 $registry = new TableDefinitionRegistry();
2651 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2652 $store = new ShadowStore();
2653 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2654 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN val TEXT', QueryKind::DDL_SIMULATED);
2655 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2656 $mutation->apply($store, []);
2657 self::assertTrue($registry->has('t'));
2658 }
2659
2660 public function testResolveAlterDropColumnDoesNotThrowWhenApplied(): void
2661 {
2662 $registry = new TableDefinitionRegistry();
2663 $registry->register('t', new TableDefinition(['id', 'val'], ['id' => 'INTEGER', 'val' => 'TEXT'], ['id'], [], []));
2664 $store = new ShadowStore();
2665 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2666 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN val', QueryKind::DDL_SIMULATED);
2667 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2668 $mutation->apply($store, []);
2669 self::assertTrue($registry->has('t'));
2670 }
2671
2672 public function testResolveAlterRenameTableAppliesAsDrop(): void
2673 {
2674 $registry = new TableDefinitionRegistry();
2675 $registry->register('old_t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2676 $store = new ShadowStore();
2677 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2678 $mutation = $resolver->resolve('ALTER TABLE old_t RENAME TO new_t', QueryKind::DDL_SIMULATED);
2679 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2680 $mutation->apply($store, []);
2681 self::assertFalse($registry->has('old_t'));
2682 }
2683
2684 public function testResolveAlterRenameColumnDoesNotThrowWhenApplied(): void
2685 {
2686 $registry = new TableDefinitionRegistry();
2687 $registry->register('t', new TableDefinition(['id', 'name'], ['id' => 'INTEGER', 'name' => 'TEXT'], ['id'], [], []));
2688 $store = new ShadowStore();
2689 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2690 $mutation = $resolver->resolve('ALTER TABLE t RENAME COLUMN name TO full_name', QueryKind::DDL_SIMULATED);
2691 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2692 $mutation->apply($store, []);
2693 self::assertTrue($registry->has('t'));
2694 }
2695
2696 public function testResolveAlterRenameToExtractsCorrectNewName(): void
2697 {
2698 $registry = new TableDefinitionRegistry();
2699 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2700 $store = new ShadowStore();
2701 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2702 $mutation = $resolver->resolve('ALTER TABLE t RENAME TO new_table_name', QueryKind::DDL_SIMULATED);
2703 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2704 self::assertSame('t', $mutation->tableName());
2705 }
2706
2707 public function testResolveAlterAddColumnEmptyParensDoesNotAppendParens(): void
2708 {
2709 $registry = new TableDefinitionRegistry();
2710 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2711 $store = new ShadowStore();
2712 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2713 $mutation = $resolver->resolve('ALTER TABLE t ADD COLUMN val INT()', QueryKind::DDL_SIMULATED);
2714 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2715 $registry->unregister('t');
2716 $mutation->apply($store, []);
2717 $def = $registry->get('t');
2718 self::assertNotNull($def);
2719 self::assertSame('INT', $def->columnTypes['val']);
2720 }
2721
2722 public function testResolveAlterDropColumnReindexesNotNull(): void
2723 {
2724 $registry = new TableDefinitionRegistry();
2725 $registry->register('t', new TableDefinition(
2726 ['id', 'name', 'age'],
2727 ['id' => 'INTEGER', 'name' => 'TEXT', 'age' => 'INTEGER'],
2728 ['id'],
2729 ['id', 'name', 'age'],
2730 [],
2731 ));
2732 $store = new ShadowStore();
2733 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2734 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN name', QueryKind::DDL_SIMULATED);
2735 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2736 $registry->unregister('t');
2737 $mutation->apply($store, []);
2738 $def = $registry->get('t');
2739 self::assertNotNull($def);
2740 self::assertSame([0, 1], array_keys($def->notNullColumns));
2741 self::assertSame([0], array_keys($def->primaryKeys));
2742 }
2743
2744 public function testResolveAlterRenameToDoesNotThrowWhenTableAlreadyGone(): void
2745 {
2746 $registry = new TableDefinitionRegistry();
2747 $registry->register('t', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2748 $store = new ShadowStore();
2749 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2750 $mutation = $resolver->resolve('ALTER TABLE t RENAME TO new_t', QueryKind::DDL_SIMULATED);
2751 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2752 $registry->unregister('t');
2753 $mutation->apply($store, []);
2754 self::assertFalse($registry->has('t'));
2755 }
2756
2757 public function testResolveAlterDropColumnReindexesPrimaryKeys(): void
2758 {
2759 $registry = new TableDefinitionRegistry();
2760 $registry->register('t', new TableDefinition(
2761 ['a', 'b', 'c'],
2762 ['a' => 'INTEGER', 'b' => 'INTEGER', 'c' => 'INTEGER'],
2763 ['a', 'b', 'c'],
2764 [],
2765 [],
2766 ));
2767 $store = new ShadowStore();
2768 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
2769 $mutation = $resolver->resolve('ALTER TABLE t DROP COLUMN b', QueryKind::DDL_SIMULATED);
2770 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2771 $registry->unregister('t');
2772 $mutation->apply($store, []);
2773 $def = $registry->get('t');
2774 self::assertNotNull($def);
2775 self::assertSame([0, 1], array_keys($def->primaryKeys));
2776 self::assertSame(['a', 'c'], $def->primaryKeys);
2777 }
2778
2779 public function testUpdateDoesNotTreatRowsFromUnknownInsertAsSchema(): void
2780 {
2781 $store = new ShadowStore();
2782 $store->insert('late_table', [['id' => 1, 'name' => 'Alice']]);
2783 $resolver = new SqliteMutationResolver(
2784 $store,
2785 new TableDefinitionRegistry(),
2786 new SqliteSchemaParser(),
2787 new SqliteParser(),
2788 );
2789
2790 $this->expectException(UnknownSchemaException::class);
2791 $resolver->resolve("UPDATE late_table SET name = 'Bob' WHERE id = 1", QueryKind::WRITE_SIMULATED);
2792 }
2793
2794 #[DataProvider('providerRemovedTableMutations')]
2795 public function testRemovedTableRejectsEveryFurtherMutation(string $sql): void
2796 {
2797 $registry = new TableDefinitionRegistry();
2798 $registry->register('records', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
2799 $registry->markRemoved('records');
2800 $resolver = new SqliteMutationResolver(
2801 new ShadowStore(),
2802 $registry,
2803 new SqliteSchemaParser(),
2804 new SqliteParser(),
2805 );
2806
2807 $this->expectException(UnsupportedSqlException::class);
2808 $this->expectExceptionMessage('Table was removed from the virtual schema');
2809 $resolver->resolve($sql, QueryKind::WRITE_SIMULATED);
2810 }
2811
2812 /**
2813 * @return Generator<string, array{string}>
2814 */
2815 public static function providerRemovedTableMutations(): Generator
2816 {
2817 yield 'update' => ['UPDATE records SET id = 2'];
2818 yield 'delete' => ['DELETE FROM records'];
2819 yield 'insert' => ['INSERT INTO records VALUES (2)'];
2820 yield 'alter' => ['ALTER TABLE records ADD COLUMN name TEXT'];
2821 }
2822
2823 public function testAlterAddPreservesCompleteDefinitionAndBuildsMigrationProjection(): void
2824 {
2825 $parser = new SqliteSchemaParser();
2826 $existing = $parser->parse(
2827 "CREATE TABLE records (id INTEGER PRIMARY KEY AUTOINCREMENT, legacy TEXT NOT NULL UNIQUE DEFAULT 'old')",
2828 );
2829 self::assertNotNull($existing);
2830 $expected = $parser->parse(
2831 "CREATE TABLE expected (id INTEGER PRIMARY KEY AUTOINCREMENT, legacy TEXT NOT NULL UNIQUE DEFAULT 'old', age INTEGER NOT NULL DEFAULT 7)",
2832 );
2833 self::assertNotNull($expected);
2834 $registry = new TableDefinitionRegistry();
2835 $registry->register('records', $existing);
2836 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, $parser, new SqliteParser());
2837
2838 $mutation = $resolver->resolve(
2839 'ALTER TABLE records ADD COLUMN age INTEGER NOT NULL DEFAULT 7;',
2840 QueryKind::DDL_SIMULATED,
2841 );
2842
2843 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2844 self::assertSame('SELECT "id", "legacy", 7 AS "age" FROM "records"', $mutation->resultSelect());
2845 $mutation->apply(new ShadowStore(), [['id' => 1, 'legacy' => 'old', 'age' => 7]]);
2846 $actual = $registry->get('records');
2847 self::assertNotNull($actual);
2848 self::assertSame($expected->columns, $actual->columns);
2849 self::assertSame($expected->columnTypes, $actual->columnTypes);
2850 self::assertSame($expected->primaryKeys, $actual->primaryKeys);
2851 self::assertSame($expected->notNullColumns, $actual->notNullColumns);
2852 self::assertSame($expected->uniqueConstraints, $actual->uniqueConstraints);
2853 self::assertEquals($expected->typedColumns, $actual->typedColumns);
2854 self::assertSame($expected->columnDefaults, $actual->columnDefaults);
2855 self::assertSame($expected->identityStrategies, $actual->identityStrategies);
2856 }
2857
2858 public function testAlterDropRemovesEveryColumnMetadataEntryAndBuildsMigrationProjection(): void
2859 {
2860 $integer = new ColumnDeclaration(ColumnTypeFamily::INTEGER, 'INTEGER');
2861 $text = new ColumnDeclaration(ColumnTypeFamily::STRING, 'TEXT');
2862 $definition = new TableDefinition(
2863 ['id', 'legacy', 'kept', 'tail'],
2864 ['id' => 'INTEGER', 'legacy' => 'TEXT', 'kept' => 'TEXT', 'tail' => 'TEXT'],
2865 ['id', 'legacy'],
2866 ['id', 'legacy', 'kept', 'tail'],
2867 [
2868 'id_legacy' => ['id', 'legacy'],
2869 'kept_tail' => ['kept', 'tail'],
2870 'legacy_only' => ['legacy'],
2871 ],
2872 ['id' => $integer, 'legacy' => $text, 'kept' => $text, 'tail' => $text],
2873 ['id' => '1', 'legacy' => "'old'", 'kept' => "'kept'", 'tail' => "'tail'"],
2874 [
2875 'id' => IdentityGenerationStrategy::MaxValue,
2876 'legacy' => IdentityGenerationStrategy::MaxValue,
2877 'kept' => IdentityGenerationStrategy::MaxValue,
2878 'tail' => IdentityGenerationStrategy::MaxValue,
2879 ],
2880 );
2881 $registry = new TableDefinitionRegistry();
2882 $registry->register('records', $definition);
2883 $resolver = new SqliteMutationResolver(
2884 new ShadowStore(),
2885 $registry,
2886 new SqliteSchemaParser(),
2887 new SqliteParser(),
2888 );
2889
2890 $mutation = $resolver->resolve('ALTER TABLE records DROP COLUMN legacy;', QueryKind::DDL_SIMULATED);
2891
2892 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2893 self::assertSame('SELECT "id", "kept", "tail" FROM "records"', $mutation->resultSelect());
2894 $mutation->apply(new ShadowStore(), []);
2895 $actual = $registry->get('records');
2896 self::assertNotNull($actual);
2897 self::assertSame(['id', 'kept', 'tail'], $actual->columns);
2898 self::assertSame(['id' => 'INTEGER', 'kept' => 'TEXT', 'tail' => 'TEXT'], $actual->columnTypes);
2899 self::assertSame(['id'], $actual->primaryKeys);
2900 self::assertSame(['id', 'kept', 'tail'], $actual->notNullColumns);
2901 self::assertSame(['id_legacy' => ['id'], 'kept_tail' => ['kept', 'tail']], $actual->uniqueConstraints);
2902 self::assertSame(['id' => $integer, 'kept' => $text, 'tail' => $text], $actual->typedColumns);
2903 self::assertSame(['id' => '1', 'kept' => "'kept'", 'tail' => "'tail'"], $actual->columnDefaults);
2904 self::assertSame([
2905 'id' => IdentityGenerationStrategy::MaxValue,
2906 'kept' => IdentityGenerationStrategy::MaxValue,
2907 'tail' => IdentityGenerationStrategy::MaxValue,
2908 ], $actual->identityStrategies);
2909 }
2910
2911 public function testAlterRenameColumnMovesEveryMetadataEntryAndBuildsMigrationProjection(): void
2912 {
2913 $text = new ColumnDeclaration(ColumnTypeFamily::STRING, 'TEXT');
2914 $definition = new TableDefinition(
2915 ['id', 'legacy'],
2916 ['id' => 'INTEGER', 'legacy' => 'TEXT'],
2917 ['legacy'],
2918 ['legacy'],
2919 ['legacy_unique' => ['legacy']],
2920 ['legacy' => $text],
2921 ['legacy' => "'old'"],
2922 ['legacy' => IdentityGenerationStrategy::MaxValue],
2923 );
2924 $registry = new TableDefinitionRegistry();
2925 $registry->register('records', $definition);
2926 $resolver = new SqliteMutationResolver(
2927 new ShadowStore(),
2928 $registry,
2929 new SqliteSchemaParser(),
2930 new SqliteParser(),
2931 );
2932
2933 $mutation = $resolver->resolve(
2934 'ALTER TABLE records RENAME COLUMN legacy TO label;',
2935 QueryKind::DDL_SIMULATED,
2936 );
2937
2938 self::assertInstanceOf(AlterTableMutation::class, $mutation);
2939 self::assertSame('SELECT "id", "legacy" AS "label" FROM "records"', $mutation->resultSelect());
2940 $mutation->apply(new ShadowStore(), []);
2941 $actual = $registry->get('records');
2942 self::assertNotNull($actual);
2943 self::assertSame(['id', 'label'], $actual->columns);
2944 self::assertSame(['id' => 'INTEGER', 'label' => 'TEXT'], $actual->columnTypes);
2945 self::assertSame(['label'], $actual->primaryKeys);
2946 self::assertSame(['label'], $actual->notNullColumns);
2947 self::assertSame(['legacy_unique' => ['label']], $actual->uniqueConstraints);
2948 self::assertSame(['label' => $text], $actual->typedColumns);
2949 self::assertSame(['label' => "'old'"], $actual->columnDefaults);
2950 self::assertSame(['label' => IdentityGenerationStrategy::MaxValue], $actual->identityStrategies);
2951 }
2952
2953 public function testAlterRenameColumnRejectsMissingSource(): void
2954 {
2955 $registry = new TableDefinitionRegistry();
2956 $registry->register('records', new TableDefinition(
2957 ['id', 'label'],
2958 ['id' => 'INTEGER', 'label' => 'TEXT'],
2959 ['id'],
2960 [],
2961 [],
2962 ));
2963 $resolver = new SqliteMutationResolver(
2964 new ShadowStore(),
2965 $registry,
2966 new SqliteSchemaParser(),
2967 new SqliteParser(),
2968 );
2969
2970 $this->expectException(ColumnNotFoundException::class);
2971 $resolver->resolve('ALTER TABLE records RENAME COLUMN missing TO title', QueryKind::DDL_SIMULATED);
2972 }
2973
2974 public function testAlterRenameColumnRejectsExistingTarget(): void
2975 {
2976 $registry = new TableDefinitionRegistry();
2977 $registry->register('records', new TableDefinition(
2978 ['id', 'label'],
2979 ['id' => 'INTEGER', 'label' => 'TEXT'],
2980 ['id'],
2981 [],
2982 [],
2983 ));
2984 $resolver = new SqliteMutationResolver(
2985 new ShadowStore(),
2986 $registry,
2987 new SqliteSchemaParser(),
2988 new SqliteParser(),
2989 );
2990
2991 $this->expectException(ColumnAlreadyExistsException::class);
2992 $resolver->resolve('ALTER TABLE records RENAME COLUMN label TO id', QueryKind::DDL_SIMULATED);
2993 }
2994
2995 public function testAlterRenameColumnSupportsCaseOnlyTarget(): void
2996 {
2997 $registry = new TableDefinitionRegistry();
2998 $registry->register('records', new TableDefinition(
2999 ['id', 'label'],
3000 ['id' => 'INTEGER', 'label' => 'TEXT'],
3001 ['id'],
3002 [],
3003 [],
3004 ));
3005 $resolver = new SqliteMutationResolver(
3006 new ShadowStore(),
3007 $registry,
3008 new SqliteSchemaParser(),
3009 new SqliteParser(),
3010 );
3011
3012 $caseOnly = $resolver->resolve(
3013 'ALTER TABLE records RENAME COLUMN label TO LABEL',
3014 QueryKind::DDL_SIMULATED,
3015 );
3016 self::assertInstanceOf(AlterTableMutation::class, $caseOnly);
3017 self::assertSame('SELECT "id", "label" AS "LABEL" FROM "records"', $caseOnly->resultSelect());
3018 }
3019
3020 #[DataProvider('providerMalformedAlterIdentifierClauses')]
3021 public function testAlterIdentifierClausesRejectExtraOrMissingTokens(string $sql): void
3022 {
3023 $registry = new TableDefinitionRegistry();
3024 $registry->register('records', new TableDefinition(
3025 ['id', 'label'],
3026 ['id' => 'INTEGER', 'label' => 'TEXT'],
3027 ['id'],
3028 [],
3029 [],
3030 ));
3031 $resolver = new SqliteMutationResolver(
3032 new ShadowStore(),
3033 $registry,
3034 new SqliteSchemaParser(),
3035 new SqliteParser(),
3036 );
3037
3038 $this->expectException(UnsupportedSqlException::class);
3039 $resolver->resolve($sql, QueryKind::DDL_SIMULATED);
3040 }
3041
3042 /**
3043 * @return Generator<string, array{string}>
3044 */
3045 public static function providerMalformedAlterIdentifierClauses(): Generator
3046 {
3047 yield 'missing operation' => ['ALTER TABLE records'];
3048 yield 'missing add clause' => ['ALTER TABLE records ADD'];
3049 yield 'missing add column definition' => ['ALTER TABLE records ADD COLUMN'];
3050 yield 'missing drop clause' => ['ALTER TABLE records DROP'];
3051 yield 'drop missing column keyword' => ['ALTER TABLE records DROP label'];
3052 yield 'drop missing keyword with extra token' => ['ALTER TABLE records DROP label extra'];
3053 yield 'unknown operation' => ['ALTER TABLE records BROKEN label TO title'];
3054 yield 'rename missing clause' => ['ALTER TABLE records RENAME'];
3055 yield 'drop extra token' => ['ALTER TABLE records DROP COLUMN label extra'];
3056 yield 'rename missing to' => ['ALTER TABLE records RENAME COLUMN label title'];
3057 yield 'rename invalid separator' => ['ALTER TABLE records RENAME COLUMN label nope title'];
3058 yield 'rename invalid source' => ['ALTER TABLE records RENAME (label) TO title'];
3059 yield 'rename mismatched bracket source' => ['ALTER TABLE records RENAME ( label ] TO title'];
3060 yield 'rename missing target' => ['ALTER TABLE records RENAME COLUMN label TO'];
3061 yield 'rename extra token' => ['ALTER TABLE records RENAME COLUMN label TO title extra'];
3062 yield 'rename table extra token' => ['ALTER TABLE records RENAME TO target extra'];
3063 }
3064
3065 public function testAlterAcceptsBracketQuotedColumnIdentifiers(): void
3066 {
3067 $registry = new TableDefinitionRegistry();
3068 $registry->register('records', new TableDefinition(
3069 ['id', 'label'],
3070 ['id' => 'INTEGER', 'label' => 'TEXT'],
3071 ['id'],
3072 [],
3073 [],
3074 ));
3075 $resolver = new SqliteMutationResolver(
3076 new ShadowStore(),
3077 $registry,
3078 new SqliteSchemaParser(),
3079 new SqliteParser(),
3080 );
3081
3082 $mutation = $resolver->resolve(
3083 'ALTER TABLE records RENAME COLUMN [label] TO [title]',
3084 QueryKind::DDL_SIMULATED,
3085 );
3086
3087 self::assertInstanceOf(AlterTableMutation::class, $mutation);
3088 self::assertSame('SELECT "id", "label" AS "title" FROM "records"', $mutation->resultSelect());
3089 }
3090
3091 public function testAlterRenameTableBuildsMigrationProjectionAndMovesDefinition(): void
3092 {
3093 $definition = new TableDefinition(
3094 ['id', 'label'],
3095 ['id' => 'INTEGER', 'label' => 'TEXT'],
3096 ['id'],
3097 [],
3098 [],
3099 );
3100 $registry = new TableDefinitionRegistry();
3101 $registry->register('records', $definition);
3102 $store = new ShadowStore();
3103 $resolver = new SqliteMutationResolver($store, $registry, new SqliteSchemaParser(), new SqliteParser());
3104
3105 $mutation = $resolver->resolve('ALTER TABLE records RENAME TO archived;', QueryKind::DDL_SIMULATED);
3106
3107 self::assertInstanceOf(AlterTableMutation::class, $mutation);
3108 self::assertSame('SELECT "id", "label" FROM "records"', $mutation->resultSelect());
3109 $mutation->apply($store, [['id' => 1, 'label' => 'kept']]);
3110 self::assertSame($definition, $registry->get('archived'));
3111 self::assertTrue($registry->isRemoved('records'));
3112 self::assertSame([['id' => 1, 'label' => 'kept']], $store->get('archived'));
3113 }
3114
3115 public function testAlterAddRejectsMissingParsedColumn(): void
3116 {
3117 $registry = new TableDefinitionRegistry();
3118 $registry->register('records', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
3119 $nullParser = static::createStub(SchemaParser::class);
3120 $nullParser->method('parse')->willReturn(null);
3121 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, $nullParser, new SqliteParser());
3122
3123 $this->expectException(UnsupportedSqlException::class);
3124 $resolver->resolve('ALTER TABLE records ADD COLUMN value TEXT', QueryKind::DDL_SIMULATED);
3125 }
3126
3127 public function testAlterAddRejectsAmbiguousParsedColumn(): void
3128 {
3129 $registry = new TableDefinitionRegistry();
3130 $registry->register('records', new TableDefinition(['id'], ['id' => 'INTEGER'], ['id'], [], []));
3131 $emptyParser = static::createStub(SchemaParser::class);
3132 $emptyParser->method('parse')->willReturn(new TableDefinition([], [], [], [], []));
3133 $resolver = new SqliteMutationResolver(new ShadowStore(), $registry, $emptyParser, new SqliteParser());
3134
3135 $this->expectException(UnsupportedSqlException::class);
3136 $resolver->resolve('ALTER TABLE records ADD COLUMN value TEXT', QueryKind::DDL_SIMULATED);
3137 }
3138}
3139