├── 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 | 
4 | [](//packagist.org/packages/aymdev/fregata)
5 | [](//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 |
--------------------------------------------------------------------------------