├── .github └── workflows │ └── main.yaml ├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src ├── Command │ └── DebugConfigurationCommand.php ├── Data │ └── DebugCommandHeader.php ├── DependencyInjection │ ├── Configuration.php │ └── SafeMigrationsExtension.php ├── Event │ ├── UnsafeMigration.php │ └── UnsafeMigrationEvent.php ├── Listener │ └── DoctrineMigrationDiffListener.php ├── MigrationFileSystem.php ├── Resources │ └── config │ │ └── services.php ├── SafeMigrationsBundle.php ├── Statement │ ├── AbstractStatement.php │ ├── CreateIndexStatement.php │ ├── DropStatement.php │ ├── ModifyStatement.php │ ├── NotNullStatement.php │ ├── RenameStatement.php │ ├── StatementInterface.php │ └── TruncateStatement.php └── Warning │ ├── Warning.php │ ├── WarningFactory.php │ └── WarningFormatter.php ├── tests ├── App │ ├── AppKernel.php │ ├── migrations │ │ └── .gitkeep │ ├── packages │ │ └── config.yaml │ ├── resources │ │ ├── Version20230501CriticalTable.php │ │ ├── Version20230501CustomStatement.php │ │ ├── Version20230501WithAddColumnNotNull.php │ │ ├── Version20230501WithDrop.php │ │ ├── Version20230501WithModify.php │ │ └── Version20230501WithRenameColumn.php │ └── src │ │ ├── Command │ │ ├── FakeDoctrineMigrationsDiffCommand.php │ │ └── FakeMakeMigrationCommand.php │ │ ├── EventListener │ │ └── UnsafeMigrationListener.php │ │ └── Statement │ │ └── CustomStatement.php ├── Func │ ├── Command │ │ ├── DebugConfigurationCommandTest.php │ │ ├── FakeDoctrineMigrationsDiffCommandTest.php │ │ └── FakeMakeMigrationCommandTest.php │ └── Listener │ │ └── DoctrineMigrationDiffListenerTest.php └── Unit │ ├── StatementTest.php │ ├── WarningFactoryTest.php │ └── WarningFormatterTest.php └── tools └── php-cs-fixer ├── .gitignore └── composer.json /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | php-cs-fixer: 9 | runs-on: ubuntu-latest 10 | name: Coding Standards 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup PHP 16 | uses: shivammathur/setup-php@v2 17 | with: 18 | php-version: '8.1' 19 | tools: php-cs-fixer 20 | 21 | - name: PHP Coding Standards Fixer 22 | run: php-cs-fixer --diff --dry-run --using-cache=no -v fix src 23 | 24 | php-stan: 25 | runs-on: ubuntu-latest 26 | name: PHPStan 27 | steps: 28 | - uses: actions/checkout@v3 29 | - uses: php-actions/composer@v6 # or alternative dependency management 30 | - uses: php-actions/phpstan@v3 31 | with: 32 | path: src/ 33 | level: 9 34 | 35 | phpunit: 36 | runs-on: ubuntu-latest 37 | strategy: 38 | matrix: 39 | php-versions: ['8.1'] 40 | fail-fast: false 41 | name: PHP ${{ matrix.php-versions }} tests 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v2 45 | 46 | - name: Setup PHP 47 | uses: shivammathur/setup-php@v2 48 | with: 49 | php-version: ${{ matrix.php-versions }} 50 | 51 | - name: Get composer cache directory 52 | id: composercache 53 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 54 | 55 | - name: Cache dependencies 56 | uses: actions/cache@v2 57 | with: 58 | path: ${{ steps.composercache.outputs.dir }} 59 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 60 | restore-keys: ${{ runner.os }}-composer- 61 | 62 | - name: Install dependencies 63 | run: composer install --prefer-dist 64 | 65 | - name: Run tests 66 | run: vendor/bin/phpunit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | 3 | .php-cs-fixer.cache 4 | .phpunit.result.cache 5 | 6 | var/ 7 | vendor/ 8 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in(__DIR__.'/{src,tests}') 5 | ; 6 | 7 | $config = new PhpCsFixer\Config(); 8 | 9 | return $config 10 | ->setRules([ 11 | '@Symfony' => true, 12 | 'concat_space' => ['spacing' => 'none'], 13 | 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'], 14 | 'array_syntax' => ['syntax' => 'short'], 15 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 16 | ]) 17 | ->setRiskyAllowed(true) 18 | ->setLineEnding("\n") 19 | ->setFinder($finder) 20 | ->setCacheFile(__DIR__.'/.php-cs-fixer.cache') 21 | ; 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | * Add compatibility with Symfony 5.4 -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 17 | ## Code of Conduct 18 | 19 | ### Our Pledge 20 | 21 | In the interest of fostering an open and welcoming environment, we as 22 | contributors and maintainers pledge to making participation in our project and 23 | our community a harassment-free experience for everyone, regardless of age, body 24 | size, disability, ethnicity, gender identity and expression, level of experience, 25 | nationality, personal appearance, race, religion, or sexual identity and 26 | orientation. 27 | 28 | ### Our Standards 29 | 30 | Examples of behavior that contributes to creating a positive environment 31 | include: 32 | 33 | * Using welcoming and inclusive language 34 | * Being respectful of differing viewpoints and experiences 35 | * Gracefully accepting constructive criticism 36 | * Focusing on what is best for the community 37 | * Showing empathy towards other community members 38 | 39 | Examples of unacceptable behavior by participants include: 40 | 41 | * The use of sexualized language or imagery and unwelcome sexual attention or 42 | advances 43 | * Trolling, insulting/derogatory comments, and personal or political attacks 44 | * Public or private harassment 45 | * Publishing others' private information, such as a physical or electronic 46 | address, without explicit permission 47 | * Other conduct which could reasonably be considered inappropriate in a 48 | professional setting 49 | 50 | ### Our Responsibilities 51 | 52 | Project maintainers are responsible for clarifying the standards of acceptable 53 | behavior and are expected to take appropriate and fair corrective action in 54 | response to any instances of unacceptable behavior. 55 | 56 | Project maintainers have the right and responsibility to remove, edit, or 57 | reject comments, commits, code, wiki edits, issues, and other contributions 58 | that are not aligned to this Code of Conduct, or to ban temporarily or 59 | permanently any contributor for other behaviors that they deem inappropriate, 60 | threatening, offensive, or harmful. 61 | 62 | ### Scope 63 | 64 | This Code of Conduct applies both within project spaces and in public spaces 65 | when an individual is representing the project or its community. Examples of 66 | representing a project or community include using an official project e-mail 67 | address, posting via an official social media account, or acting as an appointed 68 | representative at an online or offline event. Representation of a project may be 69 | further defined and clarified by project maintainers. 70 | 71 | ### Enforcement 72 | 73 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 74 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 75 | complaints will be reviewed and investigated and will result in a response that 76 | is deemed necessary and appropriate to the circumstances. The project team is 77 | obligated to maintain confidentiality with regard to the reporter of an incident. 78 | Further details of specific enforcement policies may be posted separately. 79 | 80 | Project maintainers who do not follow or enforce the Code of Conduct in good 81 | faith may face temporary or permanent repercussions as determined by other 82 | members of the project's leadership. 83 | 84 | ### Attribution 85 | 86 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 87 | available at [http://contributor-covenant.org/version/1/4][version] 88 | 89 | [homepage]: http://contributor-covenant.org 90 | [version]: http://contributor-covenant.org/version/1/4/ 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Smaine Milianni 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: ## Show this message 2 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 3 | 4 | phpcs: ## Run PHP CS Fixer 5 | ./tools/php-cs-fixer/vendor/bin/php-cs-fixer fix 6 | 7 | test: ## Run code tests 8 | ./vendor/bin/phpunit --testdox 9 | 10 | phpstan: 11 | ./vendor/bin/phpstan analyse src --level=9 12 | 13 | test-phpcs: ## Run coding standard tests 14 | ./tools/php-cs-fixer/vendor/bin/php-cs-fixer --diff --dry-run --using-cache=no -v fix 15 | 16 | all: ## Run all DX tools 17 | all: phpcs phpstan test 18 | 19 | .PHONY: test test-phpcs phpstan 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Safe Migrations Bundle 2 | 3 | ---------------- 4 | 5 | ### ⚠️ Warn you when your **auto generated** doctrine migrations contains unsafe SQL statements. 6 | 7 | An unsafe migration is: 8 | - An operation that have to be done carefully if you are doing zero downtime deployments. 9 | - An operation on a critical table defined by yourself. 10 | - An operation that can lock table such like `NOT NULL CONSTRAINT` or loss data such like remove or truncate. 11 | - An operation that can be dangerous such like `DROP` or `RENAME`. 12 | - An operation defined by yourself. 13 | 14 | When an unsafe migration is detected, a warning is displayed in the command `doctrine:migrations:diff` and a comment is added into the migration file. 15 | 16 | ![image](https://user-images.githubusercontent.com/13260307/237054338-1b1412e3-6f24-4e05-b929-15c30ab0a736.png) 17 | 18 | ![image](https://user-images.githubusercontent.com/13260307/237054375-44fe19b1-9915-4841-b366-9ac83d76e360.png) 19 | 20 | 21 | ### Unsafe statements list 22 | 23 | - CREATE INDEX 24 | - DROP 25 | - MODIFY 26 | - NOT NULL 27 | - RENAME 28 | - TRUNCATE 29 | 30 | Any of these statement present in your last migration will trigger a warning, feel free to submit a PR to add more statements. 31 | 32 | ### Features 33 | 34 | - [You can exclude a statement](#exclude-a-statement) 35 | - [You can add your own statements](#create-your-own-statement) 36 | - [You can flag a table as critical to be warned when a migration contains changes on these tables](#configure-critical-tables) 37 | - [You decorate a statement to personalize the warning message](#decorate-a-statement) 38 | 39 | ## Getting started 40 | ### Installation 41 | You can easily install Safe Migrations Bundle by composer 42 | ``` 43 | $ composer require eniams/safe-migrations --dev 44 | ``` 45 | Then, bundle should be registered. Just verify that `config\bundles.php` is containing : 46 | ```php 47 | Eniams\SafeMigrationsBundle\SafeMigrationsBundle::class => ['dev' => true], 48 | ``` 49 | 50 | ### Configuration 51 | Then, you should register it in the configuration (`config/packages/dev/safe_migrations.yaml`) : 52 | ```yaml 53 | # config/packages/safe-migrations.yaml 54 | safe_migrations: 55 | # required 56 | migrations_path: '%kernel.project_dir%/migrations' 57 | # optional 58 | critical_tables: # List of critical tables 59 | - 'user' 60 | - 'product' 61 | - # ... 62 | # optional 63 | excluded_statements: # List of operations that not need a warning 64 | - 'TRUNCATE' 65 | - # ... 66 | ``` 67 | 68 | ##### Exclude a statement 69 | If you want to exclude a statement, you can do it by adding it in the configuration file. 70 | 71 | ```yaml 72 | # config/packages/safe-migrations.yaml 73 | safe_migrations: 74 | excluded_statements: # List of operations that not need a warning 75 | - 'TRUNCATE' # The statement TRUNCATE will not be flagged as unsafe 76 | - # ... 77 | ``` 78 | 79 | 80 | ##### Create your own statement 81 | If you want to create a custom statement, you can do it by adding a new class that implements `Eniams\SafeMigrationsBundle\Statement\StatementInterface`. 82 | 83 | ###### Here is an example 84 | ```yaml 85 | # config/services.yaml 86 | services: 87 | _defaults: 88 | autoconfigure: true 89 | ``` 90 | 91 | ```php 92 | migrationWarning; 104 | } 105 | 106 | public function supports(string $migrationUpContent): bool 107 | { 108 | // The logic to determine if the statement is present in the `up` method of migration file. 109 | // The following code can be enough 110 | return str_contains(strtoupper($statement), $this->getStatement()); 111 | } 112 | 113 | public function getStatement(): string; 114 | { 115 | return 'MY_STATEMENT'; 116 | } 117 | } 118 | ``` 119 | ##### Configure critical tables 120 | If you want to flag a table as critical and be warned when a migration contains changes on it, just flag the tables like this: 121 | 122 | ```yaml 123 | # config/packages/safe-migrations.yaml 124 | safe_migrations: 125 | critical_tables: # List of critical tables 126 | - 'user' 127 | - 'product' 128 | - # ... 129 | ``` 130 | 131 | ##### Decorate a statement 132 | If you want to wrap a statement to personalize the warning message or the logic to catch the statement you can use the [decorator design pattern](https://en.wikipedia.org/wiki/Decorator_pattern#PHP). 133 | 134 | See the example bellow, you can also check [how to decorate a service with Symfony](https://symfony.com/doc/current/service_container/service_decoration.html). 135 | 136 | ```php 137 | 'onUnsafeMigration', 181 | ]; 182 | } 183 | 184 | public function onUnsafeMigration(UnsafeMigrationEvent $event) 185 | { 186 | $unsafeMigration = $event->getUnsafeMigration(); 187 | 188 | // Version20231030215756 189 | $unsafeMigration->getMigrationName(); 190 | 191 | // Migration file 192 | $unsafeMigration->getMigrationFileContent(); 193 | 194 | // Migration file with the warning. 195 | $unsafeMigration->getMigrationFileContentWithWarning(); 196 | } 197 | } 198 | ``` 199 | 200 | ##### Debug the configuration 201 | 202 | You can debug the configuration you set with the following command: 203 | `$ bin/console eniams:debug-configuration` 204 | 205 | ## Contributing 206 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 207 | 208 | After writing your fix/feature, you can run following commands to make sure that everything is still ok. 209 | 210 | ```bash 211 | # Install dev dependencies 212 | $ composer install 213 | 214 | # Running tests and quality tools locally 215 | $ make all 216 | ``` 217 | 218 | ## Authors 219 | - Smaïne Milianni - [ismail1432](https://github.com/ismail1432) - 220 | - Quentin Dequippe - [qdequippe](https://github.com/qdequippe) - 221 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "project", 3 | "name": "eniams/safe-migrations", 4 | "description": "Warn you when a migration is unsafe", 5 | "license": "proprietary", 6 | "minimum-stability": "stable", 7 | "prefer-stable": true, 8 | "require": { 9 | "php": "^8.1.0", 10 | "symfony/console": "^5.4|^6.0", 11 | "symfony/http-kernel": "^5.4|^6.0", 12 | "symfony/config": "^5.4|^6.0", 13 | "symfony/dependency-injection": "^5.4|^6.0", 14 | "symfony/finder": "^5.4|^6.0" 15 | }, 16 | "require-dev": { 17 | "phpstan/phpstan": "^1.10", 18 | "phpunit/phpunit": "^9.5", 19 | "symfony/framework-bundle": "^5.4", 20 | "symfony/browser-kit": "^5.4", 21 | "symfony/yaml": "^5.4" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Eniams\\SafeMigrationsBundle\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "Eniams\\SafeMigrationsBundle\\Tests\\": "tests/" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | tests 20 | 21 | 22 | 23 | 24 | 25 | src 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Command/DebugConfigurationCommand.php: -------------------------------------------------------------------------------- 1 | $excludedStatements 18 | */ 19 | public function __construct(private readonly iterable $statements, private readonly array $criticalTables = [], private readonly array $excludedStatements = []) 20 | { 21 | parent::__construct(); 22 | } 23 | 24 | protected function configure() 25 | { 26 | $this->setName('eniams:debug-configuration') 27 | ->setDescription('A command to debug the configuration of the Safe Migrations Bundle') 28 | ; 29 | } 30 | 31 | protected function execute(InputInterface $input, OutputInterface $output) 32 | { 33 | $this->createStatementDetails($output); 34 | $this->createExcludedStatements($output); 35 | $this->createCriticalTable($output); 36 | 37 | return Command::SUCCESS; 38 | } 39 | 40 | /* Statements and Message details 41 | +------------------------------------------------------------+----------------+ 42 | | Class | Statement | 43 | +------------------------------------------------------------+----------------+ 44 | | Eniams\SafeMigrationsBundle\Statement\DropStatement | DROP | 45 | | Eniams\SafeMigrationsBundle\Statement\CreateIndexStatement | CREATE INDEX | 46 | +------------------------------------------------------------+----------------+ 47 | Message details 48 | +----------------+----------------------------------------------+ 49 | | Statement | Message | 50 | +----------------+----------------------------------------------+ 51 | | DROP | The migration contains a DROP statement... | 52 | +----------------+----------------------------------------------+ 53 | */ 54 | private function createStatementDetails(OutputInterface $output): void 55 | { 56 | $statementsRows = []; 57 | $statementsMessageRows = []; 58 | foreach ($this->statements as $statement) { 59 | if (false === in_array($statement->getStatement(), $this->excludedStatements)) { 60 | $statementsRows[] = [ 61 | get_class($statement), 62 | $statement->getStatement(), 63 | ]; 64 | 65 | $statementsMessageRows[] = [ 66 | $statement->getStatement(), 67 | $statement->migrationWarning(), 68 | ]; 69 | } 70 | } 71 | 72 | if ([] !== $statementsRows) { 73 | $statementTable = new Table($output); 74 | $statementTable 75 | ->setHeaders([ 76 | DebugCommandHeader::Fqcn->value, 77 | DebugCommandHeader::Statement->value, 78 | ]) 79 | ; 80 | 81 | $statementMessageTable = new Table($output); 82 | $statementMessageTable 83 | ->setHeaders([ 84 | DebugCommandHeader::Statement->value, 85 | DebugCommandHeader::Message->value, 86 | ]) 87 | ; 88 | 89 | $statementTable->setRows($statementsRows); 90 | $statementMessageTable->setRows($statementsMessageRows); 91 | 92 | $output->writeln('Statement that emits a warning'); 93 | $statementTable->render(); 94 | 95 | $output->writeln('Message details'); 96 | $statementMessageTable->render(); 97 | 98 | return; 99 | } 100 | 101 | $output->writeln('No statement configured'); 102 | } 103 | 104 | /* 105 | +--------------------+ 106 | | Excluded Statement | 107 | +--------------------+ 108 | | TRUNCATE TABLE | 109 | +--------------------+ 110 | */ 111 | private function createExcludedStatements(OutputInterface $output): void 112 | { 113 | $excludedStatementsRows = []; 114 | foreach ($this->excludedStatements as $excludedStatement) { 115 | $excludedStatementsRows[] = [ 116 | $excludedStatement, 117 | ]; 118 | } 119 | 120 | if ([] !== $excludedStatementsRows) { 121 | $excludedStatementTable = new Table($output); 122 | $excludedStatementTable 123 | ->setHeaders([ 124 | 'Excluded Statement', 125 | ]) 126 | ; 127 | $excludedStatementTable->setRows($excludedStatementsRows); 128 | $excludedStatementTable->render(); 129 | 130 | return; 131 | } 132 | $output->writeln('No statement excluded'); 133 | } 134 | 135 | /* 136 | +-----------------+ 137 | | Critical Tables | 138 | +-----------------+ 139 | | user | 140 | +-----------------+ 141 | */ 142 | private function createCriticalTable(OutputInterface $output): void 143 | { 144 | $criticalTablesRows = []; 145 | foreach ($this->criticalTables as $criticalTable) { 146 | $criticalTablesRows[] = [ 147 | $criticalTable, 148 | ]; 149 | } 150 | if ([] !== $criticalTablesRows) { 151 | $criticalsTableTable = new Table($output); 152 | $criticalsTableTable 153 | ->setHeaders([ 154 | 'Critical Tables', 155 | ]) 156 | ; 157 | $criticalsTableTable->setRows($criticalTablesRows); 158 | $criticalsTableTable->render(); 159 | 160 | return; 161 | } 162 | $output->writeln('No critical tables configured'); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/Data/DebugCommandHeader.php: -------------------------------------------------------------------------------- 1 | 14 | * @author Quentin Dequippe 15 | */ 16 | class Configuration implements ConfigurationInterface 17 | { 18 | public function getConfigTreeBuilder(): TreeBuilder 19 | { 20 | $treeBuilder = new TreeBuilder('safe_migrations'); 21 | 22 | /* @phpstan-ignore-next-line */ 23 | $treeBuilder->getRootNode() 24 | ->children() 25 | ->arrayNode('critical_tables')->canBeUnset() 26 | ->scalarPrototype()->end() 27 | ->end() 28 | ->arrayNode('excluded_statements')->canBeUnset() 29 | ->scalarPrototype()->end() 30 | ->end() 31 | ->scalarNode('migrations_path')->isRequired()->end() 32 | ->end() 33 | ; 34 | 35 | return $treeBuilder; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/DependencyInjection/SafeMigrationsExtension.php: -------------------------------------------------------------------------------- 1 | 24 | * @author Quentin Dequippe 25 | */ 26 | class SafeMigrationsExtension extends ConfigurableExtension 27 | { 28 | /** @phpstan-ignore-next-line */ 29 | public function loadInternal(array $mergedConfig, ContainerBuilder $container): void 30 | { 31 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 32 | $loader->load('services.php'); 33 | 34 | $migrationsPath = $mergedConfig['migrations_path']; 35 | $criticalTables = $mergedConfig['critical_tables']; 36 | $excludedStatements = $mergedConfig['excluded_statements']; 37 | 38 | if (!is_dir($migrationsPath)) { 39 | throw new \InvalidArgumentException(sprintf('You must provide a valid path to your migrations directory. "%s" given.', $migrationsPath)); 40 | } 41 | 42 | $container->setDefinition('eniams.safe_migrations.file_system', new Definition(MigrationFileSystem::class)) 43 | ->setArgument(0, $migrationsPath) 44 | ; 45 | 46 | $container->registerForAutoconfiguration(StatementInterface::class)->addTag('eniams.safe_migrations.statement'); 47 | $excludedServiceStatements = $this->getExcludedStatements($container, $excludedStatements); 48 | 49 | $container->setDefinition('eniams.safe_migrations.warning_factory', new Definition(WarningFactory::class)) 50 | ->setArguments([ 51 | tagged_iterator('eniams.safe_migrations.statement'), 52 | $criticalTables, 53 | $excludedServiceStatements, 54 | ]) 55 | ; 56 | 57 | $container->setDefinition('eniams.safe_migrations.debug_command', new Definition(DebugConfigurationCommand::class)) 58 | ->setArguments([ 59 | tagged_iterator('eniams.safe_migrations.statement'), 60 | $criticalTables, 61 | $excludedServiceStatements, 62 | ]) 63 | ->addTag('console.command') 64 | ; 65 | } 66 | 67 | /** 68 | * @param array $excludedStatements 69 | * 70 | * @return array 71 | */ 72 | private function getExcludedStatements(ContainerBuilder $containerBuilder, array $excludedStatements): array 73 | { 74 | $excludedServiceStatements = []; 75 | foreach ($containerBuilder->findTaggedServiceIds('eniams.safe_migrations.statement') as $id => $tags) { 76 | $statement = new ($containerBuilder->getDefinition($id)->getClass()); 77 | if (in_array($statement->getStatement(), $excludedStatements, true)) { 78 | $excludedServiceStatements[] = $statement->getStatement(); 79 | } 80 | } 81 | 82 | return $excludedServiceStatements; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Event/UnsafeMigration.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | final class UnsafeMigration 9 | { 10 | public function __construct(private readonly string $migrationName, private readonly string $migrationFileContent, private readonly string $migrationFileContentWithWarning) 11 | { 12 | } 13 | 14 | public function getMigrationFileContent(): string 15 | { 16 | return $this->migrationFileContent; 17 | } 18 | 19 | public function getMigrationName(): string 20 | { 21 | return $this->migrationName; 22 | } 23 | 24 | public function getMigrationFileContentWithWarning(): string 25 | { 26 | return $this->migrationFileContentWithWarning; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Event/UnsafeMigrationEvent.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class UnsafeMigrationEvent 9 | { 10 | public function __construct( 11 | protected UnsafeMigration $unsafeMigration, 12 | ) { 13 | } 14 | 15 | public function getUnsafeMigration(): UnsafeMigration 16 | { 17 | return $this->unsafeMigration; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Listener/DoctrineMigrationDiffListener.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | final class DoctrineMigrationDiffListener implements EventSubscriberInterface 22 | { 23 | private const UP_LINE = 'public function up(Schema $schema)'; 24 | private const MIGRATION_COMMANDS = [ 25 | 'doctrine:migrations:diff', 26 | 'make:migration', 27 | ]; 28 | private WarningFormatter $warningFormatter; 29 | 30 | public function __construct( 31 | private readonly WarningFactory $warningFactory, 32 | private readonly MigrationFileSystem $fileSystem, 33 | private readonly EventDispatcherInterface $dispatcher 34 | ) { 35 | $this->warningFormatter = new WarningFormatter(); 36 | } 37 | 38 | public function onConsoleTerminate(ConsoleTerminateEvent $event): void 39 | { 40 | if (null !== $event->getCommand() && !$this->supports($event)) { 41 | return; 42 | } 43 | 44 | $io = new SymfonyStyle($event->getInput(), $event->getOutput()); 45 | 46 | if (null === $this->fileSystem->newestMigrationFileName()) { 47 | $io->info('No migration file found, skipping seeking unsafe operations...'); 48 | 49 | return; 50 | } 51 | 52 | $newestMigrationFile = $this->fileSystem->newestFilePath(); 53 | 54 | if (false === $f = fopen($newestMigrationFile, 'rb+')) { 55 | throw new \RuntimeException(sprintf('Unable to open file %s', $newestMigrationFile)); 56 | } 57 | 58 | $migrationFileContent = file_get_contents($newestMigrationFile); 59 | if (false === $migrationFileContent) { 60 | throw new \RuntimeException(sprintf('Unable to read file %s', $newestMigrationFile)); 61 | } 62 | 63 | $migration = $this->fileSystem->extractMigration($migrationFileContent); 64 | 65 | $warning = $this->warningFactory->createWarning($migration); 66 | 67 | // No critical changes found, exit. 68 | if ('' === $migrationWarning = $warning->migrationWarning()) { 69 | return; 70 | } 71 | 72 | $migrationFileContentWithWarning = ''; 73 | while (false !== $buffer = fgets($f)) { 74 | if (str_contains($buffer, self::UP_LINE)) { 75 | $position = ftell($f); 76 | $migrationFileContentWithWarning = substr_replace($migrationFileContent, $migrationWarning, $position + 6, 0); 77 | file_put_contents($newestMigrationFile, $migrationFileContentWithWarning); 78 | break; 79 | } 80 | } 81 | fclose($f); 82 | $migrationName = $this->fileSystem->migrationName(); 83 | 84 | if ('' !== $migrationFileContentWithWarning) { 85 | $this->dispatcher->dispatch(new UnsafeMigrationEvent(new UnsafeMigration($migrationName, $migrationFileContent, $migrationFileContentWithWarning)), UnsafeMigrationEvent::class); 86 | } 87 | 88 | $io->warning($this->warningFormatter->dangerousOperationMessage($migrationName)); 89 | $io->warning($warning->commandOutputWarning()); 90 | } 91 | 92 | private function supports(ConsoleTerminateEvent $event): bool 93 | { 94 | return null !== $event->getCommand() && in_array($event->getCommand()->getName(), self::MIGRATION_COMMANDS) && 0 === $event->getExitCode(); 95 | } 96 | 97 | public static function getSubscribedEvents(): array 98 | { 99 | return [ 100 | ConsoleEvents::TERMINATE => 'onConsoleTerminate', 101 | ]; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/MigrationFileSystem.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class MigrationFileSystem 14 | { 15 | private ?string $newestMigrationFileName; 16 | 17 | public function __construct(private readonly string $doctrineMigrationsDir) 18 | { 19 | } 20 | 21 | public function newestMigrationFileName(): ?string 22 | { 23 | $finder = new Finder(); 24 | $finder->in($this->doctrineMigrationsDir)->files()->name('*.php')->sortByName()->reverseSorting(); 25 | 26 | if (false === $finder->hasResults()) { 27 | return null; 28 | } 29 | 30 | /** @var SplFileInfo $lastFile */ 31 | $lastFile = $finder->getIterator()->current(); 32 | 33 | return $this->newestMigrationFileName = $lastFile->getRelativePathname(); 34 | } 35 | 36 | public function newestFilePath(): string 37 | { 38 | if (null === $this->newestMigrationFileName) { 39 | throw new \LogicException('newestMigrationFileName should be defined at this stage.'); 40 | } 41 | 42 | return sprintf('%s/%s', $this->doctrineMigrationsDir, $this->newestMigrationFileName); 43 | } 44 | 45 | public function migrationName(): string 46 | { 47 | if (null === $this->newestMigrationFileName) { 48 | throw new \LogicException('newestMigrationFileName should be defined at this stage.'); 49 | } 50 | 51 | return str_replace('.php', '', $this->newestMigrationFileName); 52 | } 53 | 54 | public function extractMigration(string $migrationContent): string 55 | { 56 | $upStart = strpos($migrationContent, 'function up'); 57 | $upEnd = strpos($migrationContent, 'function down'); 58 | 59 | if (!is_int($upStart) || 0 === $upStart || $upEnd <= $upStart) { 60 | throw new \LogicException('no function up or function down were found the migration file'); 61 | } 62 | 63 | return substr($migrationContent, $upStart, $upEnd - $upStart); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Resources/config/services.php: -------------------------------------------------------------------------------- 1 | services() 16 | ->defaults() 17 | ->private() 18 | 19 | ->set('eniams_safe_migrations.drop.statement', DropStatement::class) 20 | ->tag('eniams.safe_migrations.statement') 21 | 22 | ->set('eniams_safe_migrations.create_index.statement', CreateIndexStatement::class) 23 | ->tag('eniams.safe_migrations.statement') 24 | 25 | ->set('eniams_safe_migrations.not_null.statement', NotNullStatement::class) 26 | ->tag('eniams.safe_migrations.statement') 27 | 28 | ->set('eniams_safe_migrations.rename.statement', RenameStatement::class) 29 | ->tag('eniams.safe_migrations.statement') 30 | 31 | ->set('eniams_safe_migrations.truncate.statement', TruncateStatement::class) 32 | ->tag('eniams.safe_migrations.statement') 33 | 34 | ->set('eniams_safe_migrations.modify.statement', ModifyStatement::class) 35 | ->tag('eniams.safe_migrations.statement') 36 | 37 | ->set('safe_migrations.doctrine_migration_diff_listener', DoctrineMigrationDiffListener::class) 38 | ->tag('kernel.event_subscriber') 39 | ->args([ 40 | service('eniams.safe_migrations.warning_factory'), 41 | service('eniams.safe_migrations.file_system'), 42 | service('event_dispatcher'), 43 | ]) 44 | ; 45 | }; 46 | -------------------------------------------------------------------------------- /src/SafeMigrationsBundle.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | abstract class AbstractStatement implements StatementInterface 9 | { 10 | protected string $migrationWarning; 11 | 12 | public function __construct() 13 | { 14 | $this->migrationWarning = $this->migrationWarning ?? sprintf("The migration contains a %s statement, it's unsafe as it should be compliant with Zero downtime deployment", $this->getStatement()); 15 | } 16 | 17 | public function migrationWarning(): string 18 | { 19 | return $this->migrationWarning; 20 | } 21 | 22 | public function supports(string $migration): bool 23 | { 24 | // Avoid to search statement in migration comment 25 | // @TODO improve this. 26 | $migration = str_replace('// this up() migration is auto-generated, please modify it to your needs', '', $migration); 27 | 28 | return str_contains(strtoupper($migration), $this->getStatement()); 29 | } 30 | 31 | abstract public function getStatement(): string; 32 | } 33 | -------------------------------------------------------------------------------- /src/Statement/CreateIndexStatement.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class CreateIndexStatement extends AbstractStatement 11 | { 12 | private const STATEMENT = 'CREATE INDEX'; 13 | protected string $migrationWarning = "The migration contains a CREATE INDEX statement, it's unsafe on heavy table did you add the CONCURRENTLY option?"; 14 | 15 | public function getStatement(): string 16 | { 17 | return self::STATEMENT; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Statement/DropStatement.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class DropStatement extends AbstractStatement 11 | { 12 | private const STATEMENT = 'DROP'; 13 | protected string $migrationWarning = "The migration contains a DROP statement, it's unsafe as you may loss data and should be compliant with Zero downtime deployment"; 14 | 15 | public function getStatement(): string 16 | { 17 | return self::STATEMENT; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Statement/ModifyStatement.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class ModifyStatement extends AbstractStatement 11 | { 12 | private const STATEMENT = 'MODIFY'; 13 | 14 | public function getStatement(): string 15 | { 16 | return self::STATEMENT; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Statement/NotNullStatement.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class NotNullStatement extends AbstractStatement 11 | { 12 | private const STATEMENT = 'NOT NULL'; 13 | protected string $migrationWarning = "The migration contains a NOT NULL statement, it's unsafe on heavy table and should be compliant with Zero downtime deployment"; 14 | 15 | public function getStatement(): string 16 | { 17 | return self::STATEMENT; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Statement/RenameStatement.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class RenameStatement extends AbstractStatement 11 | { 12 | private const STATEMENT = 'RENAME'; 13 | protected string $migrationWarning; 14 | 15 | public function getStatement(): string 16 | { 17 | return self::STATEMENT; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Statement/StatementInterface.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | interface StatementInterface 9 | { 10 | public function getStatement(): string; 11 | 12 | public function supports(string $migration): bool; 13 | 14 | public function migrationWarning(): string; 15 | } 16 | -------------------------------------------------------------------------------- /src/Statement/TruncateStatement.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class TruncateStatement extends AbstractStatement 11 | { 12 | private const STATEMENT = 'TRUNCATE TABLE'; 13 | protected string $migrationWarning = "The migration contains a TRUNCATE statement, it's unsafe as you may loss data and should be compliant with Zero downtime deployment"; 14 | 15 | public function getStatement(): string 16 | { 17 | return self::STATEMENT; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Warning/Warning.php: -------------------------------------------------------------------------------- 1 | commandOutputWarning = $commandOutputWarning; 13 | $this->migrationWarning = $migrationWarning; 14 | } 15 | 16 | public function commandOutputWarning(): string 17 | { 18 | return $this->commandOutputWarning; 19 | } 20 | 21 | public function migrationWarning(): string 22 | { 23 | return $this->migrationWarning; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Warning/WarningFactory.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class WarningFactory 13 | { 14 | private WarningFormatter $warningFormatter; 15 | 16 | /** 17 | * @param StatementInterface[] $statements 18 | * @param string[] $criticalTables 19 | * @param array $excludedStatements 20 | */ 21 | public function __construct(private readonly iterable $statements, private readonly array $criticalTables = [], private readonly array $excludedStatements = []) 22 | { 23 | $this->warningFormatter = new WarningFormatter(); 24 | } 25 | 26 | public function createWarning(string $migration): Warning 27 | { 28 | $warning = $this->createWarningBasedOnCriticalTable($migration); 29 | // Early return when critical changes on tables is found. 30 | // No need to check for critical statements. 31 | if ('' !== $warning->migrationWarning()) { 32 | return $warning; 33 | } 34 | 35 | return $this->createWarningBasedOnStatements($migration); 36 | } 37 | 38 | private function createWarningBasedOnStatements(string $migration): Warning 39 | { 40 | $commandOutputWarning = $migrationWarning = ''; 41 | foreach ($this->statements as $statement) { 42 | if ($statement->supports($migration) && false === in_array($statement->getStatement(), $this->excludedStatements)) { 43 | $commandOutputWarning .= $this->warningFormatter->commandOutputWarning($statement->migrationWarning()); 44 | $migrationWarning .= $this->warningFormatter->migrationWarningLine($statement->migrationWarning()); 45 | } 46 | } 47 | 48 | return new Warning($commandOutputWarning, $migrationWarning); 49 | } 50 | 51 | private function createWarningBasedOnCriticalTable(string $migration): Warning 52 | { 53 | $commandOutputWarning = $migrationWarning = ''; 54 | foreach ($this->criticalTables as $table) { 55 | if (str_contains($migration, $table)) { 56 | $commandOutputWarning = $this->warningFormatter->messageWhenCriticalTableHasChanges(); 57 | $migrationWarning = $this->warningFormatter->migrationWarningWhenChangeOnCriticalTable($commandOutputWarning); 58 | 59 | break; 60 | } 61 | } 62 | 63 | return new Warning($commandOutputWarning, $migrationWarning); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Warning/WarningFormatter.php: -------------------------------------------------------------------------------- 1 | setParameter('kernel.project_dir', __DIR__); 27 | 28 | $loader->load(__DIR__.'/packages/config.yaml'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/App/migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ismail1432/safe-migrations-bundle/b92db12f20820ece0f8c491000188a02b87a9c3e/tests/App/migrations/.gitkeep -------------------------------------------------------------------------------- /tests/App/packages/config.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | secret: ThisIsSecret 3 | test: true 4 | 5 | services: 6 | _defaults: 7 | autowire: true 8 | autoconfigure: true 9 | public: true 10 | 11 | Eniams\SafeMigrationsBundle\Tests\App\src\: 12 | resource: '%kernel.project_dir%/src' 13 | 14 | safe_migrations: 15 | migrations_path: '%kernel.project_dir%/migrations' 16 | critical_tables: 17 | - 'my_critical_table' 18 | excluded_statements: 19 | - 'MODIFY' -------------------------------------------------------------------------------- /tests/App/resources/Version20230501CriticalTable.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE my_critical_table DROP dummy_column'); 16 | } 17 | 18 | public function down(): void 19 | { 20 | // this down() migration is auto-generated, please modify it to your needs 21 | $this->addSql('ALTER TABLE dummy DROP dummy_column'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/App/resources/Version20230501CustomStatement.php: -------------------------------------------------------------------------------- 1 | addSql('CUSTOM STATEMENT has to be check'); 16 | } 17 | 18 | public function down(): void 19 | { 20 | // this down() migration is auto-generated, please modify it to your needs 21 | $this->addSql('ALTER TABLE dummy DROP dummy_column'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/App/resources/Version20230501WithAddColumnNotNull.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE dummy ADD dummy_column VARCHAR(255) NOT NULL'); 14 | } 15 | 16 | public function down(): void 17 | { 18 | // this down() migration is auto-generated, please modify it to your needs 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/App/resources/Version20230501WithDrop.php: -------------------------------------------------------------------------------- 1 | addSql('DROP TABLE Shippers'); 16 | } 17 | 18 | public function down(): void 19 | { 20 | // this down() migration is auto-generated, please modify it to your needs 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/App/resources/Version20230501WithModify.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE "invoice" MODIFY "title" "New Data Type";'); 14 | } 15 | 16 | public function down(): void 17 | { 18 | // this down() migration is auto-generated, please modify it to your needs 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/App/resources/Version20230501WithRenameColumn.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE foo RENAME COLUMN foo_column to bar_column'); 16 | } 17 | 18 | public function down(): void 19 | { 20 | // this down() migration is auto-generated, please modify it to your needs 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/App/src/Command/FakeDoctrineMigrationsDiffCommand.php: -------------------------------------------------------------------------------- 1 | writeln('Fake doctrine migrations diff command'); 28 | $event = new ConsoleTerminateEvent($this, $input, $output, 0); 29 | $this->dispatcher->dispatch($event, ConsoleEvents::TERMINATE); 30 | 31 | return 0; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/App/src/Command/FakeMakeMigrationCommand.php: -------------------------------------------------------------------------------- 1 | writeln('Fake make migration command'); 28 | $event = new ConsoleTerminateEvent($this, $input, $output, 0); 29 | $this->dispatcher->dispatch($event, ConsoleEvents::TERMINATE); 30 | 31 | return 0; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/App/src/EventListener/UnsafeMigrationListener.php: -------------------------------------------------------------------------------- 1 | 'onUnsafeMigration', 19 | ]; 20 | } 21 | 22 | public function onUnsafeMigration(UnsafeMigrationEvent $event): void 23 | { 24 | self::$inMemoryEvents[] = $event; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/App/src/Statement/CustomStatement.php: -------------------------------------------------------------------------------- 1 | getCommandTester(); 20 | $commandTester->execute([]); 21 | 22 | $output = $this->getReadableOutput($commandTester); 23 | $this->assertStringContainsString('Statement that emits a warning', $output); 24 | $this->assertStringContainsString('Eniams\SafeMigrationsBundle\Tests\App\src\Statement\CustomStatement', $output); 25 | $this->assertStringContainsString('CUSTOM STATEMENT', $output); 26 | $this->assertStringContainsString('The migration contains a CUSTOM STATEMENT, double check the custom actions', $output); 27 | $this->assertStringContainsString('Excluded Statement', $output); 28 | $this->assertStringContainsString('MODIFY', $output); 29 | $this->assertStringContainsString('Critical Tables', $output); 30 | $this->assertStringContainsString('my_critical_table', $output); 31 | } 32 | 33 | private function getCommandTester(): CommandTester 34 | { 35 | $app = new Application(self::$kernel); 36 | 37 | return new CommandTester($app->find('eniams:debug-configuration')); 38 | } 39 | 40 | private function getReadableOutput(CommandTester $commandTester): string 41 | { 42 | return trim(preg_replace('/ +/', ' ', 43 | str_replace(PHP_EOL, '', $commandTester->getDisplay()) 44 | )); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Func/Command/FakeDoctrineMigrationsDiffCommandTest.php: -------------------------------------------------------------------------------- 1 | getCommandTester(); 20 | $commandTester->execute([]); 21 | 22 | $commandTester->assertCommandIsSuccessful('Fake doctrine migrations diff command'); 23 | } 24 | 25 | private function getCommandTester(): CommandTester 26 | { 27 | $app = new Application(self::$kernel); 28 | 29 | return new CommandTester($app->find('doctrine:migrations:diff')); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Func/Command/FakeMakeMigrationCommandTest.php: -------------------------------------------------------------------------------- 1 | getCommandTester(); 20 | $commandTester->execute([]); 21 | 22 | $commandTester->assertCommandIsSuccessful('Fake make migration command'); 23 | } 24 | 25 | private function getCommandTester(): CommandTester 26 | { 27 | $app = new Application(self::$kernel); 28 | 29 | return new CommandTester($app->find('make:migration')); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Func/Listener/DoctrineMigrationDiffListenerTest.php: -------------------------------------------------------------------------------- 1 | [ 35 | 'Version20230501CustomStatement.php', 36 | '// ⚠️ The migration contains a CUSTOM STATEMENT, double check the custom actions', 37 | ]; 38 | yield 'With not null' => [ 39 | 'Version20230501WithAddColumnNotNull.php', 40 | "// ⚠️ The migration contains a NOT NULL statement, it's unsafe on heavy table and should be compliant with Zero downtime deployment", 41 | ]; 42 | yield 'With drop' => [ 43 | 'Version20230501WithDrop.php', 44 | "// ⚠️ The migration contains a DROP statement, it's unsafe as you may loss data and should be compliant with Zero downtime deployment", 45 | ]; 46 | yield 'With rename' => [ 47 | 'Version20230501WithRenameColumn.php', 48 | "// ⚠️ The migration contains a RENAME statement, it's unsafe as it should be compliant with Zero downtime deployment", 49 | ]; 50 | yield 'With rename & make:migration command' => [ 51 | 'Version20230501WithRenameColumn.php', 52 | "// ⚠️ The migration contains a RENAME statement, it's unsafe as it should be compliant with Zero downtime deployment", 53 | 'make:migration', 54 | 'Fake make migration command', 55 | ]; 56 | } 57 | 58 | /** 59 | * @dataProvider provideMigrationFiles 60 | */ 61 | public function testItAddCommentAndDisplayWarningInCommandOutput( 62 | string $filename, 63 | string $warning, 64 | string $commandName = 'doctrine:migrations:diff', 65 | string $expectedOutputStart = 'Fake doctrine migrations diff command', 66 | ): void { 67 | $fileBaseDir = __DIR__.'/../../App'; 68 | $this->moveToMigrationDir($filename); 69 | $commandTester = $this->getCommandTester($commandName); 70 | 71 | $migrationFileContent = file_get_contents($fileBaseDir.'/migrations/'.$filename); 72 | $commandTester->execute([]); 73 | 74 | $this->assertStringContainsString($warning, $migrationFileContentWithWarning = file_get_contents($fileBaseDir.'/migrations/'.$filename), sprintf('Warning not found in: %s', $migrationFileContentWithWarning)); 75 | 76 | $this->assertStringStartsWith( 77 | sprintf('%s [WARNING] ⚠️ Dangerous operation detected in migration', $expectedOutputStart), 78 | $this->getReadableOutput($commandTester) 79 | ); 80 | 81 | $this->assertCount(1, UnsafeMigrationListener::$inMemoryEvents); 82 | $unsafeMigration = UnsafeMigrationListener::$inMemoryEvents[0]->getUnsafeMigration(); 83 | 84 | $this->assertEquals($unsafeMigration->getMigrationName(), substr($filename, 0, strrpos($filename, '.'))); 85 | $this->assertStringContainsString($unsafeMigration->getMigrationFileContentWithWarning(), $migrationFileContentWithWarning); 86 | $this->assertStringContainsString($unsafeMigration->getMigrationFileContent(), $migrationFileContent); 87 | } 88 | 89 | public function testItIgnoreExcludedOperations(): void 90 | { 91 | $fileBaseDir = __DIR__.'/../../App'; 92 | $this->moveToMigrationDir($filename = 'Version20230501WithModify.php'); 93 | $commandTester = $this->getCommandTester(); 94 | $commandTester->execute([]); 95 | 96 | $this->assertStringNotContainsString((new ModifyStatement())->migrationWarning(), $c = file_get_contents($fileBaseDir.'/migrations/'.$filename), sprintf('Warning not found in: %s', $c)); 97 | 98 | $this->assertEquals('Fake doctrine migrations diff command', $this->getReadableOutput($commandTester)); 99 | } 100 | 101 | public function testItWarningWhenMigrationContainsCriticalTable(): void 102 | { 103 | $fileBaseDir = __DIR__.'/../../App'; 104 | $this->moveToMigrationDir($filename = 'Version20230501CriticalTable.php'); 105 | $commandTester = $this->getCommandTester(); 106 | $commandTester->execute([]); 107 | 108 | $warning = 'The migration contains change(s) on a critical table(s) that can cause downtime, double check that changes are safe'; 109 | 110 | $this->assertStringContainsString($warning, $c = file_get_contents($fileBaseDir.'/migrations/'.$filename), sprintf('Warning not found in: %s', $c)); 111 | 112 | $this->assertStringStartsWith( 113 | sprintf('Fake doctrine migrations diff command [WARNING] ⚠️ Dangerous operation detected in migration'), 114 | $this->getReadableOutput($commandTester) 115 | ); 116 | } 117 | 118 | private function moveToMigrationDir(string $filename): void 119 | { 120 | $source = file_get_contents(sprintf('%s/%s', self::RESOURCES_DIR, $filename)); 121 | file_put_contents(self::MIGRATION_DIR.'/'.$filename, $source); 122 | } 123 | 124 | private function getCommandTester(string $commandName = 'doctrine:migrations:diff'): CommandTester 125 | { 126 | $app = new Application(self::$kernel); 127 | 128 | return new CommandTester($app->find($commandName)); 129 | } 130 | 131 | public function tearDown(): void 132 | { 133 | parent::tearDown(); 134 | $files = scandir(self::MIGRATION_DIR, \SCANDIR_SORT_DESCENDING); 135 | foreach ($files as $file) { 136 | if (str_ends_with($file, '.php')) { 137 | (new Filesystem())->remove(self::MIGRATION_DIR.'/'.$file); 138 | } 139 | } 140 | } 141 | 142 | private function getReadableOutput(CommandTester $commandTester): string 143 | { 144 | return trim(preg_replace('/ +/', ' ', 145 | str_replace(PHP_EOL, '', $commandTester->getDisplay()) 146 | )); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /tests/Unit/StatementTest.php: -------------------------------------------------------------------------------- 1 | [new DropStatement(), 'DROP', "The migration contains a DROP statement, it's unsafe as you may loss data and should be compliant with Zero downtime deployment"]; 19 | yield 'NotNull' => [new NotNullStatement(), 'NOT NULL', "The migration contains a NOT NULL statement, it's unsafe on heavy table and should be compliant with Zero downtime deployment"]; 20 | yield 'Rename' => [new RenameStatement(), 'RENAME', "The migration contains a RENAME statement, it's unsafe as it should be compliant with Zero downtime deployment"]; 21 | yield 'Truncate' => [new TruncateStatement(), 'TRUNCATE TABLE', "The migration contains a TRUNCATE statement, it's unsafe as you may loss data and should be compliant with Zero downtime deployment"]; 22 | yield 'Create index' => [new CreateIndexStatement(), 'CREATE INDEX', "The migration contains a CREATE INDEX statement, it's unsafe on heavy table did you add the CONCURRENTLY option?"]; 23 | yield 'Modify' => [new ModifyStatement(), 'MODIFY', "The migration contains a MODIFY statement, it's unsafe as it should be compliant with Zero downtime deployment"]; 24 | } 25 | 26 | /** 27 | * @dataProvider provideStatements 28 | */ 29 | public function testItReturnsTheStatement(AbstractStatement $statement, string $stringStatement, string $warning): void 30 | { 31 | $this->assertSame($stringStatement, $statement->getStatement()); 32 | } 33 | 34 | public function provideSupports(): iterable 35 | { 36 | yield 'Drop' => [new DropStatement(), 'DROP TABLE table_name)']; 37 | yield 'NotNull' => [new NotNullStatement(), 'ALTER TABLE BAR ADD COLUMN FOO VARCHAR(255) NOT NULL']; 38 | yield 'Rename' => [new RenameStatement(), 'ALTER TABLE FOO RENAME COLUMN BAR TO FOOBAR']; 39 | yield 'Truncate' => [new TruncateStatement(), 'TRUNCATE TABLE FOO']; 40 | yield 'Modify' => [new ModifyStatement(), 'ALTER TABLE table_name MODIFY COLUMN column_name datatype']; 41 | yield 'Create index' => [new CreateIndexStatement(), 'CREATE INDEX foo_idx ON foo (bar)']; 42 | } 43 | 44 | /** 45 | * @dataProvider provideSupports 46 | */ 47 | public function testSupports(AbstractStatement $statement, string $instruction): void 48 | { 49 | $this->assertTrue($statement->supports($instruction)); 50 | } 51 | 52 | public function provideUnSupports(): iterable 53 | { 54 | yield 'Drop' => [new DropStatement(), 'CREATE INDEX foo_idx ON foo (bar)']; 55 | yield 'NotNull' => [new NotNullStatement(), 'DROP TABLE table_name']; 56 | yield 'Rename' => [new RenameStatement(), 'ALTER TABLE BAR ADD COLUMN FOO VARCHAR(255) NOT NULL']; 57 | yield 'Truncate' => [new TruncateStatement(), 'ALTER TABLE FOO RENAME COLUMN BAR TO FOOBAR']; 58 | yield 'Modify' => [new ModifyStatement(), 'TRUNCATE TABLE FOO']; 59 | yield 'Create index' => [new CreateIndexStatement(), 'ALTER TABLE table_name MODIFY COLUMN column_name datatype']; 60 | } 61 | 62 | /** 63 | * @dataProvider provideUnSupports 64 | */ 65 | public function testItUnSupports(AbstractStatement $statement, string $instruction): void 66 | { 67 | $this->assertFalse($statement->supports($instruction)); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/Unit/WarningFactoryTest.php: -------------------------------------------------------------------------------- 1 | createWarning('ALTER TABLE user ADD COLUMN name VARCHAR(255) NOT NULL'); 19 | $this->assertEquals( 20 | " // ⚠️ ️The migration contains change(s) on a critical table(s) that can cause downtime, double check that changes are safe. \n", 21 | $warning->migrationWarning() 22 | ); 23 | $this->assertEquals( 24 | "️The migration contains change(s) on a critical table(s) that can cause downtime, double check that changes are safe. \n", 25 | $warning->commandOutputWarning() 26 | ); 27 | } 28 | 29 | public function testWarningFactoryCreateFromStatement(): void 30 | { 31 | $factory = new WarningFactory( 32 | [new NotNullStatement()], 33 | ['user', 'product'] 34 | ); 35 | 36 | $warning = $factory->createWarning('ALTER TABLE city ADD COLUMN name VARCHAR(255) NOT NULL'); 37 | $this->assertEquals( 38 | " // ⚠️ The migration contains a NOT NULL statement, it's unsafe on heavy table and should be compliant with Zero downtime deployment\n", 39 | $warning->migrationWarning() 40 | ); 41 | $this->assertEquals( 42 | trim("The migration contains a NOT NULL statement, it's unsafe on heavy table and should be compliant with Zero downtime deployment"), 43 | trim($warning->commandOutputWarning()) 44 | ); 45 | } 46 | 47 | public function testWarningIsEmptyWhenNoCriticalChangeAreFound(): void 48 | { 49 | $factory = new WarningFactory( 50 | [new NotNullStatement()], 51 | ['user', 'product'] 52 | ); 53 | 54 | $warning = $factory->createWarning('ALTER TABLE city ADD COLUMN name VARCHAR(255) DEFAULT NULL'); 55 | $this->assertEquals('', $warning->migrationWarning()); 56 | $this->assertEquals('', $warning->commandOutputWarning()); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Unit/WarningFormatterTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 17 | " // ⚠️ migration warning\n", 18 | $formatter->migrationWarningLine($warning->migrationWarning()) 19 | ); 20 | $this->assertEquals( 21 | " command output warning \n", 22 | $formatter->commandOutputWarning($warning->commandOutputWarning()) 23 | ); 24 | 25 | $this->assertEquals( 26 | "️The migration contains change(s) on a critical table(s) that can cause downtime, double check that changes are safe. \n", 27 | $formatter->messageWhenCriticalTableHasChanges() 28 | ); 29 | 30 | $this->assertEquals( 31 | '⚠️ Dangerous operation detected in migration "migrationName"!', 32 | $formatter->dangerousOperationMessage('migrationName') 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tools/php-cs-fixer/.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /tools/php-cs-fixer/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "friendsofphp/php-cs-fixer": "^3.16" 4 | } 5 | } 6 | --------------------------------------------------------------------------------