├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── _config.yml ├── bin └── phinx-migrations ├── composer.json └── src └── Migration ├── Adapter ├── Database │ ├── MySqlSchemaAdapter.php │ └── SchemaAdapterInterface.php └── Generator │ ├── PhinxMySqlColumnGenerator.php │ ├── PhinxMySqlColumnOptionGenerator.php │ ├── PhinxMySqlForeignKeyGenerator.php │ ├── PhinxMySqlGenerator.php │ ├── PhinxMySqlIndexGenerator.php │ ├── PhinxMySqlTableOptionGenerator.php │ └── RawPhpValue.php ├── Command └── GenerateCommand.php ├── Generator └── MigrationGenerator.php └── Utility └── ArrayUtil.php /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/odan/phinx-migrations-generator). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - Check the code style with ``$ composer check-style`` and fix it with ``$ composer fix-style``. 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | ## Running Tests 26 | 27 | ``` bash 28 | $ composer test 29 | ``` 30 | 31 | 32 | **Happy coding**! -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2023 odan 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 13 | > all 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 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phinx migrations generator 2 | 3 | Generates [Phinx](https://phinx.org/) migrations by comparing your current database with your schema information. 4 | 5 | [![Latest Version on Packagist](https://img.shields.io/github/release/odan/phinx-migrations-generator.svg)](https://packagist.org/packages/odan/phinx-migrations-generator) 6 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE.md) 7 | [![Build Status](https://github.com/odan/phinx-migrations-generator/workflows/build/badge.svg)](https://github.com/odan/phinx-migrations-generator/actions) 8 | [![Total Downloads](https://img.shields.io/packagist/dt/odan/phinx-migrations-generator.svg)](https://packagist.org/packages/odan/phinx-migrations-generator/stats) 9 | 10 | ## Requirements 11 | 12 | * PHP 8.1 - 8.4 13 | 14 | ## Features 15 | 16 | * Framework independent 17 | * DBMS: MySQL 5.7+, MySQL 8, MariaDB (partially supported) 18 | * Initial schema 19 | * Schema difference 20 | * Database: character set, collation 21 | * Tables: create, update, remove, engine, comment, character set, collation 22 | * Columns: create, update, remove 23 | * Indexes: create, remove 24 | * Foreign keys: create, remove, constraint name 25 | 26 | ## Install 27 | 28 | Via Composer 29 | 30 | ``` 31 | composer require odan/phinx-migrations-generator --dev 32 | ``` 33 | 34 | ## Usage 35 | 36 | ### Generating migrations 37 | 38 | The first run generates an initial schema and a migration class. 39 | The file `schema.php` contains the previous database schema and is compared with the current schema. 40 | Based on the difference, a Phinx migration class is generated. 41 | 42 | ``` 43 | vendor/bin/phinx-migrations generate 44 | ``` 45 | 46 | When the `generate` command is executed again, only the difference to the last schema is generated. 47 | 48 | ## Parameters 49 | 50 | Parameter | Values | Default | Description 51 | --- | --- | --- | --- 52 | --name | string | | The class name. 53 | --overwrite | bool | | Overwrite schema.php file. 54 | --path | string | (from phinx) | Specify the path in which to generate this migration. 55 | --environment or -e | string | (from phinx) | The target environment. 56 | --configuration or -c | string | (from phinx) | The configuration file e.g. `config/phinx.php` 57 | 58 | ### Running migrations 59 | 60 | The [Phinx migrate command](http://docs.phinx.org/en/latest/commands.html#the-migrate-command) 61 | runs all the available migrations. 62 | 63 | ``` 64 | vendor/bin/phinx migrate 65 | ``` 66 | 67 | ## Configuration 68 | 69 | The phinx-migrations-generator uses the configuration of phinx. 70 | 71 | ## Migration configuration 72 | 73 | Parameter | Values | Default | Description 74 | --- | --- | --- | --- 75 | foreign_keys | bool | false | Enable or disable foreign key migrations. 76 | default_migration_prefix | string | null | If specified, in the absence of the name parameter, the default migration name will be offered with this prefix and a random hash at the end. 77 | generate_migration_name | bool | false | If enabled, a random migration name will be generated. The user will not be prompted for a migration name anymore. The parameter `default_migration_prefix` must be specified. The `--name` parameter can overwrite this setting. 78 | mark_generated_migration | bool | true | Enable or disable marking the migration as applied after creation. 79 | migration_base_class | string | `\Phinx\Migration\AbstractMigration` | Sets up base class of created migration. 80 | schema_file | string | `%%PHINX_CONFIG_DIR%%/db/` `migrations/schema.php` | Specifies the location for saving the schema file. 81 | 82 | ### Example configuration 83 | 84 | Filename: `phinx.php` (in your project root directory) 85 | 86 | ```php 87 | PDO::ERRMODE_EXCEPTION, 97 | PDO::ATTR_PERSISTENT => false, 98 | PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci', 99 | ) 100 | ); 101 | 102 | return [ 103 | 'paths' => [ 104 | 'migrations' => __DIR__ . '/../resources/migrations', 105 | ], 106 | 'schema_file' => __DIR__ . '/../resources/schema/schema.php', 107 | 'foreign_keys' => false, 108 | 'default_migration_prefix' => '', 109 | 'mark_generated_migration' => true, 110 | 'environments' => [ 111 | 'default_environment' => 'local', 112 | 'local' => [ 113 | // Database name 114 | 'name' => $pdo->query('select database()')->fetchColumn(), 115 | 'connection' => $pdo, 116 | ] 117 | ] 118 | ]; 119 | ``` 120 | 121 | ## Testing 122 | 123 | ``` 124 | composer test 125 | ``` 126 | 127 | ## Contributing 128 | 129 | Please see [CONTRIBUTING](CONTRIBUTING.md) and [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) for details. 130 | 131 | ## License 132 | 133 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 134 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /bin/phinx-migrations: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | add(new GenerateCommand()); 20 | $application->run(); 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "odan/phinx-migrations-generator", 3 | "type": "library", 4 | "description": "Migration generator for Phinx", 5 | "keywords": [ 6 | "migration", 7 | "migrations", 8 | "generator", 9 | "phinx", 10 | "database", 11 | "mysql" 12 | ], 13 | "homepage": "https://github.com/odan/phinx-migrations-generator", 14 | "license": "MIT", 15 | "require": { 16 | "php": "8.1.* || 8.2.* || 8.3.* || 8.4.*", 17 | "ext-json": "*", 18 | "ext-pdo": "*", 19 | "riimu/kit-phpencoder": "^2.4", 20 | "robmorgan/phinx": "^0.15.2 || ^0.16" 21 | }, 22 | "require-dev": { 23 | "friendsofphp/php-cs-fixer": "^3", 24 | "phpstan/phpstan": "^1 || ^2", 25 | "phpunit/phpunit": "^10", 26 | "squizlabs/php_codesniffer": "^3", 27 | "symfony/uid": "^6 || ^7" 28 | }, 29 | "config": { 30 | "sort-packages": true 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "Odan\\Migration\\": "src/Migration/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "Odan\\Migration\\Test\\": "tests/" 40 | } 41 | }, 42 | "bin": [ 43 | "./bin/phinx-migrations" 44 | ], 45 | "scripts": { 46 | "cs:check": [ 47 | "@putenv PHP_CS_FIXER_IGNORE_ENV=1", 48 | "php-cs-fixer fix --dry-run --format=txt --verbose --diff --config=.cs.php --ansi" 49 | ], 50 | "cs:fix": [ 51 | "@putenv PHP_CS_FIXER_IGNORE_ENV=1", 52 | "php-cs-fixer fix --config=.cs.php --ansi --verbose" 53 | ], 54 | "sniffer:check": "phpcs --standard=phpcs.xml", 55 | "sniffer:fix": "phpcbf --standard=phpcs.xml", 56 | "stan": "phpstan analyse -c phpstan.neon --no-progress --ansi", 57 | "test": "phpunit --configuration phpunit.xml --do-not-cache-result --colors=always --display-warnings --display-deprecations --no-coverage", 58 | "test:all": [ 59 | "@cs:check", 60 | "@sniffer:check", 61 | "@stan", 62 | "@test" 63 | ], 64 | "test:coverage": [ 65 | "@putenv XDEBUG_MODE=coverage", 66 | "phpunit --configuration phpunit.xml --do-not-cache-result --colors=always --display-warnings --display-deprecations --coverage-clover build/coverage/clover.xml --coverage-html build/coverage --coverage-text" 67 | ] 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Migration/Adapter/Database/MySqlSchemaAdapter.php: -------------------------------------------------------------------------------- 1 | pdo = $pdo; 46 | $this->dbName = $this->getDbName(); 47 | $this->output = $output; 48 | $this->output->writeln(sprintf('Database: %s', $this->dbName)); 49 | } 50 | 51 | /** 52 | * Get current database name. 53 | * 54 | * @return string 55 | */ 56 | private function getDbName(): string 57 | { 58 | return (string)$this->createQueryStatement('select database()')->fetchColumn(); 59 | } 60 | 61 | /** 62 | * Create a new PDO statement. 63 | * 64 | * @param string $sql The sql 65 | * 66 | * @throws UnexpectedValueException 67 | * 68 | * @return PDOStatement The statement 69 | */ 70 | private function createQueryStatement(string $sql): PDOStatement 71 | { 72 | $statement = $this->pdo->query($sql); 73 | 74 | if (!$statement instanceof PDOStatement) { 75 | throw new UnexpectedValueException('Invalid statement'); 76 | } 77 | 78 | return $statement; 79 | } 80 | 81 | /** 82 | * Fetch all rows as array. 83 | * 84 | * @param string $sql The sql 85 | * 86 | * @return array The rows 87 | */ 88 | private function queryFetchAll(string $sql): array 89 | { 90 | $statement = $this->createQueryStatement($sql); 91 | 92 | return $statement->fetchAll(PDO::FETCH_ASSOC) ?: []; 93 | } 94 | 95 | /** 96 | * Load current database schema. 97 | * 98 | * @param array|null $tableNames 99 | * 100 | * @return array 101 | */ 102 | public function getSchema($tableNames = null): array 103 | { 104 | $this->output->writeln('Load current database schema.'); 105 | 106 | $result = []; 107 | 108 | $result['database'] = $this->getDatabaseSchemata($this->dbName); 109 | 110 | // processing by chunks for better speed when we have hundreds of tables 111 | $tables = $this->getTables($tableNames); 112 | 113 | $tableNameChunks = array_chunk(array_column($tables, 'table_name'), 300); 114 | 115 | foreach ($tableNameChunks as $tablesInChunk) { 116 | $columns = $this->getColumnHash($tablesInChunk); 117 | $indexes = $this->getIndexHash($tablesInChunk); 118 | $foreignKeys = $this->getForeignKeysHash($tablesInChunk); 119 | 120 | foreach ($tablesInChunk as $tableName) { 121 | $this->output->writeln( 122 | sprintf('Table: %s', $tableName), 123 | OutputInterface::VERBOSITY_VERBOSE 124 | ); 125 | $result['tables'][$tableName]['table'] = $tables[$tableName]; 126 | $result['tables'][$tableName]['columns'] = $columns[$tableName] ?? []; 127 | $result['tables'][$tableName]['indexes'] = $indexes[$tableName] ?? []; 128 | $result['tables'][$tableName]['foreign_keys'] = $foreignKeys[$tableName] ?? null; 129 | } 130 | } 131 | 132 | $array = new ArrayUtil(); 133 | $array->unsetArrayKeys($result, 'TABLE_SCHEMA'); 134 | 135 | return $result; 136 | } 137 | 138 | /** 139 | * Get database schemata. 140 | * 141 | * @param string $dbName 142 | * 143 | * @return array The schema 144 | */ 145 | private function getDatabaseSchemata(string $dbName): array 146 | { 147 | $sql = 'SELECT 148 | DEFAULT_CHARACTER_SET_NAME, 149 | DEFAULT_COLLATION_NAME 150 | FROM information_schema.SCHEMATA 151 | WHERE schema_name = %s;'; 152 | $sql = sprintf($sql, $this->quote($dbName)); 153 | 154 | return $this->queryFetch($sql); 155 | } 156 | 157 | /** 158 | * Quote value. 159 | * 160 | * @param string|null $value The value 161 | * 162 | * @return string The quotes string 163 | */ 164 | public function quote(?string $value): string 165 | { 166 | if ($value === null) { 167 | return 'NULL'; 168 | } 169 | 170 | return (string)$this->pdo->quote($value); 171 | } 172 | 173 | /** 174 | * Quote array of values. 175 | * 176 | * @param array $values The values 177 | * 178 | * @return string[] The quotes values 179 | */ 180 | public function quoteArray(array $values): array 181 | { 182 | return array_map(function ($value) { 183 | return $this->quote($value); 184 | }, $values); 185 | } 186 | 187 | /** 188 | * Get all tables. 189 | * 190 | * @param array|null $tableNames 191 | * 192 | * @return array 193 | */ 194 | private function getTables(?array $tableNames = null): array 195 | { 196 | $result = []; 197 | $sql = "SELECT * 198 | FROM 199 | information_schema.tables AS t, 200 | information_schema.collation_character_set_applicability AS ccsa 201 | WHERE 202 | ccsa.collation_name = t.table_collation 203 | AND t.table_schema=database() 204 | AND t.table_type = 'BASE TABLE'"; 205 | 206 | if ($tableNames !== null) { 207 | if (empty($tableNames)) { 208 | return []; 209 | } 210 | $quotedNames = $this->quoteArray($tableNames); 211 | $sql .= ' AND t.table_name in (' . implode(',', $quotedNames) . ')'; 212 | } 213 | 214 | $rows = $this->queryFetchAll($sql); 215 | 216 | foreach ($rows as $row) { 217 | $result[$row['TABLE_NAME']] = [ 218 | 'table_name' => $row['TABLE_NAME'], 219 | 'engine' => $row['ENGINE'], 220 | 'table_comment' => $row['TABLE_COMMENT'], 221 | 'table_collation' => $row['TABLE_COLLATION'], 222 | 'character_set_name' => $row['CHARACTER_SET_NAME'], 223 | 'row_format' => $row['ROW_FORMAT'], 224 | ]; 225 | } 226 | 227 | return $result; 228 | } 229 | 230 | /** 231 | * Get columns, grouped by table name. 232 | * 233 | * @param array $tableNames 234 | * 235 | * @return array 236 | */ 237 | private function getColumnHash(array $tableNames): array 238 | { 239 | if (empty($tableNames)) { 240 | return []; 241 | } 242 | 243 | $quotedNames = $this->quoteArray($tableNames); 244 | $sql = sprintf( 245 | 'SELECT * FROM information_schema.columns 246 | WHERE table_schema=database() 247 | AND table_name in (%s) 248 | ORDER BY ORDINAL_POSITION', 249 | implode(',', $quotedNames) 250 | ); 251 | 252 | $rows = $this->queryFetchAll($sql); 253 | 254 | $result = []; 255 | foreach ($rows as $row) { 256 | $tableName = $row['TABLE_NAME']; 257 | $columnName = $row['COLUMN_NAME']; 258 | $result[$tableName][$columnName] = $row; 259 | } 260 | 261 | return $result; 262 | } 263 | 264 | /** 265 | * Get indexes, grouped by table name. 266 | * 267 | * @param array $tableNames 268 | * 269 | * @return array 270 | */ 271 | private function getIndexHash(array $tableNames): array 272 | { 273 | if (empty($tableNames)) { 274 | return []; 275 | } 276 | 277 | $quotedNames = $this->quoteArray($tableNames); 278 | $sql = sprintf( 279 | "SELECT 280 | `TABLE_NAME` as 'Table', 281 | `NON_UNIQUE` as 'Non_unique', 282 | `INDEX_NAME` as 'Key_name', 283 | `SEQ_IN_INDEX` as 'Seq_in_index', 284 | `COLUMN_NAME` as 'Column_name', 285 | `COLLATION` as 'Collation', 286 | `SUB_PART` as 'Sub_part', 287 | `PACKED` as 'Packed', 288 | `NULLABLE` as 'Null', 289 | `INDEX_TYPE` as 'Index_type', 290 | `COMMENT` as 'Comment', 291 | `INDEX_COMMENT` as 'Index_comment' 292 | FROM information_schema.statistics 293 | WHERE table_schema=database() 294 | AND table_name in (%s)", 295 | implode(',', $quotedNames) 296 | ); 297 | 298 | $rows = $this->queryFetchAll($sql); 299 | $result = []; 300 | 301 | foreach ($rows as $row) { 302 | $tableName = $row['Table']; 303 | $name = $row['Key_name']; 304 | $seq = $row['Seq_in_index']; 305 | $result[$tableName][$name][$seq] = $row; 306 | } 307 | 308 | return $result; 309 | } 310 | 311 | /** 312 | * Escape identifier (column, table) with backtick. 313 | * 314 | * @see: http://dev.mysql.com/doc/refman/5.0/en/identifiers.html 315 | * 316 | * @param string $value The value 317 | * @param string $quote The quote character 318 | * 319 | * @return string identifier escaped string 320 | */ 321 | public function ident(string $value, string $quote = '`'): string 322 | { 323 | $value = preg_replace('/[^A-Za-z0-9_.]+/', '', $value); 324 | $value = is_string($value) ? $value : ''; 325 | 326 | if (strpos($value, '.') !== false) { 327 | $values = explode('.', $value); 328 | $value = $quote . implode($quote . '.' . $quote, $values) . $quote; 329 | } else { 330 | $value = $quote . $value . $quote; 331 | } 332 | 333 | return $value; 334 | } 335 | 336 | /** 337 | * Get foreign keys, grouped by table name. 338 | * 339 | * @param array $tableNames 340 | * 341 | * @return array|null 342 | */ 343 | private function getForeignKeysHash(array $tableNames): ?array 344 | { 345 | if (empty($tableNames)) { 346 | return []; 347 | } 348 | 349 | $quotedNames = $this->quoteArray($tableNames); 350 | $sql = sprintf( 351 | "SELECT 352 | cols.TABLE_NAME, 353 | cols.COLUMN_NAME, 354 | cRefs.CONSTRAINT_NAME, 355 | refs.REFERENCED_TABLE_NAME, 356 | refs.REFERENCED_COLUMN_NAME, 357 | cRefs.UPDATE_RULE, 358 | cRefs.DELETE_RULE 359 | FROM INFORMATION_SCHEMA.COLUMNS AS cols 360 | LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS refs 361 | ON refs.TABLE_SCHEMA=cols.TABLE_SCHEMA 362 | AND refs.REFERENCED_TABLE_SCHEMA=cols.TABLE_SCHEMA 363 | AND refs.TABLE_NAME=cols.TABLE_NAME 364 | AND refs.COLUMN_NAME=cols.COLUMN_NAME 365 | LEFT JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS cons 366 | ON cons.TABLE_SCHEMA=cols.TABLE_SCHEMA 367 | AND cons.TABLE_NAME=cols.TABLE_NAME 368 | AND cons.CONSTRAINT_NAME=refs.CONSTRAINT_NAME 369 | LEFT JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS AS cRefs 370 | ON cRefs.CONSTRAINT_SCHEMA=cols.TABLE_SCHEMA 371 | AND cRefs.CONSTRAINT_NAME=refs.CONSTRAINT_NAME 372 | WHERE 373 | cols.TABLE_NAME in (%s) 374 | AND cols.TABLE_SCHEMA = DATABASE() 375 | AND refs.REFERENCED_TABLE_NAME IS NOT NULL 376 | AND cons.CONSTRAINT_TYPE = 'FOREIGN KEY' 377 | ;", 378 | implode(',', $quotedNames) 379 | ); 380 | 381 | $rows = $this->queryFetchAll($sql); 382 | 383 | if (empty($rows)) { 384 | return null; 385 | } 386 | 387 | $result = []; 388 | foreach ($rows as $row) { 389 | $result[$row['TABLE_NAME']][$row['CONSTRAINT_NAME']] = $row; 390 | } 391 | 392 | return $result; 393 | } 394 | 395 | /** 396 | * Escape value. 397 | * 398 | * @param string|null $value 399 | * 400 | * @return string 401 | */ 402 | public function esc(?string $value): string 403 | { 404 | if ($value === null) { 405 | return 'NULL'; 406 | } 407 | 408 | return (string)substr((string)$this->pdo->quote($value), 1, -1); 409 | } 410 | 411 | /** 412 | * Get version. 413 | * 414 | * @return string The version 415 | */ 416 | public function getVersion(): string 417 | { 418 | $row = $this->queryFetch('SHOW VARIABLES LIKE "version";'); 419 | 420 | return isset($row['Value']) ? (string)$row['Value'] : ''; 421 | } 422 | 423 | /** 424 | * Query and fetch row. 425 | * 426 | * @param string $sql The sql statement 427 | * 428 | * @return array The row 429 | */ 430 | private function queryFetch(string $sql): array 431 | { 432 | $row = $this->createQueryStatement($sql)->fetch(PDO::FETCH_ASSOC) ?: []; 433 | 434 | return (array)$row; 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /src/Migration/Adapter/Database/SchemaAdapterInterface.php: -------------------------------------------------------------------------------- 1 | array = new ArrayUtil(); 37 | $this->columnOptionGenerator = new PhinxMySqlColumnOptionGenerator($dba); 38 | } 39 | 40 | /** 41 | * Get table migration (new table columns). 42 | * 43 | * @param array $output 44 | * @param array $table 45 | * @param string $tableName 46 | * @param array $new 47 | * @param array $old 48 | * 49 | * @return array 50 | */ 51 | public function getTableMigrationNewTablesColumns( 52 | array $output, 53 | array $table, 54 | string $tableName, 55 | array $new, 56 | array $old 57 | ): array { 58 | if (empty($table['columns'])) { 59 | return $output; 60 | } 61 | 62 | // Remove not used keys 63 | $this->array->unsetArrayKeys($new, 'COLUMN_KEY'); 64 | $this->array->unsetArrayKeys($old, 'COLUMN_KEY'); 65 | 66 | foreach ($table['columns'] as $columnName => $columnData) { 67 | if (!isset($old['tables'][$tableName]['columns'][$columnName])) { 68 | $output[] = $this->getColumnCreateAddNoUpdate($new, $tableName, $columnName); 69 | } elseif ($this->array->neq($new, $old, ['tables', $tableName, 'columns', $columnName])) { 70 | $output[] = $this->getColumnUpdate($new, $tableName, $columnName); 71 | } 72 | } 73 | 74 | return $output; 75 | } 76 | 77 | /** 78 | * Get addColumn method. 79 | * 80 | * @param array $schema 81 | * @param string $table 82 | * @param string $columnName 83 | * 84 | * @return string 85 | */ 86 | private function getColumnCreateAddNoUpdate(array $schema, string $table, string $columnName): string 87 | { 88 | $result = $this->getColumnCreate($schema, $table, $columnName); 89 | 90 | return sprintf("%s->addColumn('%s', '%s', %s)", $this->ind3, $result[1], $result[2], $result[3]); 91 | } 92 | 93 | /** 94 | * Generate column update. 95 | * 96 | * @param array $schema 97 | * @param string $table 98 | * @param string $columnName 99 | * 100 | * @return string 101 | */ 102 | private function getColumnUpdate(array $schema, string $table, string $columnName): string 103 | { 104 | $columns = $schema['tables'][$table]['columns']; 105 | $columnData = $columns[$columnName]; 106 | 107 | $phinxType = $this->getPhinxColumnType($columnData); 108 | $columnAttributes = $this->columnOptionGenerator->getPhinxColumnOptions($phinxType, $columnData, $columns); 109 | 110 | return sprintf("%s->changeColumn('%s', '%s', %s)", $this->ind3, $columnName, $phinxType, $columnAttributes); 111 | } 112 | 113 | /** 114 | * Generate column create. 115 | * 116 | * @param array $schema The schema 117 | * @param string $tableName The table name 118 | * @param string $columnName The column name 119 | * 120 | * @return string[] The table specification 121 | */ 122 | private function getColumnCreate(array $schema, string $tableName, string $columnName): array 123 | { 124 | $columns = $schema['tables'][$tableName]['columns']; 125 | $columnData = $columns[$columnName]; 126 | $phinxType = $this->getPhinxColumnType($columnData); 127 | $columnAttributes = $this->columnOptionGenerator->getPhinxColumnOptions($phinxType, $columnData, $columns); 128 | 129 | return [$tableName, $columnName, $phinxType, $columnAttributes]; 130 | } 131 | 132 | /** 133 | * Map MySql data type to Phinx\Db\Adapter\AdapterInterface::PHINX_TYPE_*. 134 | * 135 | * @param array $columnData The column type 136 | * 137 | * @return string The type 138 | */ 139 | private function getPhinxColumnType(array $columnData): string 140 | { 141 | $columnType = $columnData['COLUMN_TYPE']; 142 | if ($columnType === 'tinyint(1)') { 143 | return AdapterInterface::PHINX_TYPE_BOOLEAN; 144 | } 145 | $map = [ 146 | 'tinyint' => AdapterInterface::PHINX_TYPE_INTEGER, 147 | 'smallint' => AdapterInterface::PHINX_TYPE_INTEGER, 148 | 'int' => AdapterInterface::PHINX_TYPE_INTEGER, 149 | 'mediumint' => AdapterInterface::PHINX_TYPE_INTEGER, 150 | 'bigint' => AdapterInterface::PHINX_TYPE_INTEGER, 151 | 'tinytext' => AdapterInterface::PHINX_TYPE_TEXT, 152 | 'mediumtext' => AdapterInterface::PHINX_TYPE_TEXT, 153 | 'longtext' => AdapterInterface::PHINX_TYPE_TEXT, 154 | 'varchar' => AdapterInterface::PHINX_TYPE_STRING, 155 | 'tinyblob' => AdapterInterface::PHINX_TYPE_BLOB, 156 | 'mediumblob' => AdapterInterface::PHINX_TYPE_BLOB, 157 | 'longblob' => AdapterInterface::PHINX_TYPE_BLOB, 158 | 'bit' => AdapterInterface::PHINX_TYPE_BIT, 159 | ]; 160 | 161 | $type = $this->columnOptionGenerator->getMySQLColumnType($columnData); 162 | 163 | return $map[$type] ?? $type; 164 | } 165 | 166 | /** 167 | * Get table migration (old table columns). 168 | * 169 | * @param array $output 170 | * @param string $tableName 171 | * @param array $new 172 | * @param array $old 173 | * 174 | * @return array 175 | */ 176 | public function getTableMigrationOldTablesColumns(array $output, string $tableName, array $new, array $old): array 177 | { 178 | if (empty($old['tables'][$tableName]['columns'])) { 179 | return $output; 180 | } 181 | 182 | foreach ($old['tables'][$tableName]['columns'] as $oldColumnName => $oldColumnData) { 183 | if (!isset($new['tables'][$tableName]['columns'][$oldColumnName])) { 184 | $output = $this->getColumnRemove($output, $oldColumnName); 185 | } 186 | } 187 | 188 | return $output; 189 | } 190 | 191 | /** 192 | * Generate column remove. 193 | * 194 | * @param array $output 195 | * @param string $columnName 196 | * 197 | * @return array 198 | */ 199 | private function getColumnRemove(array $output, string $columnName): array 200 | { 201 | $output[] = sprintf("%s->removeColumn('%s')", $this->ind3, $columnName); 202 | 203 | return $output; 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/Migration/Adapter/Generator/PhinxMySqlColumnOptionGenerator.php: -------------------------------------------------------------------------------- 1 | dba = $dba; 32 | $this->array = new ArrayUtil(); 33 | } 34 | 35 | /** 36 | * Generate phinx column options. 37 | * 38 | * https://media.readthedocs.org/pdf/phinx/latest/phinx.pdf 39 | * 40 | * @param string $phinxType The phinx type 41 | * @param array $columnData The column data 42 | * @param array $columns The columns 43 | * 44 | * @return string THe code 45 | */ 46 | public function getPhinxColumnOptions(string $phinxType, array $columnData, array $columns): string 47 | { 48 | $attributes = []; 49 | 50 | $attributes = $this->getPhinxColumnOptionsNull($attributes, $columnData); 51 | 52 | // Default value 53 | $attributes = $this->getPhinxColumnOptionsDefault($phinxType, $attributes, $columnData); 54 | 55 | // For timestamp columns 56 | $attributes = $this->getPhinxColumnOptionsTimestamp($attributes, $columnData); 57 | 58 | // Limit / length 59 | $attributes = $this->getPhinxColumnOptionsLimit($attributes, $columnData); 60 | 61 | // Numeric attributes 62 | $attributes = $this->getPhinxColumnOptionsNumeric($attributes, $columnData); 63 | 64 | // Enum and set values 65 | if ($phinxType === AdapterInterface::PHINX_TYPE_ENUM || $phinxType === AdapterInterface::PHINX_TYPE_SET) { 66 | $attributes = $this->getOptionEnumAndSetValues($attributes, $columnData); 67 | } 68 | 69 | // Collation 70 | $attributes = $this->getPhinxColumnCollation($phinxType, $attributes, $columnData); 71 | 72 | // Encoding 73 | $attributes = $this->getPhinxColumnEncoding($phinxType, $attributes, $columnData); 74 | 75 | // Comment 76 | $attributes = $this->getPhinxColumnOptionsComment($attributes, $columnData); 77 | 78 | // After: specify the column that a new column should be placed after 79 | $attributes = $this->getPhinxColumnOptionsAfter($attributes, $columnData, $columns); 80 | 81 | return $this->array->prettifyArray($attributes, 3); 82 | } 83 | 84 | /** 85 | * Get column type. 86 | * 87 | * @param array $columnData The column data 88 | * 89 | * @return string The type 90 | */ 91 | public function getMySQLColumnType(array $columnData): string 92 | { 93 | $match = null; 94 | preg_match('/^[a-z]+/', $columnData['COLUMN_TYPE'], $match); 95 | 96 | return $match[0]; 97 | } 98 | 99 | /** 100 | * Generate phinx column options (null). 101 | * 102 | * @param array $attributes The attributes 103 | * @param array $columnData The column data 104 | * 105 | * @return array The attributes 106 | */ 107 | private function getPhinxColumnOptionsNull(array $attributes, array $columnData): array 108 | { 109 | // has NULL 110 | if ($columnData['IS_NULLABLE'] === 'YES') { 111 | $attributes['null'] = true; 112 | } else { 113 | $attributes['null'] = false; 114 | } 115 | 116 | return $attributes; 117 | } 118 | 119 | /** 120 | * Generate phinx column options (default value). 121 | * 122 | * @param string $phinxType The phinx type 123 | * @param array $attributes The attributes 124 | * @param array $columnData The column data 125 | * 126 | * @return array The attributes 127 | */ 128 | private function getPhinxColumnOptionsDefault(string $phinxType, array $attributes, array $columnData): array 129 | { 130 | if ($columnData['COLUMN_DEFAULT'] !== null) { 131 | $attributes['default'] = $columnData['COLUMN_DEFAULT']; 132 | } 133 | 134 | if ( 135 | $phinxType === AdapterInterface::PHINX_TYPE_DATETIME 136 | && isset($attributes['default']) 137 | && strtolower($attributes['default']) === 'current_timestamp()' 138 | ) { 139 | $attributes['default'] = 'CURRENT_TIMESTAMP'; 140 | 141 | // Return here because we do not want to escape it for MariaDB 142 | return $attributes; 143 | } 144 | 145 | if (isset($attributes['default']) && $phinxType === AdapterInterface::PHINX_TYPE_BIT) { 146 | // Note that default values like b'1111' are not supported by phinx 147 | $bitMappings = [ 148 | "b'1'" => true, 149 | "b'0'" => false, 150 | ]; 151 | 152 | $attributes['default'] = $bitMappings[$attributes['default']] ?? $attributes['default']; 153 | 154 | // Return here because we do not want to escape it for MariaDB 155 | return $attributes; 156 | } 157 | 158 | // MariaDB contains 'NULL' as string to define null as default 159 | if ($columnData['COLUMN_DEFAULT'] === 'NULL') { 160 | $attributes['default'] = null; 161 | } 162 | 163 | // MariaDB quotes the values 164 | if (isset($attributes['default']) && $this->isMariaDb()) { 165 | $attributes['default'] = trim($attributes['default'], "'"); 166 | } 167 | 168 | return $attributes; 169 | } 170 | 171 | /** 172 | * Is MariaDB. 173 | * 174 | * @return bool True if it's a MaraDB database 175 | */ 176 | private function isMariaDb(): bool 177 | { 178 | return stripos($this->dba->getVersion(), 'maria') !== false; 179 | } 180 | 181 | /** 182 | * Generate phinx column options (update). 183 | * 184 | * @param array $attributes The attributes 185 | * @param array $columnData The column data 186 | * 187 | * @return array The attributes 188 | */ 189 | private function getPhinxColumnOptionsTimestamp(array $attributes, array $columnData): array 190 | { 191 | // default set default value (use with CURRENT_TIMESTAMP) 192 | // on update CURRENT_TIMESTAMP 193 | if (stripos($columnData['EXTRA'], 'on update CURRENT_TIMESTAMP') !== false) { 194 | $attributes['update'] = 'CURRENT_TIMESTAMP'; 195 | } 196 | 197 | return $attributes; 198 | } 199 | 200 | /** 201 | * Generate phinx column options (update). 202 | * 203 | * @param array $attributes The attributes 204 | * @param array $columnData The column data 205 | * 206 | * @return array The attributes 207 | */ 208 | private function getPhinxColumnOptionsLimit(array $attributes, array $columnData): array 209 | { 210 | $limit = $this->getColumnLimit($columnData); 211 | if ($limit !== null) { 212 | $attributes['limit'] = $limit; 213 | } 214 | 215 | return $attributes; 216 | } 217 | 218 | /** 219 | * Generate column limit. 220 | * 221 | * @param array $columnData 222 | * 223 | * @return int|RawPhpValue|null The limit 224 | */ 225 | private function getColumnLimit(array $columnData): RawPhpValue|int|null 226 | { 227 | $type = $this->getMySQLColumnType($columnData); 228 | 229 | $mappings = [ 230 | 'int' => 'MysqlAdapter::INT_REGULAR', 231 | 'tinyint' => 'MysqlAdapter::INT_TINY', 232 | 'smallint' => 'MysqlAdapter::INT_SMALL', 233 | 'mediumint' => 'MysqlAdapter::INT_MEDIUM', 234 | 'bigint' => 'MysqlAdapter::INT_BIG', 235 | 'tinytext' => 'MysqlAdapter::TEXT_TINY', 236 | 'mediumtext' => 'MysqlAdapter::TEXT_MEDIUM', 237 | 'longtext' => 'MysqlAdapter::TEXT_LONG', 238 | 'longblob' => 'MysqlAdapter::BLOB_LONG', 239 | 'mediumblob' => 'MysqlAdapter::BLOB_MEDIUM', 240 | 'blob' => 'MysqlAdapter::BLOB_REGULAR', 241 | 'tinyblob' => 'MysqlAdapter::BLOB_TINY', 242 | ]; 243 | 244 | $adapterConst = $mappings[$type] ?? null; 245 | 246 | if ($adapterConst) { 247 | return new RawPhpValue($adapterConst); 248 | } 249 | 250 | if (!empty($columnData['CHARACTER_MAXIMUM_LENGTH'])) { 251 | return (int)$columnData['CHARACTER_MAXIMUM_LENGTH']; 252 | } 253 | 254 | if (preg_match('/\((\d+)\)/', $columnData['COLUMN_TYPE'], $match) === 1) { 255 | return (int)$match[1]; 256 | } 257 | 258 | return null; 259 | } 260 | 261 | /** 262 | * Generate phinx column options (default value). 263 | * 264 | * @param array $attributes The attributes 265 | * @param array $columnData The column data 266 | * 267 | * @return array The attributes 268 | */ 269 | private function getPhinxColumnOptionsNumeric(array $attributes, array $columnData): array 270 | { 271 | $dataType = $columnData['DATA_TYPE']; 272 | 273 | $intDefaultLimits = [ 274 | 'int' => '11', 275 | 'bigint' => '20', 276 | ]; 277 | 278 | // For integer and biginteger columns 279 | if ($dataType === 'int' || $dataType === 'bigint') { 280 | $match = null; 281 | if (preg_match('/\((\d+)\)/', $columnData['COLUMN_TYPE'], $match) === 1) { 282 | if ($match[1] !== $intDefaultLimits[$dataType]) { 283 | $attributes['limit'] = (int)$match[1]; 284 | } 285 | } 286 | 287 | // signed enable or disable the unsigned option (only applies to MySQL) 288 | $match = null; 289 | $pattern = '/unsigned$/'; 290 | if (preg_match($pattern, $columnData['COLUMN_TYPE'], $match) === 1) { 291 | $attributes['signed'] = false; 292 | } 293 | 294 | // identity enable or disable automatic incrementing 295 | if ($columnData['EXTRA'] === 'auto_increment') { 296 | $attributes['identity'] = true; 297 | } 298 | } 299 | 300 | // For decimal columns 301 | if ($dataType === 'decimal') { 302 | // Set decimal accuracy 303 | if (!empty($columnData['NUMERIC_PRECISION'])) { 304 | $attributes['precision'] = $columnData['NUMERIC_PRECISION']; 305 | } 306 | if (!empty($columnData['NUMERIC_SCALE'])) { 307 | $attributes['scale'] = $columnData['NUMERIC_SCALE']; 308 | } 309 | } 310 | 311 | return $attributes; 312 | } 313 | 314 | /** 315 | * Generate option enum values. 316 | * 317 | * @param array $attributes The attributes 318 | * @param array $columnData The column data 319 | * 320 | * @return array The attributes 321 | */ 322 | private function getOptionEnumAndSetValues(array $attributes, array $columnData): array 323 | { 324 | $match = null; 325 | $pattern = '/(enum|set)\((.*)\)/'; 326 | if (preg_match($pattern, $columnData['COLUMN_TYPE'], $match) === 1) { 327 | $values = str_getcsv($match[2], ',', "'"); 328 | $attributes['values'] = $values; 329 | } 330 | 331 | return $attributes; 332 | } 333 | 334 | /** 335 | * Set collation that differs from table defaults (only applies to MySQL). 336 | * 337 | * @param string $phinxType The phinx type 338 | * @param array $attributes The attributes 339 | * @param array $columnData The column data 340 | * 341 | * @return array The attributes 342 | */ 343 | private function getPhinxColumnCollation(string $phinxType, array $attributes, array $columnData): array 344 | { 345 | $allowedTypes = [ 346 | AdapterInterface::PHINX_TYPE_CHAR, 347 | AdapterInterface::PHINX_TYPE_STRING, 348 | AdapterInterface::PHINX_TYPE_TEXT, 349 | ]; 350 | if (!in_array($phinxType, $allowedTypes, true)) { 351 | return $attributes; 352 | } 353 | 354 | if (!empty($columnData['COLLATION_NAME'])) { 355 | $attributes['collation'] = $columnData['COLLATION_NAME']; 356 | } 357 | 358 | return $attributes; 359 | } 360 | 361 | /** 362 | * Set character set that differs from table defaults *(only applies to MySQL)* (only applies to MySQL). 363 | * 364 | * @param string $phinxType The phinx type 365 | * @param array $attributes The attributes 366 | * @param array $columnData The column data 367 | * 368 | * @return array The attributes 369 | */ 370 | private function getPhinxColumnEncoding(string $phinxType, array $attributes, array $columnData): array 371 | { 372 | $allowedTypes = [ 373 | AdapterInterface::PHINX_TYPE_CHAR, 374 | AdapterInterface::PHINX_TYPE_STRING, 375 | AdapterInterface::PHINX_TYPE_TEXT, 376 | ]; 377 | if (!in_array($phinxType, $allowedTypes, true)) { 378 | return $attributes; 379 | } 380 | 381 | if (!empty($columnData['CHARACTER_SET_NAME'])) { 382 | $attributes['encoding'] = $columnData['CHARACTER_SET_NAME']; 383 | } 384 | 385 | return $attributes; 386 | } 387 | 388 | /** 389 | * Generate phinx column options (comment). 390 | * 391 | * @param array $attributes The attributes 392 | * @param array $columnData The column data 393 | * 394 | * @return array The attributes 395 | */ 396 | private function getPhinxColumnOptionsComment(array $attributes, array $columnData): array 397 | { 398 | // Set a text comment on the column 399 | if (!empty($columnData['COLUMN_COMMENT'])) { 400 | $attributes['comment'] = $columnData['COLUMN_COMMENT']; 401 | } 402 | 403 | return $attributes; 404 | } 405 | 406 | /** 407 | * Generate phinx column options (after). 408 | * 409 | * @param array $attributes 410 | * @param array $columnData 411 | * @param array $columns 412 | * 413 | * @return array Attributes 414 | */ 415 | private function getPhinxColumnOptionsAfter(array $attributes, array $columnData, array $columns): array 416 | { 417 | $columnName = $columnData['COLUMN_NAME']; 418 | $after = null; 419 | foreach (array_keys($columns) as $column) { 420 | if ($column === $columnName) { 421 | break; 422 | } 423 | $after = $column; 424 | } 425 | if (!empty($after)) { 426 | $attributes['after'] = $after; 427 | } 428 | 429 | return $attributes; 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /src/Migration/Adapter/Generator/PhinxMySqlForeignKeyGenerator.php: -------------------------------------------------------------------------------- 1 | array = new ArrayUtil(); 28 | } 29 | 30 | /** 31 | * Generate foreign keys migrations. 32 | * 33 | * @param array $output 34 | * @param string $tableName 35 | * @param array $new New schema 36 | * @param array $old Old schema 37 | * 38 | * @return array Output 39 | */ 40 | public function getForeignKeysMigrations(array $output, string $tableName, array $new = [], array $old = []): array 41 | { 42 | if (empty($new['tables'][$tableName])) { 43 | return []; 44 | } 45 | 46 | $newTable = $new['tables'][$tableName]; 47 | 48 | $oldTable = !empty($old['tables'][$tableName]) ? $old['tables'][$tableName] : []; 49 | 50 | if (!empty($oldTable['foreign_keys'])) { 51 | foreach ($oldTable['foreign_keys'] as $fkName => $fkData) { 52 | if (!isset($newTable['foreign_keys'][$fkName])) { 53 | $output = $this->getForeignKeyRemove($output, $fkName); 54 | } 55 | } 56 | } 57 | 58 | if (!empty($newTable['foreign_keys'])) { 59 | foreach ($newTable['foreign_keys'] as $fkName => $fkData) { 60 | if (!isset($oldTable['foreign_keys'][$fkName])) { 61 | $output = $this->getForeignKeyCreate($output, $fkName, $fkData); 62 | } 63 | } 64 | } 65 | 66 | return $output; 67 | } 68 | 69 | /** 70 | * Generate foreign key remove. 71 | * 72 | * @param array $output 73 | * @param string $indexName 74 | * 75 | * @return array 76 | */ 77 | private function getForeignKeyRemove(array $output, string $indexName): array 78 | { 79 | $output[] = sprintf("%s->dropForeignKey('%s')", $this->ind3, $indexName); 80 | 81 | return $output; 82 | } 83 | 84 | /** 85 | * Generate foreign key create. 86 | * 87 | * @param array $output 88 | * @param string $fkName 89 | * @param array $fkData 90 | * 91 | * @return array 92 | */ 93 | private function getForeignKeyCreate(array $output, string $fkName, array $fkData): array 94 | { 95 | $columns = "'" . $fkData['COLUMN_NAME'] . "'"; 96 | $referencedTable = "'" . $fkData['REFERENCED_TABLE_NAME'] . "'"; 97 | $referencedColumns = "'" . $fkData['REFERENCED_COLUMN_NAME'] . "'"; 98 | $tableOptions = $this->getForeignKeyOptions($fkData, $fkName); 99 | 100 | $output[] = sprintf( 101 | '%s->addForeignKey(%s, %s, %s, %s)', 102 | $this->ind3, 103 | $columns, 104 | $referencedTable, 105 | $referencedColumns, 106 | $tableOptions 107 | ); 108 | 109 | return $output; 110 | } 111 | 112 | /** 113 | * Generate foreign key options. 114 | * 115 | * @param array $fkData The foreign key data 116 | * @param string|null $fkName The foreign key name 117 | * 118 | * @return string The code 119 | */ 120 | private function getForeignKeyOptions(array $fkData, ?string $fkName = null): string 121 | { 122 | $tableOptions = []; 123 | if (isset($fkName)) { 124 | $tableOptions['constraint'] = $fkName; 125 | } 126 | if (isset($fkData['UPDATE_RULE'])) { 127 | $tableOptions['update'] = $this->getForeignKeyRuleValue($fkData['UPDATE_RULE']); 128 | } 129 | if (isset($fkData['DELETE_RULE'])) { 130 | $tableOptions['delete'] = $this->getForeignKeyRuleValue($fkData['DELETE_RULE']); 131 | } 132 | 133 | return $this->array->prettifyArray($tableOptions, 3); 134 | } 135 | 136 | /** 137 | * Generate foreign key rule value. 138 | * 139 | * @param string $value 140 | * 141 | * @return string 142 | */ 143 | private function getForeignKeyRuleValue(string $value): string 144 | { 145 | $mappings = [ 146 | 'no action' => 'NO_ACTION', 147 | 'cascade' => 'CASCADE', 148 | 'restrict' => 'RESTRICT', 149 | 'set null' => 'SET_NULL', 150 | ]; 151 | 152 | return $mappings[strtolower($value)] ?? 'NO_ACTION'; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Migration/Adapter/Generator/PhinxMySqlGenerator.php: -------------------------------------------------------------------------------- 1 | dba = $dba; 83 | $this->array = new ArrayUtil(); 84 | $this->tableOptionGenerator = new PhinxMySqlTableOptionGenerator(); 85 | $this->columnGenerator = new PhinxMySqlColumnGenerator($dba); 86 | $this->indexOptionGenerator = new PhinxMySqlIndexGenerator(); 87 | $this->foreignKeyCreator = new PhinxMySqlForeignKeyGenerator(); 88 | 89 | $default = [ 90 | // Experimental foreign key support. 91 | 'foreign_keys' => false, 92 | // Default migration table name 93 | 'migration_table' => 'phinxlog', 94 | ]; 95 | 96 | $this->options = array_replace_recursive($default, $options) ?: []; 97 | } 98 | 99 | /** 100 | * Create migration. 101 | * 102 | * @param string $name Name of the migration 103 | * @param array $newSchema The new schema 104 | * @param array $oldSchema The old schema 105 | * 106 | * @return string The PHP code 107 | */ 108 | public function createMigration(string $name, array $newSchema, array $oldSchema): string 109 | { 110 | $className = $this->options['migration_base_class'] ?? '\Phinx\Migration\AbstractMigration'; 111 | 112 | $output = []; 113 | $output[] = 'options['namespace'])) { 116 | $output[] = ''; 117 | $output[] = sprintf('namespace %s;', $this->options['namespace']); 118 | } 119 | 120 | $output[] = ''; 121 | $output[] = 'use Phinx\Db\Adapter\MysqlAdapter;'; 122 | $output[] = ''; 123 | $output[] = sprintf('class %s extends %s', $name, $className); 124 | $output[] = '{'; 125 | $output = $this->addChangeMethod($output, $newSchema, $oldSchema); 126 | $output[] = '}'; 127 | $output[] = ''; 128 | 129 | return implode($this->nl, $output); 130 | } 131 | 132 | /** 133 | * Generate code for change function. 134 | * 135 | * @param string[] $output Output 136 | * @param array $new New schema 137 | * @param array $old Old schema 138 | * 139 | * @return string[] Output 140 | */ 141 | private function addChangeMethod(array $output, array $new, array $old): array 142 | { 143 | $output[] = $this->ind . 'public function change()'; 144 | $output[] = $this->ind . '{'; 145 | 146 | if (!empty($this->options['foreign_keys'])) { 147 | $output[] = $this->getSetIntegrityChecks(0); 148 | } 149 | 150 | $output = $this->getTableMigrationNewDatabase($output, $new, $old); 151 | $output = $this->getTableMigrationTables($output, $new, $old); 152 | 153 | if (!empty($this->options['foreign_keys'])) { 154 | $output[] = $this->getSetIntegrityChecks(1); 155 | } 156 | 157 | $output[] = $this->ind . '}'; 158 | 159 | return $output; 160 | } 161 | 162 | /** 163 | * Enable or disable the unique and foreign key checks. 164 | * 165 | * @param int $value The value (0 or 1) 166 | * 167 | * @return string The code 168 | */ 169 | private function getSetIntegrityChecks(int $value): string 170 | { 171 | return sprintf( 172 | '%s$this->execute(\'SET unique_checks=%s; SET foreign_key_checks=%s;\');', 173 | $this->ind2, 174 | $value, 175 | $value 176 | ); 177 | } 178 | 179 | /** 180 | * Get table migration (new database). 181 | * 182 | * @param array $output The output 183 | * @param array $new The new schema 184 | * @param array $old The old schema 185 | * 186 | * @return array The new output 187 | */ 188 | private function getTableMigrationNewDatabase(array $output, array $new, array $old): array 189 | { 190 | if (empty($new['database'])) { 191 | return $output; 192 | } 193 | if ($this->array->neq($new, $old, ['database', 'DEFAULT_CHARACTER_SET_NAME'])) { 194 | $output[] = $this->getAlterDatabaseCharset($new['database']['DEFAULT_CHARACTER_SET_NAME']); 195 | } 196 | if ($this->array->neq($new, $old, ['database', 'DEFAULT_COLLATION_NAME'])) { 197 | $output[] = $this->getAlterDatabaseCollate($new['database']['DEFAULT_COLLATION_NAME']); 198 | } 199 | 200 | return $output; 201 | } 202 | 203 | /** 204 | * Generate alter database charset. 205 | * 206 | * @param string $charset The charset 207 | * @param string|null $database The database name 208 | * 209 | * @return string The sql 210 | */ 211 | private function getAlterDatabaseCharset(string $charset, ?string $database = null): string 212 | { 213 | if ($database !== null) { 214 | $database = ' ' . $this->dba->ident($database); 215 | } 216 | $charset = $this->dba->quote($charset); 217 | 218 | return sprintf('%s$this->execute("ALTER DATABASE%s CHARACTER SET %s;");', $this->ind2, $database, $charset); 219 | } 220 | 221 | /** 222 | * Generate alter database collate. 223 | * 224 | * @param string $collate The collation 225 | * @param string|null $database The database name 226 | * 227 | * @return string The sql 228 | */ 229 | private function getAlterDatabaseCollate(string $collate, ?string $database = null): string 230 | { 231 | if ($database) { 232 | $database = ' ' . $this->dba->ident($database); 233 | } 234 | $collate = $this->dba->quote($collate); 235 | 236 | return sprintf('%s$this->execute("ALTER DATABASE%s COLLATE=%s;");', $this->ind2, $database, $collate); 237 | } 238 | 239 | /** 240 | * Get table migration (new tables). 241 | * 242 | * @param array $output The output 243 | * @param array $new The new schema 244 | * @param array $old The old schema 245 | * 246 | * @return array The new code 247 | */ 248 | private function getTableMigrationTables(array $output, array $new, array $old): array 249 | { 250 | foreach ($new['tables'] ?? [] as $tableName => $table) { 251 | if ($tableName === $this->options['migration_table']) { 252 | continue; 253 | } 254 | 255 | $tableDiffs = $this->array->diff($new['tables'][$tableName] ?? [], $old['tables'][$tableName] ?? []); 256 | $tableDiffsRemove = $this->array->diff($old['tables'][$tableName] ?? [], $new['tables'][$tableName] ?? []); 257 | 258 | if ($tableDiffs || $tableDiffsRemove) { 259 | $output = $this->createTableMigrationDiff( 260 | $output, 261 | $new, 262 | $old, 263 | $table, 264 | $tableName 265 | ); 266 | } 267 | } 268 | 269 | // To remove 270 | return $this->getTableMigrationDropTables($output, $new, $old); 271 | } 272 | 273 | /** 274 | * Create diff commands. 275 | * 276 | * @param array $output The output 277 | * @param array $new The new schema 278 | * @param array $old The old schema 279 | * @param array $table The table 280 | * @param string $tableName The table name 281 | * 282 | * @return array The new output 283 | */ 284 | private function createTableMigrationDiff( 285 | array $output, 286 | array $new, 287 | array $old, 288 | array $table, 289 | string $tableName 290 | ): array { 291 | $output[] = $this->getTableVariable($table, $tableName); 292 | 293 | // To add or modify 294 | $output = $this->columnGenerator->getTableMigrationNewTablesColumns( 295 | $output, 296 | $table, 297 | $tableName, 298 | $new, 299 | $old 300 | ); 301 | 302 | $output = $this->columnGenerator->getTableMigrationOldTablesColumns($output, $tableName, $new, $old); 303 | 304 | $output = $this->indexOptionGenerator->getTableMigrationIndexes( 305 | $output, 306 | $table, 307 | $tableName, 308 | $new, 309 | $old 310 | ); 311 | 312 | if (!empty($this->options['foreign_keys'])) { 313 | $output = $this->foreignKeyCreator->getForeignKeysMigrations($output, $tableName, $new, $old); 314 | } 315 | 316 | if (isset($old['tables'][$tableName])) { 317 | // Update existing table 318 | $output[] = sprintf('%s->save();', $this->ind3); 319 | } else { 320 | // Create new table 321 | $output[] = sprintf('%s->create();', $this->ind3); 322 | } 323 | 324 | return $output; 325 | } 326 | 327 | /** 328 | * Generate create table variable. 329 | * 330 | * @param array $table The table 331 | * @param string $tableName The table name 332 | * 333 | * @return string The code 334 | */ 335 | private function getTableVariable(array $table, string $tableName): string 336 | { 337 | $tableOptions = $this->tableOptionGenerator->getTableOptions($table); 338 | 339 | return sprintf('%s$this->table(\'%s\', %s)', $this->ind2, $tableName, $tableOptions); 340 | } 341 | 342 | /** 343 | * Get table migration (old tables). 344 | * 345 | * @param array $output 346 | * @param array $new 347 | * @param array $old 348 | * 349 | * @return array 350 | */ 351 | private function getTableMigrationDropTables(array $output, array $new, array $old): array 352 | { 353 | if (empty($old['tables'])) { 354 | return $output; 355 | } 356 | 357 | foreach ($old['tables'] as $tableName => $table) { 358 | if ($tableName === $this->options['migration_table']) { 359 | continue; 360 | } 361 | 362 | if (!isset($new['tables'][$tableName])) { 363 | $output[] = $this->getDropTable($tableName); 364 | } 365 | } 366 | 367 | return $output; 368 | } 369 | 370 | /** 371 | * Generate drop table. 372 | * 373 | * @param string $table The table 374 | * 375 | * @return string The code 376 | */ 377 | private function getDropTable(string $table): string 378 | { 379 | return sprintf('%s$this->table(\'%s\')->drop()->save();', $this->ind2, $table); 380 | } 381 | } 382 | -------------------------------------------------------------------------------- /src/Migration/Adapter/Generator/PhinxMySqlIndexGenerator.php: -------------------------------------------------------------------------------- 1 | array = new ArrayUtil(); 28 | } 29 | 30 | /** 31 | * Get table migration (indexes). 32 | * 33 | * @param array $output 34 | * @param array $table 35 | * @param string $tableName 36 | * @param array $new 37 | * @param array $old 38 | * 39 | * @return array 40 | */ 41 | public function getTableMigrationIndexes( 42 | array $output, 43 | array $table, 44 | string $tableName, 45 | array $new, 46 | array $old 47 | ): array { 48 | if (empty($table['indexes'])) { 49 | return $output; 50 | } 51 | foreach ($table['indexes'] as $indexName => $indexSequences) { 52 | if (!isset($old['tables'][$tableName]['indexes'][$indexName])) { 53 | $output = $this->getIndexCreate($output, $new, $tableName, $indexName); 54 | } elseif ($this->array->neq($new, $old, ['tables', $tableName, 'indexes', $indexName])) { 55 | if ($indexName !== 'PRIMARY') { 56 | $output = $this->getIndexRemove($indexName, $output); 57 | } 58 | $output = $this->getIndexCreate($output, $new, $tableName, $indexName); 59 | } 60 | } 61 | 62 | // To delete 63 | if (!empty($old['tables'][$tableName]['indexes'])) { 64 | foreach ($old['tables'][$tableName]['indexes'] as $indexName => $indexSequences) { 65 | if (!isset($new['tables'][$tableName]['indexes'][$indexName])) { 66 | $output = $this->getIndexRemove($indexName, $output); 67 | } 68 | } 69 | } 70 | 71 | return $output; 72 | } 73 | 74 | /** 75 | * Generate index create. 76 | * 77 | * @param string[] $output Output 78 | * @param array $schema Schema 79 | * @param string $table Tablename 80 | * @param string $indexName Index name 81 | * 82 | * @return array Output 83 | */ 84 | private function getIndexCreate(array $output, array $schema, string $table, string $indexName): array 85 | { 86 | if ($indexName === 'PRIMARY') { 87 | return $output; 88 | } 89 | $indexes = $schema['tables'][$table]['indexes']; 90 | $indexSequences = $indexes[$indexName]; 91 | 92 | $indexFields = $this->getIndexFields($indexSequences); 93 | $indexOptions = $this->getIndexOptions(array_values($indexSequences)); 94 | 95 | $output[] = sprintf('%s->addIndex(%s, %s)', $this->ind3, $indexFields, $indexOptions); 96 | 97 | return $output; 98 | } 99 | 100 | /** 101 | * Generate index remove. 102 | * 103 | * @param string $indexName 104 | * @param array $output 105 | * 106 | * @return array 107 | */ 108 | private function getIndexRemove(string $indexName, array $output): array 109 | { 110 | $output[] = sprintf('%s->removeIndexByName("%s")', $this->ind3, $indexName); 111 | 112 | return $output; 113 | } 114 | 115 | /** 116 | * Get index fields. 117 | * 118 | * @param array $indexSequences 119 | * 120 | * @return string The code 121 | */ 122 | private function getIndexFields(array $indexSequences): string 123 | { 124 | $indexFields = []; 125 | foreach ($indexSequences as $indexData) { 126 | $indexFields[] = $indexData['Column_name']; 127 | } 128 | 129 | return $this->array->prettifyArray($indexFields, 3); 130 | } 131 | 132 | /** 133 | * Generate index options. 134 | * 135 | * @param array $indexData 136 | * 137 | * @return string The code 138 | */ 139 | private function getIndexOptions(array $indexData): string 140 | { 141 | $indexOptions = []; 142 | 143 | foreach ($indexData as $indexPerColumn) { 144 | if (isset($indexPerColumn['Key_name'])) { 145 | $indexOptions['name'] = $indexPerColumn['Key_name']; 146 | } 147 | if (isset($indexPerColumn['Non_unique']) && (int)$indexPerColumn['Non_unique'] === 1) { 148 | $indexOptions['unique'] = false; 149 | } else { 150 | $indexOptions['unique'] = true; 151 | } 152 | 153 | // Number of characters for nonbinary string types (CHAR, VARCHAR, TEXT) 154 | // and number of bytes for binary string types (BINARY, VARBINARY, BLOB) 155 | if (isset($indexPerColumn['Sub_part'])) { 156 | $indexOptions['limit'][$indexPerColumn['Column_name']] = $indexPerColumn['Sub_part']; 157 | } 158 | // MyISAM only 159 | if (isset($indexPerColumn['Index_type']) && $indexPerColumn['Index_type'] === 'FULLTEXT') { 160 | $indexOptions['type'] = 'fulltext'; 161 | } 162 | } 163 | 164 | $result = ''; 165 | if (!empty($indexOptions)) { 166 | $result = $this->array->prettifyArray($indexOptions, 3); 167 | } 168 | 169 | return $result; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/Migration/Adapter/Generator/PhinxMySqlTableOptionGenerator.php: -------------------------------------------------------------------------------- 1 | array = new ArrayUtil(); 23 | } 24 | 25 | /** 26 | * Get table options. 27 | * 28 | * @param array $table The table 29 | * 30 | * @return string The code 31 | */ 32 | public function getTableOptions(array $table): string 33 | { 34 | $attributes = []; 35 | 36 | $attributes = $this->getPhinxTablePrimaryKey($attributes, $table); 37 | 38 | // collation 39 | $attributes = $this->getPhinxTableEngine($attributes, $table); 40 | 41 | // encoding 42 | $attributes = $this->getPhinxTableEncoding($attributes, $table); 43 | 44 | // collation 45 | $attributes = $this->getPhinxTableCollation($attributes, $table); 46 | 47 | // comment 48 | $attributes = $this->getPhinxTableComment($attributes, $table); 49 | 50 | // row_format 51 | $attributes = $this->getPhinxTableRowFormat($attributes, $table); 52 | 53 | return $this->array->prettifyArray($attributes, 3); 54 | } 55 | 56 | /** 57 | * Define table id value. 58 | * 59 | * @param array $attributes The attributes 60 | * @param array $table The table 61 | * 62 | * @return array The new attributes 63 | */ 64 | private function getPhinxTablePrimaryKey(array $attributes, array $table): array 65 | { 66 | $primaryKeys = $this->getPrimaryKeys($table); 67 | $attributes['id'] = false; 68 | 69 | if (!empty($primaryKeys)) { 70 | $attributes['primary_key'] = $primaryKeys; 71 | } 72 | 73 | return $attributes; 74 | } 75 | 76 | /** 77 | * Collect alternate primary keys. 78 | * 79 | * @param array $table The table 80 | * 81 | * @return array The keys 82 | */ 83 | private function getPrimaryKeys(array $table): array 84 | { 85 | $primaryKeys = []; 86 | foreach ($table['columns'] as $column) { 87 | $columnName = $column['COLUMN_NAME']; 88 | $columnKey = $column['COLUMN_KEY']; 89 | if ($columnKey !== 'PRI') { 90 | continue; 91 | } 92 | $primaryKeys[] = $columnName; 93 | } 94 | 95 | return $primaryKeys; 96 | } 97 | 98 | /** 99 | * Define table engine (defaults to InnoDB). 100 | * 101 | * @param array $attributes The attributes 102 | * @param array $table The table 103 | * 104 | * @return array The attributes 105 | */ 106 | private function getPhinxTableEngine(array $attributes, array $table): array 107 | { 108 | if (!empty($table['table']['engine'])) { 109 | $attributes['engine'] = $table['table']['engine']; 110 | } else { 111 | $attributes['engine'] = 'InnoDB'; 112 | } 113 | 114 | return $attributes; 115 | } 116 | 117 | /** 118 | * Define table character set (defaults to utf8). 119 | * 120 | * @param array $attributes The attributes 121 | * @param array $table The table 122 | * 123 | * @return array The attributes 124 | */ 125 | private function getPhinxTableEncoding(array $attributes, array $table): array 126 | { 127 | if (!empty($table['table']['character_set_name'])) { 128 | $attributes['encoding'] = $table['table']['character_set_name']; 129 | } else { 130 | $attributes['encoding'] = 'utf8mb4'; 131 | } 132 | 133 | return $attributes; 134 | } 135 | 136 | /** 137 | * Define table collation (defaults to `utf8mb4_unicode_ci`). 138 | * 139 | * @param array $attributes The attributes 140 | * @param array $table The table 141 | * 142 | * @return array The attributes 143 | */ 144 | private function getPhinxTableCollation(array $attributes, array $table): array 145 | { 146 | if (!empty($table['table']['table_collation'])) { 147 | $attributes['collation'] = $table['table']['table_collation']; 148 | } else { 149 | $attributes['collation'] = 'utf8mb4_unicode_ci'; 150 | } 151 | 152 | return $attributes; 153 | } 154 | 155 | /** 156 | * Set a text comment on the table. 157 | * 158 | * @param array $attributes The attributes 159 | * @param array $table The table 160 | * 161 | * @return array The attributes 162 | */ 163 | private function getPhinxTableComment(array $attributes, array $table): array 164 | { 165 | if (!empty($table['table']['table_comment'])) { 166 | $attributes['comment'] = $table['table']['table_comment']; 167 | } else { 168 | $attributes['comment'] = ''; 169 | } 170 | 171 | return $attributes; 172 | } 173 | 174 | /** 175 | * Get table for format. 176 | * 177 | * @param array $attributes The attributes 178 | * @param array $table The table 179 | * 180 | * @return array The attributes 181 | */ 182 | private function getPhinxTableRowFormat(array $attributes, array $table): array 183 | { 184 | if (!empty($table['table']['row_format'])) { 185 | $attributes['row_format'] = strtoupper($table['table']['row_format']); 186 | } 187 | 188 | return $attributes; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/Migration/Adapter/Generator/RawPhpValue.php: -------------------------------------------------------------------------------- 1 | value = $value; 23 | } 24 | 25 | /** 26 | * To php value. 27 | * 28 | * @return string 29 | */ 30 | public function toPHP(): string 31 | { 32 | return $this->value; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Migration/Command/GenerateCommand.php: -------------------------------------------------------------------------------- 1 | addOption('environment', 'e', InputOption::VALUE_REQUIRED, 'The target environment.'); 36 | 37 | $this->setName('generate'); 38 | $this->setDescription('Generate a new migration'); 39 | 40 | // Allow the migration path to be chosen non-interactively. 41 | $this->addOption( 42 | 'path', 43 | null, 44 | InputOption::VALUE_REQUIRED, 45 | 'Specify the path in which to generate this migration' 46 | ); 47 | 48 | $this->addOption( 49 | 'name', 50 | null, 51 | InputOption::VALUE_REQUIRED, 52 | 'Specify the name of the migration for this migration' 53 | ); 54 | 55 | $this->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite schema file'); 56 | } 57 | 58 | /** 59 | * Generate migration. 60 | * 61 | * @param InputInterface $input The input 62 | * @param OutputInterface $output The output 63 | * 64 | * @throws InvalidArgumentException 65 | * 66 | * @return int The value 0 on success, or an error code 67 | */ 68 | protected function execute(InputInterface $input, OutputInterface $output): int 69 | { 70 | $this->bootstrap($input, $output); 71 | 72 | $environmentName = $this->getEnvironmentName($input, $output); 73 | if (!$this->checkEnvironmentSettings($environmentName, $output)) { 74 | return 1; 75 | } 76 | 77 | $settings = $this->getGeneratorSettings($input, $environmentName); 78 | 79 | $output->writeln('using config file ' . ($settings['config_file'] ?? null)); 80 | $output->writeln('using migration path ' . ($settings['migration_path'] ?? null)); 81 | $output->writeln('using schema file ' . ($settings['schema_file'] ?? null)); 82 | 83 | $generator = $this->getMigrationGenerator($settings, $input, $output, $environmentName); 84 | 85 | return $generator->generate(); 86 | } 87 | 88 | /** 89 | * Get invironment name. 90 | * 91 | * @param InputInterface $input The input 92 | * @param OutputInterface $output The output 93 | * 94 | * @return string The name 95 | */ 96 | private function getEnvironmentName(InputInterface $input, OutputInterface $output): string 97 | { 98 | $environment = $input->getOption('environment'); 99 | $environment = is_scalar($environment) ? (string)$environment : null; 100 | 101 | if ($environment === null) { 102 | $environment = $this->getConfig()->getDefaultEnvironment(); 103 | $output->writeln('warning no environment specified, defaulting to: ' . $environment); 104 | } else { 105 | $output->writeln('using environment ' . $environment); 106 | } 107 | 108 | if (!$environment) { 109 | throw new InvalidArgumentException('Invalid or missing environment'); 110 | } 111 | 112 | return $environment; 113 | } 114 | 115 | /** 116 | * Check settings. 117 | * 118 | * @param string $environmentName The env name 119 | * @param OutputInterface $output The output 120 | * 121 | * @return bool The status 122 | */ 123 | private function checkEnvironmentSettings(string $environmentName, OutputInterface $output): bool 124 | { 125 | $environmentOptions = $this->getConfig()->getEnvironment($environmentName); 126 | 127 | if (isset($environmentOptions['adapter']) && !$this->isAdapterSupported($environmentOptions['adapter'])) { 128 | $output->writeln('adapter not supported ' . $environmentOptions['adapter']); 129 | 130 | return false; 131 | } 132 | 133 | if (isset($environmentOptions['name'])) { 134 | $output->writeln('using database ' . $environmentOptions['name']); 135 | } else { 136 | $output->writeln( 137 | 'Could not determine database name! Please specify a database name in your config file.' 138 | ); 139 | 140 | return false; 141 | } 142 | 143 | return true; 144 | } 145 | 146 | /** 147 | * Is adapter supported. 148 | * 149 | * @param string $adapterName The adapter name 150 | * 151 | * @return bool The value true if adapter with specified name is supported 152 | */ 153 | private function isAdapterSupported(string $adapterName): bool 154 | { 155 | return $adapterName === 'mysql'; 156 | } 157 | 158 | /** 159 | * Get default schema path. 160 | * 161 | * @param string $migrationsPath The path 162 | * 163 | * @return string The schema file path 164 | */ 165 | private function getDefaultSchemaFilePath(string $migrationsPath): string 166 | { 167 | return $migrationsPath . DIRECTORY_SEPARATOR . 'schema.php'; 168 | } 169 | 170 | /** 171 | * Get MigrationGenerator instance. 172 | * 173 | * @param array $settings The settings 174 | * @param InputInterface $input The input 175 | * @param OutputInterface $output The output 176 | * @param string $environment The env 177 | * 178 | * @throws UnexpectedValueException 179 | * 180 | * @return MigrationGenerator The generator 181 | */ 182 | private function getMigrationGenerator( 183 | array $settings, 184 | InputInterface $input, 185 | OutputInterface $output, 186 | string $environment 187 | ): MigrationGenerator { 188 | $manager = $this->getManager(); 189 | 190 | if (!$manager) { 191 | throw new UnexpectedValueException('Manager not found'); 192 | } 193 | 194 | $pdo = $this->getPdo($manager, $environment); 195 | $schemaAdapter = $this->getSchemaAdapter($pdo, $output); 196 | 197 | return new MigrationGenerator($settings, $input, $output, $schemaAdapter); 198 | } 199 | 200 | /** 201 | * Get SchemaAdapter instance. 202 | * 203 | * @param PDO $pdo The database connection 204 | * @param OutputInterface $output The output 205 | * 206 | * @return SchemaAdapterInterface The schema 207 | */ 208 | private function getSchemaAdapter(PDO $pdo, OutputInterface $output): SchemaAdapterInterface 209 | { 210 | return new MySqlSchemaAdapter($pdo, $output); 211 | } 212 | 213 | /** 214 | * Get settings array. 215 | * 216 | * @param InputInterface $input 217 | * @param string $environment 218 | * 219 | * @throws UnexpectedValueException On error 220 | * 221 | * @return array The settings 222 | */ 223 | private function getGeneratorSettings(InputInterface $input, string $environment): array 224 | { 225 | // Load config and database adapter 226 | $manager = $this->getManager(); 227 | 228 | if (!$manager) { 229 | throw new UnexpectedValueException('Manager not found'); 230 | } 231 | 232 | $config = $manager->getConfig(); 233 | $envOptions = $config->getEnvironment($environment); 234 | $configFilePath = $config->getConfigFilePath(); 235 | $migrationsPath = $this->getMigrationPath($input, $config); 236 | $schemaFile = $config['schema_file'] ?? $this->getDefaultSchemaFilePath($migrationsPath); 237 | $dbAdapter = $manager->getEnvironment($environment)->getAdapter(); 238 | $pdo = $this->getPdo($manager, $environment); 239 | $foreignKeys = $config['foreign_keys'] ?? false; 240 | $defaultMigrationPrefix = $config['default_migration_prefix'] ?? null; 241 | $generateMigrationName = $config['generate_migration_name'] ?? false; 242 | $markMigration = $config['mark_generated_migration'] ?? true; 243 | $defaultMigrationTable = $envOptions['migration_table'] ?? 'phinxlog'; 244 | $namespace = $config instanceof NamespaceAwareInterface ? 245 | $config->getMigrationNamespaceByPath($migrationsPath) : 246 | null; 247 | 248 | return [ 249 | 'pdo' => $pdo, 250 | 'manager' => $manager, 251 | 'environment' => $environment, 252 | 'adapter' => $dbAdapter, 253 | 'schema_file' => $schemaFile, 254 | 'migration_path' => $migrationsPath, 255 | 'foreign_keys' => (bool)$foreignKeys, 256 | 'config_file' => $configFilePath, 257 | 'name' => $input->getOption('name'), 258 | 'overwrite' => $input->getOption('overwrite'), 259 | 'mark_migration' => $markMigration, 260 | 'migration_table' => $defaultMigrationTable, 261 | 'default_migration_prefix' => $defaultMigrationPrefix, 262 | 'generate_migration_name' => $generateMigrationName, 263 | 'migration_base_class' => $config->getMigrationBaseClassName(false), 264 | 'namespace' => $namespace, 265 | ]; 266 | } 267 | 268 | /** 269 | * Get migration path. 270 | * 271 | * @param InputInterface $input The input 272 | * @param ConfigInterface $config The config 273 | * 274 | * @throws UnexpectedValueException 275 | * 276 | * @return string The path 277 | */ 278 | private function getMigrationPath(InputInterface $input, ConfigInterface $config): string 279 | { 280 | // First, try the non-interactive option: 281 | $migrationsPaths = $input->getOption('path'); 282 | if (empty($migrationsPaths)) { 283 | $migrationsPaths = $config->getMigrationPaths(); 284 | } 285 | 286 | $migrationsPaths = (array)$migrationsPaths; 287 | 288 | // No paths? That's a problem. 289 | if (empty($migrationsPaths)) { 290 | throw new UnexpectedValueException('No migration paths set in your Phinx configuration file.'); 291 | } 292 | 293 | $key = array_key_first($migrationsPaths); 294 | $migrationsPath = (string)$migrationsPaths[$key]; 295 | $this->verifyMigrationDirectory($migrationsPath); 296 | 297 | return $migrationsPath; 298 | } 299 | 300 | /** 301 | * Get PDO instance. 302 | * 303 | * @param Manager $manager Manager 304 | * @param string $environment Environment name 305 | * 306 | * @throws UnexpectedValueException On error 307 | * 308 | * @return PDO PDO object 309 | */ 310 | private function getPdo(Manager $manager, string $environment): PDO 311 | { 312 | $pdo = null; 313 | 314 | /* @var AdapterWrapper $dbAdapter */ 315 | $dbAdapter = $manager->getEnvironment($environment)->getAdapter(); 316 | 317 | if ($dbAdapter instanceof PdoAdapter) { 318 | $pdo = $dbAdapter->getConnection(); 319 | } elseif ($dbAdapter instanceof AdapterWrapper) { 320 | $dbAdapter->connect(); 321 | $pdo = $dbAdapter->getAdapter()->getConnection(); 322 | } 323 | 324 | if ($pdo === null) { 325 | $pdo = $dbAdapter->getOption('connection'); 326 | } 327 | 328 | if (!$pdo instanceof PDO) { 329 | throw new UnexpectedValueException('PDO database connection not found.'); 330 | } 331 | 332 | return $pdo; 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/Migration/Generator/MigrationGenerator.php: -------------------------------------------------------------------------------- 1 | settings = $settings; 72 | $this->dba = $dba; 73 | $this->generator = new PhinxMySqlGenerator($this->dba, $settings); 74 | $this->output = $output; 75 | $this->io = new SymfonyStyle($input, $output); 76 | } 77 | 78 | /** 79 | * Generates random name, based on "default_migration_prefix" setting. 80 | * 81 | * @return string 82 | */ 83 | private function generateDefaultMigrationName(): string 84 | { 85 | if (isset($this->settings['default_migration_prefix'])) { 86 | return $this->settings['default_migration_prefix'] . uniqid((string)mt_rand(), false); 87 | } 88 | 89 | return ''; 90 | } 91 | 92 | /** 93 | * Load current database schema. 94 | * 95 | * @return array 96 | */ 97 | public function getSchema(): array 98 | { 99 | return $this->dba->getSchema(); 100 | } 101 | 102 | /** 103 | * Generate. 104 | * 105 | * @throws InvalidArgumentException 106 | * 107 | * @return int Status 108 | */ 109 | public function generate(): int 110 | { 111 | $schema = $this->getSchema(); 112 | $oldSchema = $this->getOldSchema($this->settings); 113 | $diffs = $this->compareSchema($schema, $oldSchema); 114 | 115 | if (empty($diffs[0]) && empty($diffs[1])) { 116 | $this->output->writeln('No database changes detected.'); 117 | 118 | return 1; 119 | } 120 | 121 | $name = $this->getMigrationName(); 122 | 123 | if (empty($name)) { 124 | $this->output->writeln('Aborted'); 125 | 126 | return 1; 127 | } 128 | 129 | $path = $this->settings['migration_path']; 130 | $className = $this->createClassName($name); 131 | 132 | if (!Util::isValidPhinxClassName($className)) { 133 | throw new InvalidArgumentException( 134 | sprintf( 135 | 'The migration class name "%s" is invalid. Please use CamelCase format.', 136 | $className 137 | ) 138 | ); 139 | } 140 | 141 | if (!Util::isUniqueMigrationClassName($className, $path)) { 142 | throw new InvalidArgumentException( 143 | sprintf( 144 | 'The migration class name "%s" already exists', 145 | $className 146 | ) 147 | ); 148 | } 149 | 150 | // Compute the file path 151 | $fileName = $this->mapClassNameToFileName($className); 152 | $filePath = $path . DIRECTORY_SEPARATOR . $fileName; 153 | 154 | if (is_file($filePath)) { 155 | throw new InvalidArgumentException( 156 | sprintf( 157 | 'The file "%s" already exists', 158 | $filePath 159 | ) 160 | ); 161 | } 162 | 163 | $migration = $this->generator->createMigration($className, $schema, $oldSchema); 164 | $this->saveMigrationFile($filePath, $migration); 165 | 166 | // Mark migration as as completed 167 | if (!empty($this->settings['mark_migration'])) { 168 | $this->markMigration($className, $fileName); 169 | } 170 | 171 | // Overwrite schema file 172 | // http://symfony.com/blog/new-in-symfony-2-8-console-style-guide 173 | if (!empty($this->settings['overwrite'])) { 174 | $overwrite = 'y'; 175 | } else { 176 | $overwrite = $this->io->ask('Overwrite schema file? (y, n)', 'n'); 177 | } 178 | if ($overwrite === 'y') { 179 | $this->saveSchemaFile($schema, $this->settings); 180 | } 181 | $this->output->writeln(''); 182 | $this->output->writeln('Generate migration finished'); 183 | 184 | return 0; 185 | } 186 | 187 | /** 188 | * Turn migration names like 'CreateUserTable' into file names like 189 | * '12345678901234_create_user_table.php' or 'LimitResourceNamesTo30Chars' into 190 | * '12345678901234_limit_resource_names_to_30_chars.php'. 191 | * 192 | * @param string $className Class Name 193 | * @return string 194 | */ 195 | private function mapClassNameToFileName(string $className): string 196 | { 197 | $snake = function ($matches) { 198 | return '_' . strtolower($matches[0]); 199 | }; 200 | 201 | $fileName = preg_replace_callback('/\d+|[A-Z]/', $snake, $className); 202 | 203 | return $this->getCurrentTimestamp() . "$fileName.php"; 204 | } 205 | 206 | /** 207 | * Gets the current timestamp string, in UTC. 208 | * 209 | * @param ?int $offset 210 | * 211 | * @return string 212 | */ 213 | private function getCurrentTimestamp(?int $offset = null): string 214 | { 215 | $dt = new DateTime('now', new DateTimeZone('UTC')); 216 | 217 | if ($offset) { 218 | $dt->modify('+' . $offset . ' seconds'); 219 | } 220 | 221 | return $dt->format('YmdHis'); 222 | } 223 | 224 | /** 225 | * Get old database schema. 226 | * 227 | * @param array $settings 228 | * 229 | * @return array 230 | */ 231 | private function getOldSchema(array $settings): array 232 | { 233 | return $this->getSchemaFileData($settings); 234 | } 235 | 236 | /** 237 | * Get schema data. 238 | * 239 | * @param array $settings 240 | * 241 | * @throws InvalidArgumentException 242 | * 243 | * @return array 244 | */ 245 | private function getSchemaFileData(array $settings): array 246 | { 247 | $schemaFile = $this->getSchemaFilename($settings); 248 | $fileExt = pathinfo($schemaFile, PATHINFO_EXTENSION); 249 | 250 | if (!file_exists($schemaFile)) { 251 | return []; 252 | } 253 | 254 | if ($fileExt === 'php') { 255 | $data = (array)$this->read($schemaFile); 256 | } elseif ($fileExt === 'json') { 257 | $content = file_get_contents($schemaFile) ?: ''; 258 | $data = (array)json_decode($content, true); 259 | } else { 260 | throw new InvalidArgumentException(sprintf('Invalid schema file extension: %s', $fileExt)); 261 | } 262 | 263 | return $data; 264 | } 265 | 266 | /** 267 | * Generate schema filename. 268 | * 269 | * @param array $settings 270 | * 271 | * @return string Schema filename 272 | */ 273 | private function getSchemaFilename(array $settings): string 274 | { 275 | // Default 276 | $schemaFile = sprintf('%s/%s', getcwd(), 'schema.php'); 277 | if (!empty($settings['schema_file'])) { 278 | $schemaFile = $settings['schema_file']; 279 | } 280 | 281 | return $schemaFile; 282 | } 283 | 284 | /** 285 | * Read php file. 286 | * 287 | * @param string $filename 288 | * 289 | * @return mixed 290 | */ 291 | private function read(string $filename) 292 | { 293 | return require $filename; 294 | } 295 | 296 | /** 297 | * Compare database schemas. 298 | * 299 | * @param array $newSchema 300 | * @param array $oldSchema 301 | * 302 | * @return array Difference 303 | */ 304 | private function compareSchema(array $newSchema, array $oldSchema): array 305 | { 306 | $this->output->writeln('Comparing schema file to the database.'); 307 | 308 | $arrayUtil = new ArrayUtil(); 309 | 310 | // To add or modify 311 | $result = $arrayUtil->diff($newSchema, $oldSchema); 312 | 313 | // To remove 314 | $result2 = $arrayUtil->diff($oldSchema, $newSchema); 315 | 316 | return [$result, $result2]; 317 | } 318 | 319 | /** 320 | * Create a class name. 321 | * 322 | * @param string $name Name 323 | * 324 | * @return string Class name 325 | */ 326 | private function createClassName(string $name): string 327 | { 328 | $result = str_replace('_', ' ', $name); 329 | 330 | return str_replace(' ', '', ucwords($result)); 331 | } 332 | 333 | /** 334 | * Save migration file. 335 | * 336 | * @param string $filePath Name of migration file 337 | * @param string $migration Migration code 338 | */ 339 | private function saveMigrationFile(string $filePath, string $migration): void 340 | { 341 | $this->output->writeln(sprintf('Generate migration file: %s', $filePath)); 342 | file_put_contents($filePath, $migration); 343 | } 344 | 345 | /** 346 | * Mark migration as completed. 347 | * 348 | * @param string $migrationName migrationName 349 | * @param string $fileName fileName 350 | */ 351 | private function markMigration(string $migrationName, string $fileName): void 352 | { 353 | $this->output->writeln('Mark migration'); 354 | 355 | $schemaTableName = $this->settings['migration_table']; 356 | 357 | /** @var AdapterInterface $adapter */ 358 | $adapter = $this->settings['adapter']; 359 | 360 | // Get version from filename prefix 361 | $version = explode('_', $fileName)[0]; 362 | 363 | // Record it in the database 364 | $time = time(); 365 | $startTime = date('Y-m-d H:i:s', $time); 366 | $endTime = date('Y-m-d H:i:s', $time); 367 | $breakpoint = 0; 368 | 369 | $sql = sprintf( 370 | "INSERT INTO %s (%s, %s, %s, %s, %s) VALUES ('%s', '%s', '%s', '%s', %s);", 371 | $schemaTableName, 372 | $adapter->quoteColumnName('version'), 373 | $adapter->quoteColumnName('migration_name'), 374 | $adapter->quoteColumnName('start_time'), 375 | $adapter->quoteColumnName('end_time'), 376 | $adapter->quoteColumnName('breakpoint'), 377 | $version, 378 | substr($migrationName, 0, 100), 379 | $startTime, 380 | $endTime, 381 | $breakpoint 382 | ); 383 | 384 | $adapter->query($sql); 385 | } 386 | 387 | /** 388 | * Save schema file. 389 | * 390 | * @param array $schema 391 | * @param array $settings 392 | * 393 | * @throws InvalidArgumentException 394 | * 395 | * @return void 396 | */ 397 | private function saveSchemaFile(array $schema, array $settings): void 398 | { 399 | $schemaFile = $this->getSchemaFilename($settings); 400 | $this->output->writeln(sprintf('Save schema file: %s', basename($schemaFile))); 401 | $fileExt = pathinfo($schemaFile, PATHINFO_EXTENSION); 402 | 403 | if ($fileExt === 'php') { 404 | $content = var_export($schema, true); 405 | $content = "settings['name'])) { 423 | return (string)$this->settings['name']; 424 | } 425 | 426 | $name = $this->generateDefaultMigrationName(); 427 | 428 | if ($this->settings['generate_migration_name'] === false) { 429 | $name = (string)$this->io->ask('Enter migration name', $name); 430 | } 431 | 432 | return $name; 433 | } 434 | } 435 | -------------------------------------------------------------------------------- /src/Migration/Utility/ArrayUtil.php: -------------------------------------------------------------------------------- 1 | unsetArrayKeys($value, $unwantedKey); 27 | } 28 | } 29 | } 30 | 31 | /** 32 | * Intersect of recursive arrays. 33 | * 34 | * @param array $array1 The array 1 35 | * @param array $array2 The array 2 36 | * 37 | * @return array 38 | */ 39 | public function diff(array $array1, array $array2): array 40 | { 41 | $difference = []; 42 | 43 | foreach ($array1 as $key => $value) { 44 | if (is_array($value)) { 45 | if (!isset($array2[$key]) || !is_array($array2[$key])) { 46 | $difference[$key] = $value; 47 | } else { 48 | $new_diff = $this->diff($value, $array2[$key]); 49 | if (!empty($new_diff)) { 50 | $difference[$key] = $new_diff; 51 | } 52 | } 53 | } elseif (!array_key_exists($key, $array2) || $array2[$key] !== $value) { 54 | $difference[$key] = $value; 55 | } 56 | } 57 | 58 | return $difference; 59 | } 60 | 61 | /** 62 | * Compare array (not). 63 | * 64 | * @param array $arr 65 | * @param array $arr2 66 | * @param array $keys 67 | * 68 | * @return bool 69 | */ 70 | public function neq(array $arr, array $arr2, array $keys): bool 71 | { 72 | return !$this->eq($arr, $arr2, $keys); 73 | } 74 | 75 | /** 76 | * Compare array. 77 | * 78 | * @param array $arr 79 | * @param array $arr2 80 | * @param array $keys 81 | * 82 | * @return bool 83 | */ 84 | private function eq(array $arr, array $arr2, array $keys): bool 85 | { 86 | $val1 = $this->find($arr, $keys); 87 | $val2 = $this->find($arr2, $keys); 88 | 89 | return $val1 === $val2; 90 | } 91 | 92 | /** 93 | * Get array value by keys. 94 | * 95 | * @param array $array 96 | * @param array $parts 97 | * 98 | * @return mixed 99 | */ 100 | private function find($array, $parts) 101 | { 102 | foreach ($parts as $part) { 103 | if (!array_key_exists($part, $array)) { 104 | return null; 105 | } 106 | $array = $array[$part]; 107 | } 108 | 109 | return $array; 110 | } 111 | 112 | /** 113 | * Prettify array. 114 | * 115 | * @param array $variable Array to prettify 116 | * @param int $tabCount Initial tab count 117 | * 118 | * @return string 119 | */ 120 | public function prettifyArray(array $variable, int $tabCount): string 121 | { 122 | $encoder = new PHPEncoder(); 123 | 124 | return $encoder->encode($variable, [ 125 | 'array.base' => $tabCount * 4, 126 | 'array.inline' => true, 127 | 'array.indent' => 4, 128 | 'array.eol' => "\n", 129 | 'string.escape' => false, 130 | 'string.utf8' => true, 131 | ]); 132 | } 133 | } 134 | --------------------------------------------------------------------------------