diff --git a/composer.json b/composer.json index 4011e603c..05b561ca1 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "psr/http-factory": "^1.0", "psr/http-message": "^1.0|^2.0", "psr/log": "^3.0.0", + "rector/rector": "^2.1", "symfony/cache": "^7.3", "symfony/mailer": "^7.2.6", "symfony/process": "^7.3", @@ -66,7 +67,6 @@ "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^12.2.3", "predis/predis": "^3.0.0", - "rector/rector": "^2.0-rc2", "spatie/phpunit-snapshot-assertions": "^5.1.8", "spaze/phpstan-disallowed-calls": "^4.0", "symfony/amazon-mailer": "^7.2.0", @@ -103,6 +103,7 @@ "tempest/router": "self.version", "tempest/storage": "self.version", "tempest/support": "self.version", + "tempest/upgrade": "self.version", "tempest/validation": "self.version", "tempest/view": "self.version", "tempest/vite": "self.version" @@ -143,6 +144,7 @@ "Tempest\\Router\\": "packages/router/src", "Tempest\\Storage\\": "packages/storage/src", "Tempest\\Support\\": "packages/support/src", + "Tempest\\Upgrade\\": "packages/upgrade/src", "Tempest\\Validation\\": "packages/validation/src", "Tempest\\View\\": "packages/view/src", "Tempest\\Vite\\": "packages/vite/src" @@ -208,6 +210,7 @@ "Tempest\\Router\\Tests\\": "packages/router/tests", "Tempest\\Storage\\Tests\\": "packages/storage/tests", "Tempest\\Support\\Tests\\": "packages/support/tests", + "Tempest\\Upgrade\\Tests\\": "packages/upgrade/tests", "Tempest\\Validation\\Tests\\": "packages/validation/tests", "Tempest\\View\\Tests\\": "packages/view/tests", "Tempest\\Vite\\Tests\\": "packages/vite/tests", diff --git a/mago.toml b/mago.toml index 23a7aac47..fa9a9da0c 100644 --- a/mago.toml +++ b/mago.toml @@ -11,6 +11,8 @@ excludes = [ "./vendor/composer", "**/.cache", "**/*.stub.php", + "**/*.input.php", + "**/*.expected.php", ] [format] diff --git a/packages/upgrade/.gitattributes b/packages/upgrade/.gitattributes new file mode 100644 index 000000000..3f7775660 --- /dev/null +++ b/packages/upgrade/.gitattributes @@ -0,0 +1,14 @@ +# Exclude build/test files from the release +.github/ export-ignore +tests/ export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpunit.xml export-ignore +README.md export-ignore + +# Configure diff output +*.view.php diff=html +*.php diff=php +*.css diff=css +*.html diff=html +*.md diff=markdown diff --git a/packages/upgrade/LICENSE.md b/packages/upgrade/LICENSE.md new file mode 100644 index 000000000..54215b726 --- /dev/null +++ b/packages/upgrade/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2024 Brent Roose brendt@stitcher.io + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/upgrade/README.md b/packages/upgrade/README.md new file mode 100644 index 000000000..fb7e7d494 --- /dev/null +++ b/packages/upgrade/README.md @@ -0,0 +1,15 @@ +## Upgrade guide + + +1. Make sure rector is installed: + - `composer require rector/rector --dev` + - Run `vendor/bin/rector` if you don't have a `rector.php` config file +2. Add the necessary rector sets in your `rector.php` config file: + +```php +return RectorConfig::configure() + // … + ->withSets([__DIR__ . '/vendor/tempest/framework/packages/upgrade/src/tempest2.php']); +``` + +3. Run `vendor/bin/rector` \ No newline at end of file diff --git a/packages/upgrade/composer.json b/packages/upgrade/composer.json new file mode 100644 index 000000000..f0b11d8b7 --- /dev/null +++ b/packages/upgrade/composer.json @@ -0,0 +1,20 @@ +{ + "name": "tempest/upgrade", + "description": "A collection of Rector rules for upgrading Tempest", + "license": "MIT", + "minimum-stability": "dev", + "require": { + "php": "^8.4", + "rector/rector": "^2.1" + }, + "autoload": { + "psr-4": { + "Tempest\\Upgrade\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Tempest\\Upgrade\\Tests\\": "tests" + } + } +} diff --git a/packages/upgrade/phpunit.xml b/packages/upgrade/phpunit.xml new file mode 100644 index 000000000..ee6f5d996 --- /dev/null +++ b/packages/upgrade/phpunit.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + src + + + diff --git a/packages/upgrade/src/Tempest2/MigrationRector.php b/packages/upgrade/src/Tempest2/MigrationRector.php new file mode 100644 index 000000000..b2551ac9a --- /dev/null +++ b/packages/upgrade/src/Tempest2/MigrationRector.php @@ -0,0 +1,88 @@ +implements; + + $implementsDatabaseMigration = array_find_key( + $implements, + static fn (Node\Name $name) => $name->toString() === 'Tempest\Database\DatabaseMigration', + ); + + if ($implementsDatabaseMigration === null) { + return; + } + + // Unset the old interface + unset($implements[$implementsDatabaseMigration]); + + // Add the new MigrateUp interface + $implements[] = new Node\Name('\Tempest\Database\MigratesUp'); + $node->getMethod('up')->returnType = new Name('QueryStatement'); + + // Check whether the migration has a down method implemented or not + $downStatements = $node->getMethod('down')->stmts; + + $migratesDown = true; + + foreach ($downStatements as $statement) { + if (! ($statement instanceof Node\Stmt\Return_)) { + continue; + } + + if (! ($statement->expr instanceof Node\Expr\ConstFetch)) { + continue; + } + + $migratesDown = $statement->expr->name->toString() !== 'null'; + + break; + } + + if ($migratesDown) { + // If the migration has a down method implemented, we'll add the new MigrateDown interface + $implements[] = new Node\Name('\Tempest\Database\MigratesDown'); + $node->getMethod('down')->returnType = new Name('QueryStatement'); + } else { + // If the migration does not have a down method implemented, we'll remove it entirely + $statements = $node->stmts; + + foreach ($node->stmts as $key => $statement) { + if (! ($statement instanceof ClassMethod)) { + continue; + } + + if ($statement->name->toString() !== 'down') { + continue; + } + + unset($statements[$key]); + + $node->stmts = $statements; + } + } + + $node->implements = $implements; + } +} diff --git a/packages/upgrade/src/Tempest2/RemoveDatabaseMigrationImportRector.php b/packages/upgrade/src/Tempest2/RemoveDatabaseMigrationImportRector.php new file mode 100644 index 000000000..a19846e05 --- /dev/null +++ b/packages/upgrade/src/Tempest2/RemoveDatabaseMigrationImportRector.php @@ -0,0 +1,30 @@ +name->toString() === 'Tempest\Database\DatabaseMigration') { + return NodeVisitor::REMOVE_NODE; + } + + return null; + } +} diff --git a/packages/upgrade/src/Tempest2/RemoveIdImportRector.php b/packages/upgrade/src/Tempest2/RemoveIdImportRector.php new file mode 100644 index 000000000..1bd81e465 --- /dev/null +++ b/packages/upgrade/src/Tempest2/RemoveIdImportRector.php @@ -0,0 +1,30 @@ +name->toString() === 'Tempest\\Database\\Id') { + return NodeVisitor::REMOVE_NODE; + } + + return null; + } +} diff --git a/packages/upgrade/src/tempest2.php b/packages/upgrade/src/tempest2.php new file mode 100644 index 000000000..1bf390ac9 --- /dev/null +++ b/packages/upgrade/src/tempest2.php @@ -0,0 +1,68 @@ +importNames(); + $config->importShortClasses(); + + $config->rule(MigrationRector::class); + + $config->ruleWithConfiguration(RenameClassRector::class, [ + 'Tempest\Database\Id' => 'Tempest\Database\PrimaryKey', + 'Tempest\CommandBus\AsyncCommand' => 'Tempest\CommandBus\Async', + 'Tempest\Validation\Rules\AlphaNumeric' => 'Tempest\Validation\Rules\IsAlphaNumeric', + 'Tempest\Validation\Rules\ArrayList' => 'Tempest\Validation\Rules\IsArrayList', + 'Tempest\Validation\Rules\BeforeDate' => 'Tempest\Validation\Rules\IsBeforeDate', + 'Tempest\Validation\Rules\Between' => 'Tempest\Validation\Rules\IsBetween', + 'Tempest\Validation\Rules\BetweenDates' => 'Tempest\Validation\Rules\IsBetweenDates', + 'Tempest\Validation\Rules\Count' => 'Tempest\Validation\Rules\HasCount', + 'Tempest\Validation\Rules\DateTimeFormat' => 'Tempest\Validation\Rules\HasDateTimeFormat', + 'Tempest\Validation\Rules\DivisibleBy' => 'Tempest\Validation\Rules\IsDivisibleBy', + 'Tempest\Validation\Rules\Email' => 'Tempest\Validation\Rules\IsEmail', + 'Tempest\Validation\Rules\EndsWith' => 'Tempest\Validation\Rules\EndsWith', + 'Tempest\Validation\Rules\Even' => 'Tempest\Validation\Rules\IsEvenNumber', + 'Tempest\Validation\Rules\HexColor' => 'Tempest\Validation\Rules\IsHexColor', + 'Tempest\Validation\Rules\IP' => 'Tempest\Validation\Rules\IsIP', + 'Tempest\Validation\Rules\IPv4' => 'Tempest\Validation\Rules\IsIPv4', + 'Tempest\Validation\Rules\IPv6' => 'Tempest\Validation\Rules\IsIPv6', + 'Tempest\Validation\Rules\In' => 'Tempest\Validation\Rules\IsIn', + 'Tempest\Validation\Rules\Json' => 'Tempest\Validation\Rules\IsJsonString', + 'Tempest\Validation\Rules\Length' => 'Tempest\Validation\Rules\HasLength', + 'Tempest\Validation\Rules\Lowercase' => 'Tempest\Validation\Rules\IsLowercase', + 'Tempest\Validation\Rules\MACAddress' => 'Tempest\Validation\Rules\IsMacAddress', + 'Tempest\Validation\Rules\MultipleOf' => 'Tempest\Validation\Rules\IsMultipleOf', + 'Tempest\Validation\Rules\NotEmpty' => 'Tempest\Validation\Rules\IsNotEmptyString', + 'Tempest\Validation\Rules\NotIn' => 'Tempest\Validation\Rules\IsNotIn', + 'Tempest\Validation\Rules\NotNull' => 'Tempest\Validation\Rules\IsNotNull', + 'Tempest\Validation\Rules\Numeric' => 'Tempest\Validation\Rules\IsNumeric', + 'Tempest\Validation\Rules\Odd' => 'Tempest\Validation\Rules\IsOddNumber', + 'Tempest\Validation\Rules\Password' => 'Tempest\Validation\Rules\IsPassword', // @mago-expect security/no-literal-password + 'Tempest\Validation\Rules\PhoneNumber' => 'Tempest\Validation\Rules\IsPhoneNumber', + 'Tempest\Validation\Rules\RegEx' => 'Tempest\Validation\Rules\MatchesRegEx', + 'Tempest\Validation\Rules\Time' => 'Tempest\Validation\Rules\IsTime', + 'Tempest\Validation\Rules\Timestamp' => 'Tempest\Validation\Rules\IsUnixTimestamp', + 'Tempest\Validation\Rules\Timezone' => 'Tempest\Validation\Rules\IsTimezone', + 'Tempest\Validation\Rules\Ulid' => 'Tempest\Validation\Rules\IsUlid', + 'Tempest\Validation\Rules\Uppercase' => 'Tempest\Validation\Rules\IsUppercase', + 'Tempest\Validation\Rules\Url' => 'Tempest\Validation\Rules\IsUrl', + 'Tempest\Validation\Rules\Uuid' => 'Tempest\Validation\Rules\IsUuid', + ]); + + $config->ruleWithConfiguration(RenamePropertyRector::class, [ + new RenameProperty( + type: 'Tempest\Database\PrimaryKey', + oldProperty: 'id', + newProperty: 'value', + ), + ]); + + $config->rule(RemoveIdImportRector::class); + $config->rule(RemoveDatabaseMigrationImportRector::class); +}; diff --git a/packages/upgrade/tests/RectorTester.php b/packages/upgrade/tests/RectorTester.php new file mode 100644 index 000000000..b4925df5d --- /dev/null +++ b/packages/upgrade/tests/RectorTester.php @@ -0,0 +1,99 @@ +fixturePath = $fixturePath; + $clone->actual = $this->getActual($fixturePath); + + return $clone; + } + + /** + * @param Closure(string $actual): void $test + */ + public function assert(Closure $test): self + { + $test($this->actual); + + return $this; + } + + public function assertMatchesExpected(): self + { + $expected = file_get_contents(str_replace('.input.php', '.expected.php', $this->fixturePath)); + [$expected, $actual] = preg_replace('/^<\?php\s*/', '', [$expected, $this->actual]); + $expected = trim($expected); + $actual = trim($actual); + + Assert::assertSame($expected, $actual); + + return $this; + } + + public function assertContains(string $needle): self + { + Assert::assertStringContainsString($needle, $this->actual); + + return $this; + } + + public function assertNotContains(string $needle): self + { + Assert::assertStringNotContainsString($needle, $this->actual); + + return $this; + } + + private function getActual(string $fixturePath): string + { + $rectorContainerFactory = new RectorContainerFactory(); + $bootstrapConfigs = new BootstrapConfigs($this->configPath, []); + $container = $rectorContainerFactory->createFromBootstrapConfigs($bootstrapConfigs); + + $config = new Configuration( + isDryRun: true, + shouldClearCache: true, + showDiffs: true, + ); + + $processer = $container->make(ApplicationFileProcessor::class); + + $diff = $processer->processFiles([$fixturePath], $config)->getFileDiffs()[0] ?? null; + + return $this->cleanDiff($diff?->getDiff() ?? ''); + } + + private function cleanDiff(string $diff): string + { + $diff = preg_replace('/^\+\+\+.*\n/m', '', $diff); + $diff = preg_replace('/^@@.*?@@\n/m', '', $diff); + $diff = preg_replace('/\\\\ No newline at end of file\n/', '', $diff); + + $diff = preg_replace('/^\s/m', '', $diff); + + $diff = preg_replace('/^-.*\n/m', '', $diff); + $diff = preg_replace('/^\+/m', '', $diff); + + return trim($diff); + } +} diff --git a/packages/upgrade/tests/Tempest2/Fixtures/MigrateUpAndDownMigration.input.php b/packages/upgrade/tests/Tempest2/Fixtures/MigrateUpAndDownMigration.input.php new file mode 100644 index 000000000..a6e60103f --- /dev/null +++ b/packages/upgrade/tests/Tempest2/Fixtures/MigrateUpAndDownMigration.input.php @@ -0,0 +1,27 @@ + '00-00-0000'; + } + + public function up(): ?QueryStatement + { + return new CreateTableStatement('table') + ->primary() + ->datetime('createdAt'); + } + + public function down(): ?QueryStatement + { + return new DropTableStatement('table'); + } +} diff --git a/packages/upgrade/tests/Tempest2/Fixtures/MigrateUpMigration.expected.php b/packages/upgrade/tests/Tempest2/Fixtures/MigrateUpMigration.expected.php new file mode 100644 index 000000000..99c1f5805 --- /dev/null +++ b/packages/upgrade/tests/Tempest2/Fixtures/MigrateUpMigration.expected.php @@ -0,0 +1,20 @@ + '00-00-0000'; + } + + public function up(): QueryStatement + { + return new CreateTableStatement('table') + ->primary() + ->datetime('createdAt'); + } +} diff --git a/packages/upgrade/tests/Tempest2/Fixtures/MigrateUpMigration.input.php b/packages/upgrade/tests/Tempest2/Fixtures/MigrateUpMigration.input.php new file mode 100644 index 000000000..be72c32d3 --- /dev/null +++ b/packages/upgrade/tests/Tempest2/Fixtures/MigrateUpMigration.input.php @@ -0,0 +1,26 @@ + '00-00-0000'; + } + + public function up(): ?QueryStatement + { + return new CreateTableStatement('table') + ->primary() + ->datetime('createdAt'); + } + + public function down(): ?QueryStatement + { + return null; + } +} diff --git a/packages/upgrade/tests/Tempest2/Fixtures/Model.input.php b/packages/upgrade/tests/Tempest2/Fixtures/Model.input.php new file mode 100644 index 000000000..b1543cfbb --- /dev/null +++ b/packages/upgrade/tests/Tempest2/Fixtures/Model.input.php @@ -0,0 +1,13 @@ +id->id; + } +} diff --git a/packages/upgrade/tests/Tempest2/Tempest2RectorTest.php b/packages/upgrade/tests/Tempest2/Tempest2RectorTest.php new file mode 100644 index 000000000..6678dd841 --- /dev/null +++ b/packages/upgrade/tests/Tempest2/Tempest2RectorTest.php @@ -0,0 +1,41 @@ + new RectorTester(__DIR__ . '/tempest2_rector.php'); + } + + public function test_migration_with_only_up(): void + { + $this->rector + ->runFixture(__DIR__ . '/Fixtures/MigrateUpMigration.input.php') + ->assertMatchesExpected(); + } + + public function test_migration_with_up_and_down(): void + { + $this->rector + ->runFixture(__DIR__ . '/Fixtures/MigrateUpAndDownMigration.input.php') + ->assertContains('implements \Tempest\Database\MigratesUp, \Tempest\Database\MigratesDown') + ->assertContains('return new DropTableStatement(\'table\')') + ->assertNotContains('Tempest\Database\DatabaseMigration') + ->assertContains('public function down(): QueryStatement'); + } + + public function test_database_id_rename(): void + { + $this->rector + ->runFixture(__DIR__ . '/Fixtures/Model.input.php') + ->assertContains('public PrimaryKey $id') + ->assertContains('return $this->id->value;') + ->assertContains('Tempest\Database\PrimaryKey') + ->assertNotContains('Id') + ->assertNotContains('Tempest\Database\Id'); + } +} diff --git a/packages/upgrade/tests/Tempest2/tempest2_rector.php b/packages/upgrade/tests/Tempest2/tempest2_rector.php new file mode 100644 index 000000000..834229162 --- /dev/null +++ b/packages/upgrade/tests/Tempest2/tempest2_rector.php @@ -0,0 +1,8 @@ +withSets([ + __DIR__ . '/../../src/tempest2.php', + ]);