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 f9f652ccc..8ddb8bf92 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,7 +46,7 @@ use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; -use Migrations\Db\Table\PartitionDefinition; +use Migrations\Db\Table\Partition; use Migrations\Db\Table\TableMetadata; use Migrations\MigrationInterface; use Migrations\SeedInterface; @@ -1549,32 +1550,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 */ @@ -1656,6 +1631,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,17 +1745,19 @@ 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; + + case $action instanceof SetPartitioning: + /** @var \Migrations\Db\Action\SetPartitioning $action */ + $instructions->merge($this->getSetPartitioningInstructions( + $table, + $action->getPartition(), )); break; @@ -1785,6 +1768,57 @@ 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 + { + throw new RuntimeException('Table partitioning is not supported by this adapter'); + } + + /** + * 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 + { + throw new RuntimeException('Table partitioning is not supported by this adapter'); + } + + /** + * 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 8e2ec4a47..923141bd7 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -1339,18 +1339,80 @@ protected function quotePartitionValue(mixed $value): string } /** - * Get instructions for adding a partition to an existing table. + * Get instructions for adding partitioning to an existing table. * * @param \Migrations\Db\Table\TableMetadata $table The table - * @param \Migrations\Db\Table\PartitionDefinition $partition The partition to add + * @param \Migrations\Db\Table\Partition $partition The partition configuration * @return \Migrations\Db\AlterInstructions */ - protected function getAddPartitionInstructions(TableMetadata $table, PartitionDefinition $partition): 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. + * + * 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 { - // 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()); + $sql = 'PARTITION ' . $this->quoteColumnName($partition->getName()); // Detect RANGE vs LIST based on value type (simplified heuristic) if ($value === 'MAXVALUE' || is_scalar($value)) { @@ -1370,23 +1432,8 @@ protected function getAddPartitionInstructions(TableMetadata $table, PartitionDe 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]); + return $sql; } /** 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 diff --git a/src/Db/Plan/Plan.php b/src/Db/Plan/Plan.php index f2c36fe7d..dcbaa718d 100644 --- a/src/Db/Plan/Plan.php +++ b/src/Db/Plan/Plan.php @@ -12,16 +12,19 @@ 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; 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; @@ -70,6 +73,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 +110,7 @@ protected function createPlan(array $actions): void $this->gatherTableMoves($actions); $this->gatherIndexes($actions); $this->gatherConstraints($actions); + $this->gatherPartitions($actions); $this->resolveConflicts(); } @@ -114,6 +125,7 @@ protected function updatesSequence(): array $this->tableUpdates, $this->constraints, $this->indexes, + $this->partitions, $this->columnRemoves, $this->tableMoves, ]; @@ -129,6 +141,7 @@ protected function inverseUpdatesSequence(): array return [ $this->constraints, $this->tableMoves, + $this->partitions, $this->indexes, $this->columnRemoves, $this->tableUpdates, @@ -186,6 +199,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 +504,34 @@ 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) + && !($action instanceof SetPartitioning) + ) { + 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/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 1dce2e491..3c83eceef 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3142,4 +3142,318 @@ 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); + } + + 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 + $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 + $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']); + } } 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'); + } }