From 91fedd6421eb4d64b502218b931d063856c863dc Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 6 Jan 2026 02:50:54 +0100 Subject: [PATCH 1/8] Fix multiple partition SQL syntax for MySQL When adding multiple partitions to an existing table, MySQL requires: ALTER TABLE foo ADD PARTITION (PARTITION p1 ..., PARTITION p2 ...) Previously, each AddPartition action generated its own ADD PARTITION clause, which when joined with commas resulted in invalid SQL: ALTER TABLE foo ADD PARTITION (...), ADD PARTITION (...) This fix: - Batches AddPartition actions together in executeActions() - Adds new getAddPartitionsInstructions() method to AbstractAdapter with a default implementation that calls the single partition method - Overrides getAddPartitionsInstructions() in MysqlAdapter to generate correct batched SQL: ADD PARTITION (PARTITION p1, PARTITION p2) - Similarly batches DropPartition actions for efficiency - Adds gatherPartitions() to Plan.php to properly gather partition actions - Includes extensive tests for single/multiple partition add/drop scenarios Refs #986 --- src/Db/Adapter/AbstractAdapter.php | 68 +++++- src/Db/Adapter/MysqlAdapter.php | 84 +++++++ src/Db/Plan/Plan.php | 39 ++++ .../TestCase/Db/Adapter/MysqlAdapterTest.php | 208 ++++++++++++++++++ .../Db/Adapter/PostgresAdapterTest.php | 156 +++++++++++++ 5 files changed, 547 insertions(+), 8 deletions(-) diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index f9f652ccc..c03166f82 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -1656,6 +1656,12 @@ public function executeActions(TableMetadata $table, array $actions): void { $instructions = new AlterInstructions(); + // Collect partition actions separately as they need special batching + /** @var \Migrations\Db\Table\PartitionDefinition[] $addPartitions */ + $addPartitions = []; + /** @var string[] $dropPartitions */ + $dropPartitions = []; + foreach ($actions as $action) { switch (true) { case $action instanceof AddColumn: @@ -1764,18 +1770,12 @@ public function executeActions(TableMetadata $table, array $actions): void case $action instanceof AddPartition: /** @var \Migrations\Db\Action\AddPartition $action */ - $instructions->merge($this->getAddPartitionInstructions( - $table, - $action->getPartition(), - )); + $addPartitions[] = $action->getPartition(); break; case $action instanceof DropPartition: /** @var \Migrations\Db\Action\DropPartition $action */ - $instructions->merge($this->getDropPartitionInstructions( - $table->getName(), - $action->getPartitionName(), - )); + $dropPartitions[] = $action->getPartitionName(); break; default: @@ -1785,6 +1785,58 @@ public function executeActions(TableMetadata $table, array $actions): void } } + // Handle batched partition operations + if ($addPartitions) { + $instructions->merge($this->getAddPartitionsInstructions($table, $addPartitions)); + } + if ($dropPartitions) { + $instructions->merge($this->getDropPartitionsInstructions($table->getName(), $dropPartitions)); + } + $this->executeAlterSteps($table->getName(), $instructions); } + + /** + * Get instructions for adding multiple partitions to an existing table. + * + * This method handles batching multiple partition additions into a single + * ALTER TABLE statement where supported by the database. + * + * @param \Migrations\Db\Table\TableMetadata $table The table + * @param array<\Migrations\Db\Table\PartitionDefinition> $partitions The partitions to add + * @return \Migrations\Db\AlterInstructions + */ + protected function getAddPartitionsInstructions(TableMetadata $table, array $partitions): AlterInstructions + { + // Default implementation calls single partition method for each + // Subclasses can override for database-specific batching + $instructions = new AlterInstructions(); + foreach ($partitions as $partition) { + $instructions->merge($this->getAddPartitionInstructions($table, $partition)); + } + + return $instructions; + } + + /** + * Get instructions for dropping multiple partitions from an existing table. + * + * This method handles batching multiple partition drops into a single + * ALTER TABLE statement where supported by the database. + * + * @param string $tableName The table name + * @param array $partitionNames The partition names to drop + * @return \Migrations\Db\AlterInstructions + */ + protected function getDropPartitionsInstructions(string $tableName, array $partitionNames): AlterInstructions + { + // Default implementation calls single partition method for each + // Subclasses can override for database-specific batching + $instructions = new AlterInstructions(); + foreach ($partitionNames as $partitionName) { + $instructions->merge($this->getDropPartitionInstructions($tableName, $partitionName)); + } + + return $instructions; + } } diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index 8e2ec4a47..563c13a24 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -1389,6 +1389,90 @@ protected function getDropPartitionInstructions(string $tableName, string $parti return new AlterInstructions([$sql]); } + /** + * Get instructions for adding multiple partitions to an existing table. + * + * MySQL requires all partitions in a single ADD PARTITION clause: + * ADD PARTITION (PARTITION p1 ..., PARTITION p2 ...) + * + * @param \Migrations\Db\Table\TableMetadata $table The table + * @param array<\Migrations\Db\Table\PartitionDefinition> $partitions The partitions to add + * @return \Migrations\Db\AlterInstructions + */ + protected function getAddPartitionsInstructions(TableMetadata $table, array $partitions): AlterInstructions + { + if (empty($partitions)) { + return new AlterInstructions(); + } + + $partitionDefs = []; + foreach ($partitions as $partition) { + $partitionDefs[] = $this->getAddPartitionSql($partition); + } + + $sql = 'ADD PARTITION (' . implode(', ', $partitionDefs) . ')'; + + return new AlterInstructions([$sql]); + } + + /** + * Get instructions for dropping multiple partitions from an existing table. + * + * MySQL allows dropping multiple partitions in a single statement: + * DROP PARTITION p1, p2, p3 + * + * @param string $tableName The table name + * @param array $partitionNames The partition names to drop + * @return \Migrations\Db\AlterInstructions + */ + protected function getDropPartitionsInstructions(string $tableName, array $partitionNames): AlterInstructions + { + if (empty($partitionNames)) { + return new AlterInstructions(); + } + + $quotedNames = array_map(fn($name) => $this->quoteColumnName($name), $partitionNames); + $sql = 'DROP PARTITION ' . implode(', ', $quotedNames); + + return new AlterInstructions([$sql]); + } + + /** + * Generate the SQL definition for a single partition when adding to existing table. + * + * This method is used when adding partitions to an existing table and must + * infer the partition type from the value format since we don't have table metadata. + * + * @param \Migrations\Db\Table\PartitionDefinition $partition The partition definition + * @return string + */ + protected function getAddPartitionSql(PartitionDefinition $partition): string + { + $value = $partition->getValue(); + $sql = 'PARTITION ' . $this->quoteColumnName($partition->getName()); + + // Detect RANGE vs LIST based on value type (simplified heuristic) + if ($value === 'MAXVALUE' || is_scalar($value)) { + // Likely RANGE + if ($value === 'MAXVALUE') { + $sql .= ' VALUES LESS THAN MAXVALUE'; + } else { + $sql .= ' VALUES LESS THAN (' . $this->quotePartitionValue($value) . ')'; + } + } elseif (is_array($value)) { + // Likely LIST + $sql .= ' VALUES IN ('; + $sql .= implode(', ', array_map(fn($v) => $this->quotePartitionValue($v), $value)); + $sql .= ')'; + } + + if ($partition->getComment()) { + $sql .= ' COMMENT = ' . $this->quoteString($partition->getComment()); + } + + return $sql; + } + /** * Whether the server has a native uuid type. * (MariaDB 10.7.0+) diff --git a/src/Db/Plan/Plan.php b/src/Db/Plan/Plan.php index f2c36fe7d..ef1b87b81 100644 --- a/src/Db/Plan/Plan.php +++ b/src/Db/Plan/Plan.php @@ -12,12 +12,14 @@ 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\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; @@ -70,6 +72,13 @@ class Plan */ protected array $constraints = []; + /** + * List of partition additions or removals + * + * @var \Migrations\Db\Plan\AlterTable[] + */ + protected array $partitions = []; + /** * List of dropped columns * @@ -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); } } @@ -490,4 +503,30 @@ protected function gatherConstraints(array $actions): void $this->constraints[$name]->addAction($action); } } + + /** + * 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); + } + } } diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 1dce2e491..f251ffd7b 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3142,4 +3142,212 @@ public function testCreateTableWithExpressionPartitioning() $this->assertTrue($this->adapter->hasTable('partitioned_events')); } + + public function testAddSinglePartitionToExistingTable() + { + // 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 single 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 testAddMultiplePartitionsToExistingTable() + { + // Create a partitioned table with room to add more partitions + $table = new Table('partitioned_sales', ['id' => false, 'primary_key' => ['id', 'sale_date']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('sale_date', 'date') + ->addColumn('amount', 'decimal', ['precision' => 10, 'scale' => 2]) + ->partitionBy(Partition::TYPE_RANGE_COLUMNS, 'sale_date') + ->addPartition('p2022', '2023-01-01') + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_sales')); + + // Add multiple partitions at once - this is the main test for the fix + // MySQL requires: ADD PARTITION (PARTITION p1 ..., PARTITION p2 ...) + // NOT: ADD PARTITION (...), ADD PARTITION (...) + $table = new Table('partitioned_sales', [], $this->adapter); + $table->addPartitionToExisting('p2023', '2024-01-01') + ->addPartitionToExisting('p2024', '2025-01-01') + ->addPartitionToExisting('p2025', '2026-01-01') + ->save(); + + // Verify all partitions were added by inserting data into each + $this->adapter->execute( + "INSERT INTO partitioned_sales (id, sale_date, amount) VALUES (1, '2023-06-15', 100.00)", + ); + $this->adapter->execute( + "INSERT INTO partitioned_sales (id, sale_date, amount) VALUES (2, '2024-06-15', 200.00)", + ); + $this->adapter->execute( + "INSERT INTO partitioned_sales (id, sale_date, amount) VALUES (3, '2025-06-15', 300.00)", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_sales'); + $this->assertCount(3, $rows); + } + + public function testDropSinglePartitionFromExistingTable() + { + // 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); + } + + public function testDropMultiplePartitionsFromExistingTable() + { + // Create a partitioned table with multiple partitions + $table = new Table('partitioned_archive', ['id' => false, 'primary_key' => ['id']], $this->adapter); + $table->addColumn('id', 'biginteger') + ->addColumn('data', 'text') + ->partitionBy(Partition::TYPE_RANGE, 'id') + ->addPartition('p0', 1000000) + ->addPartition('p1', 2000000) + ->addPartition('p2', 3000000) + ->addPartition('p3', 4000000) + ->addPartition('pmax', 'MAXVALUE') + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_archive')); + + // Insert data into partitions p0 and p1 + $this->adapter->execute( + "INSERT INTO partitioned_archive (id, data) VALUES (500, 'data in p0')", + ); + $this->adapter->execute( + "INSERT INTO partitioned_archive (id, data) VALUES (1500000, 'data in p1')", + ); + $this->adapter->execute( + "INSERT INTO partitioned_archive (id, data) VALUES (2500000, 'data in p2')", + ); + + // Drop multiple partitions at once + // MySQL allows: DROP PARTITION p0, p1 + $table = new Table('partitioned_archive', [], $this->adapter); + $table->dropPartition('p0') + ->dropPartition('p1') + ->save(); + + // Verify the data was removed with the partitions + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_archive WHERE id < 2000000'); + $this->assertCount(0, $rows); + + // Verify data in p2 still exists + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_archive WHERE id = 2500000'); + $this->assertCount(1, $rows); + } + + public function testAddMultipleListPartitionsToExistingTable() + { + // Create a LIST partitioned table + $table = new Table('partitioned_regions', ['id' => false, 'primary_key' => ['id', 'region_id']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('region_id', 'integer') + ->addColumn('name', 'string', ['limit' => 100]) + ->partitionBy(Partition::TYPE_LIST, 'region_id') + ->addPartition('p_north', [1, 2, 3]) + ->addPartition('p_south', [4, 5, 6]) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_regions')); + + // Add multiple LIST partitions at once + $table = new Table('partitioned_regions', [], $this->adapter); + $table->addPartitionToExisting('p_east', [7, 8, 9]) + ->addPartitionToExisting('p_west', [10, 11, 12]) + ->save(); + + // Verify all partitions work by inserting data + $this->adapter->execute( + "INSERT INTO partitioned_regions (id, region_id, name) VALUES (1, 7, 'East Region')", + ); + $this->adapter->execute( + "INSERT INTO partitioned_regions (id, region_id, name) VALUES (2, 10, 'West Region')", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_regions WHERE region_id IN (7, 10)'); + $this->assertCount(2, $rows); + } + + public function testAddPartitionsWithMaxvalue() + { + // Create a partitioned table without MAXVALUE partition + $table = new Table('partitioned_data', ['id' => false, 'primary_key' => ['id']], $this->adapter); + $table->addColumn('id', 'biginteger') + ->addColumn('value', 'integer') + ->partitionBy(Partition::TYPE_RANGE, 'id') + ->addPartition('p0', 100) + ->addPartition('p1', 200) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_data')); + + // Add multiple partitions including one with MAXVALUE + $table = new Table('partitioned_data', [], $this->adapter); + $table->addPartitionToExisting('p2', 300) + ->addPartitionToExisting('pmax', 'MAXVALUE') + ->save(); + + // Verify MAXVALUE partition catches all higher values + $this->adapter->execute( + "INSERT INTO partitioned_data (id, value) VALUES (250, 1)", + ); + $this->adapter->execute( + "INSERT INTO partitioned_data (id, value) VALUES (999999, 2)", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_data WHERE id >= 200'); + $this->assertCount(2, $rows); + } } diff --git a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php index c832b84e1..2bc5988e4 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,159 @@ public function testInsertOrUpdateModeResetsAfterSave() ['code' => 'ITEM1', 'name' => 'Different Name'], ])->save(); } + + public function testAddSinglePartitionToExistingTable() + { + // 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); + + // Cleanup - drop partitioned table (CASCADE drops partitions) + $this->adapter->dropTable('partitioned_orders'); + } + + public function testAddMultiplePartitionsToExistingTable() + { + // Create a partitioned table + $table = new Table('partitioned_sales', ['id' => false, 'primary_key' => ['id', 'sale_date']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('sale_date', 'date') + ->addColumn('amount', 'decimal', ['precision' => 10, 'scale' => 2]) + ->partitionBy(Partition::TYPE_RANGE, 'sale_date') + ->addPartition('p2022', ['from' => '2022-01-01', 'to' => '2023-01-01']) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_sales')); + + // Add multiple partitions at once + $table = new Table('partitioned_sales', [], $this->adapter); + $table->addPartitionToExisting('p2023', ['from' => '2023-01-01', 'to' => '2024-01-01']) + ->addPartitionToExisting('p2024', ['from' => '2024-01-01', 'to' => '2025-01-01']) + ->addPartitionToExisting('p2025', ['from' => '2025-01-01', 'to' => '2026-01-01']) + ->save(); + + // Verify all partitions were added by inserting data into each + $this->adapter->execute( + "INSERT INTO partitioned_sales (id, sale_date, amount) VALUES (1, '2023-06-15', 100.00)", + ); + $this->adapter->execute( + "INSERT INTO partitioned_sales (id, sale_date, amount) VALUES (2, '2024-06-15', 200.00)", + ); + $this->adapter->execute( + "INSERT INTO partitioned_sales (id, sale_date, amount) VALUES (3, '2025-06-15', 300.00)", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_sales'); + $this->assertCount(3, $rows); + + // Cleanup + $this->adapter->dropTable('partitioned_sales'); + } + + public function testDropSinglePartitionFromExistingTable() + { + // 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); + + // Cleanup - drop partitioned table (CASCADE drops remaining partitions) + $this->adapter->dropTable('partitioned_logs'); + } + + public function testDropMultiplePartitionsFromExistingTable() + { + // Create a partitioned table with multiple partitions + $table = new Table('partitioned_archive', ['id' => false, 'primary_key' => ['id']], $this->adapter); + $table->addColumn('id', 'biginteger') + ->addColumn('data', 'text') + ->partitionBy(Partition::TYPE_RANGE, 'id') + ->addPartition('p0', ['from' => 0, 'to' => 1000000]) + ->addPartition('p1', ['from' => 1000000, 'to' => 2000000]) + ->addPartition('p2', ['from' => 2000000, 'to' => 3000000]) + ->addPartition('p3', ['from' => 3000000, 'to' => 4000000]) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_archive')); + + // Insert data into partitions + $this->adapter->execute( + "INSERT INTO partitioned_archive (id, data) VALUES (500, 'data in p0')", + ); + $this->adapter->execute( + "INSERT INTO partitioned_archive (id, data) VALUES (1500000, 'data in p1')", + ); + $this->adapter->execute( + "INSERT INTO partitioned_archive (id, data) VALUES (2500000, 'data in p2')", + ); + + // Drop multiple partitions at once + $table = new Table('partitioned_archive', [], $this->adapter); + $table->dropPartition('p0') + ->dropPartition('p1') + ->save(); + + // Verify the partition tables were dropped + $this->assertFalse($this->adapter->hasTable('partitioned_archive_p0')); + $this->assertFalse($this->adapter->hasTable('partitioned_archive_p1')); + + // Verify data in p2 still exists + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_archive WHERE id = 2500000'); + $this->assertCount(1, $rows); + + // Cleanup + $this->adapter->dropTable('partitioned_archive'); + } } From 31aea488f732f4a218239b9a1992bf6706f0a7a0 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 6 Jan 2026 02:54:19 +0100 Subject: [PATCH 2/8] Fix coding standard - use single quotes --- tests/TestCase/Db/Adapter/MysqlAdapterTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index f251ffd7b..0edfd111f 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3341,10 +3341,10 @@ public function testAddPartitionsWithMaxvalue() // Verify MAXVALUE partition catches all higher values $this->adapter->execute( - "INSERT INTO partitioned_data (id, value) VALUES (250, 1)", + 'INSERT INTO partitioned_data (id, value) VALUES (250, 1)', ); $this->adapter->execute( - "INSERT INTO partitioned_data (id, value) VALUES (999999, 2)", + 'INSERT INTO partitioned_data (id, value) VALUES (999999, 2)', ); $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_data WHERE id >= 200'); From fd05bf24c220e620397b72394e062e7759149813 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 6 Jan 2026 02:57:09 +0100 Subject: [PATCH 3/8] Trigger CI re-run From e978c2368949660090a1dc7e44ae6edf284b1881 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 6 Jan 2026 03:29:11 +0100 Subject: [PATCH 4/8] 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 0edfd111f..ddaad69df 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3350,4 +3350,37 @@ public function testAddPartitionsWithMaxvalue() $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_data WHERE id >= 200'); $this->assertCount(2, $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 60a200a02d655a53d73c0e68823da932fa458040 Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Mon, 5 Jan 2026 22:07:29 -0500 Subject: [PATCH 5/8] Add SetPartitioning action for partitionBy() + update() Enables adding partitioning to existing non-partitioned tables using: $table->partitionBy(Partition::TYPE_RANGE_COLUMNS, 'created') ->addPartition('p2023', '2024-01-01') ->update(); Previously this generated no SQL. Now it properly generates: ALTER TABLE `table` PARTITION BY RANGE COLUMNS (created) (...) Changes: - Add SetPartitioning action class - Update Plan to handle SetPartitioning in gatherPartitions() - Add getSetPartitioningInstructions() to AbstractAdapter/MysqlAdapter - Create SetPartitioning action in Table::executeActions() when updating --- src/Db/Action/SetPartitioning.php | 45 +++++++++++++++++++ src/Db/Adapter/AbstractAdapter.php | 23 ++++++++++ src/Db/Adapter/MysqlAdapter.php | 14 ++++++ src/Db/Plan/Plan.php | 7 ++- src/Db/Table.php | 9 ++++ .../TestCase/Db/Adapter/MysqlAdapterTest.php | 38 ++++++++++++++++ 6 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 src/Db/Action/SetPartitioning.php diff --git a/src/Db/Action/SetPartitioning.php b/src/Db/Action/SetPartitioning.php new file mode 100644 index 000000000..0e24e048a --- /dev/null +++ b/src/Db/Action/SetPartitioning.php @@ -0,0 +1,45 @@ +partition = $partition; + } + + /** + * Returns the partition configuration + * + * @return \Migrations\Db\Table\Partition + */ + public function getPartition(): Partition + { + return $this->partition; + } +} diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index c03166f82..11ef5c040 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -37,6 +37,7 @@ use Migrations\Db\Action\RemoveColumn; use Migrations\Db\Action\RenameColumn; use Migrations\Db\Action\RenameTable; +use Migrations\Db\Action\SetPartitioning; use Migrations\Db\AlterInstructions; use Migrations\Db\InsertMode; use Migrations\Db\Literal; @@ -45,6 +46,7 @@ use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; +use Migrations\Db\Table\Partition; use Migrations\Db\Table\PartitionDefinition; use Migrations\Db\Table\TableMetadata; use Migrations\MigrationInterface; @@ -1778,6 +1780,14 @@ public function executeActions(TableMetadata $table, array $actions): void $dropPartitions[] = $action->getPartitionName(); break; + case $action instanceof SetPartitioning: + /** @var \Migrations\Db\Action\SetPartitioning $action */ + $instructions->merge($this->getSetPartitioningInstructions( + $table, + $action->getPartition(), + )); + break; + default: throw new InvalidArgumentException( sprintf("Don't know how to execute action `%s`", get_class($action)), @@ -1839,4 +1849,17 @@ protected function getDropPartitionsInstructions(string $tableName, array $parti return $instructions; } + + /** + * Get instructions for adding partitioning to an existing table. + * + * @param \Migrations\Db\Table\TableMetadata $table The table + * @param \Migrations\Db\Table\Partition $partition The partition configuration + * @throws \RuntimeException If partitioning is not supported + * @return \Migrations\Db\AlterInstructions + */ + protected function getSetPartitioningInstructions(TableMetadata $table, Partition $partition): AlterInstructions + { + throw new RuntimeException('Adding partitioning to existing tables is not supported by this adapter'); + } } diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index 563c13a24..b14cc0b74 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -1389,6 +1389,20 @@ protected function getDropPartitionInstructions(string $tableName, string $parti return new AlterInstructions([$sql]); } + /** + * Get instructions for adding partitioning to an existing table. + * + * @param \Migrations\Db\Table\TableMetadata $table The table + * @param \Migrations\Db\Table\Partition $partition The partition configuration + * @return \Migrations\Db\AlterInstructions + */ + protected function getSetPartitioningInstructions(TableMetadata $table, Partition $partition): AlterInstructions + { + $sql = $this->getPartitionSqlDefinition($partition); + + return new AlterInstructions([$sql]); + } + /** * Get instructions for adding multiple partitions to an existing table. * diff --git a/src/Db/Plan/Plan.php b/src/Db/Plan/Plan.php index ef1b87b81..dcbaa718d 100644 --- a/src/Db/Plan/Plan.php +++ b/src/Db/Plan/Plan.php @@ -24,6 +24,7 @@ use Migrations\Db\Action\RemoveColumn; use Migrations\Db\Action\RenameColumn; use Migrations\Db\Action\RenameTable; +use Migrations\Db\Action\SetPartitioning; use Migrations\Db\Adapter\AdapterInterface; use Migrations\Db\Plan\Solver\ActionSplitter; use Migrations\Db\Table\TableMetadata; @@ -513,7 +514,11 @@ protected function gatherConstraints(array $actions): void protected function gatherPartitions(array $actions): void { foreach ($actions as $action) { - if (!($action instanceof AddPartition) && !($action instanceof DropPartition)) { + if ( + !($action instanceof AddPartition) + && !($action instanceof DropPartition) + && !($action instanceof SetPartitioning) + ) { continue; } elseif (isset($this->tableCreates[$action->getTable()->getName()])) { continue; diff --git a/src/Db/Table.php b/src/Db/Table.php index fd7a0a7ac..d54658aa5 100644 --- a/src/Db/Table.php +++ b/src/Db/Table.php @@ -26,6 +26,7 @@ use Migrations\Db\Action\RemoveColumn; use Migrations\Db\Action\RenameColumn; use Migrations\Db\Action\RenameTable; +use Migrations\Db\Action\SetPartitioning; use Migrations\Db\Adapter\AdapterInterface; use Migrations\Db\Adapter\MysqlAdapter; use Migrations\Db\Plan\Intent; @@ -1017,6 +1018,14 @@ protected function executeActions(bool $exists): void } } + // If table exists and has partition configuration, create SetPartitioning action + if ($exists) { + $partition = $this->table->getPartition(); + if ($partition !== null && $partition->getDefinitions()) { + $this->actions->addAction(new SetPartitioning($this->table, $partition)); + } + } + // If the table does not exist, the last command in the chain needs to be // a CreateTable action. if (!$exists) { diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index ddaad69df..441421816 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3351,6 +3351,44 @@ public function testAddPartitionsWithMaxvalue() $this->assertCount(2, $rows); } + public function testAddPartitioningToExistingTable(): void + { + // Create a non-partitioned table + $table = new Table('orders', ['id' => false, 'primary_key' => ['id', 'created_at']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('created_at', 'datetime') + ->addColumn('amount', 'decimal', ['precision' => 10, 'scale' => 2]) + ->create(); + + $this->assertTrue($this->adapter->hasTable('orders')); + + // Add partitioning to the existing table + $table = new Table('orders', ['id' => false, 'primary_key' => ['id', 'created_at']], $this->adapter); + $table->partitionBy(Partition::TYPE_RANGE_COLUMNS, 'created_at') + ->addPartition('p2023', '2024-01-01') + ->addPartition('p2024', '2025-01-01') + ->addPartition('pmax', 'MAXVALUE') + ->update(); + + // Verify partitioning was added by inserting data + $this->adapter->execute( + "INSERT INTO orders (id, created_at, amount) VALUES (1, '2023-06-15', 100.00)", + ); + $this->adapter->execute( + "INSERT INTO orders (id, created_at, amount) VALUES (2, '2024-06-15', 200.00)", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM orders'); + $this->assertCount(2, $rows); + + // Verify partitions exist by querying information_schema + $partitions = $this->adapter->fetchAll( + "SELECT PARTITION_NAME FROM information_schema.PARTITIONS + WHERE TABLE_NAME = 'orders' AND TABLE_SCHEMA = DATABASE() AND PARTITION_NAME IS NOT NULL", + ); + $this->assertCount(3, $partitions); + } + public function testCombinedPartitionAndColumnOperations(): void { // Create a partitioned table From e33c666a0448fa38f17d692f7723111ed9bb2ebd Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Tue, 6 Jan 2026 08:11:04 -0500 Subject: [PATCH 6/8] Add test for composite partition keys --- .../TestCase/Db/Adapter/MysqlAdapterTest.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 441421816..3c83eceef 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3351,6 +3351,41 @@ public function testAddPartitionsWithMaxvalue() $this->assertCount(2, $rows); } + public function testCreateTableWithCompositePartitionKey(): void + { + // Test composite partition keys - partitioning by multiple columns + // MySQL RANGE COLUMNS supports multiple columns + $table = new Table('composite_partitioned', ['id' => false, 'primary_key' => ['id', 'year', 'month']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('year', 'integer') + ->addColumn('month', 'integer') + ->addColumn('data', 'string', ['limit' => 100]) + ->partitionBy(Partition::TYPE_RANGE_COLUMNS, ['year', 'month']) + ->addPartition('p202401', [2024, 2]) + ->addPartition('p202402', [2024, 3]) + ->addPartition('p202403', [2024, 4]) + ->create(); + + $this->assertTrue($this->adapter->hasTable('composite_partitioned')); + + // Verify partitioning works by inserting data into different partitions + $this->adapter->execute( + "INSERT INTO composite_partitioned (id, year, month, data) VALUES (1, 2024, 1, 'January')", + ); + $this->adapter->execute( + "INSERT INTO composite_partitioned (id, year, month, data) VALUES (2, 2024, 2, 'February')", + ); + $this->adapter->execute( + "INSERT INTO composite_partitioned (id, year, month, data) VALUES (3, 2024, 3, 'March')", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM composite_partitioned ORDER BY month'); + $this->assertCount(3, $rows); + $this->assertEquals('January', $rows[0]['data']); + $this->assertEquals('February', $rows[1]['data']); + $this->assertEquals('March', $rows[2]['data']); + } + public function testAddPartitioningToExistingTable(): void { // Create a non-partitioned table From 83530337b67c84c985b4e36b3b92c93e6c5402d1 Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Tue, 6 Jan 2026 08:39:24 -0500 Subject: [PATCH 7/8] Each adapter now only needs to implement the collection methods --- src/Db/Adapter/AbstractAdapter.php | 44 ++------------------------ src/Db/Adapter/MysqlAdapter.php | 51 ------------------------------ src/Db/Adapter/PostgresAdapter.php | 42 +++++++++++++++++++++--- 3 files changed, 40 insertions(+), 97 deletions(-) diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index 11ef5c040..522822895 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -1551,32 +1551,6 @@ public function dropCheckConstraint(string $tableName, string $constraintName): */ abstract protected function getDropCheckConstraintInstructions(string $tableName, string $constraintName): AlterInstructions; - /** - * Returns the instructions to add a partition to an existing partitioned table. - * - * @param \Migrations\Db\Table\TableMetadata $table The table - * @param \Migrations\Db\Table\PartitionDefinition $partition The partition definition to add - * @throws \RuntimeException If partitioning is not supported - * @return \Migrations\Db\AlterInstructions - */ - protected function getAddPartitionInstructions(TableMetadata $table, PartitionDefinition $partition): AlterInstructions - { - throw new RuntimeException('Table partitioning is not supported by this adapter'); - } - - /** - * Returns the instructions to drop a partition from an existing partitioned table. - * - * @param string $tableName The table name - * @param string $partitionName The partition name to drop - * @throws \RuntimeException If partitioning is not supported - * @return \Migrations\Db\AlterInstructions - */ - protected function getDropPartitionInstructions(string $tableName, string $partitionName): AlterInstructions - { - throw new RuntimeException('Table partitioning is not supported by this adapter'); - } - /** * @inheritdoc */ @@ -1818,14 +1792,7 @@ public function executeActions(TableMetadata $table, array $actions): void */ protected function getAddPartitionsInstructions(TableMetadata $table, array $partitions): AlterInstructions { - // Default implementation calls single partition method for each - // Subclasses can override for database-specific batching - $instructions = new AlterInstructions(); - foreach ($partitions as $partition) { - $instructions->merge($this->getAddPartitionInstructions($table, $partition)); - } - - return $instructions; + throw new RuntimeException('Table partitioning is not supported by this adapter'); } /** @@ -1840,14 +1807,7 @@ protected function getAddPartitionsInstructions(TableMetadata $table, array $par */ protected function getDropPartitionsInstructions(string $tableName, array $partitionNames): AlterInstructions { - // Default implementation calls single partition method for each - // Subclasses can override for database-specific batching - $instructions = new AlterInstructions(); - foreach ($partitionNames as $partitionName) { - $instructions->merge($this->getDropPartitionInstructions($tableName, $partitionName)); - } - - return $instructions; + throw new RuntimeException('Table partitioning is not supported by this adapter'); } /** diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index b14cc0b74..923141bd7 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -1338,57 +1338,6 @@ protected function quotePartitionValue(mixed $value): string return $this->quoteString((string)$value); } - /** - * Get instructions for adding a partition to an existing table. - * - * @param \Migrations\Db\Table\TableMetadata $table The table - * @param \Migrations\Db\Table\PartitionDefinition $partition The partition to add - * @return \Migrations\Db\AlterInstructions - */ - protected function getAddPartitionInstructions(TableMetadata $table, PartitionDefinition $partition): AlterInstructions - { - // For MySQL, we need to know the partition type to generate correct SQL - // This is a simplified version - in practice you'd need to query the table's partition type - $value = $partition->getValue(); - $sql = 'ADD PARTITION (PARTITION ' . $this->quoteColumnName($partition->getName()); - - // Detect RANGE vs LIST based on value type (simplified heuristic) - if ($value === 'MAXVALUE' || is_scalar($value)) { - // Likely RANGE - if ($value === 'MAXVALUE') { - $sql .= ' VALUES LESS THAN MAXVALUE'; - } else { - $sql .= ' VALUES LESS THAN (' . $this->quotePartitionValue($value) . ')'; - } - } elseif (is_array($value)) { - // Likely LIST - $sql .= ' VALUES IN ('; - $sql .= implode(', ', array_map(fn($v) => $this->quotePartitionValue($v), $value)); - $sql .= ')'; - } - - if ($partition->getComment()) { - $sql .= ' COMMENT = ' . $this->quoteString($partition->getComment()); - } - $sql .= ')'; - - return new AlterInstructions([$sql]); - } - - /** - * Get instructions for dropping a partition from an existing table. - * - * @param string $tableName The table name - * @param string $partitionName The partition name to drop - * @return \Migrations\Db\AlterInstructions - */ - protected function getDropPartitionInstructions(string $tableName, string $partitionName): AlterInstructions - { - $sql = 'DROP PARTITION ' . $this->quoteColumnName($partitionName); - - return new AlterInstructions([$sql]); - } - /** * Get instructions for adding partitioning to an existing table. * diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php index f45ef1861..b1d411c28 100644 --- a/src/Db/Adapter/PostgresAdapter.php +++ b/src/Db/Adapter/PostgresAdapter.php @@ -1439,13 +1439,30 @@ protected function quotePartitionValue(mixed $value): string } /** - * Get instructions for adding a partition to an existing table. + * Get instructions for adding multiple partitions to an existing table. + * + * @param \Migrations\Db\Table\TableMetadata $table The table + * @param array<\Migrations\Db\Table\PartitionDefinition> $partitions The partitions to add + * @return \Migrations\Db\AlterInstructions + */ + protected function getAddPartitionsInstructions(TableMetadata $table, array $partitions): AlterInstructions + { + $instructions = new AlterInstructions(); + foreach ($partitions as $partition) { + $instructions->merge($this->getAddPartitionSql($table, $partition)); + } + + return $instructions; + } + + /** + * Get instructions for adding a single partition to an existing table. * * @param \Migrations\Db\Table\TableMetadata $table The table * @param \Migrations\Db\Table\PartitionDefinition $partition The partition to add * @return \Migrations\Db\AlterInstructions */ - protected function getAddPartitionInstructions(TableMetadata $table, PartitionDefinition $partition): AlterInstructions + private function getAddPartitionSql(TableMetadata $table, PartitionDefinition $partition): AlterInstructions { // PostgreSQL requires creating partition tables using CREATE TABLE ... PARTITION OF // This is more complex as we need the partition type info @@ -1483,13 +1500,30 @@ protected function getAddPartitionInstructions(TableMetadata $table, PartitionDe } /** - * Get instructions for dropping a partition from an existing table. + * Get instructions for dropping multiple partitions from an existing table. + * + * @param string $tableName The table name + * @param array $partitionNames The partition names to drop + * @return \Migrations\Db\AlterInstructions + */ + protected function getDropPartitionsInstructions(string $tableName, array $partitionNames): AlterInstructions + { + $instructions = new AlterInstructions(); + foreach ($partitionNames as $partitionName) { + $instructions->merge($this->getDropPartitionSql($tableName, $partitionName)); + } + + return $instructions; + } + + /** + * Get instructions for dropping a single partition from an existing table. * * @param string $tableName The table name * @param string $partitionName The partition name to drop * @return \Migrations\Db\AlterInstructions */ - protected function getDropPartitionInstructions(string $tableName, string $partitionName): AlterInstructions + private function getDropPartitionSql(string $tableName, string $partitionName): AlterInstructions { // In PostgreSQL, partitions are tables, so we drop the partition table // The partition name is typically the table_partitionname From 5cf54beb4914c8824d4a655d8b2a85a464f120c9 Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Tue, 6 Jan 2026 09:01:06 -0500 Subject: [PATCH 8/8] Remove unused import --- src/Db/Adapter/AbstractAdapter.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index 522822895..8ddb8bf92 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -47,7 +47,6 @@ use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; use Migrations\Db\Table\Partition; -use Migrations\Db\Table\PartitionDefinition; use Migrations\Db\Table\TableMetadata; use Migrations\MigrationInterface; use Migrations\SeedInterface;