├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json └── src ├── Atomizer ├── Atomizer.php ├── Renderer.php ├── RendererInterface.php └── TableSorter.php ├── Capsule.php ├── CapsuleInterface.php ├── Config └── MigrationConfig.php ├── Exception ├── BlueprintException.php ├── CapsuleException.php ├── ContextException.php ├── MigrationException.php ├── Operation │ ├── ColumnException.php │ ├── ForeignKeyException.php │ ├── IndexException.php │ └── TableException.php ├── OperationException.php └── RepositoryException.php ├── FileRepository.php ├── Migration.php ├── MigrationInterface.php ├── Migrator.php ├── Operation ├── AbstractOperation.php ├── Column │ ├── Add.php │ ├── Alter.php │ ├── Column.php │ ├── Drop.php │ └── Rename.php ├── ForeignKey │ ├── Add.php │ ├── Alter.php │ ├── Drop.php │ └── ForeignKey.php ├── Index │ ├── Add.php │ ├── Alter.php │ ├── Drop.php │ └── Index.php ├── Table │ ├── Create.php │ ├── Drop.php │ ├── PrimaryKeys.php │ ├── Rename.php │ └── Update.php └── Traits │ └── OptionsTrait.php ├── OperationInterface.php ├── RepositoryInterface.php ├── State.php └── TableBlueprint.php /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant 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 making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and 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 at team@spiralscout.com. 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 https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Feel free to contribute to the development of the Cycle Database Migrations. 3 | Please make sure that the following requirements are satisfied before submitting your pull request: 4 | 5 | * KISS 6 | * PSR-12 7 | * `declare(strict_types=1);` is mandatory 8 | * Your code must include tests 9 | 10 | > Use our discord server to check for the advice or suggestion https://discord.gg/FZ9BCWg 11 | 12 | ## Testing 13 | To test Cycle Database Migrations locally, download the `cycle/migrations` repository and start docker containers inside the tests folder: 14 | 15 | ```bash 16 | cd tests/ 17 | docker composer up 18 | ``` 19 | 20 | To run full test suite: 21 | 22 | ```bash 23 | ./vendor/bin/phpunit 24 | ``` 25 | 26 | To run quick test suite: 27 | 28 | ```bash 29 | ./vendor/bin/phpunit tests/Migrations/SQLite 30 | ``` 31 | 32 | ## Help Needed In 33 | If you want to help but don't know where to start: 34 | 35 | * TODOs 36 | * Updating to latest dev-dependencies (PHPUnit, Mockery, etc) 37 | * Quality recommendations and improvements 38 | * Check [Open Issues](https://github.com/cycle/migrations/issues) 39 | * More tests are always welcome 40 | * Typos 41 | 42 | Feel free to propose any ideas related to architecture, docs (___docs are never complete___), adaptation or community. 43 | 44 | > Original guide author is not a native English speaker, feel free to create PR for any text corrections. 45 | 46 | ## Critical/Security Issues 47 | If you found something which shouldn't be there or a bug which opens a security hole please let me know immediately by email 48 | [team@spiralscout.com](mailto:team@spiralscout.com) 49 | 50 | ## Official Support 51 | Cycle ORM and all related components are maintained by [Spiral Scout](https://spiralscout.com/). 52 | 53 | For commercial support please contact team@spiralscout.com. 54 | 55 | ## Licensing 56 | Cycle Database Migrations will remain under [MIT license](/LICENSE) indefinitely. 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Spiral Scout 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cycle Database Migrations 2 | 3 | [![Latest Stable Version](https://poser.pugx.org/cycle/migrations/v/stable)](https://packagist.org/packages/cycle/migrations) 4 | [![Build Status](https://github.com/cycle/migrations/workflows/build/badge.svg)](https://github.com/cycle/migrations/actions) 5 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/cycle/migrations/badges/quality-score.png?b=4.x)](https://scrutinizer-ci.com/g/cycle/migrations/?branch=4.x) 6 | [![Codecov](https://codecov.io/gh/cycle/migrations/branch/4.x/graph/badge.svg)](https://codecov.io/gh/cycle/migrations/) 7 | 8 | Migrations are a convenient way for you to alter your database in a structured and organized manner. This package adds 9 | additional functionality for versioning your database schema and easily deploying changes to it. It is a very easy to 10 | use and a powerful tool. 11 | 12 | ## Installation 13 | 14 | ```bash 15 | composer require cycle/migrations ^4.0 16 | ``` 17 | 18 | ## Configuration 19 | 20 | ```php 21 | use Cycle\Migrations; 22 | use Cycle\Database; 23 | use Cycle\Database\Config; 24 | 25 | $dbal = new Database\DatabaseManager(new Config\DatabaseConfig([ 26 | 'default' => 'default', 27 | 'databases' => [ 28 | 'default' => [ 29 | 'connection' => 'sqlite' 30 | ] 31 | ], 32 | 'connections' => [ 33 | 'sqlite' => new Config\SQLiteDriverConfig( 34 | connection: new Config\SQLite\MemoryConnectionConfig(), 35 | queryCache: true, 36 | ), 37 | ] 38 | ])); 39 | 40 | $config = new Migrations\Config\MigrationConfig([ 41 | 'directory' => __DIR__ . '/../migrations/', // where to store migrations 42 | 'vendorDirectories' => [ // Where to look for vendor package migrations 43 | __DIR__ . '/../vendor/vendorName/packageName/migrations/' 44 | ], 45 | 'table' => 'migrations' // database table to store migration status 46 | 'safe' => true // When set to true no confirmation will be requested on migration run. 47 | ]); 48 | 49 | $migrator = new Migrations\Migrator( 50 | $config, 51 | $dbal, 52 | new Migrations\FileRepository($config) 53 | ); 54 | 55 | // Init migration table 56 | $migrator->configure(); 57 | ``` 58 | 59 | ## Running 60 | 61 | ```php 62 | while (($migration = $migrator->run()) !== null) { 63 | echo 'Migrate ' . $migration->getState()->getName(); 64 | } 65 | ``` 66 | 67 | ## Generate Migrations 68 | 69 | You can automatically generate a set of migration files during schema compilation. In this case, you have the freedom to 70 | alter such migrations manually before running them. To achieve that you must install 71 | the [Schema migrations generator extension](https://github.com/cycle/schema-migrations-generator). 72 | 73 | ## License: 74 | 75 | MIT License (MIT). Please see [`LICENSE`](./LICENSE) for more information. Maintained 76 | by [Spiral Scout](https://spiralscout.com). 77 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cycle/migrations", 3 | "type": "library", 4 | "description": "Database migrations, migration scaffolding", 5 | "license": "MIT", 6 | "require": { 7 | "php": ">=8.1", 8 | "cycle/database": "^2.7.0", 9 | "spiral/core": "^3.0", 10 | "spiral/files": "^3.0", 11 | "spiral/tokenizer": "^3.0", 12 | "spiral/reactor": "^3.0" 13 | }, 14 | "require-dev": { 15 | "buggregator/trap": "^1.11", 16 | "mockery/mockery": "^1.5", 17 | "phpunit/phpunit": "^9.5", 18 | "spiral/code-style": "^2.2.0", 19 | "vimeo/psalm": "^6.4" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "Cycle\\Migrations\\": "src/" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "Cycle\\Migrations\\Tests\\": "tests/Migrations/", 29 | "Cycle\\Migrations\\Fixtures\\": "tests/Fixtures/" 30 | } 31 | }, 32 | "config": { 33 | "sort-packages": true 34 | }, 35 | "scripts": { 36 | "cs:diff": "php-cs-fixer fix --dry-run -v --diff", 37 | "cs:fix": "php-cs-fixer fix -v", 38 | "psalm": "psalm", 39 | "psalm:baseline": "psalm --set-baseline=psalm-baseline.xml", 40 | "psalm:ci": "psalm --output-format=github --shepherd --show-info=false --stats --threads=4", 41 | "test": "phpunit --color=always" 42 | }, 43 | "minimum-stability": "dev", 44 | "prefer-stable": true 45 | } 46 | -------------------------------------------------------------------------------- /src/Atomizer/Atomizer.php: -------------------------------------------------------------------------------- 1 | tables[] = $table; 30 | 31 | return $this; 32 | } 33 | 34 | /** 35 | * @param AbstractTable[] $tables 36 | */ 37 | public function setTables(array $tables): self 38 | { 39 | $this->tables = $tables; 40 | 41 | return $this; 42 | } 43 | 44 | /** 45 | * Get all atomizer tables. 46 | * 47 | * @return AbstractTable[] 48 | */ 49 | public function getTables(): array 50 | { 51 | return $this->tables; 52 | } 53 | 54 | /** 55 | * Generate set of commands needed to describe migration (up command). 56 | */ 57 | public function declareChanges(Method $method): void 58 | { 59 | foreach ($this->tableSorter->sort($this->tables) as $table) { 60 | if (!$table->getComparator()->hasChanges()) { 61 | continue; 62 | } 63 | 64 | if (!$table->exists()) { 65 | $this->renderer->createTable($method, $table); 66 | } else { 67 | $this->renderer->updateTable($method, $table); 68 | } 69 | } 70 | } 71 | 72 | /** 73 | * Generate set of lines needed to rollback migration (down command). 74 | */ 75 | public function revertChanges(Method $method): void 76 | { 77 | foreach ($this->tableSorter->sort($this->tables, true) as $table) { 78 | if (!$table->getComparator()->hasChanges()) { 79 | continue; 80 | } 81 | 82 | if (!$table->exists()) { 83 | $this->renderer->dropTable($method, $table); 84 | } else { 85 | $this->renderer->revertTable($method, $table); 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Atomizer/Renderer.php: -------------------------------------------------------------------------------- 1 | addBody('$this->table(?)', [$this->getTableName($table)]); 26 | 27 | $comparator = $table->getComparator(); 28 | 29 | $this->declareColumns($method, $comparator); 30 | $this->declareIndexes($method, $comparator); 31 | $this->declareForeignKeys($method, $comparator, $table->getPrefix()); 32 | 33 | if (\count($table->getPrimaryKeys())) { 34 | $method->addBody('->setPrimaryKeys(?)', [$table->getPrimaryKeys()]); 35 | } 36 | 37 | //Finalization 38 | $method->addBody('->create();'); 39 | } 40 | 41 | public function updateTable(Method $method, AbstractTable $table): void 42 | { 43 | $method->addBody('$this->table(?)', [$this->getTableName($table)]); 44 | $comparator = $table->getComparator(); 45 | 46 | if ($comparator->isPrimaryChanged()) { 47 | $method->addBody('->setPrimaryKeys(?)', [$table->getPrimaryKeys()]); 48 | } 49 | 50 | $this->declareColumns($method, $comparator); 51 | $this->declareIndexes($method, $comparator); 52 | $this->declareForeignKeys($method, $comparator, $table->getPrefix()); 53 | 54 | //Finalization 55 | $method->addBody('->update();'); 56 | } 57 | 58 | public function revertTable(Method $method, AbstractTable $table): void 59 | { 60 | //Get table blueprint 61 | $method->addBody('$this->table(?)', [$this->getTableName($table)]); 62 | $comparator = $table->getComparator(); 63 | 64 | $this->revertForeignKeys($method, $comparator, $table->getPrefix()); 65 | $this->revertIndexes($method, $comparator); 66 | $this->revertColumns($method, $comparator); 67 | 68 | //Finalization 69 | $method->addBody('->update();'); 70 | } 71 | 72 | public function dropTable(Method $method, AbstractTable $table): void 73 | { 74 | $method->addBody('$this->table(?)->drop();', [$this->getTableName($table)]); 75 | } 76 | 77 | protected function alterColumn( 78 | Method $method, 79 | AbstractColumn $column, 80 | AbstractColumn $original, 81 | ): void { 82 | if ($column->getName() !== $original->getName()) { 83 | $name = $original->getName(); 84 | } else { 85 | $name = $column->getName(); 86 | } 87 | 88 | $method->addBody('->alterColumn(?, ?, ?)', [ 89 | $name, 90 | $column->getDeclaredType() ?? $column->getAbstractType(), 91 | $this->columnOptions($column), 92 | ]); 93 | 94 | if ($column->getName() !== $original->getName()) { 95 | $method->addBody('->renameColumn(?, ?)', [ 96 | $name, 97 | $column->getName(), 98 | ]); 99 | } 100 | } 101 | 102 | private function declareColumns(Method $method, Comparator $comparator): void 103 | { 104 | foreach ($comparator->addedColumns() as $column) { 105 | $method->addBody('->addColumn(?, ?, ?)', [ 106 | $column->getName(), 107 | $column->getDeclaredType() ?? $column->getAbstractType(), 108 | $this->columnOptions($column), 109 | ]); 110 | } 111 | 112 | foreach ($comparator->alteredColumns() as $pair) { 113 | $this->alterColumn( 114 | $method, 115 | $pair[self::NEW_STATE], 116 | $pair[self::ORIGINAL_STATE], 117 | ); 118 | } 119 | 120 | foreach ($comparator->droppedColumns() as $column) { 121 | $method->addBody('->dropColumn(?)', [$column->getName()]); 122 | } 123 | } 124 | 125 | private function declareIndexes(Method $method, Comparator $comparator): void 126 | { 127 | foreach ($comparator->addedIndexes() as $index) { 128 | $method->addBody('->addIndex(?, ?)', [$index->getColumns(), $this->indexOptions($index)]); 129 | } 130 | 131 | foreach ($comparator->alteredIndexes() as $pair) { 132 | /** @var AbstractIndex $index */ 133 | $index = $pair[self::NEW_STATE]; 134 | $method->addBody('->alterIndex(?, ?)', [$index->getColumns(), $this->indexOptions($index)]); 135 | } 136 | 137 | foreach ($comparator->droppedIndexes() as $index) { 138 | $method->addBody('->dropIndex(?)', [$index->getColumns()]); 139 | } 140 | } 141 | 142 | /** 143 | * @param string $prefix Database isolation prefix 144 | */ 145 | private function declareForeignKeys(Method $method, Comparator $comparator, string $prefix = ''): void 146 | { 147 | foreach ($comparator->addedForeignKeys() as $key) { 148 | $method->addBody('->addForeignKey(?, ?, ?, ?)', [ 149 | $key->getColumns(), 150 | \substr($key->getForeignTable(), \strlen($prefix)), 151 | $key->getForeignKeys(), 152 | $this->foreignKeyOptions($key), 153 | ]); 154 | } 155 | 156 | foreach ($comparator->alteredForeignKeys() as $pair) { 157 | /** @var AbstractForeignKey $key */ 158 | $key = $pair[self::NEW_STATE]; 159 | $method->addBody('->alterForeignKey(?, ?, ?, ?)', [ 160 | $key->getColumns(), 161 | \substr($key->getForeignTable(), \strlen($prefix)), 162 | $key->getForeignKeys(), 163 | $this->foreignKeyOptions($key), 164 | ]); 165 | } 166 | 167 | foreach ($comparator->droppedForeignKeys() as $key) { 168 | $method->addBody('->dropForeignKey(?)', [$key->getColumns()]); 169 | } 170 | } 171 | 172 | private function revertColumns(Method $method, Comparator $comparator): void 173 | { 174 | foreach ($comparator->droppedColumns() as $column) { 175 | $method->addBody('->addColumn(?, ?, ?)', [ 176 | $column->getName(), 177 | $column->getDeclaredType() ?? $column->getAbstractType(), 178 | $this->columnOptions($column), 179 | ]); 180 | } 181 | 182 | foreach ($comparator->alteredColumns() as $pair) { 183 | $this->alterColumn( 184 | $method, 185 | $pair[self::ORIGINAL_STATE], 186 | $pair[self::NEW_STATE], 187 | ); 188 | } 189 | 190 | foreach ($comparator->addedColumns() as $column) { 191 | $method->addBody('->dropColumn(?)', [$column->getName()]); 192 | } 193 | } 194 | 195 | private function revertIndexes(Method $method, Comparator $comparator): void 196 | { 197 | foreach ($comparator->droppedIndexes() as $index) { 198 | $method->addBody('->addIndex(?, ?)', [$index->getColumns(), $this->indexOptions($index)]); 199 | } 200 | 201 | foreach ($comparator->alteredIndexes() as $pair) { 202 | /** @var AbstractIndex $index */ 203 | $index = $pair[self::ORIGINAL_STATE]; 204 | $method->addBody('->alterIndex(?, ?)', [$index->getColumns(), $this->indexOptions($index)]); 205 | } 206 | 207 | foreach ($comparator->addedIndexes() as $index) { 208 | $method->addBody('->dropIndex(?)', [$index->getColumns()]); 209 | } 210 | } 211 | 212 | /** 213 | * @param string $prefix Database isolation prefix. 214 | */ 215 | private function revertForeignKeys(Method $method, Comparator $comparator, string $prefix = ''): void 216 | { 217 | foreach ($comparator->droppedForeignKeys() as $key) { 218 | $method->addBody('->addForeignKey(?, ?, ?, ?)', [ 219 | $key->getColumns(), 220 | \substr($key->getForeignTable(), \strlen($prefix)), 221 | $key->getForeignKeys(), 222 | $this->foreignKeyOptions($key), 223 | ]); 224 | } 225 | 226 | foreach ($comparator->alteredForeignKeys() as $pair) { 227 | /** @var AbstractForeignKey $key */ 228 | $key = $pair[self::ORIGINAL_STATE]; 229 | $method->addBody('->alterForeignKey(?, ?, ?, ?)', [ 230 | $key->getColumns(), 231 | \substr($key->getForeignTable(), \strlen($prefix)), 232 | $key->getForeignKeys(), 233 | $this->foreignKeyOptions($key), 234 | ]); 235 | } 236 | 237 | foreach ($comparator->addedForeignKeys() as $key) { 238 | $method->addBody('->dropForeignKey(?)', [ 239 | $key->getColumns(), 240 | ]); 241 | } 242 | } 243 | 244 | private function columnOptions(AbstractColumn $column): array 245 | { 246 | $options = [ 247 | 'nullable' => $column->isNullable(), 248 | 'defaultValue' => $column->getDefaultValue(), 249 | ]; 250 | 251 | if ($column->getAbstractType() === 'enum') { 252 | $options['values'] = $column->getEnumValues(); 253 | } 254 | 255 | foreach ($column->getAttributes() as $attribute => $value) { 256 | if ($attribute === 'size' && $value === 0) { 257 | continue; 258 | } 259 | $options[$attribute] = $value; 260 | } 261 | 262 | $default = $options['defaultValue']; 263 | if ($column::DATETIME_NOW === ($default instanceof \Stringable ? (string) $default : $default)) { 264 | $options['defaultValue'] = AbstractColumn::DATETIME_NOW; 265 | } 266 | 267 | return $options; 268 | } 269 | 270 | private function indexOptions(AbstractIndex $index): array 271 | { 272 | return [ 273 | 'name' => $index->getName(), 274 | 'unique' => $index->isUnique(), 275 | ]; 276 | } 277 | 278 | private function foreignKeyOptions(AbstractForeignKey $reference): array 279 | { 280 | return [ 281 | 'name' => $reference->getName(), 282 | 'delete' => $reference->getDeleteRule(), 283 | 'update' => $reference->getUpdateRule(), 284 | 'indexCreate' => $reference->hasIndex(), 285 | ]; 286 | } 287 | 288 | private function getTableName(AbstractTable $table): string 289 | { 290 | return \substr($table->getName(), \strlen($table->getPrefix())); 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/Atomizer/RendererInterface.php: -------------------------------------------------------------------------------- 1 | addTable($table); 25 | } 26 | 27 | $sorted = $reflector->sortedTables(); 28 | if ($reverse) { 29 | return \array_reverse($sorted); 30 | } 31 | 32 | return $sorted; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Capsule.php: -------------------------------------------------------------------------------- 1 | */ 18 | private array $schemas = []; 19 | 20 | public function __construct(private DatabaseInterface $database) {} 21 | 22 | public function getDatabase(): DatabaseInterface 23 | { 24 | return $this->database; 25 | } 26 | 27 | public function getTable(string $table): TableInterface 28 | { 29 | return $this->database->table($table); 30 | } 31 | 32 | public function getSchema(string $table): AbstractTable 33 | { 34 | if (!isset($this->schemas[$table])) { 35 | //We have to declare existed to prevent dropping existed schema 36 | $this->schemas[$table] = $this->database->table($table)->getSchema(); 37 | } 38 | 39 | return $this->schemas[$table]; 40 | } 41 | 42 | /** 43 | * 44 | * 45 | * @throws \Throwable 46 | */ 47 | public function execute(array $operations): void 48 | { 49 | foreach ($operations as $operation) { 50 | if (!$operation instanceof OperationInterface) { 51 | throw new CapsuleException( 52 | \sprintf( 53 | 'Migration operation expected to be an instance of `OperationInterface`, `%s` given', 54 | $operation::class, 55 | ), 56 | ); 57 | } 58 | 59 | $operation->execute($this); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/CapsuleInterface.php: -------------------------------------------------------------------------------- 1 | config['directory'] ?? ''; 19 | } 20 | 21 | /** 22 | * Vendor migrations directories. 23 | */ 24 | public function getVendorDirectories(): array 25 | { 26 | return (array) ($this->config['vendorDirectories'] ?? []); 27 | } 28 | 29 | /** 30 | * Table to store list of executed migrations. 31 | */ 32 | public function getTable(): string 33 | { 34 | return $this->config['table'] ?? 'migrations'; 35 | } 36 | 37 | /** 38 | * Is it safe to run migration without user confirmation? Attention, this option does not 39 | * used in component directly and left for component consumers. 40 | */ 41 | public function isSafe(): bool 42 | { 43 | return $this->config['safe'] ?? false; 44 | } 45 | 46 | /** 47 | * Namespace for generated migration class 48 | */ 49 | public function getNamespace(): string 50 | { 51 | return $this->config['namespace'] ?? 'Migration'; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Exception/BlueprintException.php: -------------------------------------------------------------------------------- 1 | files = new Files(); 45 | $this->factory = $factory ?? new Container(); 46 | $this->inflector = (new InflectorFactory())->build(); 47 | } 48 | 49 | public function getMigrations(): array 50 | { 51 | $timestamps = []; 52 | $chunks = []; 53 | $migrations = []; 54 | 55 | foreach ($this->getFilesIterator() as $f) { 56 | if (!\class_exists($f['class'], false)) { 57 | //Attempting to load migration class (we can not relay on autoloading here) 58 | require_once($f['filename']); 59 | } 60 | 61 | /** @var MigrationInterface $migration */ 62 | $migration = $this->factory->make($f['class']); 63 | 64 | $timestamps[] = $f['created']->getTimestamp(); 65 | $chunks[] = $f['chunk']; 66 | $migrations[] = $migration->withState(new State($f['name'], $f['created'])); 67 | } 68 | 69 | \array_multisort($timestamps, $chunks, SORT_NATURAL, $migrations); 70 | 71 | return $migrations; 72 | } 73 | 74 | public function registerMigration(string $name, string $class, ?string $body = null): string 75 | { 76 | if (empty($body) && !\class_exists($class)) { 77 | throw new RepositoryException( 78 | "Unable to register migration '{$class}', representing class does not exists", 79 | ); 80 | } 81 | 82 | $currentTimeStamp = \date(self::TIMESTAMP_FORMAT); 83 | $inflectedName = $this->inflector->tableize($name); 84 | 85 | foreach ($this->getMigrations() as $migration) { 86 | if ($migration::class === $class) { 87 | throw new RepositoryException( 88 | "Unable to register migration '{$class}', migration already exists", 89 | ); 90 | } 91 | 92 | if ( 93 | $migration->getState()->getName() === $inflectedName 94 | && $migration->getState()->getTimeCreated()->format(self::TIMESTAMP_FORMAT) === $currentTimeStamp 95 | ) { 96 | throw new RepositoryException( 97 | "Unable to register migration '{$inflectedName}', migration under the same name already exists", 98 | ); 99 | } 100 | } 101 | 102 | if (empty($body)) { 103 | //Let's read body from a given class filename 104 | $body = $this->files->read((new \ReflectionClass($class))->getFileName()); 105 | } 106 | 107 | $filename = $this->createFilename($name); 108 | 109 | //Copying 110 | $this->files->write($filename, $body, FilesInterface::READONLY, true); 111 | 112 | return $filename; 113 | } 114 | 115 | /** 116 | * @return \Generator 117 | */ 118 | private function getFilesIterator(): \Generator 119 | { 120 | foreach ( 121 | \array_merge( 122 | [$this->config->getDirectory()], 123 | $this->config->getVendorDirectories(), 124 | ) as $directory 125 | ) { 126 | $directory === '' and $directory = '.'; 127 | yield from $this->getFiles($directory); 128 | } 129 | } 130 | 131 | /** 132 | * Internal method to fetch all migration filenames. 133 | * 134 | * @param non-empty-string $directory 135 | * 136 | * @return \Generator 137 | */ 138 | private function getFiles(string $directory): \Generator 139 | { 140 | foreach ($this->files->getFiles($directory, '*.php') as $filename) { 141 | $reflection = new ReflectionFile($filename); 142 | $definition = \explode('_', \basename($filename, '.php'), 3); 143 | 144 | if (\count($definition) < 3) { 145 | throw new RepositoryException("Invalid migration filename '{$filename}'"); 146 | } 147 | 148 | $created = \DateTime::createFromFormat(self::TIMESTAMP_FORMAT, $definition[0]); 149 | if ($created === false) { 150 | throw new RepositoryException("Invalid migration filename '{$filename}' - corrupted date format"); 151 | } 152 | 153 | yield [ 154 | 'filename' => $filename, 155 | 'class' => $reflection->getClasses()[0], 156 | 'created' => $created, 157 | 'chunk' => $definition[1], 158 | 'name' => $definition[2], 159 | ]; 160 | } 161 | } 162 | 163 | /** 164 | * Request new migration filename based on user input and current timestamp. 165 | */ 166 | private function createFilename(string $name): string 167 | { 168 | $name = $this->inflector->tableize($name); 169 | 170 | $filename = \sprintf( 171 | self::FILENAME_FORMAT, 172 | \date(self::TIMESTAMP_FORMAT), 173 | $this->chunkID++, 174 | $name, 175 | ); 176 | 177 | return $this->files->normalizePath( 178 | $this->config->getDirectory() . FilesInterface::SEPARATOR . $filename, 179 | ); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/Migration.php: -------------------------------------------------------------------------------- 1 | capsule = $capsule; 31 | 32 | return $migration; 33 | } 34 | 35 | public function withState(State $state): MigrationInterface 36 | { 37 | $migration = clone $this; 38 | $migration->state = $state; 39 | 40 | return $migration; 41 | } 42 | 43 | public function getState(): State 44 | { 45 | if (empty($this->state)) { 46 | throw new MigrationException('Unable to get migration state, no state are set'); 47 | } 48 | 49 | return $this->state; 50 | } 51 | 52 | /** 53 | * @return Database 54 | */ 55 | protected function database(): DatabaseInterface 56 | { 57 | if ($this->capsule === null) { 58 | throw new MigrationException('Unable to get database, no capsule are set'); 59 | } 60 | 61 | return $this->capsule->getDatabase(); 62 | } 63 | 64 | /** 65 | * Get table schema builder (blueprint). 66 | */ 67 | protected function table(string $table): TableBlueprint 68 | { 69 | if ($this->capsule === null) { 70 | throw new MigrationException('Unable to get table blueprint, no capsule are set'); 71 | } 72 | 73 | return new TableBlueprint($this->capsule, $table); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/MigrationInterface.php: -------------------------------------------------------------------------------- 1 | config; 34 | } 35 | 36 | public function getRepository(): RepositoryInterface 37 | { 38 | return $this->repository; 39 | } 40 | 41 | /** 42 | * Check if all related databases are configures with migrations. 43 | */ 44 | public function isConfigured(): bool 45 | { 46 | foreach ($this->getDatabases() as $db) { 47 | if (!$db->hasTable($this->config->getTable()) || !$this->checkMigrationTableStructure($db)) { 48 | return false; 49 | } 50 | } 51 | 52 | return !$this->isRestoreMigrationDataRequired(); 53 | } 54 | 55 | /** 56 | * Configure all related databases with migration table. 57 | */ 58 | public function configure(): void 59 | { 60 | if ($this->isConfigured()) { 61 | return; 62 | } 63 | 64 | foreach ($this->getDatabases() as $db) { 65 | $schema = $db->table($this->config->getTable())->getSchema(); 66 | 67 | // Schema update will automatically sync all needed data 68 | $schema->primary('id'); 69 | $schema->string('migration', 191)->nullable(false); 70 | $schema->datetime('time_executed')->datetime(); 71 | $schema->datetime('created_at')->datetime(); 72 | $schema->index(['migration', 'created_at']) 73 | ->unique(true); 74 | 75 | if ($schema->hasIndex(['migration'])) { 76 | $schema->dropIndex(['migration']); 77 | } 78 | 79 | $schema->save(); 80 | } 81 | 82 | if ($this->isRestoreMigrationDataRequired()) { 83 | $this->restoreMigrationData(); 84 | } 85 | } 86 | 87 | /** 88 | * Get every available migration with valid meta information. 89 | * 90 | * @return MigrationInterface[] 91 | */ 92 | public function getMigrations(): array 93 | { 94 | $result = []; 95 | foreach ($this->repository->getMigrations() as $migration) { 96 | //Populating migration state and execution time (if any) 97 | $result[] = $migration->withState($this->resolveState($migration)); 98 | } 99 | 100 | return $result; 101 | } 102 | 103 | /** 104 | * Execute one migration and return it's instance. 105 | * 106 | * @throws MigrationException 107 | */ 108 | public function run(?CapsuleInterface $capsule = null): ?MigrationInterface 109 | { 110 | if (!$this->isConfigured()) { 111 | throw new MigrationException('Unable to run migration, Migrator not configured'); 112 | } 113 | 114 | foreach ($this->getMigrations() as $migration) { 115 | if ($migration->getState()->getStatus() !== State::STATUS_PENDING) { 116 | continue; 117 | } 118 | 119 | try { 120 | $capsule = $capsule ?? new Capsule($this->dbal->database($migration->getDatabase())); 121 | $capsule->getDatabase()->transaction( 122 | static function () use ($migration, $capsule): void { 123 | $migration->withCapsule($capsule)->up(); 124 | }, 125 | ); 126 | 127 | $this->migrationTable($migration->getDatabase())->insertOne( 128 | [ 129 | 'migration' => $migration->getState()->getName(), 130 | 'time_executed' => new \DateTime('now'), 131 | 'created_at' => $this->getMigrationCreatedAtForDb($migration), 132 | ], 133 | ); 134 | 135 | return $migration->withState($this->resolveState($migration)); 136 | } catch (\Throwable $exception) { 137 | throw new MigrationException( 138 | \sprintf( 139 | 'Error in the migration (%s) occurred: %s', 140 | \sprintf( 141 | '%s (%s)', 142 | $migration->getState()->getName(), 143 | $migration->getState()->getTimeCreated()->format(self::DB_DATE_FORMAT), 144 | ), 145 | $exception->getMessage(), 146 | ), 147 | (int) $exception->getCode(), 148 | $exception, 149 | ); 150 | } 151 | } 152 | 153 | return null; 154 | } 155 | 156 | /** 157 | * Rollback last migration and return it's instance. 158 | * 159 | * @throws \Throwable 160 | */ 161 | public function rollback(?CapsuleInterface $capsule = null): ?MigrationInterface 162 | { 163 | if (!$this->isConfigured()) { 164 | throw new MigrationException('Unable to run migration, Migrator not configured'); 165 | } 166 | 167 | /** @var MigrationInterface $migration */ 168 | foreach (\array_reverse($this->getMigrations()) as $migration) { 169 | if ($migration->getState()->getStatus() !== State::STATUS_EXECUTED) { 170 | continue; 171 | } 172 | 173 | $capsule = $capsule ?? new Capsule($this->dbal->database($migration->getDatabase())); 174 | $capsule->getDatabase()->transaction( 175 | static function () use ($migration, $capsule): void { 176 | $migration->withCapsule($capsule)->down(); 177 | }, 178 | ); 179 | 180 | $migrationData = $this->fetchMigrationData($migration); 181 | 182 | if (!empty($migrationData)) { 183 | $this->migrationTable($migration->getDatabase()) 184 | ->delete(['id' => $migrationData['id']]) 185 | ->run(); 186 | } 187 | 188 | return $migration->withState($this->resolveState($migration)); 189 | } 190 | 191 | return null; 192 | } 193 | 194 | /** 195 | * Clarify migration state with valid status and execution time 196 | */ 197 | protected function resolveState(MigrationInterface $migration): State 198 | { 199 | $db = $this->dbal->database($migration->getDatabase()); 200 | 201 | $data = $this->fetchMigrationData($migration); 202 | 203 | if (empty($data['time_executed'])) { 204 | return $migration->getState()->withStatus(State::STATUS_PENDING); 205 | } 206 | 207 | return $migration->getState()->withStatus( 208 | State::STATUS_EXECUTED, 209 | new \DateTimeImmutable($data['time_executed'], $db->getDriver()->getTimezone()), 210 | ); 211 | } 212 | 213 | /** 214 | * Migration table, all migration information will be stored in it. 215 | * 216 | */ 217 | protected function migrationTable(?string $database = null): Table 218 | { 219 | return $this->dbal->database($database)->table($this->config->getTable()); 220 | } 221 | 222 | protected function checkMigrationTableStructure(Database $db): bool 223 | { 224 | $table = $db->table($this->config->getTable()); 225 | 226 | foreach (self::MIGRATION_TABLE_FIELDS_LIST as $field) { 227 | if (!$table->hasColumn($field)) { 228 | return false; 229 | } 230 | } 231 | 232 | return !(!$table->hasIndex(['migration', 'created_at'])); 233 | } 234 | 235 | /** 236 | * Fetch migration information from database 237 | */ 238 | protected function fetchMigrationData(MigrationInterface $migration): ?array 239 | { 240 | $migrationData = $this->migrationTable($migration->getDatabase()) 241 | ->select('id', 'time_executed', 'created_at') 242 | ->where( 243 | [ 244 | 'migration' => $migration->getState()->getName(), 245 | 'created_at' => $this->getMigrationCreatedAtForDb($migration)->format(self::DB_DATE_FORMAT), 246 | ], 247 | ) 248 | ->run() 249 | ->fetch(); 250 | 251 | return \is_array($migrationData) ? $migrationData : []; 252 | } 253 | 254 | protected function restoreMigrationData(): void 255 | { 256 | foreach ($this->repository->getMigrations() as $migration) { 257 | $migrationData = $this->migrationTable($migration->getDatabase()) 258 | ->select('id') 259 | ->where( 260 | [ 261 | 'migration' => $migration->getState()->getName(), 262 | 'created_at' => null, 263 | ], 264 | ) 265 | ->run() 266 | ->fetch(); 267 | 268 | if (!empty($migrationData)) { 269 | $this->migrationTable($migration->getDatabase()) 270 | ->update( 271 | ['created_at' => $this->getMigrationCreatedAtForDb($migration)], 272 | ['id' => $migrationData['id']], 273 | ) 274 | ->run(); 275 | } 276 | } 277 | } 278 | 279 | /** 280 | * Check if some data modification required 281 | */ 282 | protected function isRestoreMigrationDataRequired(): bool 283 | { 284 | foreach ($this->getDatabases() as $db) { 285 | $table = $db->table($this->config->getTable()); 286 | 287 | if ( 288 | $table->select('id') 289 | ->where(['created_at' => null]) 290 | ->count() > 0 291 | ) { 292 | return true; 293 | } 294 | } 295 | 296 | return false; 297 | } 298 | 299 | protected function getMigrationCreatedAtForDb(MigrationInterface $migration): \DateTimeInterface 300 | { 301 | $db = $this->dbal->database($migration->getDatabase()); 302 | 303 | return \DateTimeImmutable::createFromFormat( 304 | self::DB_DATE_FORMAT, 305 | $migration->getState()->getTimeCreated()->format(self::DB_DATE_FORMAT), 306 | $db->getDriver()->getTimezone(), 307 | ); 308 | } 309 | 310 | /** 311 | * @return iterable 312 | */ 313 | private function getDatabases(): iterable 314 | { 315 | if ($this->dbal instanceof DatabaseManager) { 316 | return \array_filter( 317 | $this->dbal->getDatabases(), 318 | static fn(DatabaseInterface $db): bool => !$db->getDriver()->isReadonly(), 319 | ); 320 | } 321 | return []; 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/Operation/AbstractOperation.php: -------------------------------------------------------------------------------- 1 | table; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Operation/Column/Add.php: -------------------------------------------------------------------------------- 1 | getSchema($this->getTable()); 15 | 16 | if ($schema->hasColumn($this->name)) { 17 | throw new ColumnException( 18 | "Unable to create column '{$schema->getName()}'.'{$this->name}', column already exists", 19 | ); 20 | } 21 | 22 | //Declaring column 23 | $this->declareColumn($schema); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Operation/Column/Alter.php: -------------------------------------------------------------------------------- 1 | getSchema($this->getTable()); 15 | 16 | if (!$schema->hasColumn($this->name)) { 17 | throw new ColumnException( 18 | "Unable to alter column '{$schema->getName()}'.'{$this->name}', column does not exists", 19 | ); 20 | } 21 | 22 | //Declaring column change 23 | $this->declareColumn($schema); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Operation/Column/Column.php: -------------------------------------------------------------------------------- 1 | ['length', 'limit'], 22 | 'default' => ['defaultValue'], 23 | 'null' => ['nullable'], 24 | ]; 25 | 26 | public function __construct( 27 | string $table, 28 | protected string $name, 29 | protected string $type = 'string', 30 | array $options = [], 31 | ) { 32 | $this->options = $options; 33 | parent::__construct($table); 34 | } 35 | 36 | /** 37 | * @throws ColumnException 38 | */ 39 | protected function declareColumn(AbstractTable $schema): AbstractColumn 40 | { 41 | $column = $schema->column($this->name); 42 | 43 | //Type configuring 44 | if (\method_exists($column, $this->type)) { 45 | $arguments = []; 46 | $variadic = false; 47 | 48 | $method = new \ReflectionMethod($column, $this->type); 49 | foreach ($method->getParameters() as $parameter) { 50 | if ($this->hasOption($parameter->getName())) { 51 | $arguments[$parameter->getName()] = $this->getOption($parameter->getName()); 52 | } elseif (!$parameter->isOptional()) { 53 | throw new ColumnException( 54 | "Option '{$parameter->getName()}' are required to define column with type '{$this->type}'", 55 | ); 56 | } elseif ($parameter->isDefaultValueAvailable()) { 57 | $arguments[$parameter->getName()] = $parameter->getDefaultValue(); 58 | } elseif ($parameter->isVariadic()) { 59 | $variadic = true; 60 | } 61 | } 62 | 63 | \call_user_func_array( 64 | [$column, $this->type], 65 | $variadic ? $arguments + $this->options + $column->getAttributes() : $arguments, 66 | ); 67 | } else { 68 | $column->type($this->type); 69 | } 70 | 71 | $column->nullable($this->getOption('nullable', false)); 72 | $column->setAttributes($this->options + $column->getAttributes()); 73 | 74 | if ($this->hasOption('defaultValue')) { 75 | $column->defaultValue($this->getOption('defaultValue', null)); 76 | // @deprecated since v4.3 77 | } elseif ($this->hasOption('default')) { 78 | $column->defaultValue($this->getOption('default', null)); 79 | } 80 | 81 | return $column; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Operation/Column/Drop.php: -------------------------------------------------------------------------------- 1 | getSchema($this->getTable()); 21 | 22 | if (!$schema->hasColumn($this->name)) { 23 | throw new ColumnException( 24 | "Unable to drop column '{$schema->getName()}'.'{$this->name}', column does not exists", 25 | ); 26 | } 27 | 28 | //Declaring column 29 | $schema->dropColumn($this->name); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Operation/Column/Rename.php: -------------------------------------------------------------------------------- 1 | getSchema($this->getTable()); 21 | 22 | if (!$schema->hasColumn($this->name)) { 23 | throw new ColumnException( 24 | "Unable to rename column '{$schema->getName()}'.'{$this->name}', column does not exists", 25 | ); 26 | } 27 | 28 | if ($schema->hasColumn($this->newName)) { 29 | throw new ColumnException( 30 | \sprintf( 31 | "Unable to rename column '%s'.'%s', column '%s' already exists", 32 | $schema->getName(), 33 | $this->name, 34 | $this->newName, 35 | ), 36 | ); 37 | } 38 | 39 | //Declaring column 40 | $schema->renameColumn($this->name, $this->newName); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Operation/ForeignKey/Add.php: -------------------------------------------------------------------------------- 1 | options = $options; 24 | parent::__construct($table, $columns); 25 | } 26 | 27 | public function execute(CapsuleInterface $capsule): void 28 | { 29 | $schema = $capsule->getSchema($this->getTable()); 30 | 31 | if ($schema->hasForeignKey($this->columns)) { 32 | throw new ForeignKeyException( 33 | "Unable to add foreign key '{$schema->getName()}'.({$this->columnNames()}), " 34 | . 'foreign key already exists', 35 | ); 36 | } 37 | 38 | $foreignSchema = $capsule->getSchema($this->foreignTable); 39 | 40 | if ($this->foreignTable !== $this->table && !$foreignSchema->exists()) { 41 | throw new ForeignKeyException( 42 | "Unable to add foreign key '{$schema->getName()}'.'{$this->columnNames()}', " 43 | . "foreign table '{$this->foreignTable}' does not exists", 44 | ); 45 | } 46 | 47 | foreach ($this->foreignKeys as $fk) { 48 | if ($this->foreignTable !== $this->table && !$foreignSchema->hasColumn($fk)) { 49 | throw new ForeignKeyException( 50 | "Unable to add foreign key '{$schema->getName()}'.'{$this->columnNames()}'," 51 | . " foreign column '{$this->foreignTable}'.'{$fk}' does not exists", 52 | ); 53 | } 54 | } 55 | 56 | $foreignKey = $schema->foreignKey($this->columns, $this->getOption('indexCreate', true))->references( 57 | $this->foreignTable, 58 | $this->foreignKeys, 59 | ); 60 | 61 | if ($this->hasOption('name')) { 62 | $foreignKey->setName($this->getOption('name')); 63 | } 64 | 65 | /* 66 | * We are allowing both formats "NO_ACTION" and "NO ACTION". 67 | */ 68 | 69 | $foreignKey->onDelete( 70 | \str_replace( 71 | '_', 72 | ' ', 73 | $this->getOption('delete', ForeignKeyInterface::NO_ACTION), 74 | ), 75 | ); 76 | 77 | $foreignKey->onUpdate( 78 | \str_replace( 79 | '_', 80 | ' ', 81 | $this->getOption('update', ForeignKeyInterface::NO_ACTION), 82 | ), 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Operation/ForeignKey/Alter.php: -------------------------------------------------------------------------------- 1 | options = $options; 24 | parent::__construct($table, $columns); 25 | } 26 | 27 | public function execute(CapsuleInterface $capsule): void 28 | { 29 | $schema = $capsule->getSchema($this->getTable()); 30 | 31 | if (!$schema->hasForeignKey($this->columns)) { 32 | throw new ForeignKeyException( 33 | "Unable to alter foreign key '{$schema->getName()}'.({$this->columnNames()}), " 34 | . 'key does not exists', 35 | ); 36 | } 37 | 38 | $outerSchema = $capsule->getSchema($this->foreignTable); 39 | 40 | if ($this->foreignTable != $this->table && !$outerSchema->exists()) { 41 | throw new ForeignKeyException( 42 | "Unable to alter foreign key '{$schema->getName()}'.'{$this->columnNames()}', " 43 | . "foreign table '{$this->foreignTable}' does not exists", 44 | ); 45 | } 46 | 47 | 48 | foreach ($this->foreignKeys as $fk) { 49 | if ($this->foreignTable != $this->table && !$outerSchema->hasColumn($fk)) { 50 | throw new ForeignKeyException( 51 | "Unable to alter foreign key '{$schema->getName()}'.'{$this->columnNames()}'," 52 | . " foreign column '{$this->foreignTable}'.'{$fk}' does not exists", 53 | ); 54 | } 55 | } 56 | 57 | $foreignKey = $schema->foreignKey($this->columns)->references( 58 | $this->foreignTable, 59 | $this->foreignKeys, 60 | ); 61 | 62 | /* 63 | * We are allowing both formats "NO_ACTION" and "NO ACTION". 64 | */ 65 | 66 | $foreignKey->onDelete( 67 | \str_replace( 68 | '_', 69 | ' ', 70 | $this->getOption('delete', ForeignKeyInterface::NO_ACTION), 71 | ), 72 | ); 73 | 74 | $foreignKey->onUpdate( 75 | \str_replace( 76 | '_', 77 | ' ', 78 | $this->getOption('update', ForeignKeyInterface::NO_ACTION), 79 | ), 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Operation/ForeignKey/Drop.php: -------------------------------------------------------------------------------- 1 | getSchema($this->getTable()); 15 | 16 | if (!$schema->hasForeignKey($this->columns)) { 17 | throw new ForeignKeyException( 18 | "Unable to drop foreign key '{$schema->getName()}'.'{$this->columnNames()}', " 19 | . 'foreign key does not exists', 20 | ); 21 | } 22 | 23 | $schema->dropForeignKey($this->columns); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Operation/ForeignKey/ForeignKey.php: -------------------------------------------------------------------------------- 1 | ['delete'], 16 | 'onUpdate' => ['update'], 17 | ]; 18 | 19 | public function __construct(string $table, protected array $columns) 20 | { 21 | parent::__construct($table); 22 | } 23 | 24 | public function columnNames(): string 25 | { 26 | return \implode(', ', $this->columns); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Operation/Index/Add.php: -------------------------------------------------------------------------------- 1 | options = $options; 18 | parent::__construct($table, $columns); 19 | } 20 | 21 | public function execute(CapsuleInterface $capsule): void 22 | { 23 | $schema = $capsule->getSchema($this->getTable()); 24 | 25 | if ($schema->hasIndex($this->columns)) { 26 | $columns = \implode(',', $this->columns); 27 | throw new IndexException( 28 | "Unable to create index '{$schema->getName()}'.({$columns}), index already exists", 29 | ); 30 | } 31 | 32 | $schema->index($this->columns)->unique( 33 | $this->getOption('unique', false), 34 | ); 35 | 36 | if ($this->hasOption('name')) { 37 | $schema->index($this->columns)->setName($this->getOption('name')); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Operation/Index/Alter.php: -------------------------------------------------------------------------------- 1 | options = $options; 18 | parent::__construct($table, $columns); 19 | } 20 | 21 | public function execute(CapsuleInterface $capsule): void 22 | { 23 | $schema = $capsule->getSchema($this->getTable()); 24 | 25 | if (!$schema->hasIndex($this->columns)) { 26 | $columns = \implode(',', $this->columns); 27 | throw new IndexException( 28 | "Unable to alter index '{$schema->getName()}'.({$columns}), no such index", 29 | ); 30 | } 31 | 32 | $schema->index($this->columns)->unique( 33 | $this->getOption('unique', false), 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Operation/Index/Drop.php: -------------------------------------------------------------------------------- 1 | getSchema($this->getTable()); 15 | 16 | if (!$schema->hasIndex($this->columns)) { 17 | $columns = \implode(',', $this->columns); 18 | throw new IndexException( 19 | "Unable to drop index '{$schema->getName()}'.({$columns}), index does not exists", 20 | ); 21 | } 22 | 23 | $schema->dropIndex($this->columns); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Operation/Index/Index.php: -------------------------------------------------------------------------------- 1 | getSchema($this->getTable()); 17 | $database = $this->database ?? '[default]'; 18 | 19 | if ($schema->exists()) { 20 | throw new TableException( 21 | "Unable to create table '{$database}'.'{$this->getTable()}', table already exists", 22 | ); 23 | } 24 | 25 | if (empty($schema->getColumns())) { 26 | throw new TableException( 27 | "Unable to create table '{$database}'.'{$this->getTable()}', no columns were added", 28 | ); 29 | } 30 | 31 | $schema->save(HandlerInterface::DO_ALL); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Operation/Table/Drop.php: -------------------------------------------------------------------------------- 1 | getSchema($this->getTable()); 17 | $database = $this->database ?? '[default]'; 18 | 19 | if (!$schema->exists()) { 20 | throw new TableException( 21 | "Unable to drop table '{$database}'.'{$this->getTable()}', table does not exists", 22 | ); 23 | } 24 | 25 | $schema->declareDropped(); 26 | $schema->save(HandlerInterface::DO_ALL); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Operation/Table/PrimaryKeys.php: -------------------------------------------------------------------------------- 1 | getSchema($this->getTable()); 21 | $database = $this->database ?? '[default]'; 22 | 23 | if ($schema->exists()) { 24 | throw new TableException( 25 | "Unable to set primary keys for table '{$database}'.'{$this->getTable()}', table already exists", 26 | ); 27 | } 28 | 29 | $schema->setPrimaryKeys($this->columns); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Operation/Table/Rename.php: -------------------------------------------------------------------------------- 1 | getSchema($this->getTable()); 22 | $database = $this->database ?? '[default]'; 23 | 24 | if (!$schema->exists()) { 25 | throw new TableException( 26 | "Unable to rename table '{$database}'.'{$this->getTable()}', table does not exists", 27 | ); 28 | } 29 | 30 | if ($capsule->getDatabase()->hasTable($this->newName)) { 31 | throw new TableException( 32 | "Unable to rename table '{$database}'.'{$this->getTable()}', table '{$this->newName}' already exists", 33 | ); 34 | } 35 | 36 | $schema->setName($this->newName); 37 | $schema->save(HandlerInterface::DO_ALL); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Operation/Table/Update.php: -------------------------------------------------------------------------------- 1 | getSchema($this->getTable()); 17 | $database = $this->database ?? '[default]'; 18 | 19 | if (!$schema->exists()) { 20 | throw new TableException( 21 | "Unable to update table '{$database}'.'{$this->getTable()}', no table exists", 22 | ); 23 | } 24 | 25 | $schema->save(HandlerInterface::DO_ALL); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Operation/Traits/OptionsTrait.php: -------------------------------------------------------------------------------- 1 | options)) { 17 | return true; 18 | } 19 | 20 | if (!isset($this->aliases[$name])) { 21 | return false; 22 | } 23 | 24 | foreach ($this->aliases as $source => $aliases) { 25 | if (\in_array($name, $aliases, true)) { 26 | return true; 27 | } 28 | } 29 | 30 | return false; 31 | } 32 | 33 | protected function getOption(string $name, mixed $default = null): mixed 34 | { 35 | if (!$this->hasOption($name)) { 36 | return $default; 37 | } 38 | 39 | if (\array_key_exists($name, $this->options)) { 40 | return $this->options[$name]; 41 | } 42 | 43 | if (!isset($this->aliases[$name])) { 44 | return $default; 45 | } 46 | 47 | foreach ($this->aliases as $source => $aliases) { 48 | if (\in_array($name, $aliases, true)) { 49 | return $this->getOption($source); 50 | } 51 | } 52 | 53 | return false; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/OperationInterface.php: -------------------------------------------------------------------------------- 1 | name; 27 | } 28 | 29 | public function getStatus(): int 30 | { 31 | return $this->status; 32 | } 33 | 34 | public function getTimeCreated(): \DateTimeInterface 35 | { 36 | return $this->timeCreated; 37 | } 38 | 39 | public function getTimeExecuted(): ?\DateTimeInterface 40 | { 41 | return $this->timeExecuted; 42 | } 43 | 44 | public function withStatus(int $status, ?\DateTimeInterface $timeExecuted = null): self 45 | { 46 | $state = clone $this; 47 | $state->status = $status; 48 | $state->timeExecuted = $timeExecuted; 49 | 50 | return $state; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/TableBlueprint.php: -------------------------------------------------------------------------------- 1 | capsule->getSchema($this->table); 27 | } 28 | 29 | /** 30 | * Example: 31 | * $table->addColumn('name', 'string', ['length' => 64]); 32 | * $table->addColumn('status', 'enum', [ 33 | * 'values' => ['active', 'disabled'] 34 | * ]); 35 | */ 36 | public function addColumn(string $name, string $type, array $options = []): self 37 | { 38 | return $this->addOperation( 39 | new Operation\Column\Add($this->table, $name, $type, $options), 40 | ); 41 | } 42 | 43 | /** 44 | * Example: 45 | * $table->alterColumn('name', 'string', ['length' => 128]); 46 | */ 47 | public function alterColumn(string $name, string $type, array $options = []): self 48 | { 49 | return $this->addOperation( 50 | new Operation\Column\Alter($this->table, $name, $type, $options), 51 | ); 52 | } 53 | 54 | /** 55 | * Example: 56 | * $table->renameColumn('column', 'new_name'); 57 | */ 58 | public function renameColumn(string $name, string $newName): self 59 | { 60 | return $this->addOperation( 61 | new Operation\Column\Rename($this->table, $name, $newName), 62 | ); 63 | } 64 | 65 | /** 66 | * Example: 67 | * $table->dropColumn('email'); 68 | */ 69 | public function dropColumn(string $name): self 70 | { 71 | return $this->addOperation( 72 | new Operation\Column\Drop($this->table, $name), 73 | ); 74 | } 75 | 76 | /** 77 | * Example: 78 | * $table->addIndex(['email'], ['unique' => true]); 79 | */ 80 | public function addIndex(array $columns, array $options = []): self 81 | { 82 | return $this->addOperation( 83 | new Operation\Index\Add($this->table, $columns, $options), 84 | ); 85 | } 86 | 87 | /** 88 | * Example: 89 | * $table->alterIndex(['email'], ['unique' => false]); 90 | */ 91 | public function alterIndex(array $columns, array $options): self 92 | { 93 | return $this->addOperation( 94 | new Operation\Index\Alter($this->table, $columns, $options), 95 | ); 96 | } 97 | 98 | /** 99 | * Example: 100 | * $table->dropIndex(['email']); 101 | */ 102 | public function dropIndex(array $columns): self 103 | { 104 | return $this->addOperation( 105 | new Operation\Index\Drop($this->table, $columns), 106 | ); 107 | } 108 | 109 | /** 110 | * Example: 111 | * $table->addForeignKey(['user_id'], 'users', ['id'], ['delete' => 'CASCADE']); 112 | * 113 | * @param string $foreignTable Database isolation prefix will be automatically added. 114 | */ 115 | public function addForeignKey( 116 | array $columns, 117 | string $foreignTable, 118 | array $foreignKeys, 119 | array $options = [], 120 | ): self { 121 | return $this->addOperation( 122 | new Operation\ForeignKey\Add( 123 | $this->table, 124 | $columns, 125 | $foreignTable, 126 | $foreignKeys, 127 | $options, 128 | ), 129 | ); 130 | } 131 | 132 | /** 133 | * Example: 134 | * $table->alterForeignKey(['user_id'], 'users', ['id'], ['delete' => 'NO ACTION']); 135 | */ 136 | public function alterForeignKey( 137 | array $columns, 138 | string $foreignTable, 139 | array $foreignKeys, 140 | array $options = [], 141 | ): self { 142 | return $this->addOperation( 143 | new Operation\ForeignKey\Alter( 144 | $this->table, 145 | $columns, 146 | $foreignTable, 147 | $foreignKeys, 148 | $options, 149 | ), 150 | ); 151 | } 152 | 153 | /** 154 | * Example: 155 | * $table->dropForeignKey(['user_id']); 156 | */ 157 | public function dropForeignKey(array $columns): self 158 | { 159 | return $this->addOperation( 160 | new Operation\ForeignKey\Drop($this->table, $columns), 161 | ); 162 | } 163 | 164 | /** 165 | * Set table primary keys index. Attention, you can only call it when table being created. 166 | */ 167 | public function setPrimaryKeys(array $keys): self 168 | { 169 | return $this->addOperation( 170 | new Operation\Table\PrimaryKeys($this->table, $keys), 171 | ); 172 | } 173 | 174 | /** 175 | * Create table schema. Must be last operation in the sequence. 176 | */ 177 | public function create(): void 178 | { 179 | $this->addOperation( 180 | new Operation\Table\Create($this->table), 181 | ); 182 | 183 | $this->execute(); 184 | } 185 | 186 | /** 187 | * Update table schema. Must be last operation in the sequence. 188 | */ 189 | public function update(): void 190 | { 191 | $this->addOperation( 192 | new Operation\Table\Update($this->table), 193 | ); 194 | 195 | $this->execute(); 196 | } 197 | 198 | /** 199 | * Drop table. Must be last operation in the sequence. 200 | */ 201 | public function drop(): void 202 | { 203 | $this->addOperation( 204 | new Operation\Table\Drop($this->table), 205 | ); 206 | 207 | $this->execute(); 208 | } 209 | 210 | /** 211 | * Rename table. Must be last operation in the sequence. 212 | */ 213 | public function rename(string $newName): void 214 | { 215 | $this->addOperation( 216 | new Operation\Table\Rename($this->table, $newName), 217 | ); 218 | 219 | $this->execute(); 220 | } 221 | 222 | /** 223 | * Register new operation. 224 | */ 225 | public function addOperation(OperationInterface $operation): self 226 | { 227 | $this->operations[] = $operation; 228 | 229 | return $this; 230 | } 231 | 232 | /** 233 | * Execute blueprint operations. 234 | */ 235 | private function execute(): void 236 | { 237 | if ($this->executed) { 238 | throw new BlueprintException('Only one create/update/rename/drop is allowed per blueprint.'); 239 | } 240 | 241 | $this->capsule->execute($this->operations); 242 | $this->executed = true; 243 | } 244 | } 245 | --------------------------------------------------------------------------------