├── Makefile ├── .gitignore ├── tests ├── Configuration │ ├── Fixtures │ │ ├── configuration_tasks_empty.yaml │ │ ├── configuration_parent.yaml │ │ ├── configuration_migrators_directory.yaml │ │ ├── configuration_options.yaml │ │ ├── configuration_migrators.yaml │ │ ├── configuration_tasks_after.yaml │ │ ├── configuration_tasks_before.yaml │ │ └── ExtensionTestDirectoryMigrator.php │ ├── CommandsCompilerPassTest.php │ ├── FregataCompilerPassTest.php │ ├── ConfigurationTest.php │ ├── AbstractFregataKernelTest.php │ └── FregataExtensionTest.php ├── Migration │ ├── Migrator │ │ └── Component │ │ │ ├── Fixtures │ │ │ ├── FixturePullerInterface.php │ │ │ ├── TestPusher.php │ │ │ ├── TestItemPuller.php │ │ │ └── TestBatchPuller.php │ │ │ └── ExecutorTest.php │ ├── MigrationContextTest.php │ ├── MigrationRegistryTest.php │ └── MigrationTest.php ├── Adapter │ └── Doctrine │ │ └── DBAL │ │ ├── ForeignKey │ │ ├── ForeignKeyTest.php │ │ ├── CopyColumnHelperTest.php │ │ └── Task │ │ │ ├── ForeignKeyBeforeTaskTest.php │ │ │ └── ForeignKeyAfterTaskTest.php │ │ ├── Fixtures │ │ ├── TestForeignKeyPuller.php │ │ ├── TestReferencedMigrator.php │ │ ├── TestReferencedPusher.php │ │ ├── TestReferencingPusher.php │ │ └── TestReferencingMigrator.php │ │ └── AbstractDbalTestCase.php └── Console │ ├── MigrationExecuteCommandTest.php │ ├── MigrationShowCommandTest.php │ └── MigrationListCommandTest.php ├── docker-compose.yml ├── phpunit.xml ├── src ├── Migration │ ├── Migrator │ │ ├── Component │ │ │ ├── PusherInterface.php │ │ │ ├── BatchPullerInterface.php │ │ │ ├── PullerInterface.php │ │ │ └── Executor.php │ │ ├── DependentMigratorInterface.php │ │ └── MigratorInterface.php │ ├── TaskInterface.php │ ├── MigrationRegistry.php │ ├── MigrationException.php │ ├── MigrationContext.php │ └── Migration.php ├── Console │ ├── CommandHelper.php │ ├── MigrationListCommand.php │ ├── MigrationShowCommand.php │ └── MigrationExecuteCommand.php ├── Adapter │ └── Doctrine │ │ └── DBAL │ │ └── ForeignKey │ │ ├── Migrator │ │ └── HasForeignKeysInterface.php │ │ ├── ForeignKeyException.php │ │ ├── ForeignKey.php │ │ ├── CopyColumnHelper.php │ │ └── Task │ │ ├── ForeignKeyBeforeTask.php │ │ └── ForeignKeyAfterTask.php └── Configuration │ ├── ConfigurationException.php │ ├── Configuration.php │ ├── CommandsCompilerPass.php │ ├── FregataCompilerPass.php │ ├── AbstractFregataKernel.php │ └── FregataExtension.php ├── phpcs.xml.dist ├── phpstan.neon ├── LICENSE.txt ├── bin └── fregata ├── .github └── workflows │ ├── coding_standards.yml │ └── testing.yml ├── composer.json ├── CHANGELOG.md └── README.md /Makefile: -------------------------------------------------------------------------------- 1 | start: 2 | docker-compose up -d 3 | 4 | stop: 5 | docker-compose down -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /vendor/ 3 | composer.lock 4 | 5 | .phpunit.result.cache 6 | .phpcs-cache 7 | 8 | /_implementation/ 9 | -------------------------------------------------------------------------------- /tests/Configuration/Fixtures/configuration_tasks_empty.yaml: -------------------------------------------------------------------------------- 1 | fregata: 2 | migrations: 3 | test: 4 | tasks: -------------------------------------------------------------------------------- /tests/Configuration/Fixtures/configuration_parent.yaml: -------------------------------------------------------------------------------- 1 | fregata: 2 | migrations: 3 | test: 4 | child: 5 | parent: test -------------------------------------------------------------------------------- /tests/Configuration/Fixtures/configuration_migrators_directory.yaml: -------------------------------------------------------------------------------- 1 | fregata: 2 | migrations: 3 | test: 4 | migrators_directory: path/to/migrators -------------------------------------------------------------------------------- /tests/Configuration/Fixtures/configuration_options.yaml: -------------------------------------------------------------------------------- 1 | fregata: 2 | migrations: 3 | test: 4 | options: 5 | foo: bar 6 | baz: boom -------------------------------------------------------------------------------- /tests/Configuration/Fixtures/configuration_migrators.yaml: -------------------------------------------------------------------------------- 1 | fregata: 2 | migrations: 3 | test: 4 | migrators: 5 | - first 6 | - second 7 | - third -------------------------------------------------------------------------------- /tests/Configuration/Fixtures/configuration_tasks_after.yaml: -------------------------------------------------------------------------------- 1 | fregata: 2 | migrations: 3 | test: 4 | tasks: 5 | after: 6 | - foo 7 | - bar 8 | - baz -------------------------------------------------------------------------------- /tests/Configuration/Fixtures/configuration_tasks_before.yaml: -------------------------------------------------------------------------------- 1 | fregata: 2 | migrations: 3 | test: 4 | tasks: 5 | before: 6 | - foo 7 | - bar 8 | - baz -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | mysql: 5 | image: "mysql:5.7" 6 | ports: 7 | - 3306:3306 8 | environment: 9 | MYSQL_ROOT_PASSWORD: root 10 | MYSQL_DATABASE: fregata_source -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | tests 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/Migration/Migrator/Component/PusherInterface.php: -------------------------------------------------------------------------------- 1 | [$key, get_class($obj)], 17 | array_keys($objects), 18 | $objects 19 | ); 20 | 21 | $io->table( 22 | ['#', $columnName], 23 | $objects 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Migration/Migrator/Component/Fixtures/TestPusher.php: -------------------------------------------------------------------------------- 1 | data[] = $data; 21 | return 1; 22 | } 23 | 24 | /** 25 | * @return string[] 26 | */ 27 | public function getData(): array 28 | { 29 | return $this->data; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | bin/fregata 16 | src/ 17 | tests/ 18 | 19 | 20 | tests/* 21 | 22 | -------------------------------------------------------------------------------- /tests/Migration/Migrator/Component/Fixtures/TestItemPuller.php: -------------------------------------------------------------------------------- 1 | items; 23 | } 24 | 25 | public function count(): ?int 26 | { 27 | return count($this->items); 28 | } 29 | 30 | /** 31 | * @return string[] 32 | */ 33 | public function getItems(): array 34 | { 35 | return $this->items; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 9 3 | paths: 4 | - bin 5 | - src 6 | - tests 7 | typeAliases: 8 | migrationConfig: 'array{name: string, parent: string|null, options: mixed[]|null, migrators_directory: string|null, migrators: class-string[]|null, tasks: array{before: class-string[]|null, after: class-string[]|null}}' 9 | excludePaths: 10 | analyse: 11 | - ./tests/Configuration/Fixtures 12 | ignoreErrors: 13 | - 14 | message: '~^Call to an undefined method [\w\\]+::[a-zA-Z]+\(\).$~' 15 | path: src/Configuration/Configuration.php 16 | - 17 | message: '~^Parameter #\d \$\w+ of static method [\w\\]+::[a-zA-Z]+\(\) expects class-string<([\w\\]+)&([\w\\]+)>, class-string<\1>&class-string<\2> given\.$~' 18 | path: tests -------------------------------------------------------------------------------- /tests/Adapter/Doctrine/DBAL/ForeignKey/ForeignKeyTest.php: -------------------------------------------------------------------------------- 1 | getConstraint()); 23 | self::assertSame($table, $foreignKey->getTableName()); 24 | self::assertSame($allowNull, $foreignKey->getAllowNull()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Migration/Migrator/MigratorInterface.php: -------------------------------------------------------------------------------- 1 | 'bar', 20 | ]; 21 | $parent = 'parent-migration'; 22 | 23 | $context = new MigrationContext($migration, $name, $options, $parent); 24 | 25 | self::assertInstanceOf(Migration::class, $context->getMigration()); 26 | self::assertSame($name, $context->getMigrationName()); 27 | self::assertSame($options, $context->getOptions()); 28 | self::assertSame($parent, $context->getParentName()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Adapter/Doctrine/DBAL/ForeignKey/ForeignKeyException.php: -------------------------------------------------------------------------------- 1 | |string[][] 21 | */ 22 | public function pull(): \Generator 23 | { 24 | foreach ($this->items as $item) { 25 | yield [$item]; 26 | } 27 | } 28 | 29 | public function count(): ?int 30 | { 31 | return count($this->items); 32 | } 33 | 34 | /** 35 | * @return string[] 36 | */ 37 | public function getItems(): array 38 | { 39 | return $this->items; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Configuration/ConfigurationException.php: -------------------------------------------------------------------------------- 1 | */ 11 | private array $migrations = []; 12 | 13 | /** 14 | * Register a new migration 15 | * @throws MigrationException 16 | */ 17 | public function add(string $name, Migration $migration): void 18 | { 19 | if (isset($this->migrations[$name])) { 20 | throw MigrationException::duplicateMigration($name); 21 | } 22 | $this->migrations[$name] = $migration; 23 | } 24 | 25 | /** 26 | * Fetch a single migration 27 | * @param string $name the migration identifier 28 | */ 29 | public function get(string $name): ?Migration 30 | { 31 | return $this->migrations[$name] ?? null; 32 | } 33 | 34 | /** 35 | * Fetch all known migrations 36 | * @return array 37 | */ 38 | public function getAll(): array 39 | { 40 | return $this->migrations; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Adapter/Doctrine/DBAL/Fixtures/TestForeignKeyPuller.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 21 | $this->tableName = $tableName; 22 | } 23 | 24 | public function pull() 25 | { 26 | /** @var DriverStatement $result */ 27 | $result = $this->connection->createQueryBuilder() 28 | ->select('*') 29 | ->from($this->tableName) 30 | ->execute(); 31 | return $result->fetchAll(FetchMode::ASSOCIATIVE); 32 | } 33 | 34 | public function count(): ?int 35 | { 36 | return null; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /bin/fregata: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getContainer(); 40 | $application = $container->get(Application::class); 41 | $application->run(); 42 | -------------------------------------------------------------------------------- /tests/Adapter/Doctrine/DBAL/AbstractDbalTestCase.php: -------------------------------------------------------------------------------- 1 | connection = DriverManager::getConnection([ 16 | 'url' => 'mysql://root:root@127.0.0.1:3306/fregata_source' 17 | ]); 18 | 19 | $this->dropTables(); 20 | } 21 | 22 | protected function tearDown(): void 23 | { 24 | $this->dropTables(); 25 | 26 | $this->connection->close(); 27 | } 28 | 29 | protected function dropTables(): void 30 | { 31 | $dropTables = implode('', array_map( 32 | fn(string $tableName) => sprintf('DROP TABLE IF EXISTS %s;', $tableName), 33 | $this->getTables() 34 | )); 35 | 36 | $this->connection->exec(<<connection = $connection; 23 | $this->columnHelper = $columnHelper; 24 | } 25 | 26 | public function getPuller(): PullerInterface 27 | { 28 | return new TestForeignKeyPuller($this->connection, 'source_referenced'); 29 | } 30 | 31 | public function getPusher(): PusherInterface 32 | { 33 | return new TestReferencedPusher($this->connection, $this->columnHelper, 'target_referenced'); 34 | } 35 | 36 | public function getExecutor(): Executor 37 | { 38 | return new Executor(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Migration/Migrator/Component/ExecutorTest.php: -------------------------------------------------------------------------------- 1 | execute($puller, $pusher) as $itemInserted) { 24 | $inserts += $itemInserted; 25 | } 26 | 27 | self::assertSame($puller->count(), $inserts); 28 | self::assertSame($puller->getItems(), $pusher->getData()); 29 | } 30 | 31 | /** 32 | * @return FixturePullerInterface[][] 33 | */ 34 | public function providePuller(): array 35 | { 36 | return [ 37 | [new TestItemPuller()], 38 | [new TestBatchPuller()], 39 | ]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Migration/MigrationRegistryTest.php: -------------------------------------------------------------------------------- 1 | getAll()); 20 | self::assertCount(0, $registry->getAll()); 21 | 22 | $registry->add('first', new Migration()); 23 | self::assertCount(1, $registry->getAll()); 24 | 25 | $migration = $registry->get('first'); 26 | self::assertInstanceOf(Migration::class, $migration); 27 | 28 | self::assertNull($registry->get('unknown')); 29 | } 30 | 31 | /** 32 | * Migration names must be unique 33 | */ 34 | public function testCannotRegisterDuplicateMigrations(): void 35 | { 36 | self::expectException(MigrationException::class); 37 | self::expectExceptionCode(1619880941371); 38 | 39 | $registry = new MigrationRegistry(); 40 | 41 | $registry->add('duplicate', new Migration()); 42 | $registry->add('duplicate', new Migration()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Adapter/Doctrine/DBAL/Fixtures/TestReferencedPusher.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 22 | $this->columnHelper = $columnHelper; 23 | $this->tableName = $tableName; 24 | } 25 | 26 | /** 27 | * @param string[] $data 28 | * @throws Exception 29 | */ 30 | public function push($data): int 31 | { 32 | $columnName = $this->columnHelper->foreignColumn($this->tableName, 'pk'); 33 | 34 | /** @var int $insertCount */ 35 | $insertCount = $this->connection->createQueryBuilder() 36 | ->insert($this->tableName) 37 | ->values([ 38 | $columnName => $data['pk'] 39 | ]) 40 | ->execute(); 41 | return $insertCount; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Adapter/Doctrine/DBAL/Fixtures/TestReferencingPusher.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 22 | $this->columnHelper = $columnHelper; 23 | $this->tableName = $tableName; 24 | } 25 | 26 | /** 27 | * @param string[] $data 28 | * @throws Exception 29 | */ 30 | public function push($data): int 31 | { 32 | $columnName = $this->columnHelper->localColumn($this->tableName, 'fk'); 33 | 34 | /** @var int $insertCount */ 35 | $insertCount = $this->connection->createQueryBuilder() 36 | ->insert($this->tableName) 37 | ->values([ 38 | $columnName => $data['fk'] 39 | ]) 40 | ->execute(); 41 | return $insertCount; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/coding_standards.yml: -------------------------------------------------------------------------------- 1 | name: Coding Standards 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | coding-standards: 6 | name: PHP CodeSniffer & PHPCPD 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | # Setup 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | 14 | - name: PHP setup 15 | uses: shivammathur/setup-php@v2 16 | with: 17 | php-version: 8.1 18 | extensions: json pdo_mysql 19 | 20 | # Cache Composer dependencies 21 | - name: Get composer cache directory 22 | id: composer-cache 23 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 24 | 25 | - name: Cache dependencies 26 | uses: actions/cache@v1 27 | with: 28 | path: ${{ steps.composer-cache.outputs.dir }} 29 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 30 | restore-keys: ${{ runner.os }}-composer- 31 | 32 | - name: Install dependencies 33 | run: composer install --prefer-dist 34 | 35 | # Run PHP CodeSniffer 36 | - name: Run PHP CodeSniffer 37 | run: php vendor/bin/phpcs 38 | 39 | # Run PHP Copy Paste Detector 40 | - name: Run PHP CPD 41 | run: php vendor/bin/phpcpd --exclude ./vendor . 42 | -------------------------------------------------------------------------------- /src/Migration/MigrationException.php: -------------------------------------------------------------------------------- 1 | getMessage()), 43 | 1619911058924, 44 | $previous 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Migration/MigrationContext.php: -------------------------------------------------------------------------------- 1 | migration = $migration; 26 | $this->migrationName = $migrationName; 27 | $this->options = $options ?? []; 28 | $this->parentName = $parentName; 29 | } 30 | 31 | /** 32 | * Get the current migration object 33 | */ 34 | public function getMigration(): Migration 35 | { 36 | return $this->migration; 37 | } 38 | 39 | /** 40 | * Get the migration name as written in the configuration 41 | */ 42 | public function getMigrationName(): string 43 | { 44 | return $this->migrationName; 45 | } 46 | 47 | /** 48 | * Get options for the migration as defined in the configuration 49 | * @return mixed[] 50 | */ 51 | public function getOptions(): array 52 | { 53 | return $this->options; 54 | } 55 | 56 | /** 57 | * Get the parent migration name if set 58 | */ 59 | public function getParentName(): ?string 60 | { 61 | return $this->parentName; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Configuration/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode() 15 | ->children() 16 | ->arrayNode('migrations') 17 | ->useAttributeAsKey('name') 18 | ->arrayPrototype() 19 | ->children() 20 | ->variableNode('options')->end() 21 | ->scalarNode('parent')->end() 22 | ->scalarNode('migrators_directory')->end() 23 | ->arrayNode('migrators') 24 | ->scalarPrototype()->end() 25 | ->end() 26 | ->arrayNode('tasks') 27 | ->children() 28 | ->arrayNode('before') 29 | ->scalarPrototype()->end() 30 | ->end() 31 | ->arrayNode('after') 32 | ->scalarPrototype()->end() 33 | ->end() 34 | ->end() 35 | ->end() 36 | ->end() 37 | ->end() 38 | ->end() 39 | ->end() 40 | ; 41 | 42 | return $treeBuilder; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Adapter/Doctrine/DBAL/ForeignKey/CopyColumnHelperTest.php: -------------------------------------------------------------------------------- 1 | localColumn('table', 'column'); 19 | self::assertStringStartsWith($prefix, $localColumn); 20 | 21 | $localIndex = $helper->localColumnIndex('table', 'column'); 22 | self::assertStringStartsWith($prefix, $localIndex); 23 | 24 | $foreignColumn = $helper->foreignColumn('table', 'column'); 25 | self::assertStringStartsWith($prefix, $foreignColumn); 26 | 27 | $foreignIndex = $helper->foreignColumnIndex('table', 'column'); 28 | self::assertStringStartsWith($prefix, $foreignIndex); 29 | } 30 | 31 | /** 32 | * Generated column/index names must be unique 33 | */ 34 | public function testGeneratedNamesAreUnique(): void 35 | { 36 | $helper = new CopyColumnHelper(); 37 | 38 | $names = [ 39 | $helper->localColumn('table', 'column'), 40 | $helper->localColumnIndex('table', 'column'), 41 | $helper->foreignColumn('table', 'column'), 42 | $helper->foreignColumnIndex('table', 'column'), 43 | ]; 44 | 45 | self::assertSame( 46 | count($names), 47 | count(array_unique($names)) 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Migration/Migrator/Component/Executor.php: -------------------------------------------------------------------------------- 1 | pull() as $batch) { 32 | foreach ($batch as $item) { 33 | yield $pusher->push($item); 34 | } 35 | } 36 | return; 37 | } 38 | 39 | // Default migration (item by item) 40 | $data = $puller->pull(); 41 | 42 | if (is_iterable($data)) { 43 | foreach ($data as $item) { 44 | yield $pusher->push($item); 45 | } 46 | } else { 47 | yield $pusher->push($data); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Adapter/Doctrine/DBAL/ForeignKey/ForeignKey.php: -------------------------------------------------------------------------------- 1 | constraint = $constraint; 31 | $this->tableName = $tableName; 32 | $this->allowNull = $allowNull; 33 | } 34 | 35 | public function getConstraint(): ForeignKeyConstraint 36 | { 37 | return $this->constraint; 38 | } 39 | 40 | public function getTableName(): string 41 | { 42 | return $this->tableName; 43 | } 44 | 45 | /** 46 | * @return string[] 47 | */ 48 | public function getAllowNull(): array 49 | { 50 | return $this->allowNull; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aymdev/fregata", 3 | "description": "Database migration framework allowing data migration between different DBMS or database structures.", 4 | "type": "framework", 5 | "keywords": [ 6 | "database", 7 | "doctrine", 8 | "migration", 9 | "migration-framework", 10 | "migration-tool", 11 | "sql" 12 | ], 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "AymDev", 17 | "email": "aymericmayeux@gmail.com" 18 | } 19 | ], 20 | "minimum-stability": "stable", 21 | "require": { 22 | "php": ">=7.4", 23 | "doctrine/dbal": "^2.10", 24 | "symfony/console": "^4.4||^5.0||^6.0", 25 | "symfony/yaml": "^4.4||^5.0||^6.0", 26 | "symfony/config": "^4.4||^5.0||^6.0", 27 | "symfony/dependency-injection": "^4.4||^5.0||^6.0", 28 | "hanneskod/classtools": "^1.2", 29 | "marcj/topsort": "^2.0", 30 | "symfony/string": "^5.0||^6.0" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "Fregata\\": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "Fregata\\Tests\\": "tests/" 40 | } 41 | }, 42 | "require-dev": { 43 | "phpunit/phpunit": "^9.1", 44 | "mikey179/vfsstream": "^1.6", 45 | "squizlabs/php_codesniffer": "^3.6", 46 | "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1", 47 | "phpcompatibility/php-compatibility": "^9.3", 48 | "phpstan/phpstan": "^1.2", 49 | "phpstan/phpstan-phpunit": "^1.0", 50 | "phpstan/phpstan-strict-rules": "^1.1", 51 | "sebastian/phpcpd": "^6.0" 52 | }, 53 | "bin": ["bin/fregata"], 54 | "extra": { 55 | "branch-alias": { 56 | "dev-master": "1.0.x-dev" 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Configuration/CommandsCompilerPass.php: -------------------------------------------------------------------------------- 1 | setDefinition(CommandHelper::class, $commandHelperDefinition); 32 | 33 | // Application 34 | $applicationDefinition = new Definition(Application::class); 35 | $applicationDefinition 36 | ->setPublic(true) 37 | ->addMethodCall('setName', ['Fregata CLI']) 38 | ->addMethodCall('setVersion', [AbstractFregataKernel::VERSION]); 39 | ; 40 | $container->setDefinition(Application::class, $applicationDefinition); 41 | 42 | // Commands 43 | foreach (self::COMMAND_CLASSES as $commandClass) { 44 | $commandDefinition = new Definition($commandClass); 45 | $commandDefinition->setAutowired(true); 46 | 47 | $container->setDefinition($commandClass, $commandDefinition); 48 | $applicationDefinition->addMethodCall('add', [new Reference($commandClass)]); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test Suite 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | unit-tests: 6 | name: PHPUnit & PHPStan on PHP ${{ matrix.php }} 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | php: [ '7.4', '8.0', '8.1' ] 11 | 12 | services: 13 | # MySQL source DB 14 | mysql: 15 | image: mysql:5.7 16 | env: 17 | MYSQL_ROOT_PASSWORD: root 18 | MYSQL_DATABASE: fregata_source 19 | ports: 20 | - 3306:3306 21 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 22 | 23 | steps: 24 | # Setup 25 | - name: Checkout 26 | uses: actions/checkout@v2 27 | 28 | - name: PHP setup 29 | uses: shivammathur/setup-php@v2 30 | with: 31 | php-version: ${{ matrix.php }} 32 | extensions: json pdo_mysql 33 | coverage: xdebug 34 | 35 | # Cache Composer dependencies 36 | - name: Get composer cache directory 37 | id: composer-cache 38 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 39 | 40 | - name: Cache dependencies 41 | uses: actions/cache@v1 42 | with: 43 | path: ${{ steps.composer-cache.outputs.dir }} 44 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 45 | restore-keys: ${{ runner.os }}-composer- 46 | 47 | - name: Install dependencies 48 | run: composer install --prefer-dist 49 | 50 | # Run tests suite 51 | - name: Run test suite 52 | run: php vendor/bin/phpunit --coverage-text 53 | 54 | # Run PHPStan 55 | - name: Run PHPStan 56 | run: php vendor/bin/phpstan 57 | -------------------------------------------------------------------------------- /tests/Configuration/CommandsCompilerPassTest.php: -------------------------------------------------------------------------------- 1 | process($container); 26 | 27 | self::assertTrue($container->has(CommandHelper::class)); 28 | self::assertTrue($container->has(Application::class)); 29 | self::assertTrue($container->has(MigrationListCommand::class)); 30 | self::assertTrue($container->has(MigrationShowCommand::class)); 31 | self::assertTrue($container->has(MigrationExecuteCommand::class)); 32 | 33 | $methodCalls = $container->getDefinition(Application::class)->getMethodCalls(); 34 | $methodCalls = array_map(function (array $call) { 35 | /** @var Reference $reference */ 36 | $reference = $call[1][0]; 37 | $call[1] = (string)$reference; 38 | return $call; 39 | }, $methodCalls); 40 | 41 | self::assertEqualsCanonicalizing( 42 | ['add', 'setName', 'setVersion'], 43 | array_unique(array_column($methodCalls, 0)) 44 | ); 45 | self::assertEqualsCanonicalizing( 46 | [MigrationListCommand::class, MigrationShowCommand::class, MigrationExecuteCommand::class], 47 | array_column(array_filter($methodCalls, fn(array $call) => $call[0] === 'add'), 1) 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Configuration/FregataCompilerPass.php: -------------------------------------------------------------------------------- 1 | setDefinition('fregata.migration_registry', $registryDefinition); 20 | $container 21 | ->setDefinition(MigrationRegistry::class, $registryDefinition) 22 | ->setPublic(true) 23 | ; 24 | 25 | // Add migrations to the registry 26 | foreach ($container->findTaggedServiceIds('fregata.migration') as $migrationServiceId => $tags) { 27 | $registryDefinition = $container->getDefinition('fregata.migration_registry'); 28 | $registryDefinition->addMethodCall('add', [ 29 | $tags[0]['name'], 30 | new Reference($migrationServiceId) 31 | ]); 32 | } 33 | 34 | // Default executor 35 | $executorDefinition = new Definition(Executor::class); 36 | $container->setDefinition('fregata.executor', $executorDefinition); 37 | $container 38 | ->setDefinition(Executor::class, $executorDefinition) 39 | ->setPublic(true) 40 | ; 41 | 42 | // Column helper 43 | $columnHelperDefinition = new Definition(CopyColumnHelper::class); 44 | $container->setDefinition('fregata.doctrine.dbal.column_helper', $columnHelperDefinition); 45 | $container 46 | ->setDefinition(CopyColumnHelper::class, $columnHelperDefinition) 47 | ->setPublic(true) 48 | ; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/Adapter/Doctrine/DBAL/Fixtures/TestReferencingMigrator.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 26 | $this->columnHelper = $columnHelper; 27 | } 28 | 29 | public function getPuller(): PullerInterface 30 | { 31 | return new TestForeignKeyPuller($this->connection, 'source_referencing'); 32 | } 33 | 34 | public function getPusher(): PusherInterface 35 | { 36 | return new TestReferencingPusher($this->connection, $this->columnHelper, 'target_referencing'); 37 | } 38 | 39 | public function getExecutor(): Executor 40 | { 41 | return new Executor(); 42 | } 43 | 44 | public function getConnection(): Connection 45 | { 46 | return $this->connection; 47 | } 48 | 49 | public function getForeignKeys(): array 50 | { 51 | return array_map( 52 | fn(ForeignKeyConstraint $constraint) => new ForeignKey($constraint, 'target_referencing', ['fk']), 53 | $this->connection->getSchemaManager()->listTableForeignKeys('target_referencing') 54 | ); 55 | } 56 | 57 | public function getDependencies(): array 58 | { 59 | return [ 60 | TestReferencedMigrator::class, 61 | ]; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Configuration/FregataCompilerPassTest.php: -------------------------------------------------------------------------------- 1 | process($container); 26 | 27 | self::assertTrue($container->has(MigrationRegistry::class)); 28 | self::assertTrue($container->has(Executor::class)); 29 | self::assertTrue($container->has(CopyColumnHelper::class)); 30 | } 31 | 32 | /** 33 | * Migrations must be registered in the registry 34 | */ 35 | public function testMigrationRegistrationInRegistry(): void 36 | { 37 | $migrationDefinition = new Definition(Migration::class); 38 | $migrationDefinition->addTag('fregata.migration', ['name' => 'test']); 39 | 40 | $container = new ContainerBuilder(); 41 | $container->setDefinition('fregata.migration.test', $migrationDefinition); 42 | 43 | $compilerPass = new FregataCompilerPass(); 44 | $compilerPass->process($container); 45 | 46 | // Should call "add" method once 47 | $methodCalls = $container->getDefinition(MigrationRegistry::class)->getMethodCalls(); 48 | 49 | $method = $methodCalls[0][0]; 50 | self::assertSame('add', $method); 51 | 52 | $nameArg = $methodCalls[0][1][0]; 53 | self::assertSame('test', $nameArg); 54 | 55 | /** @var Reference $migrationArg */ 56 | $migrationArg = $methodCalls[0][1][1]; 57 | $migrationId = $migrationArg->__toString(); 58 | self::assertSame('fregata.migration.test', $migrationId); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Adapter/Doctrine/DBAL/ForeignKey/CopyColumnHelper.php: -------------------------------------------------------------------------------- 1 | migrationRegistry = $migrationRegistry; 22 | $this->commandHelper = $commandHelper; 23 | 24 | parent::__construct(); 25 | } 26 | 27 | protected function configure(): void 28 | { 29 | $this 30 | ->setDescription('List all registered migrations with additional informations.') 31 | ->setHelp('List all registered migrations.') 32 | ->addOption( 33 | 'with-migrators', 34 | 'm', 35 | InputOption::VALUE_NONE, 36 | 'Lists the migrators associated with each migration.' 37 | ) 38 | ->addOption( 39 | 'with-tasks', 40 | 't', 41 | InputOption::VALUE_NONE, 42 | 'Lists the before and after tasks associated with each migration.' 43 | ) 44 | ; 45 | } 46 | 47 | protected function execute(InputInterface $input, OutputInterface $output): int 48 | { 49 | $io = new SymfonyStyle($input, $output); 50 | 51 | $migrations = $this->migrationRegistry->getAll(); 52 | $io->title(sprintf('Registered migrations: %d', count($migrations))); 53 | 54 | foreach ($migrations as $name => $migration) { 55 | $io->writeln($name); 56 | 57 | if ($input->getOption('with-tasks')) { 58 | $this->commandHelper->printObjectTable($io, $migration->getBeforeTasks(), 'Before Task'); 59 | } 60 | if ($input->getOption('with-migrators')) { 61 | $this->commandHelper->printObjectTable($io, $migration->getMigrators(), 'Migrator Name'); 62 | } 63 | if ($input->getOption('with-tasks')) { 64 | $this->commandHelper->printObjectTable($io, $migration->getAfterTasks(), 'After Task'); 65 | } 66 | } 67 | 68 | $io->newLine(); 69 | return 0; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Console/MigrationShowCommand.php: -------------------------------------------------------------------------------- 1 | migrationRegistry = $migrationRegistry; 23 | $this->commandHelper = $commandHelper; 24 | 25 | parent::__construct(); 26 | } 27 | 28 | protected function configure(): void 29 | { 30 | $this 31 | ->setDescription('List all registered migrators sorted for a given migrations.') 32 | ->setHelp('List migrators of a migration.') 33 | ->addArgument( 34 | 'migration', 35 | InputArgument::REQUIRED, 36 | 'The name of the migration.' 37 | ) 38 | ->addOption( 39 | 'with-tasks', 40 | 't', 41 | InputOption::VALUE_NONE, 42 | 'Lists the before and after tasks associated with each migration.' 43 | ) 44 | ; 45 | } 46 | 47 | protected function execute(InputInterface $input, OutputInterface $output): int 48 | { 49 | $io = new SymfonyStyle($input, $output); 50 | 51 | /** @var string $migrationName */ 52 | $migrationName = $input->getArgument('migration'); 53 | $migration = $this->migrationRegistry->get($migrationName); 54 | 55 | if (null === $migration) { 56 | $io->error(sprintf('No migration registered with the name "%s".', $migrationName)); 57 | return 1; 58 | } 59 | 60 | $migrators = $migration->getMigrators(); 61 | $io->title(sprintf('%s : %d migrators', $migrationName, count($migrators))); 62 | 63 | if ($input->getOption('with-tasks')) { 64 | $this->commandHelper->printObjectTable($io, $migration->getBeforeTasks(), 'Before Task'); 65 | } 66 | 67 | $this->commandHelper->printObjectTable($io, $migration->getMigrators(), 'Migrator Name'); 68 | 69 | if ($input->getOption('with-tasks')) { 70 | $this->commandHelper->printObjectTable($io, $migration->getAfterTasks(), 'After Task'); 71 | } 72 | 73 | $io->newLine(); 74 | return 0; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/Configuration/ConfigurationTest.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, $parsedYamlConfig); 22 | 23 | self::assertArrayHasKey('migrations', $processedConfiguration); 24 | self::assertIsArray($processedConfiguration['migrations']); 25 | 26 | foreach ($processedConfiguration['migrations'] as $migration) { 27 | if (array_key_exists('options', $migration)) { 28 | self::assertIsArray($migration['options']); 29 | } 30 | 31 | if (array_key_exists('migrators_directory', $migration)) { 32 | self::assertIsString($migration['migrators_directory']); 33 | } 34 | 35 | if (array_key_exists('parent', $migration)) { 36 | self::assertIsString($migration['parent']); 37 | } 38 | 39 | if (array_key_exists('migrators', $migration)) { 40 | self::assertIsArray($migration['migrators']); 41 | self::assertContainsOnly('string', $migration['migrators']); 42 | } 43 | 44 | if (array_key_exists('tasks', $migration)) { 45 | self::assertIsArray($migration['tasks']); 46 | 47 | if (array_key_exists('before', $migration['tasks'])) { 48 | self::assertIsArray($migration['tasks']['before']); 49 | self::assertContainsOnly('string', $migration['tasks']['before']); 50 | } 51 | 52 | if (array_key_exists('after', $migration['tasks'])) { 53 | self::assertIsArray($migration['tasks']['after']); 54 | self::assertContainsOnly('string', $migration['tasks']['after']); 55 | } 56 | } 57 | } 58 | } 59 | 60 | /** 61 | * @return \Generator|mixed[][] 62 | */ 63 | public function provideConfiguration(): \Generator 64 | { 65 | /** @var string[] $configFiles */ 66 | $configFiles = glob(__DIR__ . '/Fixtures/configuration_*.yaml'); 67 | 68 | foreach ($configFiles as $file) { 69 | /** @var string $content */ 70 | $content = file_get_contents($file); 71 | 72 | yield [Yaml::parse($content)]; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | ## [1.1.0] - 2022-03-12 9 | ### Added 10 | - **Symfony 6** support 11 | - Service tags for migrations, migrators and tasks 12 | 13 | ### Deprecated 14 | - usage of `Executor` without **puller**. 15 | 16 | ## [1.0.3] - 2021-05-24 17 | ### Fixed 18 | - Added missing `CopyColumnHelper` service definition 19 | - Removed table alias in `ForeignKeyAfterTask` for **PostgreSQL** foreign keys `UPDATE` query 20 | 21 | ## [1.0.2] - 2021-05-22 22 | ### Fixed 23 | - Added missing autowiring for *before* and *after* **tasks** 24 | 25 | ## [1.0.1] - 2021-05-18 26 | ### Changed 27 | - Changed visibility of `FregataExtension` methods to `protected` to allow reusability in the Symfony bundle. 28 | - Unmarked `FregataExtension` and `FregataCompilerPass` as internal for reuse in the Symfony bundle. 29 | 30 | ## [1.0.0] - 2021-05-17 31 | ### Removed 32 | - `v0` code (**Fregata** has been completely rewritten) 33 | - **PHP-DI** 34 | - **Doctrine DBAL** integration is not coupled with the migrators anymore. 35 | 36 | ### Changed 37 | - multiple migrations are registerable per project 38 | 39 | ### Added 40 | - a **migration registry** holds the migrations list 41 | - CLI commands listing migrations and their content 42 | - migrator components: **puller**, **pusher** and **executor** 43 | - **Symfony**'s **DependencyInjection** component as *service container* 44 | - a *kernel* to configure the framework 45 | - a **migration context** service to get migration metadata 46 | - *before* and *after* **tasks** 47 | - **migration options** readable in the *context* 48 | - migrations can extend others and get the same *options*, *tasks* and *migrators* as the *parent* 49 | - a **Doctrine DBAL** integration to keep foreign key constraints 50 | 51 | ## [0.3.2] - 2020-06-23 52 | ### Fixed 53 | - prevents *connections* from generating multiple database connections. 54 | 55 | ## [0.3.1] - 2020-06-07 56 | ### Fixed 57 | - adds blank line in CLI output to avoid readability issues 58 | 59 | ## [0.3.0] - 2020-06-07 60 | ### Added 61 | - service container (**PHP-DI**) to autowire migrators constructor arguments 62 | - console command shows migration progress bar 63 | - optional batch fetching for large datasets 64 | - foreign key preservation system 65 | 66 | ## [0.2.0] - 2020-05-23 67 | ### Added 68 | - Composer package binary 69 | 70 | ## [0.1.0] - 2020-05-21 71 | ### Added 72 | - Composer setup 73 | - Connection abstract wrapper class 74 | - Migrator system with interface and abstract class 75 | 76 | [Unreleased]: https://github.com/AymDev/Fregata/compare/v1.1.0...HEAD 77 | [1.1.0]: https://github.com/AymDev/Fregata/compare/v1.0.3...v1.1.0 78 | [1.0.3]: https://github.com/AymDev/Fregata/compare/v1.0.2...v1.0.3 79 | [1.0.2]: https://github.com/AymDev/Fregata/compare/v1.0.1...v1.0.2 80 | [1.0.1]: https://github.com/AymDev/Fregata/compare/v1.0.0...v1.0.1 81 | [1.0.0]: https://github.com/AymDev/Fregata/compare/v0.3.1...v1.0.0 82 | [0.3.2]: https://github.com/AymDev/Fregata/compare/v0.3.1...v0.3.2 83 | [0.3.1]: https://github.com/AymDev/Fregata/compare/v0.3.0...v0.3.1 84 | [0.3.0]: https://github.com/AymDev/Fregata/compare/v0.2.0...v0.3.0 85 | [0.2.0]: https://github.com/AymDev/Fregata/compare/v0.1.0...v0.2.0 86 | [0.1.0]: https://github.com/AymDev/Fregata/releases/tag/v0.1.0 -------------------------------------------------------------------------------- /tests/Console/MigrationExecuteCommandTest.php: -------------------------------------------------------------------------------- 1 | method('getPuller')->willReturn($puller); 30 | $migrator->method('getPusher')->willReturn($pusher); 31 | $migrator->method('getExecutor')->willReturn(new Executor()); 32 | 33 | $migration = new Migration(); 34 | $migration->add($migrator); 35 | $migration->addBeforeTask($task); 36 | $migration->addAfterTask($task); 37 | 38 | $registry = new MigrationRegistry(); 39 | $registry->add('test-migration', $migration); 40 | 41 | $command = new MigrationExecuteCommand($registry); 42 | $tester = new CommandTester($command); 43 | 44 | $tester->execute([ 45 | 'migration' => 'test-migration', 46 | ]); 47 | 48 | // Command is successful 49 | self::assertSame(0, $tester->getStatusCode()); 50 | self::assertStringContainsString('[OK]', $tester->getDisplay()); 51 | 52 | // Migration progress is shown 53 | self::assertStringContainsString(get_class($migrator), $tester->getDisplay()); 54 | self::assertStringContainsString( 55 | sprintf('%1$d / %1$d', count($puller->getItems())), 56 | $tester->getDisplay() 57 | ); 58 | 59 | // Tasks are shown in order 60 | self::assertMatchesRegularExpression( 61 | sprintf( 62 | '~Before.+%1$s.+Migrators.+%2$s.+%1$s~is', 63 | preg_quote(get_class($task)), 64 | preg_quote(get_class($migrator)) 65 | ), 66 | $tester->getDisplay() 67 | ); 68 | 69 | // Data has been migrated 70 | self::assertSame($puller->getItems(), $pusher->getData()); 71 | } 72 | 73 | /** 74 | * Get an error for unknown migration 75 | */ 76 | public function testErrorOnUnknownMigration(): void 77 | { 78 | $command = new MigrationExecuteCommand(new MigrationRegistry()); 79 | $tester = new CommandTester($command); 80 | 81 | $tester->execute( 82 | [ 83 | 'migration' => 'unknown', 84 | ], 85 | [ 86 | // To get a ConsoleOutput 87 | 'capture_stderr_separately' => true, 88 | ] 89 | ); 90 | 91 | self::assertNotSame(0, $tester->getStatusCode()); 92 | self::assertStringContainsString('[ERROR]', $tester->getDisplay()); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/Console/MigrationShowCommandTest.php: -------------------------------------------------------------------------------- 1 | setMockClassName('TestFirstMigrator') 26 | ->getMockForAbstractClass(); 27 | $secondMigrator = self::getMockBuilder(MigratorInterface::class) 28 | ->setMockClassName('TestSecondMigrator') 29 | ->getMockForAbstractClass(); 30 | 31 | $migration = new Migration(); 32 | $migration->add($firstMigrator); 33 | $migration->add($secondMigrator); 34 | 35 | $registry = new MigrationRegistry(); 36 | $registry->add('foo', $migration); 37 | 38 | $command = new MigrationShowCommand($registry, new CommandHelper()); 39 | $tester = new CommandTester($command); 40 | 41 | $tester->execute([ 42 | 'migration' => 'foo', 43 | ]); 44 | $display = $tester->getDisplay(); 45 | 46 | // Number of migrators: 47 | $firstLine = strtok($display, "\n"); 48 | self::assertSame('foo : 2 migrators', $firstLine); 49 | 50 | // Get table data lines 51 | $firstClass = preg_quote(get_class($firstMigrator), '~'); 52 | $secondClass = preg_quote(get_class($secondMigrator), '~'); 53 | self::assertMatchesRegularExpression( 54 | '~0\s+' . $firstClass . '\s+\R\s+1\s+' . $secondClass . '\s+\R~', 55 | $display 56 | ); 57 | } 58 | 59 | /** 60 | * Get an error for unknown migration 61 | */ 62 | public function testErrorOnUnknownMigration(): void 63 | { 64 | $command = new MigrationShowCommand(new MigrationRegistry(), new CommandHelper()); 65 | $tester = new CommandTester($command); 66 | 67 | $tester->execute([ 68 | 'migration' => 'unknown', 69 | ]); 70 | 71 | self::assertNotSame(0, $tester->getStatusCode()); 72 | self::assertStringContainsString('[ERROR]', $tester->getDisplay()); 73 | } 74 | 75 | /** 76 | * The --with-tasks option must list before and after tasks 77 | */ 78 | public function testListMigrationsWithTasks(): void 79 | { 80 | $beforeTask = self::getMockForAbstractClass(TaskInterface::class); 81 | $afterTask = self::getMockForAbstractClass(TaskInterface::class); 82 | 83 | $migration = new Migration(); 84 | $migration->addBeforeTask($beforeTask); 85 | $migration->addAfterTask($afterTask); 86 | 87 | $registry = new MigrationRegistry(); 88 | $registry->add('foo', $migration); 89 | 90 | $command = new MigrationShowCommand($registry, new CommandHelper()); 91 | $tester = new CommandTester($command); 92 | 93 | $tester->execute([ 94 | 'migration' => 'foo', 95 | '--with-tasks' => null, 96 | ]); 97 | $display = $tester->getDisplay(); 98 | 99 | // Get table data lines 100 | $firstClass = preg_quote(get_class($beforeTask), '~'); 101 | $secondClass = preg_quote(get_class($afterTask), '~'); 102 | self::assertMatchesRegularExpression( 103 | '~0\s+' . $firstClass . '\s+\R.+0\s+' . $secondClass . '\s+\R~s', 104 | $display 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/Console/MigrationListCommandTest.php: -------------------------------------------------------------------------------- 1 | add('foo', new Migration()); 28 | $registry->add('bar', new Migration()); 29 | 30 | $command = new MigrationListCommand($registry, new CommandHelper()); 31 | $tester = new CommandTester($command); 32 | 33 | $tester->execute([]); 34 | $display = $tester->getDisplay(); 35 | 36 | // Number of migrations: 37 | $firstLine = strval(strtok($display, "\n")); 38 | self::assertStringEndsWith('2', $firstLine); 39 | 40 | // Migration names 41 | self::assertStringContainsString('foo', $display); 42 | self::assertStringContainsString('bar', $display); 43 | 44 | // No migrators table 45 | self::assertStringNotContainsString('# Class name', $display); 46 | } 47 | 48 | /** 49 | * The --with-migrators option must list migrators 50 | */ 51 | public function testListMigrationsWithMigrators(): void 52 | { 53 | $firstMigrator = self::getMockBuilder(MigratorInterface::class) 54 | ->setMockClassName('TestFirstMigrator') 55 | ->getMockForAbstractClass(); 56 | $secondMigrator = self::getMockBuilder(MigratorInterface::class) 57 | ->setMockClassName('TestSecondMigrator') 58 | ->getMockForAbstractClass(); 59 | 60 | $migration = new Migration(); 61 | $migration->add($firstMigrator); 62 | $migration->add($secondMigrator); 63 | 64 | $registry = new MigrationRegistry(); 65 | $registry->add('foo', $migration); 66 | 67 | $command = new MigrationListCommand($registry, new CommandHelper()); 68 | $tester = new CommandTester($command); 69 | 70 | $tester->execute([ 71 | '--with-migrators' => null, 72 | ]); 73 | $display = $tester->getDisplay(); 74 | 75 | // Get table data lines 76 | $firstClass = preg_quote(get_class($firstMigrator), '~'); 77 | $secondClass = preg_quote(get_class($secondMigrator), '~'); 78 | self::assertMatchesRegularExpression( 79 | '~0\s+' . $firstClass . '\s+\R\s+1\s+' . $secondClass . '\s+\R~', 80 | $display 81 | ); 82 | } 83 | 84 | /** 85 | * The --with-tasks option must list before and after tasks 86 | */ 87 | public function testListMigrationsWithTasks(): void 88 | { 89 | $beforeTask = self::getMockForAbstractClass(TaskInterface::class); 90 | $afterTask = self::getMockForAbstractClass(TaskInterface::class); 91 | 92 | $migration = new Migration(); 93 | $migration->addBeforeTask($beforeTask); 94 | $migration->addAfterTask($afterTask); 95 | 96 | $registry = new MigrationRegistry(); 97 | $registry->add('foo', $migration); 98 | 99 | $command = new MigrationListCommand($registry, new CommandHelper()); 100 | $tester = new CommandTester($command); 101 | 102 | $tester->execute([ 103 | '--with-tasks' => null, 104 | ]); 105 | $display = $tester->getDisplay(); 106 | 107 | // Get table data lines 108 | $firstClass = preg_quote(get_class($beforeTask), '~'); 109 | $secondClass = preg_quote(get_class($afterTask), '~'); 110 | self::assertMatchesRegularExpression( 111 | '~0\s+' . $firstClass . '\s+\R.+0\s+' . $secondClass . '\s+\R~s', 112 | $display 113 | ); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Migration/Migration.php: -------------------------------------------------------------------------------- 1 | migrators))) { 34 | throw MigrationException::duplicateMigrator($migrator); 35 | } 36 | 37 | $this->sorted = false; 38 | $this->migrators[] = $migrator; 39 | } 40 | 41 | /** 42 | * List sorted Migrators 43 | * @return MigratorInterface[] 44 | * @throws MigrationException 45 | */ 46 | public function getMigrators(): array 47 | { 48 | if (false === $this->sorted) { 49 | $this->sort(); 50 | } 51 | return $this->migrators; 52 | } 53 | 54 | /** 55 | * Add a task to execute before the migration 56 | */ 57 | public function addBeforeTask(TaskInterface $task): void 58 | { 59 | $this->beforeTasks[] = $task; 60 | } 61 | 62 | /** 63 | * List tasks to execute before the migration 64 | * @return TaskInterface[] 65 | */ 66 | public function getBeforeTasks(): array 67 | { 68 | return $this->beforeTasks; 69 | } 70 | 71 | /** 72 | * Add a task to execute after the migration 73 | */ 74 | public function addAfterTask(TaskInterface $task): void 75 | { 76 | $this->afterTasks[] = $task; 77 | } 78 | 79 | /** 80 | * List tasks to execute after the migration 81 | * @return TaskInterface[] 82 | */ 83 | public function getAfterTasks(): array 84 | { 85 | return $this->afterTasks; 86 | } 87 | 88 | /** 89 | * Yields the migrators without executing them 90 | * The migration process must executed outside the class (CLI command, async message, ...) 91 | * @return \Generator|MigratorInterface[] 92 | * @throws MigrationException 93 | */ 94 | public function process(): \Generator 95 | { 96 | if (false === $this->sorted) { 97 | $this->sort(); 98 | } 99 | 100 | foreach ($this->migrators as $migrator) { 101 | yield $migrator; 102 | } 103 | } 104 | 105 | /** 106 | * Sort migrators according to their dependencies 107 | * @throws MigrationException 108 | * @see DependentMigratorInterface 109 | */ 110 | private function sort(): void 111 | { 112 | $sorter = new FixedArraySort(); 113 | 114 | // Register migrators to sort (with strings only) 115 | foreach ($this->migrators as $migrator) { 116 | $sorter->add( 117 | get_class($migrator), 118 | $migrator instanceof DependentMigratorInterface ? $migrator->getDependencies() : [] 119 | ); 120 | } 121 | 122 | // Sort 123 | try { 124 | $sortedMigrators = $sorter->sort(); 125 | } catch (CircularDependencyException | ElementNotFoundException $exception) { 126 | throw MigrationException::invalidMigratorDependencies($exception); 127 | } 128 | 129 | // Index by class name to remap 130 | /** @var array, MigratorInterface> $migrators */ 131 | $migrators = array_combine( 132 | array_map('get_class', $this->migrators), 133 | $this->migrators 134 | ); 135 | 136 | // Remap sorted classes with migrator objects 137 | $this->migrators = array_map( 138 | fn(string $migratorClass) => $migrators[$migratorClass], 139 | $sortedMigrators 140 | ); 141 | $this->sorted = true; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /tests/Configuration/AbstractFregataKernelTest.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'fregata.yaml' => 'fregata:', 20 | ], 21 | 'cache' => [] 22 | ]); 23 | 24 | // Create kernel 25 | $kernel = new class ($fileSystem) extends AbstractFregataKernel { 26 | private vfsStreamDirectory $vfs; 27 | 28 | public function __construct(vfsStreamDirectory $vfs) 29 | { 30 | $this->vfs = $vfs; 31 | } 32 | 33 | protected function getConfigurationDirectory(): string 34 | { 35 | return $this->vfs->url() . '/config'; 36 | } 37 | 38 | protected function getCacheDirectory(): string 39 | { 40 | return $this->vfs->url() . '/cache'; 41 | } 42 | 43 | protected function getContainerClassName(): string 44 | { 45 | return parent::getContainerClassName() . 'AbstractFregataKernelTest'; 46 | } 47 | }; 48 | 49 | // Container is compiled 50 | $container = $kernel->getContainer(); 51 | self::assertInstanceOf(Container::class, $container); 52 | self::assertTrue($container->isCompiled()); 53 | 54 | // Has minimal services from extension 55 | self::assertTrue($container->has(MigrationRegistry::class)); 56 | 57 | // Has parameters 58 | self::assertTrue($container->hasParameter('fregata.root_dir')); 59 | self::assertTrue($container->hasParameter('fregata.config_dir')); 60 | } 61 | 62 | /** 63 | * An exception is thrown when an invalid configuration directory is given 64 | * @dataProvider provideInvalidConfigurationPaths 65 | */ 66 | public function testContainerCreationWithInvalidPaths( 67 | int $exceptionCode, 68 | string $cachePath, 69 | string $configPath 70 | ): void { 71 | $this->expectException(ConfigurationException::class); 72 | $this->expectExceptionCode($exceptionCode); 73 | 74 | // Create kernel 75 | $kernel = new class ($cachePath, $configPath) extends AbstractFregataKernel { 76 | private string $cacheDir; 77 | private string $configDir; 78 | 79 | public function __construct(string $cacheDir, string $configDir) 80 | { 81 | $this->cacheDir = $cacheDir; 82 | $this->configDir = $configDir; 83 | } 84 | 85 | protected function getConfigurationDirectory(): string 86 | { 87 | return $this->configDir; 88 | } 89 | 90 | protected function getCacheDirectory(): string 91 | { 92 | return $this->cacheDir; 93 | } 94 | }; 95 | 96 | $kernel->getContainer(); 97 | } 98 | 99 | /** 100 | * @return array{int, string, string}[] 101 | */ 102 | public function provideInvalidConfigurationPaths(): array 103 | { 104 | return [ 105 | // Cache directory does not exist 106 | [ 107 | 1619874486570, 108 | __DIR__ . '/does-not-exists', 109 | '' 110 | ], 111 | // Cache directory is a file 112 | [ 113 | 1619874486570, 114 | __FILE__, 115 | '' 116 | ], 117 | // Config directory does not exist 118 | [ 119 | 1619865822238, 120 | __DIR__, 121 | __DIR__ . '/does-not-exists' 122 | ], 123 | // Config directory is a file 124 | [ 125 | 1619865822238, 126 | __DIR__, 127 | __FILE__ 128 | ], 129 | ]; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /tests/Adapter/Doctrine/DBAL/ForeignKey/Task/ForeignKeyBeforeTaskTest.php: -------------------------------------------------------------------------------- 1 | connection->getWrappedConnection()->exec(<<method('getConnection')->willReturn($this->connection); 47 | $migrator->method('getForeignKeys')->willReturnCallback(function () { 48 | return array_map( 49 | fn (ForeignKeyConstraint $constraint) => new ForeignKey($constraint, 'target_referencing', ['fk']), 50 | $this->connection->getSchemaManager()->listTableForeignKeys('target_referencing') 51 | ); 52 | }); 53 | 54 | // Setup task 55 | $migration = new Migration(); 56 | $migration->add($migrator); 57 | $context = new MigrationContext($migration, 'copy_columns'); 58 | 59 | // Execute task 60 | $task = new ForeignKeyBeforeTask($context, new CopyColumnHelper()); 61 | $task->execute(); 62 | 63 | // Check referenced table 64 | $columns = $this->connection->getSchemaManager()->listTableColumns('target_referenced'); 65 | self::assertCount(2, $columns); 66 | 67 | $tempColumn = array_values($columns)[1]; 68 | self::assertStringStartsWith('_fregata', $tempColumn->getName()); 69 | self::assertFalse($tempColumn->getAutoincrement()); 70 | self::assertFalse($tempColumn->getNotnull()); 71 | self::assertNull($tempColumn->getDefault()); 72 | 73 | // Check referencing table 74 | $columns = $this->connection->getSchemaManager()->listTableColumns('target_referencing'); 75 | self::assertCount(2, $columns); 76 | 77 | $originalColumn = $columns['fk']; 78 | self::assertFalse($originalColumn->getNotnull()); 79 | 80 | $tempColumn = array_values($columns)[1]; 81 | self::assertStringStartsWith('_fregata', $tempColumn->getName()); 82 | self::assertFalse($tempColumn->getNotnull()); 83 | self::assertNull($tempColumn->getDefault()); 84 | } 85 | 86 | /** 87 | * SQLite is an incompatible platform as it does not support foreign key constraints 88 | */ 89 | public function testIncompatiblePlatform(): void 90 | { 91 | self::expectException(ForeignKeyException::class); 92 | self::expectExceptionCode(1621088365786); 93 | 94 | $migrator = self::getMockForAbstractClass(HasForeignKeysInterface::class); 95 | $migrator->method('getConnection')->willReturn(DriverManager::getConnection(['url' => 'sqlite:///:memory:'])); 96 | 97 | // Setup task 98 | $migration = new Migration(); 99 | $migration->add($migrator); 100 | $context = new MigrationContext($migration, 'incompatible'); 101 | 102 | // Execute task 103 | $task = new ForeignKeyBeforeTask($context, new CopyColumnHelper()); 104 | $task->execute(); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/Adapter/Doctrine/DBAL/ForeignKey/Task/ForeignKeyAfterTaskTest.php: -------------------------------------------------------------------------------- 1 | connection->getWrappedConnection()->exec(<<add(new TestReferencingMigrator($this->connection, new CopyColumnHelper())); 62 | $migration->add(new TestReferencedMigrator($this->connection, new CopyColumnHelper())); 63 | $context = new MigrationContext($migration, 'copy_columns'); 64 | 65 | // Execute before task 66 | $task = new ForeignKeyBeforeTask($context, new CopyColumnHelper()); 67 | $task->execute(); 68 | 69 | foreach ($migration->process() as $migrator) { 70 | $generator = $migrator->getExecutor()->execute($migrator->getPuller(), $migrator->getPusher()); 71 | while ($generator->valid()) { 72 | $generator->current(); 73 | $generator->next(); 74 | } 75 | } 76 | 77 | // Execute after task 78 | $task = new ForeignKeyAfterTask($context, new CopyColumnHelper()); 79 | $task->execute(); 80 | 81 | // Check referenced table 82 | $columns = $this->connection->getSchemaManager()->listTableColumns('target_referenced'); 83 | self::assertCount(1, $columns); 84 | 85 | // Check referencing table 86 | $columns = $this->connection->getSchemaManager()->listTableColumns('target_referencing'); 87 | self::assertCount(1, $columns); 88 | 89 | $originalColumn = $columns['fk']; 90 | self::assertTrue($originalColumn->getNotnull()); 91 | 92 | // Check data 93 | /** @var DriverStatement $referencedData */ 94 | $referencedData = $this->connection->createQueryBuilder() 95 | ->select('*') 96 | ->from('target_referenced') 97 | ->execute(); 98 | $referencedData = $referencedData->fetchAll(FetchMode::COLUMN); 99 | self::assertSame([1, 2, 3], array_map('intval', $referencedData)); 100 | 101 | /** @var DriverStatement $referencingData */ 102 | $referencingData = $this->connection->createQueryBuilder() 103 | ->select('*') 104 | ->from('target_referencing') 105 | ->orderBy('fk', 'DESC') 106 | ->execute(); 107 | $referencingData = $referencingData->fetchAll(FetchMode::COLUMN); 108 | self::assertSame([3, 2, 2, 1], array_map('intval', $referencingData)); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/Migration/MigrationTest.php: -------------------------------------------------------------------------------- 1 | getMigrators()); 22 | self::assertCount(0, $migration->getMigrators()); 23 | 24 | /** @var MigratorInterface $migrator */ 25 | $migrator = $this->createMock(MigratorInterface::class); 26 | 27 | $migration->add($migrator); 28 | self::assertCount(1, $migration->getMigrators()); 29 | self::assertContains($migrator, $migration->getMigrators()); 30 | } 31 | 32 | /** 33 | * Migrators must be sorted by their dependencies (topological sorting) 34 | */ 35 | public function testMigratorsAreTolologicallySorted(): void 36 | { 37 | $migration = new Migration(); 38 | 39 | $migrator = $this->createMock(MigratorInterface::class); 40 | $dependentMigrator = $this->createMock(DependentMigratorInterface::class); 41 | $dependentMigrator->method('getDependencies')->willReturn([get_class($migrator)]); 42 | 43 | // Add in reverse order 44 | $migration->add($dependentMigrator); 45 | $migration->add($migrator); 46 | 47 | $sortedMigrators = $migration->getMigrators(); 48 | self::assertSame($migrator, $sortedMigrators[0]); 49 | self::assertSame($dependentMigrator, $sortedMigrators[1]); 50 | } 51 | 52 | /** 53 | * Migrators must be unique in a migration 54 | */ 55 | public function testMigratorCannotBeAddedTwice(): void 56 | { 57 | $this->expectException(MigrationException::class); 58 | $this->expectExceptionCode(1619907353293); 59 | 60 | $migration = new Migration(); 61 | $migrator = $this->createMock(MigratorInterface::class); 62 | 63 | $migration->add($migrator); 64 | $migration->add($migrator); 65 | } 66 | 67 | /** 68 | * Circular dependencies must be detected 69 | */ 70 | public function testCircularDependencyDetection(): void 71 | { 72 | $this->expectException(MigrationException::class); 73 | $this->expectExceptionCode(1619911058924); 74 | 75 | // Mocks are created in different ways to get different class names 76 | $circularFirstMigrator = $this->getMockBuilder(DependentMigratorInterface::class)->getMockForAbstractClass(); 77 | $circularSecondMigrator = $this->createMock(DependentMigratorInterface::class); 78 | $circularFirstMigrator->method('getDependencies')->willReturn([get_class($circularSecondMigrator)]); 79 | $circularSecondMigrator->method('getDependencies')->willReturn([get_class($circularFirstMigrator)]); 80 | 81 | $migration = new Migration(); 82 | 83 | $migration->add($circularFirstMigrator); 84 | $migration->add($circularSecondMigrator); 85 | 86 | $migration->getMigrators(); 87 | } 88 | 89 | /** 90 | * Unregistered dependencies must be detected 91 | */ 92 | public function testUnregisteredDependencyDetection(): void 93 | { 94 | $this->expectException(MigrationException::class); 95 | $this->expectExceptionCode(1619911058924); 96 | 97 | $migrator = $this->createMock(DependentMigratorInterface::class); 98 | $migrator->method('getDependencies')->willReturn(['unknown dependency']); 99 | 100 | $migration = new Migration(); 101 | $migration->add($migrator); 102 | 103 | $migration->getMigrators(); 104 | } 105 | 106 | /** 107 | * Tasks management 108 | */ 109 | public function testCanAddTasks(): void 110 | { 111 | $migration = new Migration(); 112 | self::assertCount(0, $migration->getBeforeTasks()); 113 | self::assertCount(0, $migration->getAfterTasks()); 114 | 115 | $migration->addBeforeTask($this->createMock(TaskInterface::class)); 116 | self::assertCount(1, $migration->getBeforeTasks()); 117 | self::assertCount(0, $migration->getAfterTasks()); 118 | 119 | $migration->addAfterTask($this->createMock(TaskInterface::class)); 120 | self::assertCount(1, $migration->getBeforeTasks()); 121 | self::assertCount(1, $migration->getAfterTasks()); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Adapter/Doctrine/DBAL/ForeignKey/Task/ForeignKeyBeforeTask.php: -------------------------------------------------------------------------------- 1 | context = $context; 31 | $this->columnHelper = $columnHelper; 32 | } 33 | 34 | public function execute(): ?string 35 | { 36 | foreach ($this->context->getMigration()->getMigrators() as $migrator) { 37 | if ($migrator instanceof HasForeignKeysInterface) { 38 | if (false === $migrator->getConnection()->getDatabasePlatform()->supportsForeignKeyConstraints()) { 39 | throw ForeignKeyException::incompatiblePlatform($migrator->getConnection()->getDatabasePlatform()); 40 | } 41 | 42 | foreach ($migrator->getForeignKeys() as $foreignKey) { 43 | // Create columns in the referenced table for the primary key columns 44 | $this->createReferencedColumnCopy($migrator->getConnection(), $foreignKey->getConstraint()); 45 | 46 | // Create columns in the referencing table for the foreign key columns 47 | // and drop real referencing columns NOT NULL 48 | $this->createReferencingColumnCopy($migrator->getConnection(), $foreignKey); 49 | } 50 | } 51 | } 52 | 53 | return null; 54 | } 55 | 56 | private function createReferencedColumnCopy(Connection $connection, ForeignKeyConstraint $constraint): void 57 | { 58 | $originalTable = $connection->getSchemaManager()->listTableDetails($constraint->getForeignTableName()); 59 | $changedTable = clone $originalTable; 60 | 61 | $columns = array_map(function (string $columnName) use ($originalTable) { 62 | return [ 63 | 'original' => $columnName, 64 | 'copy' => $this->columnHelper->foreignColumn($originalTable->getName(), $columnName), 65 | 'index' => $this->columnHelper->foreignColumnIndex($originalTable->getName(), $columnName), 66 | ]; 67 | }, $constraint->getForeignColumns()); 68 | 69 | $this->createCopyColumns($changedTable, $columns); 70 | 71 | $comparator = new Comparator(); 72 | /** @var TableDiff $tableDiff */ 73 | $tableDiff = $comparator->diffTable($originalTable, $changedTable); 74 | 75 | $connection->getSchemaManager()->alterTable($tableDiff); 76 | } 77 | 78 | private function createReferencingColumnCopy(Connection $connection, ForeignKey $foreignKey): void 79 | { 80 | // Create copy columns 81 | $originalTable = $connection->getSchemaManager()->listTableDetails($foreignKey->getTableName()); 82 | $changedTable = clone $originalTable; 83 | 84 | $columns = array_map(function (string $columnName) use ($originalTable) { 85 | return [ 86 | 'original' => $columnName, 87 | 'copy' => $this->columnHelper->localColumn($originalTable->getName(), $columnName), 88 | 'index' => $this->columnHelper->localColumnIndex($originalTable->getName(), $columnName), 89 | ]; 90 | }, $foreignKey->getConstraint()->getLocalColumns()); 91 | 92 | $this->createCopyColumns($changedTable, $columns); 93 | 94 | // Drops given columns NOT NULL 95 | $nullableColumnNames = array_intersect( 96 | $foreignKey->getConstraint()->getLocalColumns(), 97 | $foreignKey->getAllowNull() 98 | ); 99 | 100 | foreach ($nullableColumnNames as $columnName) { 101 | $changedTable->changeColumn($columnName, ['notnull' => false]); 102 | } 103 | 104 | $comparator = new Comparator(); 105 | /** @var TableDiff $tableDiff */ 106 | $tableDiff = $comparator->diffTable($originalTable, $changedTable); 107 | 108 | $connection->getSchemaManager()->alterTable($tableDiff); 109 | } 110 | 111 | /** 112 | * @param string[][] $columnList 113 | * @throws SchemaException 114 | */ 115 | private function createCopyColumns(Table $table, array $columnList): void 116 | { 117 | foreach ($columnList as $column) { 118 | // Stop if copy column already exists 119 | if ($table->hasColumn($column['copy'])) { 120 | continue; 121 | } 122 | 123 | $originalColumn = $table->getColumn($column['original']); 124 | 125 | // Create column 126 | $table->addColumn($column['copy'], $originalColumn->getType()->getName(), [ 127 | 'length' => $originalColumn->getLength(), 128 | 'precision' => $originalColumn->getPrecision(), 129 | 'scale' => $originalColumn->getScale(), 130 | 'fixed' => $originalColumn->getFixed(), 131 | 'unsigned' => $originalColumn->getUnsigned(), 132 | 'notnull' => false, 133 | 'default' => null, 134 | ]); 135 | 136 | // Create index for copy column 137 | $table->addIndex([$column['copy']], $column['index']); 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Configuration/AbstractFregataKernel.php: -------------------------------------------------------------------------------- 1 | rootDir) { 40 | $reflection = new \ReflectionClass(ClassLoader::class); 41 | /** @var string $filePath */ 42 | $filePath = $reflection->getFileName(); 43 | $this->rootDir = dirname($filePath, 3); 44 | } 45 | 46 | return $this->rootDir; 47 | } 48 | 49 | /** 50 | * Get the container class name. Overriden in the framework tests. 51 | * @internal 52 | */ 53 | protected function getContainerClassName(): string 54 | { 55 | return self::CONTAINER_CLASS_NAME; 56 | } 57 | 58 | 59 | /** 60 | * Creates a cached service container to use in a standalone Fregata project where no other container exists 61 | * @throws ConfigurationException 62 | * @throws \Exception 63 | */ 64 | public function getContainer(): Container 65 | { 66 | if (null === $this->container) { 67 | // TODO: manage environments to make the debug mode dynamic 68 | $containerLocation = $this->getCachedContainerLocation(); 69 | $containerConfigCache = new ConfigCache($containerLocation, true); 70 | 71 | // Create the container 72 | if (false === $containerConfigCache->isFresh()) { 73 | $containerBuilder = $this->createContainer(); 74 | $this->dumpCachedContainer($containerBuilder); 75 | } 76 | 77 | // Load and start the container 78 | require_once $containerLocation; 79 | 80 | /** @var class-string $containerClassName */ 81 | $containerClassName = sprintf('\Fregata\%s', $this->getContainerClassName()); 82 | 83 | $this->container = new $containerClassName(); 84 | } 85 | 86 | return $this->container; 87 | } 88 | 89 | /** 90 | * Builds the path to the cached service container and checks the cache directory path 91 | * @throws ConfigurationException 92 | */ 93 | private function getCachedContainerLocation(): string 94 | { 95 | if (false === is_dir($this->getCacheDirectory())) { 96 | $cacheDirectory = realpath($this->getCacheDirectory()) ?: $this->getCacheDirectory(); 97 | throw ConfigurationException::invalidCacheDirectory($cacheDirectory); 98 | } 99 | 100 | return $this->getCacheDirectory() . DIRECTORY_SEPARATOR . $this->getContainerClassName() . '.php'; 101 | } 102 | 103 | /** 104 | * Creates a service container 105 | * @throws ConfigurationException 106 | * @throws \Exception 107 | */ 108 | private function createContainer(): ContainerBuilder 109 | { 110 | $containerBuilder = new ContainerBuilder(); 111 | 112 | // Set configuration directory of the application 113 | if (false === is_dir($this->getConfigurationDirectory())) { 114 | $configurationDirectory = realpath($this->getConfigurationDirectory()) 115 | ?: $this->getConfigurationDirectory(); 116 | throw ConfigurationException::invalidConfigurationDirectory($configurationDirectory); 117 | } 118 | $containerBuilder->setParameter('fregata.root_dir', $this->getRootDirectory()); 119 | $containerBuilder->setParameter('fregata.config_dir', $this->getConfigurationDirectory()); 120 | 121 | // register migration services 122 | $containerBuilder->registerExtension(new FregataExtension()); 123 | $containerBuilder->addCompilerPass(new FregataCompilerPass()); 124 | $containerBuilder->addCompilerPass(new CommandsCompilerPass()); 125 | 126 | // Register main services 127 | $this->loadMainServices($containerBuilder); 128 | 129 | return $containerBuilder; 130 | } 131 | 132 | /** 133 | * Load the main services definitions file of the application if it exists 134 | * @throws \Exception 135 | */ 136 | private function loadMainServices(ContainerBuilder $container): void 137 | { 138 | /** @var string $directory */ 139 | $directory = $container->getParameter('fregata.config_dir'); 140 | $fileLocator = new FileLocator($directory); 141 | 142 | $loader = new YamlFileLoader($container, $fileLocator); 143 | $loader->import('services.yaml', null, 'not_found'); 144 | $loader->import('fregata.yaml', null, 'not_found'); 145 | } 146 | 147 | /** 148 | * Dumps the container 149 | * @throws ConfigurationException 150 | */ 151 | private function dumpCachedContainer(ContainerBuilder $container): void 152 | { 153 | $container->compile(); 154 | 155 | // Dump the cache version 156 | $dumper = new PhpDumper($container); 157 | $containerContent = $dumper->dump([ 158 | 'namespace' => 'Fregata', 159 | 'class' => $this->getContainerClassName(), 160 | ]); 161 | 162 | file_put_contents($this->getCachedContainerLocation(), $containerContent); 163 | } 164 | 165 | /** 166 | * Create a kernel with default configuration 167 | */ 168 | public static function createDefaultKernel(): self 169 | { 170 | return new class extends AbstractFregataKernel { 171 | protected function getConfigurationDirectory(): string 172 | { 173 | return $this->getRootDirectory() . DIRECTORY_SEPARATOR . 'config'; 174 | } 175 | 176 | protected function getCacheDirectory(): string 177 | { 178 | return $this->getRootDirectory() . DIRECTORY_SEPARATOR . 'cache'; 179 | } 180 | }; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Console/MigrationExecuteCommand.php: -------------------------------------------------------------------------------- 1 | migrationRegistry = $migrationRegistry; 29 | 30 | parent::__construct(); 31 | } 32 | 33 | protected function configure(): void 34 | { 35 | $this 36 | ->setDescription('Execute a migration.') 37 | ->setHelp('Execute a migration.') 38 | ->addArgument( 39 | 'migration', 40 | InputArgument::REQUIRED, 41 | 'The name of the migration.' 42 | ) 43 | ; 44 | } 45 | 46 | protected function execute(InputInterface $input, OutputInterface $output): int 47 | { 48 | $isInteractive = $input->hasOption('no-interaction') && false === $input->getOption('no-interaction'); 49 | $io = new SymfonyStyle($input, $output); 50 | 51 | /** @var string $migrationName */ 52 | $migrationName = $input->getArgument('migration'); 53 | $migration = $this->migrationRegistry->get($migrationName); 54 | 55 | if (null === $migration) { 56 | $io->error(sprintf('No migration registered with the name "%s".', $migrationName)); 57 | return 1; 58 | } 59 | 60 | // Confirm execution 61 | if ($isInteractive) { 62 | $confirmationMessage = sprintf('Confirm execution of the "%s" migration ?', $migrationName); 63 | $confirmation = $io->confirm($confirmationMessage, false); 64 | 65 | if (false === $confirmation) { 66 | $io->error('Aborting.'); 67 | return 1; 68 | } 69 | } 70 | 71 | // Starting title 72 | $migrators = $migration->getMigrators(); 73 | $io->success(sprintf('Starting "%s" migration: %s migrators', $migrationName, count($migrators))); 74 | 75 | // Run before tasks 76 | if (0 !== count($migration->getBeforeTasks())) { 77 | $io->title(sprintf('Before tasks: %d', count($migration->getBeforeTasks()))); 78 | 79 | foreach ($migration->getBeforeTasks() as $task) { 80 | $this->runTask($io, $task); 81 | } 82 | $io->newLine(); 83 | } 84 | 85 | // Run migrators 86 | $io->title(sprintf('Migrators: %d', count($migrators))); 87 | 88 | foreach ($migrators as $key => $migrator) { 89 | $totalItems = $migrator->getPuller()->count(); 90 | 91 | if (!$output instanceof ConsoleOutputInterface) { 92 | $this->runMigratorWithCustomProgress($io, $migrator, $totalItems, $key); 93 | } elseif (null !== $totalItems) { 94 | $this->runMigratorWithProgressBar($io, $migrator, $totalItems, $key); 95 | } else { 96 | $this->runMigratorWithoutProgressBar($io, $output, $migrator, $key); 97 | } 98 | $io->newLine(); 99 | } 100 | 101 | // Run before tasks 102 | if (0 !== count($migration->getAfterTasks())) { 103 | $io->title(sprintf('After tasks: %d', count($migration->getAfterTasks()))); 104 | 105 | foreach ($migration->getAfterTasks() as $task) { 106 | $this->runTask($io, $task); 107 | } 108 | $io->newLine(); 109 | } 110 | 111 | $io->success('Migrated successfully !'); 112 | $io->newLine(); 113 | return 0; 114 | } 115 | 116 | /** 117 | * Execute a migrator with a custom progress line 118 | */ 119 | private function runMigratorWithCustomProgress( 120 | SymfonyStyle $io, 121 | MigratorInterface $migrator, 122 | ?int $itemCount, 123 | int $migratorIndex 124 | ): void { 125 | $io->title(sprintf('%d - Executing "%s" :', $migratorIndex, get_class($migrator))); 126 | $totalPushCount = 0; 127 | 128 | $puller = $migrator->getPuller(); 129 | $pusher = $migrator->getPusher(); 130 | foreach ($migrator->getExecutor()->execute($puller, $pusher) as $pushedItemCount) { 131 | $totalPushCount += $pushedItemCount; 132 | $io->write(sprintf('%s %d', self::LINE_ERASER, $totalPushCount)); 133 | 134 | if (null !== $itemCount) { 135 | $io->write(sprintf(' / %d', $itemCount)); 136 | } 137 | } 138 | $io->newLine(); 139 | } 140 | 141 | /** 142 | * Execute a migrator with a progress bar 143 | */ 144 | private function runMigratorWithProgressBar( 145 | SymfonyStyle $io, 146 | MigratorInterface $migrator, 147 | int $itemCount, 148 | int $migratorIndex 149 | ): void { 150 | $io->title(sprintf('%d - Executing "%s" [%d items] :', $migratorIndex, get_class($migrator), $itemCount)); 151 | $io->progressStart($itemCount); 152 | 153 | $puller = $migrator->getPuller(); 154 | $pusher = $migrator->getPusher(); 155 | foreach ($migrator->getExecutor()->execute($puller, $pusher) as $pushedItemCount) { 156 | $io->progressAdvance($pushedItemCount); 157 | } 158 | 159 | $io->progressFinish(); 160 | } 161 | 162 | /** 163 | * Execute a migrator without progress bar 164 | */ 165 | private function runMigratorWithoutProgressBar( 166 | SymfonyStyle $io, 167 | ConsoleOutputInterface $output, 168 | MigratorInterface $migrator, 169 | int $migratorIndex 170 | ): void { 171 | $io->title(sprintf('%d - Executing "%s" :', $migratorIndex, get_class($migrator))); 172 | 173 | $section = $output->section(); 174 | $totalPushCount = 0; 175 | 176 | $section->writeln(sprintf('Migrated items: %d', $totalPushCount)); 177 | 178 | $puller = $migrator->getPuller(); 179 | $pusher = $migrator->getPusher(); 180 | foreach ($migrator->getExecutor()->execute($puller, $pusher) as $pushedItemCount) { 181 | $totalPushCount += $pushedItemCount; 182 | $section->overwrite(sprintf('Migrated items: %d', $totalPushCount)); 183 | } 184 | } 185 | 186 | /** 187 | * Execute a before / after task 188 | */ 189 | private function runTask(SymfonyStyle $io, TaskInterface $task): void 190 | { 191 | $io->write(sprintf(' %s : ...', get_class($task))); 192 | $result = $task->execute(); 193 | 194 | $io->write(sprintf('%s %s : %s', self::LINE_ERASER, get_class($task), $result ?? 'OK')); 195 | $io->newLine(); 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/Configuration/FregataExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, $configs); 38 | 39 | $this->createServiceDefinitions($container); 40 | 41 | // Save complete configuration for access to migrations referenced as parent 42 | $this->configuration = $config['migrations']; 43 | array_walk($this->configuration, fn (&$migrationConfig, $key) => $migrationConfig['name'] = $key); 44 | 45 | foreach ($this->configuration as $config) { 46 | $this->registerMigration($container, $config); 47 | } 48 | } 49 | 50 | protected function createServiceDefinitions(ContainerBuilder $container): void 51 | { 52 | // Base migration service 53 | $container 54 | ->setDefinition('fregata.migration', new Definition(Migration::class)) 55 | ->setPublic(false) 56 | ; 57 | } 58 | 59 | /** 60 | * @phpstan-param migrationConfig $migrationConfig 61 | */ 62 | protected function registerMigration(ContainerBuilder $container, array $migrationConfig): void 63 | { 64 | // Migration definition 65 | $migrationDefinition = new ChildDefinition('fregata.migration'); 66 | $migrationId = 'fregata.migration.' . $migrationConfig['name']; 67 | $migrationDefinition->addTag(self::TAG_MIGRATION, [ 68 | 'name' => $migrationConfig['name'] 69 | ]); 70 | $container->setDefinition($migrationId, $migrationDefinition); 71 | 72 | // Migration context 73 | $contextDefinition = new Definition(MigrationContext::class); 74 | $contextDefinition->setArguments([ 75 | new Reference($migrationId), 76 | $migrationConfig['name'], 77 | $this->findOptionsForMigration($migrationConfig), 78 | $migrationConfig['parent'] ?? null, 79 | ]); 80 | $contextId = $migrationId . '.context'; 81 | $container->setDefinition($contextId, $contextDefinition); 82 | 83 | // Migrator definitions 84 | foreach ($this->findMigratorsForMigration($migrationConfig) as $migratorClass) { 85 | $migratorDefinition = new Definition($migratorClass); 86 | $migratorId = $migrationId . '.migrator.' . (new UnicodeString($migratorClass))->snake(); 87 | $migratorDefinition 88 | ->setAutowired(true) 89 | ->addTag(self::TAG_MIGRATOR) 90 | ; 91 | $container->setDefinition($migratorId, $migratorDefinition); 92 | 93 | $migratorDefinition->setBindings([MigrationContext::class => new BoundArgument($contextDefinition, false)]); 94 | $migrationDefinition->addMethodCall('add', [new Reference($migratorId)]); 95 | } 96 | 97 | // Before tasks 98 | foreach ($this->findBeforeTaskForMigration($migrationConfig) as $beforeTaskClass) { 99 | $taskDefinition = new Definition($beforeTaskClass); 100 | $taskId = $migrationId . '.task.before.' . (new UnicodeString($beforeTaskClass))->snake(); 101 | $taskDefinition 102 | ->setAutowired(true) 103 | ->addTag(self::TAG_TASK) 104 | ->addTag(self::TAG_TASK_BEFORE) 105 | ; 106 | $container->setDefinition($taskId, $taskDefinition); 107 | 108 | $taskDefinition->setBindings([MigrationContext::class => new BoundArgument($contextDefinition, false)]); 109 | $migrationDefinition->addMethodCall('addBeforeTask', [new Reference($taskId)]); 110 | } 111 | 112 | // After tasks 113 | foreach ($this->findAfterTaskForMigration($migrationConfig) as $afterTaskClass) { 114 | $taskDefinition = new Definition($afterTaskClass); 115 | $taskId = $migrationId . '.task.after.' . (new UnicodeString($afterTaskClass))->snake(); 116 | $taskDefinition 117 | ->setAutowired(true) 118 | ->addTag(self::TAG_TASK) 119 | ->addTag(self::TAG_TASK_AFTER) 120 | ; 121 | $container->setDefinition($taskId, $taskDefinition); 122 | 123 | $taskDefinition->setBindings([MigrationContext::class => new BoundArgument($contextDefinition, false)]); 124 | $migrationDefinition->addMethodCall('addAfterTask', [new Reference($taskId)]); 125 | } 126 | } 127 | 128 | /** 129 | * @phpstan-param migrationConfig $migrationConfig 130 | * @return mixed[] 131 | */ 132 | protected function findOptionsForMigration(array $migrationConfig): array 133 | { 134 | $options = []; 135 | 136 | // Migration has a parent 137 | if (isset($migrationConfig['parent'])) { 138 | $parent = $migrationConfig['parent']; 139 | $options = $this->findOptionsForMigration($this->configuration[$parent]); 140 | } 141 | 142 | // Migration has an options list 143 | return array_merge($options, $migrationConfig['options'] ?? []); 144 | } 145 | 146 | /** 147 | * @phpstan-param migrationConfig $migrationConfig 148 | * @return class-string[] 149 | */ 150 | protected function findBeforeTaskForMigration(array $migrationConfig): array 151 | { 152 | $tasks = []; 153 | 154 | // Migration has a parent 155 | if (isset($migrationConfig['parent'])) { 156 | $parent = $migrationConfig['parent']; 157 | $tasks = $this->findBeforeTaskForMigration($this->configuration[$parent]); 158 | } 159 | 160 | // Migration has a task list 161 | return array_merge($tasks, $migrationConfig['tasks']['before'] ?? []); 162 | } 163 | 164 | /** 165 | * @phpstan-param migrationConfig $migrationConfig 166 | * @return class-string[] 167 | */ 168 | protected function findAfterTaskForMigration(array $migrationConfig): array 169 | { 170 | $tasks = []; 171 | 172 | // Migration has a parent 173 | if (isset($migrationConfig['parent'])) { 174 | $parent = $migrationConfig['parent']; 175 | $tasks = $this->findAfterTaskForMigration($this->configuration[$parent]); 176 | } 177 | 178 | // Migration has a task list 179 | return array_merge($tasks, $migrationConfig['tasks']['after'] ?? []); 180 | } 181 | 182 | /** 183 | * @param mixed[] $migrationConfig 184 | * @phpstan-param migrationConfig $migrationConfig 185 | * @return class-string[] 186 | */ 187 | protected function findMigratorsForMigration(array $migrationConfig): array 188 | { 189 | $migrators = []; 190 | 191 | // Migration has a parent 192 | if (isset($migrationConfig['parent'])) { 193 | $parent = $migrationConfig['parent']; 194 | $migrators = $this->findMigratorsForMigration($this->configuration[$parent]); 195 | } 196 | 197 | // Migration has a migrator directory 198 | if (isset($migrationConfig['migrators_directory'])) { 199 | $dirMigrators = $this->findMigratorsInDirectory($migrationConfig['migrators_directory']); 200 | $migrators = array_merge($migrators, $dirMigrators); 201 | } 202 | 203 | // Migration has a migrator list 204 | return array_merge($migrators, $migrationConfig['migrators'] ?? []); 205 | } 206 | 207 | /** 208 | * @return class-string[] 209 | */ 210 | protected function findMigratorsInDirectory(string $path): array 211 | { 212 | $finder = new Finder(); 213 | $iterator = new ClassIterator($finder->in($path)); 214 | $iterator->enableAutoloading(); 215 | 216 | /** @var ClassIterator $iterator */ 217 | $iterator = $iterator->type(MigratorInterface::class); 218 | $iterator = $iterator->where('isInstantiable', true); 219 | 220 | $classes = []; 221 | 222 | /** @var \ReflectionClass $class */ 223 | foreach ($iterator as $class) { 224 | $classes[] = $class->getName(); 225 | } 226 | 227 | return $classes; 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /tests/Configuration/FregataExtensionTest.php: -------------------------------------------------------------------------------- 1 | load([], $container); 29 | 30 | // Base migration 31 | $migration = $container->get('fregata.migration'); 32 | self::assertInstanceOf(Migration::class, $migration); 33 | } 34 | 35 | /** 36 | * Migration services must be defined 37 | */ 38 | public function testMigrationDefinition(): void 39 | { 40 | $container = new ContainerBuilder(); 41 | $extension = new FregataExtension(); 42 | 43 | $migrator = self::getMockForAbstractClass(MigratorInterface::class); 44 | $migratorClass = get_class($migrator); 45 | 46 | $configuration = [ 47 | 'migrations' => [ 48 | 'test_migration' => [ 49 | 'migrators_directory' => __DIR__ . '/Fixtures', 50 | 'migrators' => [ 51 | $migratorClass 52 | ], 53 | ] 54 | ] 55 | ]; 56 | $extension->load([$configuration], $container); 57 | 58 | // Migration 59 | $testMigrationId = 'fregata.migration.test_migration'; 60 | self::assertTrue($container->has($testMigrationId)); 61 | 62 | // Migrators 63 | $firstMigratorId = $testMigrationId; 64 | $firstMigratorId .= '.migrator.fregata_tests_configuration_fixtures_extension_test_directory_migrator'; 65 | self::assertTrue($container->has($firstMigratorId)); 66 | 67 | $secondMigratorId = sprintf( 68 | '%s.migrator.%s', 69 | $testMigrationId, 70 | (new UnicodeString($migratorClass))->snake() 71 | ); 72 | self::assertTrue($container->has($secondMigratorId)); 73 | 74 | // Migrators have autowiring 75 | $migratorDefinition = $container->getDefinition($secondMigratorId); 76 | self::assertTrue($migratorDefinition->isAutowired()); 77 | } 78 | 79 | /** 80 | * Before tasks must be defined 81 | */ 82 | public function testBeforeTaskDefinitions(): void 83 | { 84 | $container = new ContainerBuilder(); 85 | $extension = new FregataExtension(); 86 | 87 | $task = self::getMockForAbstractClass(TaskInterface::class); 88 | $taskClass = get_class($task); 89 | 90 | $configuration = [ 91 | 'migrations' => [ 92 | 'test_migration' => [ 93 | 'tasks' => [ 94 | 'before' => [ 95 | $taskClass, 96 | ] 97 | ] 98 | ] 99 | ] 100 | ]; 101 | $extension->load([$configuration], $container); 102 | 103 | // Migration 104 | $testMigrationId = 'fregata.migration.test_migration'; 105 | self::assertTrue($container->has($testMigrationId)); 106 | 107 | // Task 108 | $taskId = sprintf( 109 | '%s.task.before.%s', 110 | $testMigrationId, 111 | (new UnicodeString($taskClass))->snake() 112 | ); 113 | self::assertTrue($container->has($taskId)); 114 | 115 | // Before tasks have autowiring 116 | $taskDefinition = $container->getDefinition($taskId); 117 | self::assertTrue($taskDefinition->isAutowired()); 118 | } 119 | 120 | /** 121 | * After tasks must be defined 122 | */ 123 | public function testAfterTaskDefinitions(): void 124 | { 125 | $container = new ContainerBuilder(); 126 | $extension = new FregataExtension(); 127 | 128 | $task = self::getMockForAbstractClass(TaskInterface::class); 129 | $taskClass = get_class($task); 130 | 131 | $configuration = [ 132 | 'migrations' => [ 133 | 'test_migration' => [ 134 | 'tasks' => [ 135 | 'after' => [ 136 | $taskClass, 137 | ] 138 | ] 139 | ] 140 | ] 141 | ]; 142 | $extension->load([$configuration], $container); 143 | 144 | // Migration 145 | $testMigrationId = 'fregata.migration.test_migration'; 146 | self::assertTrue($container->has($testMigrationId)); 147 | 148 | // Task 149 | $taskId = sprintf( 150 | '%s.task.after.%s', 151 | $testMigrationId, 152 | (new UnicodeString($taskClass))->snake() 153 | ); 154 | self::assertTrue($container->has($taskId)); 155 | 156 | // After tasks have autowiring 157 | $taskDefinition = $container->getDefinition($taskId); 158 | self::assertTrue($taskDefinition->isAutowired()); 159 | } 160 | 161 | /** 162 | * Context must be defined 163 | */ 164 | public function testContextDefinitions(): void 165 | { 166 | $container = new ContainerBuilder(); 167 | $extension = new FregataExtension(); 168 | 169 | $configuration = [ 170 | 'migrations' => [ 171 | 'test_migration' => [] 172 | ] 173 | ]; 174 | $extension->load([$configuration], $container); 175 | 176 | // Context 177 | self::assertTrue($container->has('fregata.migration.test_migration.context')); 178 | } 179 | 180 | /** 181 | * Context must be defined according to the migration 182 | */ 183 | public function testContextIsAccurate(): void 184 | { 185 | $migrator = self::getMockForAbstractClass(MigratorInterface::class); 186 | $migratorClass = get_class($migrator); 187 | $taskClass = TestTask::class; 188 | 189 | $fileSystem = vfsStream::setup('fregata-extension-test', null, [ 190 | 'config' => [ 191 | 'fregata.yaml' => << [] 207 | ]); 208 | 209 | // Create kernel 210 | $kernel = new class ($fileSystem) extends AbstractFregataKernel { 211 | private vfsStreamDirectory $vfs; 212 | 213 | public function __construct(vfsStreamDirectory $vfs) 214 | { 215 | $this->vfs = $vfs; 216 | } 217 | 218 | protected function getConfigurationDirectory(): string 219 | { 220 | return $this->vfs->url() . '/config'; 221 | } 222 | 223 | protected function getCacheDirectory(): string 224 | { 225 | return $this->vfs->url() . '/cache'; 226 | } 227 | 228 | protected function getContainerClassName(): string 229 | { 230 | return parent::getContainerClassName() . 'FregataExtensionTest'; 231 | } 232 | }; 233 | 234 | $container = $kernel->getContainer(); 235 | 236 | /** @var MigrationRegistry $registry */ 237 | $registry = $container->get('fregata.migration_registry'); 238 | self::assertInstanceOf(MigrationRegistry::class, $registry); 239 | 240 | /** @var Migration $migration */ 241 | $migration = $registry->get('child_migration'); 242 | 243 | $migrator = $migration->getMigrators()[0]; 244 | self::assertInstanceOf($migratorClass, $migrator); 245 | 246 | /** @var TestTask $task */ 247 | $task = $migration->getBeforeTasks()[0]; 248 | $context = $task->getContext(); 249 | 250 | self::assertInstanceOf(MigrationContext::class, $context); 251 | self::assertSame($migration, $context->getMigration()); 252 | self::assertSame(['foo' => 'bar'], $context->getOptions()); 253 | self::assertSame('child_migration', $context->getMigrationName()); 254 | self::assertSame('test_migration', $context->getParentName()); 255 | } 256 | 257 | /** 258 | * Extension must tag migration services 259 | */ 260 | public function testServicesAreTagged(): void 261 | { 262 | $container = new ContainerBuilder(); 263 | $extension = new FregataExtension(); 264 | 265 | $migrator = self::getMockForAbstractClass(MigratorInterface::class); 266 | $migratorClass = get_class($migrator); 267 | $taskClass = TestTask::class; 268 | 269 | $configuration = [ 270 | 'migrations' => [ 271 | 'test_migration' => [ 272 | 'migrators' => [ 273 | $migratorClass 274 | ], 275 | 'tasks' => [ 276 | 'before' => [ 277 | $taskClass, 278 | ], 279 | 'after' => [ 280 | $taskClass, 281 | ], 282 | ] 283 | ] 284 | ] 285 | ]; 286 | $extension->load([$configuration], $container); 287 | 288 | $migrations = $container->findTaggedServiceIds(FregataExtension::TAG_MIGRATION); 289 | self::assertCount(1, $migrations); 290 | 291 | $migrators = $container->findTaggedServiceIds(FregataExtension::TAG_MIGRATOR); 292 | self::assertCount(1, $migrators); 293 | 294 | $tasks = $container->findTaggedServiceIds(FregataExtension::TAG_TASK); 295 | self::assertCount(2, $tasks); 296 | 297 | $beforeTasks = $container->findTaggedServiceIds(FregataExtension::TAG_TASK_BEFORE); 298 | self::assertCount(1, $beforeTasks); 299 | 300 | $afterTasks = $container->findTaggedServiceIds(FregataExtension::TAG_TASK_AFTER); 301 | self::assertCount(1, $afterTasks); 302 | } 303 | } 304 | 305 | /** 306 | * Mock 307 | * @see FregataExtensionTest::testContextIsAccurate() 308 | */ 309 | class TestTask implements TaskInterface 310 | { 311 | private MigrationContext $context; 312 | 313 | public function __construct(MigrationContext $context) 314 | { 315 | $this->context = $context; 316 | } 317 | 318 | public function execute(): ?string 319 | { 320 | return null; 321 | } 322 | 323 | public function getContext(): MigrationContext 324 | { 325 | return $this->context; 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /src/Adapter/Doctrine/DBAL/ForeignKey/Task/ForeignKeyAfterTask.php: -------------------------------------------------------------------------------- 1 | context = $context; 32 | $this->columnHelper = $columnHelper; 33 | } 34 | 35 | public function execute(): ?string 36 | { 37 | $savedRelations = 0; 38 | 39 | foreach ($this->context->getMigration()->getMigrators() as $migrator) { 40 | if ($migrator instanceof HasForeignKeysInterface) { 41 | foreach ($migrator->getForeignKeys() as $foreignKey) { 42 | // Fixes the relations with the correct values 43 | $savedRelations += $this->updateForeignKeyValues($migrator->getConnection(), $foreignKey); 44 | 45 | // Drops copy columns on referenced tables 46 | $this->dropReferencedCopyColumns($migrator->getConnection(), $foreignKey); 47 | 48 | // Drops copy columns on referencing tables and reset NOT NULL on edited columns 49 | $this->dropReferencingCopyColumns($migrator->getConnection(), $foreignKey); 50 | } 51 | } 52 | } 53 | 54 | return sprintf( 55 | '%d relations saved !', 56 | number_format($savedRelations, 0, '.', ' ') 57 | ); 58 | } 59 | 60 | /** 61 | * @return int number of updated rows 62 | */ 63 | private function updateForeignKeyValues(Connection $connection, ForeignKey $foreignKey): int 64 | { 65 | // Create combinations of local/foreign columns/copies used in every query versions 66 | $columnCombinations = array_map( 67 | fn(string $local, string $foreign) => [ 68 | 'local' => $local, 69 | 'foreign' => $foreign, 70 | 'local_copy' => $this->columnHelper->localColumn($foreignKey->getTableName(), $local), 71 | 'foreign_copy' => $this->columnHelper->foreignColumn( 72 | $foreignKey->getConstraint()->getForeignTableName(), 73 | $foreign 74 | ) 75 | ], 76 | $foreignKey->getConstraint()->getLocalColumns(), 77 | $foreignKey->getConstraint()->getForeignColumns() 78 | ); 79 | 80 | /* 81 | * If possible, perform an UPDATE JOIN query which has a better performance. 82 | * As it is not supported by Doctrine as it is not cross-platform, query is built manually 83 | * 84 | * Common UPDATE JOIN query parts: 85 | */ 86 | 87 | // Common SET clause 88 | $setClause = implode(', ', array_map( 89 | fn(array $colNames) => sprintf('_l.%s = _f.%s', $colNames['local'], $colNames['foreign']), 90 | $columnCombinations 91 | )); 92 | 93 | // Common JOIN conditions (not always in the FROM clause) 94 | $joinConditions = implode(' AND ', array_map( 95 | fn(array $colNames) => sprintf('_l.%s = _f.%s', $colNames['local_copy'], $colNames['foreign_copy']), 96 | $columnCombinations 97 | )); 98 | 99 | if ($connection->getDatabasePlatform() instanceof MySqlPlatform) { 100 | /* 101 | * Example MySQL query with 2 local/foreign columns: 102 | * UPDATE local _l 103 | * INNER JOIN foreign _f ON 104 | * _l.local_copy_1 = _f.foreign_copy_1 105 | * AND _l.local_copy_2 = _f.foreign_copy_2 106 | * SET _l.local_col_1 = _f.foreign_col_1, 107 | * _l.local_col_2 = _f.foreign_col_2 108 | */ 109 | 110 | $updateQuery = sprintf( 111 | 'UPDATE %s _l INNER JOIN %s _f ON %s SET %s', 112 | $foreignKey->getTableName(), 113 | $foreignKey->getConstraint()->getForeignTableName(), 114 | $joinConditions, 115 | $setClause 116 | ); 117 | } elseif ($connection->getDatabasePlatform() instanceof PostgreSqlPlatform) { 118 | /* 119 | * Example PostgreSQL query with 2 local/foreign columns: 120 | * UPDATE local _l SET 121 | * _l.local_col_1 = _f.foreign_col_1, 122 | * _l.local_col_2 = _f.foreign_col_2 123 | * FROM foreign _f 124 | * WHERE _l.local_copy_1 = _f.foreign_copy_1 125 | * AND _l.local_copy_2 = _f.foreign_copy_2 126 | */ 127 | 128 | // PostgreSQL does not support table aliasing in the left side of a SET clause 129 | $postgresSetClause = implode(', ', array_map( 130 | fn(array $colNames) => sprintf('%s = _f.%s', $colNames['local'], $colNames['foreign']), 131 | $columnCombinations 132 | )); 133 | 134 | $updateQuery = sprintf( 135 | 'UPDATE %s _l SET %s FROM %s _f WHERE %s', 136 | $foreignKey->getTableName(), 137 | $postgresSetClause, 138 | $foreignKey->getConstraint()->getForeignTableName(), 139 | $joinConditions 140 | ); 141 | } elseif ($connection->getDatabasePlatform() instanceof SQLServerPlatform) { 142 | /* 143 | * Example SQL Server query with 2 local/foreign columns: 144 | * UPDATE _l SET 145 | * _l.local_col_1 = _f.foreign_col_1, 146 | * _l.local_col_2 = _f.foreign_col_2 147 | * FROM local _l 148 | * INNER JOIN foreign _f ON 149 | * _l.local_copy_1 = _f.foreign_copy_1 150 | * AND _l.local_copy_2 = _f.foreign_copy_2 151 | */ 152 | 153 | $updateQuery = sprintf( 154 | 'UPDATE _l SET %s FROM %s _l INNER JOIN %s _f ON %s', 155 | $setClause, 156 | $foreignKey->getTableName(), 157 | $foreignKey->getConstraint()->getForeignTableName(), 158 | $joinConditions 159 | ); 160 | } else { 161 | /* 162 | * Example query for DBMS not supporting UPDATE JOIN, with 2 local/foreign columns: 163 | * UPDATE local _l 164 | * SET _l.local_col_1 = ( 165 | * SELECT _f.foreign_col_1 166 | * FROM foreign _f 167 | * WHERE _l.local_copy_1 = _f.foreign_copy_1 168 | * AND _l.local_copy_2 = _f.foreign_copy_2 169 | * ), 170 | * _l.local_col_2 = ( 171 | * SELECT _f.foreign_col_2 172 | * FROM foreign _f 173 | * WHERE _l.local_copy_1 = _f.foreign_copy_1 174 | * AND _l.local_copy_2 = _f.foreign_copy_2 175 | * ), 176 | * WHERE _l.local_copy_1 IS NOT NULL 177 | * OR _l.local_copy_2 IS NOT NULL 178 | */ 179 | 180 | // Start UPDATE query 181 | $updateQuery = $connection->createQueryBuilder() 182 | ->update($foreignKey->getTableName(), '_l'); 183 | 184 | // Add to the SET clause per foreign key local columns 185 | foreach ($columnCombinations as $colNames) { 186 | // Start the SELECT subquery 187 | $selectSubQuery = $connection->createQueryBuilder() 188 | ->select(sprintf('_f.%s', $colNames['foreign'])) 189 | ->from($foreignKey->getConstraint()->getForeignTableName(), '_f'); 190 | 191 | foreach ($columnCombinations as $whereClause) { 192 | // Add the WHERE clause to the subquery, acting as would do a join condition in an UPDATE JOIN query 193 | $selectSubQuery->andWhere(sprintf( 194 | '_l.%s = _f.%s', 195 | $whereClause['local_copy'], 196 | $whereClause['foreign_copy'] 197 | )); 198 | 199 | /* WHERE clause of the main query will prevent updating rows that existed before the migration and 200 | resetting their relations to NULL: only update migrated rows */ 201 | $updateQuery->orWhere(sprintf('_l.%s IS NOT NULL', $whereClause['local_copy'])); 202 | } 203 | 204 | // Finalized SET clause for the current column 205 | $updateQuery->set(sprintf('_l.%s', $colNames['local']), sprintf('(%s)', $selectSubQuery->getSQL())); 206 | } 207 | 208 | // Get QueryBuilder SQL 209 | $updateQuery = $updateQuery->getSQL(); 210 | } 211 | 212 | return (int) $connection->executeStatement($updateQuery); 213 | } 214 | 215 | private function dropReferencedCopyColumns(Connection $connection, ForeignKey $foreignKey): void 216 | { 217 | $foreignTableName = $foreignKey->getConstraint()->getForeignTableName(); 218 | $originalTable = $connection->getSchemaManager()->listTableDetails($foreignTableName); 219 | $changedTable = clone $originalTable; 220 | 221 | foreach ($foreignKey->getConstraint()->getForeignColumns() as $columnName) { 222 | $changedTable->dropColumn($this->columnHelper->foreignColumn($changedTable->getName(), $columnName)); 223 | $changedTable->dropIndex($this->columnHelper->foreignColumnIndex($changedTable->getName(), $columnName)); 224 | } 225 | 226 | $comparator = new Comparator(); 227 | /** @var TableDiff $tableDiff */ 228 | $tableDiff = $comparator->diffTable($originalTable, $changedTable); 229 | 230 | $connection->getSchemaManager()->alterTable($tableDiff); 231 | } 232 | 233 | private function dropReferencingCopyColumns(Connection $connection, ForeignKey $foreignKey): void 234 | { 235 | $originalTable = $connection->getSchemaManager()->listTableDetails($foreignKey->getTableName()); 236 | $changedTable = clone $originalTable; 237 | 238 | // Remove copy columns and indexes 239 | foreach ($foreignKey->getConstraint()->getLocalColumns() as $columnName) { 240 | $changedTable->dropColumn($this->columnHelper->localColumn($changedTable->getName(), $columnName)); 241 | $changedTable->dropIndex($this->columnHelper->localColumnIndex($changedTable->getName(), $columnName)); 242 | 243 | // Reset NOT NULL on edited columns 244 | if (in_array($columnName, $foreignKey->getAllowNull())) { 245 | $changedTable->changeColumn($columnName, ['notnull' => true]); 246 | } 247 | } 248 | 249 | $comparator = new Comparator(); 250 | /** @var TableDiff $tableDiff */ 251 | $tableDiff = $comparator->diffTable($originalTable, $changedTable); 252 | 253 | $connection->getSchemaManager()->alterTable($tableDiff); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fregata - PHP database migrator 2 | 3 | ![](https://github.com/AymDev/Fregata/workflows/Unit%20Test%20Suite/badge.svg) 4 | [![Latest Stable Version](https://poser.pugx.org/aymdev/fregata/v)](//packagist.org/packages/aymdev/fregata) 5 | [![License](https://poser.pugx.org/aymdev/fregata/license)](//packagist.org/packages/aymdev/fregata) 6 | 7 | **Fregata** is a data migration framework. You can use it to migrate any kind of data, but it has features to help you 8 | migrate between different DBMS or database structures. 9 | 10 | **Documentation**: 11 | 12 | 1. [Introduction](#introduction) 13 | 2. [Setup](#setup) 14 | 1. [Installation](#installation) 15 | 2. [Configuration](#configuration) 16 | 1. [Kernel and service container](#kernel-and-service-container) 17 | 2. [YAML configuration](#yaml-configuration) 18 | 3. [Components](#components) 19 | 1. [Migration Registry](#migration-registry) 20 | 2. [Migration](#migration) 21 | 1. [Options](#options) 22 | 2. [Parent migration](#parent-migration) 23 | 3. [Task](#task) 24 | 4. [Migrator](#migrator) 25 | 1. [Puller](#puller) 26 | 2. [Pusher](#pusher) 27 | 3. [Executor](#executor) 28 | 4. [Tools](#tools) 29 | 1. [Migration Context](#migration-context) 30 | 5. [Features](#features) 31 | 1. [Dependent migrators](#dependent-migrators) 32 | 2. [Batch pulling](#batch-pulling) 33 | 3. [Foreign Key migrations](#foreign-key-migrations) 34 | 6. [CLI usage](#cli-usage) 35 | 1. [List migrations](#list-migrations) 36 | 2. [Get details of a migration](#get-details-of-a-migration) 37 | 3. [Execute a migration](#execute-a-migration) 38 | 7. [Contributing](#contributing) 39 | 40 | # Introduction 41 | **Fregata** is a data migration framework. It can probably be compared to an *ETL (Extract Transform - Load)* tool. 42 | 43 | You can use it to migrate data from files, databases, or anything you want, it is completely agnostic on this part 44 | (some of its test migrate data between PHP arrays). But note that it was initially targeting databases, providing a way 45 | to migrate data between different DBMS, even with a different structure. Some included features are specifically built 46 | for databases. 47 | 48 | >Why creating a framework for data migration ? 49 | 50 | While database migrations might not be your everyday task, I encountered it multiple times on different projects. That's 51 | why I created **Fregata** to have a migration workflow I could reuse. 52 | 53 | >What are the use cases ? 54 | 55 | Here are some example use cases (from experience): 56 | 57 | - when you want to change from a DBMS to another 58 | - when you want to sync your staging database with the production one (useful for CMS-based projects) 59 | 60 | 61 | # Setup 62 | 63 | ## Installation 64 | 65 | Install with Composer: 66 | ```shell 67 | composer require aymdev/fregata 68 | ``` 69 | 70 | ## Configuration 71 | 72 | **Fregata** expects you to have a `config` and a `cache` directory at your project root by default. 73 | 74 | ### Kernel and service container 75 | 76 | If you need to use a different directory structure than the default one, you can extend the 77 | `Fregata\Configuration\AbstractFregataKernel` class. 78 | Then you will have to implement methods to specify your configuration and cache directory. 79 | >**Important**: your kernel full qualified class name ***must*** be `App\FregataKernel`. 80 | 81 | The *kernel* holds a *service container*, built from **Symfony**'s **DependencyInjection** component. 82 | This means you can define your own services as you would do it in a **Symfony** application, in a 83 | `services.yaml` file in your configuration directory. 84 | 85 | Here's a recommended minimal **services.yaml** to start your project: 86 | ```yaml 87 | services: 88 | _defaults: 89 | autowire: true 90 | 91 | App\: 92 | resource: '../src/' 93 | ``` 94 | 95 | ### YAML configuration 96 | 97 | To configure **Fregata** itself, you will need a `fregata.yaml` file in your configuration directory. 98 | 99 | Example configuration file: 100 | ```yaml 101 | fregata: 102 | migrations: 103 | # define any name for your migration 104 | main_migration: 105 | # define custom options for your migrations 106 | options: 107 | custom_opt: 'opt_value' 108 | special_cfg: 109 | foo: bar 110 | # load migrators from a directory 111 | # use the %fregata.root_dir% parameter to define a relative path from the project root 112 | migrators_directory: '%fregata.root_dir%/src/MainMigration' 113 | # load individual migrators 114 | # can be combined with migrators_directory 115 | migrators: 116 | - App\MainMigration\FirstMigrator 117 | # load tasks to execute before or after the migrators 118 | tasks: 119 | before: 120 | - App\MainMigration\BeforeTask 121 | after: 122 | - App\MainMigration\AfterTask 123 | 124 | other_migration: 125 | # extend an other migration to inherit its options, tasks and migrators 126 | parent: main_migration 127 | # overwrite a part of the options 128 | options: 129 | custom_opt: 'another_value' 130 | # load additional migrators or tasks 131 | migrators: 132 | - App\OtherMigration\Migrator 133 | ``` 134 | 135 | # Components 136 | 137 | ## Migration Registry 138 | 139 | The **migration registry** contains every defined migrations. You shouldn't have to interact with it. 140 | 141 | ## Migration 142 | 143 | A **migration** project holds the steps of a migration. For example, data migration from your production 144 | database to staging one. 145 | Each **migration** is created and saved into the registry based on your configuration. You don't need to 146 | instantiate migration objects by yourself. 147 | 148 | Migrations contain **tasks** and **migrators**. When a migration is run, components are executed in the 149 | following order: 150 | 151 | - before tasks 152 | - migrators 153 | - after tasks 154 | 155 | ### Options 156 | 157 | You may need to set specific configuration to your migration project, which could be used by **tasks** 158 | or **migrators**. 159 | With the `options` key you can define your migration specific configuration, they will be accessible to 160 | the components from the [migration context](#migration-context). 161 | 162 | ### Parent migration 163 | 164 | When having multiple **migrations** for different environments, you probably want to avoid duplicating your 165 | whole configuration. 166 | You can extend a migration with the `parent` key. The *"child"* migration will inherit the parent's 167 | *options*, **tasks** and **migrators**. You can still add more tasks and migrators, and overwrite options. 168 | 169 | ## Task 170 | 171 | A **task** can be executed *before* or *after* **migrators**. They can be useful to bootstrap your migration 172 | (before tasks) or to clean temporary data at the end (after tasks): 173 | 174 | ```php 175 | use Fregata\Migration\TaskInterface; 176 | 177 | class MyTask implements TaskInterface 178 | { 179 | public function execute() : ?string 180 | { 181 | // perform some verifications, delete temporary data, ... 182 | return 'Optional result message'; 183 | } 184 | } 185 | ``` 186 | 187 | ## Migrator 188 | 189 | The **migrators** are the main components of the framework. A single migrator holds 3 components: 190 | 191 | - a **puller** 192 | - a **pusher** 193 | - an **executor** 194 | 195 | It must return its components from getter methods by implementing 196 | `Fregata\Migration\Migrator\MigratorInterface`. 197 | A **migrator** represents the migration of a data from a **source** to a **target**. For example, migrating data 198 | from a *MySQL* table to a *PostgreSQL* one. 199 | 200 | ### Puller 201 | 202 | A **puller** is a **migrator** component responsible for *pulling data from a source*. It returns data 203 | and optionally the number of items to migrate: 204 | 205 | ```php 206 | use Doctrine\DBAL\Connection; 207 | use Fregata\Migration\Migrator\Component\PullerInterface; 208 | 209 | class Puller implements PullerInterface 210 | { 211 | private Connection $connection; 212 | 213 | public function __construct(Connection $connection) 214 | { 215 | $this->connection = $connection; 216 | } 217 | 218 | public function pull() 219 | { 220 | return $this->connection 221 | ->executeQuery('SELECT * FROM my_table') 222 | ->fetchAllAssociative(); 223 | } 224 | 225 | public function count() : ?int 226 | { 227 | return $this->connection 228 | ->executeQuery('SELECT COUNT(*) FROM my_table') 229 | ->fetchColumn(); 230 | } 231 | } 232 | ``` 233 | 234 | ### Pusher 235 | 236 | A **pusher** gets item fetched by the **puller** 1 by 1 and has to *push the data to a target*: 237 | 238 | ```php 239 | use Doctrine\DBAL\Connection; 240 | use Fregata\Migration\Migrator\Component\PusherInterface; 241 | 242 | class Pusher implements PusherInterface 243 | { 244 | private Connection $connection; 245 | 246 | public function __construct(Connection $connection) 247 | { 248 | $this->connection = $connection; 249 | } 250 | 251 | /** 252 | * @return int number of items inserted 253 | */ 254 | public function push($data): int 255 | { 256 | return $this->connection->executeStatement( 257 | 'INSERT INTO my_table VALUES (:foo, :bar, :baz)', 258 | [ 259 | 'foo' => $data['foo'], 260 | 'bar' => some_function($data['bar']), 261 | 'baz' => 'default value', 262 | ] 263 | ); 264 | } 265 | } 266 | ``` 267 | Here `$data` is a single item from the example **puller** returned value. The `push()` method is called 268 | multiple times. 269 | The separation of **pullers** and **pushers** allow you to migrate between different sources: pull from 270 | a file and push to a database, etc. 271 | 272 | ### Executor 273 | 274 | The **executor** is the component which plugs a **puller** with a **pusher**. A default one is provided 275 | and should work for most cases: `Fregata\Migration\Migrator\Component\Executor`. 276 | Extend the default **executor** if you need a specific behaviour. 277 | 278 | # Tools 279 | 280 | ## Migration Context 281 | 282 | You can get some informations about the current **migration** by injecting the 283 | `Fregata\Migration\MigrationContext` service in a **task** or **migration**. 284 | 285 | It provides: 286 | 287 | - current **migration** object 288 | - current migration **name** 289 | - migration **options** 290 | - **parent** migration name if applicable 291 | 292 | # Features 293 | 294 | ## Dependent migrators 295 | 296 | If your **migrators** need to be executed in a specific order you can define dependencies, and they will 297 | be sorted automatically: 298 | 299 | ```php 300 | use Fregata\Migration\Migrator\DependentMigratorInterface; 301 | 302 | class DependentMigrator implements DependentMigratorInterface 303 | { 304 | public function getDependencies() : array 305 | { 306 | return [ 307 | DependencyMigrator::class, 308 | ]; 309 | } 310 | 311 | // other migrator methods ... 312 | } 313 | ``` 314 | Here, `DependencyMigrator` will be executed before `DependentMigrator`. 315 | 316 | ## Batch pulling 317 | 318 | When a **puller** works with very large datasets you might want to pull the data by chunks: 319 | 320 | ```php 321 | use Doctrine\DBAL\Connection; 322 | use Fregata\Migration\Migrator\Component\BatchPullerInterface; 323 | 324 | class BatchPulling implements BatchPullerInterface 325 | { 326 | private Connection $connection; 327 | private ?int $count = null; 328 | 329 | public function __construct(Connection $connection) 330 | { 331 | $this->connection = $connection; 332 | } 333 | 334 | public function pull(): \Generator 335 | { 336 | $limit = 50; 337 | $offset = 0; 338 | 339 | while ($offset < $this->count()) { 340 | yield $this->connection 341 | ->executeQuery(sprintf('SELECT * FROM my_table LIMIT %d, %d', $offset, $limit)) 342 | ->fetchAllAssociative(); 343 | 344 | $offset += $limit; 345 | } 346 | } 347 | 348 | public function count() : ?int 349 | { 350 | if (null === $this->count) { 351 | $this->count = $this->connection 352 | ->executeQuery('SELECT COUNT(*) FROM my_table') 353 | ->fetchColumn(); 354 | } 355 | 356 | return $this->count; 357 | } 358 | } 359 | ``` 360 | 361 | ## Foreign Key migrations 362 | 363 | One of the most complex parts of a database migration is about **foreign keys**. There are multiple steps 364 | to follow to perform a valid foreign key migration. This is done using **Doctrine DBAL**. 365 | 366 | You must add 2 tasks to your migration: 367 | 368 | - **before** task: `Fregata\Adapter\Doctrine\DBAL\ForeignKey\Task\ForeignKeyBeforeTask` 369 | - **after** task: `Fregata\Adapter\Doctrine\DBAL\ForeignKey\Task\ForeignKeyAfterTask` 370 | 371 | The before task will create temporary columns in your target database to keep the original referenced and 372 | referencing columns. It may also change referencing columns to allow `NULL` (only if you specify it). 373 | The after task will set the real values in your original referencing columns and then drop the temporary 374 | columns. 375 | 376 | Then the migrators must provide the database connection and the list of foreign keys: 377 | 378 | ```php 379 | use Doctrine\DBAL\Connection; 380 | use Doctrine\DBAL\Schema\ForeignKeyConstraint; 381 | use Fregata\Adapter\Doctrine\DBAL\ForeignKey\ForeignKey; 382 | use Fregata\Adapter\Doctrine\DBAL\ForeignKey\Migrator\HasForeignKeysInterface; 383 | 384 | class ReferencingMigrator implements HasForeignKeysInterface 385 | { 386 | private Connection $connection; 387 | 388 | public function __construct(Connection $connection) 389 | { 390 | $this->connection = $connection; 391 | } 392 | 393 | public function getConnection() : Connection 394 | { 395 | return $this->connection; 396 | } 397 | 398 | /** 399 | * List the foreign keys constraints to keep 400 | * @return ForeignKey[] 401 | */ 402 | public function getForeignKeys() : array 403 | { 404 | $constraints = $this->connection->getSchemaManager()->listTableForeignKeys('my_table'); 405 | return array_map( 406 | function (ForeignKeyConstraint $constraint) { 407 | return new ForeignKey( 408 | $constraint, // DBAL constraint object 409 | 'target_referencing', // name of the referencing table 410 | ['fk'] // columns to change to allow NULL (will be set back to NOT NULL in the after task) 411 | ); 412 | }, 413 | $constraints 414 | ); 415 | } 416 | 417 | // other migrator methods ... 418 | } 419 | ``` 420 | 421 | The migrators are responsible for the data migration, this means you need to fill the temporary columns 422 | with original primary/foreign key from the source database. 423 | To get the name of a temporary column, require the `CopyColumnHelper` service in your **pusher**: 424 | 425 | ```php 426 | use Doctrine\DBAL\Connection; 427 | use Fregata\Adapter\Doctrine\DBAL\ForeignKey\CopyColumnHelper; 428 | use Fregata\Migration\Migrator\Component\PusherInterface; 429 | 430 | class ReferencingForeignKeyPusher implements PusherInterface 431 | { 432 | private Connection $connection; 433 | private CopyColumnHelper $columnHelper; 434 | 435 | public function __construct(Connection $connection, CopyColumnHelper $columnHelper) 436 | { 437 | $this->connection = $connection; 438 | $this->columnHelper = $columnHelper; 439 | } 440 | 441 | /** 442 | * @return int number of items inserted 443 | */ 444 | public function push($data): int 445 | { 446 | return $this->connection->executeStatement( 447 | sprintf( 448 | 'INSERT INTO my_table (column, %s) VALUES (:value, :old_fk)', 449 | $this->columnHelper->localColumn('my_table', 'fk_column') 450 | ), 451 | [ 452 | 'value' => $data['value'], 453 | 'old_fk' => $data['fk_column'], 454 | ] 455 | ); 456 | } 457 | } 458 | ``` 459 | This example show the *local* (or *referencing*) side but this need to be done for the *foreign* (or 460 | *referenced*) side too, using `CopyColumnHelper::foreignColumn()`. 461 | 462 | # CLI usage 463 | 464 | **Fregata** provides a simple program to run the migrations, you can launch it with: 465 | ```shell 466 | php vendor/bin/fregata 467 | ``` 468 | 469 | ## List migrations 470 | 471 | To list the migrations of your installation, run the `migration:list` command: 472 | ```shell 473 | > php vendor/bin/fregata migration:list 474 | 475 | Registered migrations: 2 476 | ======================== 477 | 478 | main_migration 479 | other_migration 480 | 481 | ``` 482 | 483 | ## Get details of a migration 484 | 485 | To get information about a single migration, run the `migration:show` command: 486 | ```shell 487 | > php vendor/bin/fregata migration:show main_migration 488 | 489 | main_migration : 1 migrators 490 | ============================ 491 | 492 | --- --------------------------------- 493 | # Migrator Name 494 | --- --------------------------------- 495 | 0 App\MainMigration\FirstMigrator 496 | --- --------------------------------- 497 | 498 | ``` 499 | 500 | ## Execute a migration 501 | 502 | And the most important one to run a migration: `migration:execute`. 503 | ```shell 504 | > php vendor/bin/fregata migration:execute main_migration 505 | 506 | Confirm execution of the "main_migration" migration ? (yes/no) [no]: 507 | > yes 508 | 509 | 510 | [OK] Starting "main_migration" migration: 1 migrators 511 | 512 | 513 | Before tasks: 1 514 | =============== 515 | 516 | App\MainMigration\BeforeTask : OK 517 | 518 | Migrators: 1 519 | ============ 520 | 521 | 0 - Executing "App\MainMigration\FirstMigrator" [3 items] : 522 | =========================================================== 523 | 524 | 3/3 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100% 525 | 526 | 527 | After tasks: 1 528 | ============== 529 | 530 | App\MainMigration\AfterTask : OK 531 | 532 | 533 | [OK] Migrated successfully ! 534 | 535 | ``` 536 | 537 | # Contributing 538 | 539 | A **Docker** setup is available, providing a **MySQL 5.7** service. 540 | 541 | If you want to test the implementation of the framework (using a **Composer** 542 | [path repository](https://getcomposer.org/doc/05-repositories.md#path)), install it in a `_implementation` 543 | directory at the root of the project, it is ignored by Git by default and will ensure you are using your 544 | implementation autoloader. 545 | --------------------------------------------------------------------------------