diff --git a/composer.json b/composer.json index 6503367..b0daa1a 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,7 @@ "symfony/expression-language": "^5.4 || ^6.4 || ^7.0", "symfony/filesystem": "^5.4 || ^6.4 || ^7.0", "symfony/http-client": "^5.4 || ^6.4 || ^7.0", + "symfony/maker-bundle": "^1.62", "symfony/messenger": "^5.4 || ^6.4 || ^7.0", "symfony/process": "^5.4 || ^6.4 || ^7.0", "symfony/serializer": "^5.4 || ^6.4 || ^7.0", diff --git a/config/services.php b/config/services.php index d87f9b5..bf9efe3 100644 --- a/config/services.php +++ b/config/services.php @@ -18,6 +18,7 @@ use Sofascore\PurgatoryBundle\Command\DebugCommand; use Sofascore\PurgatoryBundle\Doctrine\DBAL\Middleware; use Sofascore\PurgatoryBundle\Listener\EntityChangeListener; +use Sofascore\PurgatoryBundle\Maker\MakeRouteProvider; use Sofascore\PurgatoryBundle\Purger\AsyncPurger; use Sofascore\PurgatoryBundle\Purger\InMemoryPurger; use Sofascore\PurgatoryBundle\Purger\Messenger\PurgeMessageHandler; @@ -234,5 +235,11 @@ service('doctrine'), ]) ->tag('console.command') + + ->set('sofascore.purgatory.maker.make_provider', MakeRouteProvider::class) + ->args([ + service('doctrine'), + ]) + ->tag('maker.command') ; }; diff --git a/psalm-baseline.xml b/psalm-baseline.xml index a69be82..7c72c80 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + getPath()]]> @@ -85,4 +85,31 @@ + + + + + + + + + diff --git a/src/Maker/MakeRouteProvider.php b/src/Maker/MakeRouteProvider.php new file mode 100644 index 0000000..cb8af92 --- /dev/null +++ b/src/Maker/MakeRouteProvider.php @@ -0,0 +1,196 @@ +addArgument('name', InputArgument::OPTIONAL, 'The name of the Purgatory route provider class (e.g. BlogPostRouteProvider)'); + } + + /** + * {@inheritDoc} + */ + public function configureDependencies(DependencyBuilder $dependencies): void + { + } + + /** + * {@inheritDoc} + */ + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + /** @var string $name */ + $name = $input->getArgument('name'); + + if (false === $entity = $this->inputEntity($io)) { + return; + } + + $variables = [ + 'entity' => Str::getShortClassName($entity), + ]; + + if (null !== $actions = $this->inputActions($io)) { + $variables['actions'] = new ActionCollection($actions); + } + + $classData = ClassData::create( + class: \sprintf('Purgatory\RouteProvider\%s', $name), + suffix: 'RouteProvider', + useStatements: [ + RouteProviderInterface::class, + PurgeRoute::class, + Action::class, + $entity, + ], + ); + + $generator->generateClassFromClassData( + classData: $classData, + templateName: __DIR__.'/../../templates/maker/RouteProvider.tpl.php', + variables: $variables, + ); + + $generator->writeChanges(); + } + + /** + * @return class-string|false + */ + private function inputEntity(ConsoleStyle $io): string|false + { + $q = new Question('What entity is the purge route provider for?'); + $q->setTrimmable(true); + + /** @var string $purgeEntity */ + $purgeEntity = $io->askQuestion($q); + + $entities = $this->getEntityCollection(); + $entities = array_filter( + $entities, + static fn (string $name): bool => str_contains(strtolower($name), strtolower($purgeEntity)), + \ARRAY_FILTER_USE_KEY, + ); + + if ([] === $entities) { + $io->error('No entities found'); + + return false; + } + + if (1 === \count($entities) && 1 === \count($entities[array_key_first($entities)])) { + $entity = $entities[array_key_first($entities)][0]; + } else { + $entityChoices = array_keys($entities); + sort($entityChoices, \SORT_STRING | \SORT_FLAG_CASE); + + /** @var string $target */ + $target = $io->choice('Select one of the available entities', $entityChoices); + + /** @var class-string $entity */ + $entity = \count($entities[$target]) > 1 + ? $io->choice('Select one of the available entities', $entities[$target]) + : $entities[$target][0]; + } + + return $entity; + } + + /** + * @return ?non-empty-list<'Create'|'Update'|'Delete'> + */ + private function inputActions(ConsoleStyle $io): ?array + { + if ($io->confirm('Should route provider handle only some actions (update/create/delete)?', default: false)) { + /** @var non-empty-list<'Create'|'Update'|'Delete'> $actions */ + $actions = $io->choice( + question: 'Select actions', + choices: [ + Action::Create->name, + Action::Update->name, + Action::Delete->name, + ], + multiSelect: true, + ); + + $actions = array_values(array_unique($actions)); + } else { + $actions = null; + } + + return $actions; + } + + /** + * @return array> + */ + private function getEntityCollection(): array + { + /** @var array> $entities */ + $entities = []; + + foreach ($this->managerRegistry->getManagers() as $manager) { + foreach ($manager->getMetadataFactory()->getAllMetadata() as $metadata) { + $entityFqcn = $metadata->getName(); + $name = strrchr($entityFqcn, '\\'); + $name = substr(false === $name ? $entityFqcn : $name, 1); + + if (isset($entities[$name])) { + if (!\in_array($entityFqcn, $entities[$name], true)) { + $entities[$name][] = $entityFqcn; + } + } else { + $entities[$name] = [$entityFqcn]; + } + } + } + + foreach ($entities as &$entityFqcns) { + sort($entityFqcns, \SORT_STRING | \SORT_FLAG_CASE); + } + + return $entities; + } +} diff --git a/src/Maker/Util/ActionCollection.php b/src/Maker/Util/ActionCollection.php new file mode 100644 index 0000000..65fc751 --- /dev/null +++ b/src/Maker/Util/ActionCollection.php @@ -0,0 +1,30 @@ + $actions + */ + public function __construct( + private readonly array $actions, + ) { + } + + public function __toString(): string + { + if (1 === \count($this->actions)) { + return "Action::{$this->actions[0]} === \$action"; + } + + $haystack = implode( + ', ', + array_map(static fn (string $name): string => "Action::$name", $this->actions), + ); + + return "\in_array(\$action, [$haystack], true)"; + } +} diff --git a/templates/maker/RouteProvider.tpl.php b/templates/maker/RouteProvider.tpl.php new file mode 100644 index 0000000..0bc29ba --- /dev/null +++ b/templates/maker/RouteProvider.tpl.php @@ -0,0 +1,37 @@ + + +namespace getNamespace(); ?>; + +getUseStatements(); ?> + +/** + * @implements RouteProviderInterface<> + */ +getClassDeclaration(); ?> implements RouteProviderInterface +{ + /** + * {@inheritDoc} + */ + public function provideRoutesFor(Action $action, object $entity, array $entityChangeSet): iterable + { + // add with your own logic if needed + + yield new PurgeRoute( + name: 'app_route', // replace it with your route + params: [ + 'param1' => $entity, // replace it with correct data for route parameters + ], + ); + } + + public function supports(Action $action, object $entity): bool + { + return $entity instanceof + +; + + + && ; + + } +} diff --git a/tests/Functional/TestApplication/config/app_config.yaml b/tests/Functional/TestApplication/config/app_config.yaml index 79962e6..c313c43 100644 --- a/tests/Functional/TestApplication/config/app_config.yaml +++ b/tests/Functional/TestApplication/config/app_config.yaml @@ -5,3 +5,6 @@ services: Sofascore\PurgatoryBundle\Tests\Functional\TestApplication\: resource: '../' + +maker: + root_namespace: 'Sofascore\PurgatoryBundle\Tests\Functional\TestApplication\Generated' diff --git a/tests/Functional/TestKernel.php b/tests/Functional/TestKernel.php index 44ecfc9..50b6b21 100644 --- a/tests/Functional/TestKernel.php +++ b/tests/Functional/TestKernel.php @@ -7,6 +7,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Sofascore\PurgatoryBundle\PurgatoryBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\MakerBundle\MakerBundle; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; @@ -63,6 +64,7 @@ public function registerBundles(): iterable yield new FrameworkBundle(); yield new DoctrineBundle(); yield new PurgatoryBundle(); + yield new MakerBundle(); } public function shutdown(): void diff --git a/tests/Maker/Expected/AnimalCompetitionRouteProvider.txt b/tests/Maker/Expected/AnimalCompetitionRouteProvider.txt new file mode 100644 index 0000000..21cb0e9 --- /dev/null +++ b/tests/Maker/Expected/AnimalCompetitionRouteProvider.txt @@ -0,0 +1,35 @@ + + */ +final class AnimalCompetitionRouteProvider implements RouteProviderInterface +{ + /** + * {@inheritDoc} + */ + public function provideRoutesFor(Action $action, object $entity, array $entityChangeSet): iterable + { + // add with your own logic if needed + + yield new PurgeRoute( + name: 'app_route', // replace it with your route + params: [ + 'param1' => $entity, // replace it with correct data for route parameters + ], + ); + } + + public function supports(Action $action, object $entity): bool + { + return $entity instanceof AnimalCompetition + && \in_array($action, [Action::Create, Action::Update, Action::Delete], true); + } +} diff --git a/tests/Maker/Expected/PlaneRouteProvider.txt b/tests/Maker/Expected/PlaneRouteProvider.txt new file mode 100644 index 0000000..2088def --- /dev/null +++ b/tests/Maker/Expected/PlaneRouteProvider.txt @@ -0,0 +1,34 @@ + + */ +final class PlaneRouteProvider implements RouteProviderInterface +{ + /** + * {@inheritDoc} + */ + public function provideRoutesFor(Action $action, object $entity, array $entityChangeSet): iterable + { + // add with your own logic if needed + + yield new PurgeRoute( + name: 'app_route', // replace it with your route + params: [ + 'param1' => $entity, // replace it with correct data for route parameters + ], + ); + } + + public function supports(Action $action, object $entity): bool + { + return $entity instanceof Plane; + } +} diff --git a/tests/Maker/Expected/ShipBuildRouteProvider.txt b/tests/Maker/Expected/ShipBuildRouteProvider.txt new file mode 100644 index 0000000..3811aa7 --- /dev/null +++ b/tests/Maker/Expected/ShipBuildRouteProvider.txt @@ -0,0 +1,35 @@ + + */ +final class ShipBuildRouteProvider implements RouteProviderInterface +{ + /** + * {@inheritDoc} + */ + public function provideRoutesFor(Action $action, object $entity, array $entityChangeSet): iterable + { + // add with your own logic if needed + + yield new PurgeRoute( + name: 'app_route', // replace it with your route + params: [ + 'param1' => $entity, // replace it with correct data for route parameters + ], + ); + } + + public function supports(Action $action, object $entity): bool + { + return $entity instanceof Ship + && Action::Create === $action; + } +} diff --git a/tests/Maker/Expected/VehicleFixRouteProvider.txt b/tests/Maker/Expected/VehicleFixRouteProvider.txt new file mode 100644 index 0000000..44a0071 --- /dev/null +++ b/tests/Maker/Expected/VehicleFixRouteProvider.txt @@ -0,0 +1,35 @@ + + */ +final class VehicleFixRouteProvider implements RouteProviderInterface +{ + /** + * {@inheritDoc} + */ + public function provideRoutesFor(Action $action, object $entity, array $entityChangeSet): iterable + { + // add with your own logic if needed + + yield new PurgeRoute( + name: 'app_route', // replace it with your route + params: [ + 'param1' => $entity, // replace it with correct data for route parameters + ], + ); + } + + public function supports(Action $action, object $entity): bool + { + return $entity instanceof Vehicle + && \in_array($action, [Action::Update, Action::Delete], true); + } +} diff --git a/tests/Maker/MakeRouteProviderTest.php b/tests/Maker/MakeRouteProviderTest.php new file mode 100644 index 0000000..dcc469d --- /dev/null +++ b/tests/Maker/MakeRouteProviderTest.php @@ -0,0 +1,85 @@ +colSize = getenv('COLUMNS'); + putenv('COLUMNS=300'); + + self::initializeApplication(['test_case' => 'TestApplication', 'config' => 'app_config.yaml']); + + $this->command = new CommandTester((new Application(self::$kernel))->find('make:purgatory-provider')); + } + + protected function tearDown(): void + { + putenv($this->colSize ? 'COLUMNS='.$this->colSize : 'COLUMNS'); + + unset( + $this->colSize, + $this->command, + ); + + (new Filesystem())->remove(__DIR__.'/../Functional/TestApplication/Generated/'); + + parent::tearDown(); + } + + #[TestWith([ + 'input' => ['PlaneRouteProvider', 'Plane', 'no'], + 'expected' => 'PlaneRouteProvider', + ])] + #[TestWith([ + 'input' => ['Plane', 'Plane', 'no'], + 'expected' => 'PlaneRouteProvider', + ])] + #[TestWith([ + 'input' => ['ShipBuild', 'Ship', 'yes', '0'], + 'expected' => 'ShipBuildRouteProvider', + ])] + #[TestWith([ + 'input' => ['VehicleFix', 'Vehicle', 'yes', '1,2'], + 'expected' => 'VehicleFixRouteProvider', + ])] + #[TestWith([ + 'input' => ['AnimalCompetition', 'Animal', '1', 'yes', '0,1,2'], + 'expected' => 'AnimalCompetitionRouteProvider', + ])] + public function testGenerateRouteProvider(array $input, string $expected): void + { + $this->command->setInputs([implode(\PHP_EOL, $input).\PHP_EOL]); + $this->command->execute([], ['interactive' => true]); + + self::assertFileExists(__DIR__."/../Functional/TestApplication/Generated/Purgatory/RouteProvider/$expected.php"); + self::assertFileEquals( + expected: __DIR__."/Expected/$expected.txt", + actual: __DIR__."/../Functional/TestApplication/Generated/Purgatory/RouteProvider/$expected.php", + ); + } + + public function testInvalidEntityInput(): void + { + $input = ['foo', 'BlogPost']; + $this->command->setInputs([implode(\PHP_EOL, $input).\PHP_EOL]); + $this->command->execute([], ['interactive' => true]); + + self::assertStringContainsString('[ERROR] No entities found', $this->command->getDisplay()); + } +} diff --git a/tests/Maker/Util/ActionCollectionTest.php b/tests/Maker/Util/ActionCollectionTest.php new file mode 100644 index 0000000..5c0378f --- /dev/null +++ b/tests/Maker/Util/ActionCollectionTest.php @@ -0,0 +1,32 @@ +