From 4a5512edc386ae5b4cf7b5afc629fe7db2f5b5b5 Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Mon, 5 Jan 2026 22:04:32 +0000 Subject: [PATCH 1/7] Copilot: Add support for Partitions to plans --- src/Db/Plan/Plan.php | 39 ++++++++++++++++++++++++++ tests/TestCase/Db/Plan/PlanTest.php | 43 +++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 tests/TestCase/Db/Plan/PlanTest.php diff --git a/src/Db/Plan/Plan.php b/src/Db/Plan/Plan.php index f2c36fe7d..89ff96354 100644 --- a/src/Db/Plan/Plan.php +++ b/src/Db/Plan/Plan.php @@ -12,10 +12,12 @@ use Migrations\Db\Action\AddColumn; use Migrations\Db\Action\AddForeignKey; use Migrations\Db\Action\AddIndex; +use Migrations\Db\Action\AddPartition; use Migrations\Db\Action\ChangeColumn; use Migrations\Db\Action\ChangeComment; use Migrations\Db\Action\ChangePrimaryKey; use Migrations\Db\Action\CreateTable; +use Migrations\Db\Action\DropPartition; use Migrations\Db\Action\DropForeignKey; use Migrations\Db\Action\DropIndex; use Migrations\Db\Action\DropTable; @@ -63,6 +65,13 @@ class Plan */ protected array $indexes = []; + /** + * List of partition additions or removals + * + * @var \Migrations\Db\Plan\AlterTable[] + */ + protected array $partitions = []; + /** * List of constraint additions or removals * @@ -100,6 +109,7 @@ protected function createPlan(array $actions): void $this->gatherTableMoves($actions); $this->gatherIndexes($actions); $this->gatherConstraints($actions); + $this->gatherPartitions($actions); $this->resolveConflicts(); } @@ -114,6 +124,7 @@ protected function updatesSequence(): array $this->tableUpdates, $this->constraints, $this->indexes, + $this->partitions, $this->columnRemoves, $this->tableMoves, ]; @@ -129,6 +140,7 @@ protected function inverseUpdatesSequence(): array return [ $this->constraints, $this->tableMoves, + $this->partitions, $this->indexes, $this->columnRemoves, $this->tableUpdates, @@ -186,6 +198,7 @@ protected function resolveConflicts(): void $this->tableUpdates = $this->forgetTable($action->getTable(), $this->tableUpdates); $this->constraints = $this->forgetTable($action->getTable(), $this->constraints); $this->indexes = $this->forgetTable($action->getTable(), $this->indexes); + $this->partitions = $this->forgetTable($action->getTable(), $this->partitions); $this->columnRemoves = $this->forgetTable($action->getTable(), $this->columnRemoves); } } @@ -468,6 +481,32 @@ protected function gatherIndexes(array $actions): void } } + /** + * Collects all partition creation and drops from the given intent + * + * @param \Migrations\Db\Action\Action[] $actions The actions to parse + * @return void + */ + protected function gatherPartitions(array $actions): void + { + foreach ($actions as $action) { + if (!($action instanceof AddPartition) && !($action instanceof DropPartition)) { + continue; + } elseif (isset($this->tableCreates[$action->getTable()->getName()])) { + continue; + } + + $table = $action->getTable(); + $name = $table->getName(); + + if (!isset($this->partitions[$name])) { + $this->partitions[$name] = new AlterTable($table); + } + + $this->partitions[$name]->addAction($action); + } + } + /** * Collects all foreign key creation and drops from the given intent * diff --git a/tests/TestCase/Db/Plan/PlanTest.php b/tests/TestCase/Db/Plan/PlanTest.php new file mode 100644 index 000000000..9a23f302c --- /dev/null +++ b/tests/TestCase/Db/Plan/PlanTest.php @@ -0,0 +1,43 @@ +addAction(new AddPartition($table, new PartitionDefinition('p2024', '2025-01-01'))); + $intent->addAction(new DropPartition($table, 'p2023')); + + $plan = new Plan($intent); + + $adapter = $this->createMock(AdapterInterface::class); + $adapter->expects($this->never())->method('createTable'); + $adapter->expects($this->once()) + ->method('executeActions') + ->with( + $this->callback(fn (TableMetadata $passedTable) => $passedTable->getName() === 'orders'), + $this->callback(function (array $actions): bool { + $this->assertCount(2, $actions); + $this->assertInstanceOf(AddPartition::class, $actions[0]); + $this->assertInstanceOf(DropPartition::class, $actions[1]); + + return true; + }), + ); + + $plan->execute($adapter); + } +} From d397fd709f0e2e5a242d21e44dfabe8b2109c09e Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Mon, 5 Jan 2026 17:17:34 -0500 Subject: [PATCH 2/7] Create better tests in the correct place --- .../TestCase/Db/Adapter/MysqlAdapterTest.php | 65 +++++++++++++++++++ tests/TestCase/Db/Plan/PlanTest.php | 43 ------------ 2 files changed, 65 insertions(+), 43 deletions(-) delete mode 100644 tests/TestCase/Db/Plan/PlanTest.php diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 1dce2e491..46dbc74fa 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3142,4 +3142,69 @@ public function testCreateTableWithExpressionPartitioning() $this->assertTrue($this->adapter->hasTable('partitioned_events')); } + + public function testAddPartitionToExistingTable() + { + // Create a partitioned table with room to add more partitions + $table = new Table('partitioned_orders', ['id' => false, 'primary_key' => ['id', 'order_date']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('order_date', 'date') + ->addColumn('amount', 'decimal', ['precision' => 10, 'scale' => 2]) + ->partitionBy(Partition::TYPE_RANGE_COLUMNS, 'order_date') + ->addPartition('p2022', '2023-01-01') + ->addPartition('p2023', '2024-01-01') + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_orders')); + + // Add a new partition to the existing table + $table = new Table('partitioned_orders', [], $this->adapter); + $table->addPartitionToExisting('p2024', '2025-01-01') + ->save(); + + // Verify the partition was added by inserting data that belongs in the new partition + $this->adapter->execute( + "INSERT INTO partitioned_orders (id, order_date, amount) VALUES (1, '2024-06-15', 100.00)" + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_orders WHERE order_date = "2024-06-15"'); + $this->assertCount(1, $rows); + } + + public function testDropPartitionFromExistingTable() + { + // Create a partitioned table with multiple partitions + $table = new Table('partitioned_logs', ['id' => false, 'primary_key' => ['id']], $this->adapter); + $table->addColumn('id', 'biginteger') + ->addColumn('message', 'text') + ->partitionBy(Partition::TYPE_RANGE, 'id') + ->addPartition('p0', 1000000) + ->addPartition('p1', 2000000) + ->addPartition('pmax', 'MAXVALUE') + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_logs')); + + // Insert data into partition p0 + $this->adapter->execute( + "INSERT INTO partitioned_logs (id, message) VALUES (500, 'test message')" + ); + + // Drop the partition (this also removes the data) + $table = new Table('partitioned_logs', [], $this->adapter); + $table->dropPartition('p0') + ->save(); + + // Verify the data was removed with the partition + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_logs WHERE id = 500'); + $this->assertCount(0, $rows); + + // Verify the table still works by inserting into the next partition + $this->adapter->execute( + "INSERT INTO partitioned_logs (id, message) VALUES (1500000, 'another message')" + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_logs WHERE id = 1500000'); + $this->assertCount(1, $rows); + } } diff --git a/tests/TestCase/Db/Plan/PlanTest.php b/tests/TestCase/Db/Plan/PlanTest.php deleted file mode 100644 index 9a23f302c..000000000 --- a/tests/TestCase/Db/Plan/PlanTest.php +++ /dev/null @@ -1,43 +0,0 @@ -addAction(new AddPartition($table, new PartitionDefinition('p2024', '2025-01-01'))); - $intent->addAction(new DropPartition($table, 'p2023')); - - $plan = new Plan($intent); - - $adapter = $this->createMock(AdapterInterface::class); - $adapter->expects($this->never())->method('createTable'); - $adapter->expects($this->once()) - ->method('executeActions') - ->with( - $this->callback(fn (TableMetadata $passedTable) => $passedTable->getName() === 'orders'), - $this->callback(function (array $actions): bool { - $this->assertCount(2, $actions); - $this->assertInstanceOf(AddPartition::class, $actions[0]); - $this->assertInstanceOf(DropPartition::class, $actions[1]); - - return true; - }), - ); - - $plan->execute($adapter); - } -} From 7b900b748698bae8d3b1f5e3a5c0efe50f756b38 Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Mon, 5 Jan 2026 17:20:36 -0500 Subject: [PATCH 3/7] Fix CS issues --- src/Db/Plan/Plan.php | 2 +- tests/TestCase/Db/Adapter/MysqlAdapterTest.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Db/Plan/Plan.php b/src/Db/Plan/Plan.php index 89ff96354..06cbbd9bb 100644 --- a/src/Db/Plan/Plan.php +++ b/src/Db/Plan/Plan.php @@ -17,9 +17,9 @@ use Migrations\Db\Action\ChangeComment; use Migrations\Db\Action\ChangePrimaryKey; use Migrations\Db\Action\CreateTable; -use Migrations\Db\Action\DropPartition; use Migrations\Db\Action\DropForeignKey; use Migrations\Db\Action\DropIndex; +use Migrations\Db\Action\DropPartition; use Migrations\Db\Action\DropTable; use Migrations\Db\Action\RemoveColumn; use Migrations\Db\Action\RenameColumn; diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 46dbc74fa..f86dd2764 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3164,7 +3164,7 @@ public function testAddPartitionToExistingTable() // Verify the partition was added by inserting data that belongs in the new partition $this->adapter->execute( - "INSERT INTO partitioned_orders (id, order_date, amount) VALUES (1, '2024-06-15', 100.00)" + "INSERT INTO partitioned_orders (id, order_date, amount) VALUES (1, '2024-06-15', 100.00)", ); $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_orders WHERE order_date = "2024-06-15"'); @@ -3187,7 +3187,7 @@ public function testDropPartitionFromExistingTable() // Insert data into partition p0 $this->adapter->execute( - "INSERT INTO partitioned_logs (id, message) VALUES (500, 'test message')" + "INSERT INTO partitioned_logs (id, message) VALUES (500, 'test message')", ); // Drop the partition (this also removes the data) @@ -3201,7 +3201,7 @@ public function testDropPartitionFromExistingTable() // Verify the table still works by inserting into the next partition $this->adapter->execute( - "INSERT INTO partitioned_logs (id, message) VALUES (1500000, 'another message')" + "INSERT INTO partitioned_logs (id, message) VALUES (1500000, 'another message')", ); $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_logs WHERE id = 1500000'); From 94653cd5d9bc0c81c660f3e920fbf7a8e31e3497 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 6 Jan 2026 01:18:29 +0100 Subject: [PATCH 4/7] Add PostgreSQL partition tests for addPartitionToExisting and dropPartition Tests ensure the Plan.php fix properly handles partition actions for PostgreSQL, which uses different syntax (CREATE TABLE ... PARTITION OF) than MySQL. --- .../Db/Adapter/PostgresAdapterTest.php | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php index c832b84e1..23e1d0a6f 100644 --- a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php +++ b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php @@ -17,6 +17,7 @@ use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; +use Migrations\Db\Table\Partition; use PDO; use PDOException; use PHPUnit\Framework\Attributes\DataProvider; @@ -2983,4 +2984,71 @@ public function testInsertOrUpdateModeResetsAfterSave() ['code' => 'ITEM1', 'name' => 'Different Name'], ])->save(); } + + public function testAddPartitionToExistingTable() + { + // Create a partitioned table with room to add more partitions + $table = new Table('partitioned_orders', ['id' => false, 'primary_key' => ['id', 'order_date']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('order_date', 'date') + ->addColumn('amount', 'decimal', ['precision' => 10, 'scale' => 2]) + ->partitionBy(Partition::TYPE_RANGE, 'order_date') + ->addPartition('p2022', ['from' => '2022-01-01', 'to' => '2023-01-01']) + ->addPartition('p2023', ['from' => '2023-01-01', 'to' => '2024-01-01']) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_orders')); + + // Add a new partition to the existing table + $table = new Table('partitioned_orders', [], $this->adapter); + $table->addPartitionToExisting('p2024', ['from' => '2024-01-01', 'to' => '2025-01-01']) + ->save(); + + // Verify the partition was added by inserting data that belongs in the new partition + $this->adapter->execute( + "INSERT INTO partitioned_orders (id, order_date, amount) VALUES (1, '2024-06-15', 100.00)", + ); + + $rows = $this->adapter->fetchAll("SELECT * FROM partitioned_orders WHERE order_date = '2024-06-15'"); + $this->assertCount(1, $rows); + } + + public function testDropPartitionFromExistingTable() + { + // Create a partitioned table with multiple partitions + $table = new Table('partitioned_logs', ['id' => false, 'primary_key' => ['id']], $this->adapter); + $table->addColumn('id', 'biginteger') + ->addColumn('message', 'text') + ->partitionBy(Partition::TYPE_RANGE, 'id') + ->addPartition('p0', ['from' => 0, 'to' => 1000000]) + ->addPartition('p1', ['from' => 1000000, 'to' => 2000000]) + ->addPartition('p2', ['from' => 2000000, 'to' => 3000000]) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_logs')); + + // Insert data into partition p0 + $this->adapter->execute( + "INSERT INTO partitioned_logs (id, message) VALUES (500, 'test message')", + ); + + // Drop the partition (this also removes the data in PostgreSQL) + $table = new Table('partitioned_logs', [], $this->adapter); + $table->dropPartition('p0') + ->save(); + + // Verify the partition table was dropped + $this->assertFalse($this->adapter->hasTable('partitioned_logs_p0')); + + // Verify the main partitioned table still exists + $this->assertTrue($this->adapter->hasTable('partitioned_logs')); + + // Verify the table still works by inserting into the next partition + $this->adapter->execute( + "INSERT INTO partitioned_logs (id, message) VALUES (1500000, 'another message')", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_logs WHERE id = 1500000'); + $this->assertCount(1, $rows); + } } From 8517d18331958a9df8a520629fd5f7fbd3b71794 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 6 Jan 2026 01:24:47 +0100 Subject: [PATCH 5/7] Add table cleanup to PostgreSQL partition tests --- tests/TestCase/Db/Adapter/PostgresAdapterTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php index 23e1d0a6f..c58ba0975 100644 --- a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php +++ b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php @@ -3011,6 +3011,9 @@ public function testAddPartitionToExistingTable() $rows = $this->adapter->fetchAll("SELECT * FROM partitioned_orders WHERE order_date = '2024-06-15'"); $this->assertCount(1, $rows); + + // Cleanup - drop partitioned table (CASCADE drops partitions) + $this->adapter->dropTable('partitioned_orders'); } public function testDropPartitionFromExistingTable() @@ -3050,5 +3053,8 @@ public function testDropPartitionFromExistingTable() $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_logs WHERE id = 1500000'); $this->assertCount(1, $rows); + + // Cleanup - drop partitioned table (CASCADE drops remaining partitions) + $this->adapter->dropTable('partitioned_logs'); } } From 22c5c94d30fa75d210ec85ca6dc412f9dabd5ef7 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 6 Jan 2026 03:17:57 +0100 Subject: [PATCH 6/7] Add test for combined partition and column operations --- .../TestCase/Db/Adapter/MysqlAdapterTest.php | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index f86dd2764..295db76c7 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3207,4 +3207,37 @@ public function testDropPartitionFromExistingTable() $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_logs WHERE id = 1500000'); $this->assertCount(1, $rows); } + + public function testCombinedPartitionAndColumnOperations(): void + { + // Create a partitioned table + $table = new Table('combined_test', ['id' => false, 'primary_key' => ['id', 'created_year']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('created_year', 'integer') + ->addColumn('name', 'string', ['limit' => 100]) + ->partitionBy(Partition::TYPE_RANGE, 'created_year') + ->addPartition('p2022', 2023) + ->addPartition('p2023', 2024) + ->create(); + + $this->assertTrue($this->adapter->hasTable('combined_test')); + + // Combine adding a column AND adding a partition in one save() + $table = new Table('combined_test', [], $this->adapter); + $table->addColumn('description', 'text', ['null' => true]) + ->addPartitionToExisting('p2024', 2025) + ->save(); + + // Verify the column was added + $this->assertTrue($this->adapter->hasColumn('combined_test', 'description')); + + // Verify the partition was added by inserting data + $this->adapter->execute( + "INSERT INTO combined_test (id, created_year, name, description) VALUES (1, 2024, 'Test', 'A description')", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM combined_test WHERE created_year = 2024'); + $this->assertCount(1, $rows); + $this->assertEquals('A description', $rows[0]['description']); + } } From 97c06c683eeefb160a169a3e4a6d2d2956921eca Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 6 Jan 2026 03:29:25 +0100 Subject: [PATCH 7/7] Revert "Add test for combined partition and column operations" This reverts commit 22c5c94d30fa75d210ec85ca6dc412f9dabd5ef7. --- .../TestCase/Db/Adapter/MysqlAdapterTest.php | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 295db76c7..f86dd2764 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3207,37 +3207,4 @@ public function testDropPartitionFromExistingTable() $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_logs WHERE id = 1500000'); $this->assertCount(1, $rows); } - - public function testCombinedPartitionAndColumnOperations(): void - { - // Create a partitioned table - $table = new Table('combined_test', ['id' => false, 'primary_key' => ['id', 'created_year']], $this->adapter); - $table->addColumn('id', 'integer') - ->addColumn('created_year', 'integer') - ->addColumn('name', 'string', ['limit' => 100]) - ->partitionBy(Partition::TYPE_RANGE, 'created_year') - ->addPartition('p2022', 2023) - ->addPartition('p2023', 2024) - ->create(); - - $this->assertTrue($this->adapter->hasTable('combined_test')); - - // Combine adding a column AND adding a partition in one save() - $table = new Table('combined_test', [], $this->adapter); - $table->addColumn('description', 'text', ['null' => true]) - ->addPartitionToExisting('p2024', 2025) - ->save(); - - // Verify the column was added - $this->assertTrue($this->adapter->hasColumn('combined_test', 'description')); - - // Verify the partition was added by inserting data - $this->adapter->execute( - "INSERT INTO combined_test (id, created_year, name, description) VALUES (1, 2024, 'Test', 'A description')", - ); - - $rows = $this->adapter->fetchAll('SELECT * FROM combined_test WHERE created_year = 2024'); - $this->assertCount(1, $rows); - $this->assertEquals('A description', $rows[0]['description']); - } }