doctrine2: how to convert a one-to-many to a many-to-many without losing data

OK, I found a way using doctrine migrations indeed.

I first made sure my changes where correctly set using a doctrine:schema:update --force, checking the profiler, and then reverting to the last database.

So using doctrine migrations bundle :

doctrine:migrations:diff

creates a new migration class

edit the class to suit your needs. Mine was :

<?php

namespace Application\Migrations;

use AppBundle\Entity\Core\Media;
use AppBundle\Entity\FoodAnalytics\Recipe;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Auto-generated Migration: Please modify to your needs!
 */
class Version20150525154902 extends AbstractMigration implements ContainerAwareInterface
{

    private $customSQL = array();

    /** @var Container */
    private $container;


    public function setContainer(ContainerInterface $container = null)
    {
        $this->container = $container;
    }

    /**
     * @param Schema $schema
     */
    public function preUp(Schema $schema)
    {
        $query = "SELECT id as mediaId, recipeId FROM `media` WHERE recipeId IS NOT NULL";
        $data = $this->connection->prepare($query);
        $data->execute();
        foreach ($data as $row)
        {
            $mediaId = $row['mediaId'];
            $recipeId = $row['recipeId'];
            $this->customSQL[] = "($mediaId, $recipeId)";
        }

    }

    /**
     * @param Schema $schema
     */
    public function up(Schema $schema)
    {
        // this up() migration is auto-generated, please modify it to your needs
        $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.');

        $this->addSql('CREATE TABLE media_recipes (mediaId INT UNSIGNED NOT NULL, recipeId INT UNSIGNED NOT NULL, INDEX IDX_C2BE64FC27D9F5AC (mediaId), INDEX IDX_C2BE64FC6DCBA54 (recipeId), PRIMARY KEY(mediaId, recipeId)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB');
        $this->addSql('ALTER TABLE media_recipes ADD CONSTRAINT FK_C2BE64FC27D9F5AC FOREIGN KEY (mediaId) REFERENCES media (id) ON DELETE CASCADE');
        $this->addSql('ALTER TABLE media_recipes ADD CONSTRAINT FK_C2BE64FC6DCBA54 FOREIGN KEY (recipeId) REFERENCES Recipe (id) ON DELETE CASCADE');
        $this->addSql('ALTER TABLE media DROP FOREIGN KEY FK_6A2CA10C6DCBA54');
        $this->addSql('DROP INDEX IDX_6A2CA10C6DCBA54 ON media');
        $this->addSql('ALTER TABLE media DROP recipeId');
        $this->addSql('ALTER TABLE recipe ADD versionDetails VARCHAR(200) DEFAULT NULL');

    }

    public function postUp(Schema $schema)
    {
        $SQL = 'INSERT INTO media_recipes (mediaId, recipeId) VALUES ' . implode(', ', $this->customSQL);

        $this->connection->executeQuery($SQL);
    }



    /**
     * @param Schema $schema
     */
    public function down(Schema $schema)
    {
        // this down() migration is auto-generated, please modify it to your needs
        $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.');

        $this->addSql('DROP TABLE media_recipes');
        $this->addSql('ALTER TABLE media ADD recipeId INT UNSIGNED DEFAULT NULL');
        $this->addSql('ALTER TABLE media ADD CONSTRAINT FK_6A2CA10C6DCBA54 FOREIGN KEY (recipeId) REFERENCES recipe (id) ON DELETE CASCADE');
        $this->addSql('CREATE INDEX IDX_6A2CA10C6DCBA54 ON media (recipeId)');
    }
}

then perform the update :

doctrine:migrations:migrate

and confirm y


If you want to continue using orm:schema-tool:update --force instead of adopting doctrine:migrations:migrate, then you can migrate a relationship from many-to-one to many-to-many using a two-step process.

  1. Create a many-to-many-step1 branch
    • Add the new @ManyToMany/@JoinTable properties and accessors alongside the old @ManyToOne/@JoinColumn/@OneToMany properties and accessors
    • Deploy the code and run orm:schema-tool:update --force
    • Populate the new join table with the old join column contents using an INSERT INTO ... SELECT statement
  2. Create a many-to-many-step2 branch
    • Change the code to use the new many-to-many relationship and accessors
    • Remove all artifacts of the old many-to-one relationship
    • Deploy the code and run orm:schema-tool:update --force

I have solved this by simply generating a migration with php bin/console make:migration and then extending the migration with an INSERT statement that copies all the data from the old oneToMany field into the new manyToMany table before dropping the old column.

To stick with the media/recipes example from the question, I would add this line just before the $this->addSql('ALTER TABLE media DROP recipeId'); line in the generaded migration:

$this->addSql('INSERT INTO media_recipes (mediaId, recipeId) SELECT id, recipeId FROM media WHERE recipeId IS NOT NULL');