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..93de9e1 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\MakePurgeOn; use Sofascore\PurgatoryBundle\Purger\AsyncPurger; use Sofascore\PurgatoryBundle\Purger\InMemoryPurger; use Sofascore\PurgatoryBundle\Purger\Messenger\PurgeMessageHandler; @@ -234,5 +235,14 @@ service('doctrine'), ]) ->tag('console.command') + + ->set('sofascore.purgatory.maker.make_purgeon', MakePurgeOn::class) + ->args([ + service('router'), + service('doctrine'), + '%kernel.project_dir%', + [], + ]) + ->tag('maker.command') ; }; diff --git a/src/DependencyInjection/PurgatoryExtension.php b/src/DependencyInjection/PurgatoryExtension.php index 7017b9a..35e6597 100644 --- a/src/DependencyInjection/PurgatoryExtension.php +++ b/src/DependencyInjection/PurgatoryExtension.php @@ -125,6 +125,9 @@ static function (ChildDefinition $definition, AsExpressionLanguageFunction $attr $container->getDefinition('sofascore.purgatory.route_metadata_provider.yaml') ->replaceArgument(1, $files); + + $container->getDefinition('sofascore.purgatory.maker.make_purgeon') + ->replaceArgument(3, $files); } else { $container->removeDefinition('sofascore.purgatory.route_metadata_provider.yaml'); } diff --git a/src/Maker/MakePurgeOn.php b/src/Maker/MakePurgeOn.php new file mode 100644 index 0000000..f00f403 --- /dev/null +++ b/src/Maker/MakePurgeOn.php @@ -0,0 +1,660 @@ + $yamlPurgeRuleFiles + */ + public function __construct( + private readonly RouterInterface $router, + private readonly ManagerRegistry $managerRegistry, + private readonly string $projectDir, + private readonly array $yamlPurgeRuleFiles, + ) { + $this->parser = (new ParserFactory())->createForHostVersion(); + } + + /** + * {@inheritDoc} + */ + public static function getCommandName(): string + { + return 'make:purgatory-rule'; + } + + public static function getCommandDescription(): string + { + return 'Create new purge rule'; + } + + /** + * {@inheritDoc} + */ + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + } + + /** + * {@inheritDoc} + */ + public function configureDependencies(DependencyBuilder $dependencies): void + { + } + + /** + * {@inheritDoc} + */ + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + if (false === $result = $this->inputRoute($io)) { + return; + } + [$routeName, $route] = $result; + + $generateAttribute = $route->hasDefault('_controller'); + + if ($generateAttribute) { + /** @var string $controllerAction */ + $controllerAction = $route->getDefault('_controller'); + + /* + * If alias does not exist, it means that there are multiple routes + * for same method so we have to explicitly state which route is PurgeOn for. + */ + $explicitRouteName = null === $this->router->getRouteCollection()->getAlias($controllerAction) + ? $routeName + : null; + } else { + // YAML configuration will be created so the route name is mandatory + $explicitRouteName = $routeName; + } + + [$file, $line] = $this->getPurgeOnLocation($io, $route, $routeName); + + if (false === $entity = $this->inputEntity($io)) { + return; + } + + $io->comment("Selected entity $entity"); + + if (false === $target = $this->inputTarget($io)) { + return; + } + + $routeParams = $this->inputRouteParams($io, $route); + + $includeIf = $io->confirm('Should a purge rule have if expression?', default: false); + + $actions = $this->inputActions($io); + + $this->mumboJumbo($generateAttribute, $file, $line, $entity, $explicitRouteName, $target, $routeParams, $includeIf, $actions); + $io->success('Purge rule created'); + } + + private function removeInlineDefaultsAndRequirements(string $pattern): string + { + if (false === strpbrk($pattern, '?<:')) { + return $pattern; + } + + /* @see Route::extractInlineDefaultsAndRequirements */ + return preg_replace_callback( + '#\{(!?)([\w\x80-\xFF]++)(:[\w\x80-\xFF]++)?(<.*?>)?(\?[^\}]*+)?\}#', + static fn ($m): string => '{'.$m[1].$m[2].'}', + $pattern, + ) ?? throw new \RuntimeException('Something went wrong'); + } + + /** + * @param class-string $entity + * @param string|non-empty-list|ForGroups|null $target + * @param array|null $routeParams + * @param ?non-empty-list<'Update'|'Create'|'Delete'> $actions + */ + private function mumboJumbo( + bool $generateAttribute, + string $fileName, + ?int $line, + string $entity, + ?string $route, + string|array|ForGroups|null $target, + ?array $routeParams, + bool $includeIf, + ?array $actions, + ): void { + $file = file_get_contents($fileName); + if (false === $file) { + throw new RuntimeException("Could not read file '$fileName'"); + } + + $lines = explode("\n", $file); + $offset = -1; + + $purgeOnBuilder = $this->preparePurgeOnBuilder($generateAttribute, $entity, $target, $routeParams, $actions, $file, $line, $offset, $lines); + + if (null !== $target) { + $purgeOnBuilder->addTarget($target); + } + + if (null !== $routeParams) { + $purgeOnBuilder->addRouteParams($routeParams); + } + + if ($includeIf) { + $purgeOnBuilder->includeIf(); + } + + if (null !== $route) { + $purgeOnBuilder->addRoute($route); + } + + if (null !== $actions) { + $purgeOnBuilder->addActions($actions); + } + + $offset = null === $line ? -1 : $line + $offset; + array_splice($lines, $offset, 0, $purgeOnBuilder->generate()); + file_put_contents($fileName, implode("\n", $lines)); + } + + /** + * @return array{0: string, 1: Route}|false + */ + private function inputRoute(ConsoleStyle $io): array|false + { + $q = new Question('What route is the PurgeOn rule for?'); + $q->setTrimmable(true); + + $matchingRoutes = $this->getMatchingRoutes($io->askQuestion($q)); + + if ([] === $matchingRoutes) { + $io->error('No route found matching the pattern'); + + return false; + } + + if (1 === \count($matchingRoutes)) { + $routeName = array_key_first($matchingRoutes); + } else { + /** @var ?string $selected */ + $selected = $io->choice('Choose route', $this->formatRouteChoices($matchingRoutes)); + + if (null === $selected) { + $io->error('Invalid selection'); + + return false; + } + + $routeName = strtok($selected, ' '); + } + + if (!\is_string($routeName)) { + throw new \RuntimeException('Could not get route name'); + } + + /** @var Route $route */ + $route = $this->router->getRouteCollection()->get($routeName); + + return [$routeName, $route]; + } + + /** + * @return array{0: string, 1: ?int} + */ + private function getPurgeOnLocation(ConsoleStyle $io, Route $route, string $routeName): array + { + if (!$route->hasDefault('_controller')) { + $io->comment('Could not find controller for route. YAML config will be created.'); + + return $this->inputYamlConfigLocation($io, $routeName); + } + + /** @var string $controllerAction */ + $controllerAction = $route->getDefault('_controller'); + + $reflection = new \ReflectionMethod($controllerAction); + if (false === $file = $reflection->getFileName()) { + throw new \RuntimeException('Could not get file name for route'); + } + + if (false === $line = $reflection->getStartLine()) { + throw new \RuntimeException("Could not get start line for '$controllerAction'"); + } + + return [$file, $line]; + } + + /** + * @return array + */ + private function getMatchingRoutes(string $pattern): array + { + $routeCollection = $this->router->getRouteCollection()->all(); + + return array_filter( + $routeCollection, + function (Route $route, string $routeName) use ($pattern) { + return str_contains($routeName, $pattern) + || ($route->hasDefault('_controller') && str_contains($route->getDefault('_controller'), $pattern)) + || (str_contains($route->getPath(), $pattern) || str_contains($route->getPath(), $this->removeInlineDefaultsAndRequirements($pattern))); + }, + \ARRAY_FILTER_USE_BOTH, + ); + } + + /** + * @param array $routes + * + * @return list + */ + private function formatRouteChoices(array $routes): array + { + $routeNameCols = $pathCols = 0; + foreach ($routes as $routeName => $route) { + $routeNameCols = max($routeNameCols, \strlen($routeName)); + $pathCols = max($pathCols, \strlen($route->getPath())); + } + + $choices = []; + + foreach ($routes as $routeName => $route) { + $choice = str_pad($routeName, $routeNameCols).' '.str_pad($route->getPath(), $pathCols); + if ($route->hasDefault('_controller')) { + $choice .= " {$route->getDefault('_controller')}"; + } + + $choices[] = $choice; + } + + return $choices; + } + + /** + * @return class-string|false + */ + private function inputEntity(ConsoleStyle $io): string|false + { + $q = new Question('What entity is the PurgeOn rule for?'); + $q->setTrimmable(true); + $purgeEntity = (string) $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)])) { + /** @var class-string $entity */ + $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 ForGroups|string|non-empty-list|false|null + */ + private function inputTarget(ConsoleStyle $io): ForGroups|string|array|false|null + { + $targetType = $io->choice( + question: 'Select target type', + choices: [ + 'Properties', + 'Serialization groups', + ], + default: 'Properties', + ); + if ('Properties' === $targetType) { + if ($io->confirm('Should purge rule trigger for any change on entity?')) { + $target = null; + } else { + $properties = []; + + while (null !== $property = $io->ask('Insert property (press enter to continue)')) { + if (\in_array($property, $properties, true)) { + $io->warning("'$property' is already targeted"); + continue; + } + $properties[] = $property; + } + + /** @var string|non-empty-list|null $target */ + $target = match (\count($properties)) { + 0 => null, + 1 => $properties[0], + default => $properties, + }; + } + } else { + /** @var list $groups */ + $groups = []; + + while (null !== $group = $io->ask('Insert serialization group (press enter to continue)')) { + if (\in_array($group, $groups, true)) { + $io->warning("Group '$group' is already targeted"); + continue; + } + + $groups[] = (string) $group; + } + + if ([] === $groups) { + $io->error('Must define at least one serialization group'); + + return false; + } + + $target = new ForGroups($groups); + } + + return $target; + } + + /** + * @return array|null + */ + private function inputRouteParams(ConsoleStyle $io, Route $route): ?array + { + $routeParams = []; + + /** @var string $routeParam */ + foreach ($route->compile()->getPathVariables() as $routeParam) { + /** @var string $routeParamType */ + $routeParamType = $io->choice( + question: "How will route param $routeParam be generated? (press to skip)", + choices: [ + 'Property path', + 'Enum cases', + 'Literal value(s)', + 'Service', + 'SKIP', + ], + default: 'SKIP', + ); + + if ('SKIP' === $routeParamType) { + continue; + } + + $routeParams[$routeParam] = match ($routeParamType) { + 'Property path' => PropertyValues::class, + 'Enum cases' => EnumValues::class, + 'Literal value(s)' => RawValues::class, + 'Service' => DynamicValues::class, + }; + } + + if ([] === $routeParams) { + $routeParams = null; + } + + return $routeParams; + } + + /** + * @return ?non-empty-list<'Create'|'Update'|'Delete'> + */ + private function inputActions(ConsoleStyle $io): ?array + { + if ($io->confirm('Should purge rule be limited to an action (create/update/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{0: string, 1: ?int} + */ + private function inputYamlConfigLocation(ConsoleStyle $io, string $routeName): array + { + $choices = array_map( + function (string $path): string { + if (!str_starts_with($path, $this->projectDir)) { + throw new \RuntimeException('Something went wrong'); + } + + return substr($path, \strlen($this->projectDir.'/config/purgatory/')); + }, + $this->yamlPurgeRuleFiles, + ); + + $filename = "$this->projectDir/config/purgatory/"; + if ([] === $choices) { + $io->comment('No YAML purge configuration found. Creating...'); + $filename .= 'purge_rules.yaml'; + touch($filename); + } elseif (1 === \count($choices)) { + $filename .= $choices[0]; + } else { + /** @var string $file */ + $file = $io->choice( + question: 'Choose where to store purge rule', + choices: $choices, + ); + $filename .= $file; + } + + $io->comment("Purge rule will be generated in '$filename'"); + + $file = file_get_contents($filename); + if (false === $file) { + throw new \RuntimeException("Could not read file '$filename'"); + } + + $rows = explode("\n", $file); + + $line = null; + foreach ($rows as $i => $row) { + if ("$routeName:" === $row) { + $line = $i + 2; + break; + } + } + + return [$filename, $line]; + } + + /** + * @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; + } + + /** + * @param string|list|ForGroups|null $target + * @param class-string $entity + */ + private function preparePurgeOnBuilder( + bool $generateAttribute, + string $entity, + string|array|ForGroups|null $target, + ?array $routeParams, + ?array $actions, + string $file, + ?int $line, + int &$offset, + array &$lines, + ): PurgeOnBuilderInterface { + if (!$generateAttribute) { + return new PurgeOnYamlBuilder($entity, includeRouteName: null === $line); + } + + $purgeOnDeclarationVisitor = new UseDeclarationVisitor(PurgeOn::class); + $classDeclarationVisitor = new UseDeclarationVisitor($entity); + + /** @var UseDeclarationVisitor[] $useDeclarationVisitors */ + $useDeclarationVisitors = [ + $purgeOnDeclarationVisitor, + $classDeclarationVisitor, + ]; + + if ($target instanceof ForGroups) { + $forGroupsDeclarationVisitor = new UseDeclarationVisitor(ForGroups::class); + $useDeclarationVisitors[] = $forGroupsDeclarationVisitor; + } + + if (array_any($routeParams ?? [], static fn (string $value): bool => RawValues::class === $value)) { + $rawValuesDeclarationVisitor = new UseDeclarationVisitor(RawValues::class); + $useDeclarationVisitors[] = $rawValuesDeclarationVisitor; + } + + if (array_any($routeParams ?? [], static fn (string $value): bool => EnumValues::class === $value)) { + $enumValuesDeclarationVisitor = new UseDeclarationVisitor(EnumValues::class); + $useDeclarationVisitors[] = $enumValuesDeclarationVisitor; + } + + if (array_any($routeParams ?? [], static fn (string $value): bool => DynamicValues::class === $value)) { + $dynamicValuesDeclarationVisitor = new UseDeclarationVisitor(DynamicValues::class); + $useDeclarationVisitors[] = $dynamicValuesDeclarationVisitor; + } + + if (null !== $actions) { + $actionsValuesDeclarationVisitor = new UseDeclarationVisitor(Action::class); + $useDeclarationVisitors[] = $actionsValuesDeclarationVisitor; + } + + usort( + $useDeclarationVisitors, + static function (UseDeclarationVisitor $a, UseDeclarationVisitor $b): int { + return $a->getFqcn() <=> $b->getFqcn(); + }, + ); + + $code = $this->parser->parse($file) ?? throw new \RuntimeException('Failed parsing file'); + $traverser = new NodeTraverser(...$useDeclarationVisitors); + + $traverser->traverse($code); + + foreach ($useDeclarationVisitors as $useDeclarationVisitor) { + if ($useDeclarationVisitor->isImported()) { + continue; + } + + if (null === $useDeclarationVisitor->getInsertAtLine() || null === $useDeclarationVisitor->getAlias()) { + throw new \RuntimeException('something went wrong'); + } + + if ($useDeclarationVisitor->importWithSameNameExists()) { + $stmt = "use {$useDeclarationVisitor->getFqcn()} as {$useDeclarationVisitor->getAlias()};"; + } else { + $stmt = "use {$useDeclarationVisitor->getFqcn()};"; + } + + array_splice($lines, $useDeclarationVisitor->getInsertAtLine() + $offset++, 0, $stmt); + } + + $purgeOnBuilder = new PurgeOnAttributeBuilder( + purgeOnAlias: $purgeOnDeclarationVisitor->getAlias() ?? throw new \RuntimeException('Could not determine PurgeOn alias'), + entityAlias: $classDeclarationVisitor->getAlias() ?? throw new \RuntimeException('Could not determine entity alias'), + ); + + if (isset($forGroupsDeclarationVisitor) && null !== $forGroupsDeclarationVisitor->getAlias()) { + $purgeOnBuilder->setForGroupsAlias($forGroupsDeclarationVisitor->getAlias()); + } + if (isset($rawValuesDeclarationVisitor) && null !== $rawValuesDeclarationVisitor->getAlias()) { + $purgeOnBuilder->setRawValuesAlias($rawValuesDeclarationVisitor->getAlias()); + } + if (isset($enumValuesDeclarationVisitor) && null !== $enumValuesDeclarationVisitor->getAlias()) { + $purgeOnBuilder->setEnumValuesAlias($enumValuesDeclarationVisitor->getAlias()); + } + if (isset($dynamicValuesDeclarationVisitor) && null !== $dynamicValuesDeclarationVisitor->getAlias()) { + $purgeOnBuilder->setDynamicValuesAlias($dynamicValuesDeclarationVisitor->getAlias()); + } + if (isset($actionsValuesDeclarationVisitor) && null !== $actionsValuesDeclarationVisitor->getAlias()) { + $purgeOnBuilder->setActionsAlias($actionsValuesDeclarationVisitor->getAlias()); + } + + return $purgeOnBuilder; + } +} diff --git a/src/Maker/Util/PurgeOnAttributeBuilder.php b/src/Maker/Util/PurgeOnAttributeBuilder.php new file mode 100644 index 0000000..c4d8d41 --- /dev/null +++ b/src/Maker/Util/PurgeOnAttributeBuilder.php @@ -0,0 +1,219 @@ +purgeOn = " #[$this->purgeOnAlias($this->entityAlias::class,"; + } + + public function addTarget(string|array|ForGroups $target): self + { + if ($this->generated) { + throw new \RuntimeException('PurgeOn already generated'); + } + + if ($this->targetAdded) { + throw new \RuntimeException('Target already added'); + } + + $this->targetAdded = true; + + if (\is_string($target)) { + $this->purgeOn .= "\n target: '$target',"; + } elseif (\is_array($target)) { + $values = implode(', ', array_map(static fn (string $t): string => "'$t'", $target)); + $this->purgeOn .= "\n target: [$values],"; + } else { + if (null === $this->forGroupsAlias) { + throw new \RuntimeException('ForGroups alias is missing'); + } + + $values = implode(', ', array_map(static fn (string $t): string => "'$t'", $target->groups)); + $this->purgeOn .= "\n target: new $this->forGroupsAlias([$values]),"; + } + + return $this; + } + + /** + * {@inheritDoc} + */ + public function addRouteParams(array $routeParams): self + { + if ($this->generated) { + throw new \RuntimeException('PurgeOn already generated'); + } + + if ($this->routeParamsAdded) { + throw new \RuntimeException('Route params already added'); + } + + $this->routeParamsAdded = true; + + $this->purgeOn .= "\n routeParams: ["; + foreach ($routeParams as $routeParam => $type) { + $this->purgeOn .= "\n '$routeParam' => "; + if (PropertyValues::class === $type) { + $this->purgeOn .= "'',"; + continue; + } + + $alias = match ($type) { + RawValues::class => $this->rawValuesAlias ?? throw new \RuntimeException('RawValues alias is missing'), + EnumValues::class => $this->enumValuesAlias ?? throw new \RuntimeException('EnumValues alias is missing'), + DynamicValues::class => $this->dynamicValuesAlias ?? throw new \RuntimeException('DynamicValues alias is missing'), + }; + + $this->purgeOn .= "new $alias(),"; + } + $this->purgeOn .= "\n ],"; + + return $this; + } + + public function includeIf(): self + { + if ($this->generated) { + throw new \RuntimeException('PurgeOn already generated'); + } + + if ($this->ifAdded) { + throw new \RuntimeException('If expression already added'); + } + + $this->ifAdded = true; + + $this->purgeOn .= "\n if: '',"; + + return $this; + } + + public function addRoute(string $route): self + { + if ($this->generated) { + throw new \RuntimeException('PurgeOn already generated'); + } + + if ($this->routeAdded) { + throw new \RuntimeException('Route already added'); + } + + $this->routeAdded = true; + + $this->purgeOn .= "\n route: '$route',"; + + return $this; + } + + /** + * {@inheritDoc} + */ + public function addActions(string|array $actions): self + { + if ($this->generated) { + throw new \RuntimeException('PurgeOn already generated'); + } + + if ($this->actionsAdded) { + throw new \RuntimeException('Actions already added'); + } + + if (null === $this->actionsAlias) { + throw new \RuntimeException('Actions alias is missing'); + } + + $this->actionsAdded = true; + + if (\is_string($actions)) { + $this->purgeOn .= "\n actions: $this->actionsAlias::$actions,"; + } else { + $values = implode(', ', array_map(fn (string $action): string => "$this->actionsAlias::$action", $actions)); + $this->purgeOn .= "\n actions: [$values],"; + } + + return $this; + } + + public function generate(): string + { + if ($this->generated) { + return $this->purgeOn; + } + + if ($this->isOneLiner()) { + $this->purgeOn = " #[$this->purgeOnAlias($this->entityAlias::class)]"; + } else { + $this->purgeOn .= "\n )]"; + } + + $this->generated = true; + + return $this->purgeOn; + } + + public function setForGroupsAlias(string $alias): void + { + $this->forGroupsAlias = $alias; + } + + public function setRawValuesAlias(string $alias): void + { + $this->rawValuesAlias = $alias; + } + + public function setEnumValuesAlias(string $alias): void + { + $this->enumValuesAlias = $alias; + } + + public function setDynamicValuesAlias(string $alias): void + { + $this->dynamicValuesAlias = $alias; + } + + public function setActionsAlias(string $alias): void + { + $this->actionsAlias = $alias; + } + + private function isOneLiner(): bool + { + return !$this->targetAdded + && !$this->routeParamsAdded + && !$this->ifAdded + && !$this->routeAdded + && !$this->actionsAdded; + } +} diff --git a/src/Maker/Util/PurgeOnBuilderInterface.php b/src/Maker/Util/PurgeOnBuilderInterface.php new file mode 100644 index 0000000..cefbf9b --- /dev/null +++ b/src/Maker/Util/PurgeOnBuilderInterface.php @@ -0,0 +1,34 @@ +|ForGroups $target + */ + public function addTarget(string|array|ForGroups $target): self; + + /** + * @param array $routeParams + */ + public function addRouteParams(array $routeParams): self; + + public function includeIf(): self; + + public function addRoute(string $route): self; + + /** + * @param 'Create'|'Update'|'Delete'|non-empty-list<'Create'|'Update'|'Delete'> $actions + */ + public function addActions(string|array $actions); +} diff --git a/src/Maker/Util/PurgeOnYamlBuilder.php b/src/Maker/Util/PurgeOnYamlBuilder.php new file mode 100644 index 0000000..645b067 --- /dev/null +++ b/src/Maker/Util/PurgeOnYamlBuilder.php @@ -0,0 +1,186 @@ +purgeOn = " - class: $entity\n"; + } + + public function generate(): string + { + if ($this->generated) { + return $this->purgeOn; + } + + if (!$this->routeAdded && $this->includeRouteName) { + throw new \RuntimeException('Can not generate purge rule without route name'); + } + + $this->generated = true; + + return $this->purgeOn; + } + + /** + * {@inheritDoc} + */ + public function addTarget(string|array|ForGroups $target): self + { + if ($this->generated) { + throw new \RuntimeException('PurgeOn already generated'); + } + + if ($this->targetAdded) { + throw new \RuntimeException('Target already added'); + } + + $this->targetAdded = true; + + if (\is_string($target)) { + $this->purgeOn .= " target: $target\n"; + } elseif (\is_array($target)) { + $values = implode(', ', $target); + $this->purgeOn .= " target: [ $values ]\n"; + } else { + $values = implode(', ', $target->groups); + $this->purgeOn .= " target: !for_groups [ $values ]\n"; + } + + return $this; + } + + /** + * {@inheritDoc} + */ + public function addRouteParams(array $routeParams): self + { + if ($this->generated) { + throw new \RuntimeException('PurgeOn already generated'); + } + + if ($this->routeParamsAdded) { + throw new \RuntimeException('Route params already added'); + } + + $this->routeParamsAdded = true; + + $this->purgeOn .= " route_params:\n"; + foreach ($routeParams as $routeParam => $type) { + $this->purgeOn .= " $routeParam: "; + if (PropertyValues::class === $type) { + $this->purgeOn .= "\n"; + continue; + } + + $this->purgeOn .= match ($type) { + RawValues::class => "!raw \n", + EnumValues::class => "!enum \n", + DynamicValues::class => "!dynamic \n", + }; + } + + return $this; + } + + public function includeIf(): self + { + if ($this->generated) { + throw new \RuntimeException('PurgeOn already generated'); + } + + if ($this->ifAdded) { + throw new \RuntimeException('If expression already added'); + } + + $this->ifAdded = true; + + $this->purgeOn .= " if: ''\n"; + + return $this; + } + + public function addRoute(string $route): self + { + if ($this->generated) { + throw new \RuntimeException('PurgeOn already generated'); + } + + if ($this->routeAdded) { + throw new \RuntimeException('Route already added'); + } + + $this->routeAdded = true; + + if ($this->includeRouteName) { + $this->purgeOn = "$route:\n".$this->purgeOn; + } + + return $this; + } + + /** + * {@inheritDoc} + */ + public function addActions(string|array $actions): self + { + if ($this->generated) { + throw new \RuntimeException('PurgeOn already generated'); + } + + if ($this->actionsAdded) { + throw new \RuntimeException('Actions already added'); + } + + $this->actionsAdded = true; + + if (\is_string($actions)) { + $this->purgeOn .= " actions: {$this->getActionValue($actions)}\n"; + } else { + $values = implode( + ', ', + array_map(fn (string $actionName): string => $this->getActionValue($actionName), $actions), + ); + $this->purgeOn .= " actions: [ $values ]\n"; + } + + return $this; + } + + private function getActionValue(string $actionName): string + { + foreach (Action::cases() as $action) { + if ($action->name === $actionName) { + return $action->value; + } + } + + throw new \RuntimeException("Invalid action '$actionName'"); + } +} diff --git a/src/Maker/Util/UseDeclarationVisitor.php b/src/Maker/Util/UseDeclarationVisitor.php new file mode 100644 index 0000000..c2a6aba --- /dev/null +++ b/src/Maker/Util/UseDeclarationVisitor.php @@ -0,0 +1,133 @@ +namespace = Str::getNamespace($fqcn); + $this->className = Str::getShortClassName($fqcn); + } + + public function enterNode(Node $node): void + { + if ($this->imported) { + return; + } + + if ($node instanceof Node\Stmt\GroupUse && $this->namespace === $node->prefix->name) { + foreach ($node->uses as $useItem) { + $this->processUseItem($useItem, checkFqcn: false); + } + } + + if ($node instanceof Node\UseItem) { + $this->processUseItem($node, checkFqcn: true); + } + + if ($node instanceof Node\Stmt\GroupUse && Node\Stmt\Use_::TYPE_NORMAL === $node->uses[0]->type) { + $class = "{$node->prefix->name}\\{$node->uses[0]->name->name}"; + if (!Str::areClassesAlphabetical($this->fqcn, $class) && $node->hasAttribute('endLine')) { + $this->insertAtLine = (int) $node->getAttribute('endLine') + 1; + } + } + + if ($node instanceof Node\Stmt\Use_ + && Node\Stmt\Use_::TYPE_NORMAL === $node->type + && !Str::areClassesAlphabetical($this->fqcn, $node->uses[0]->name->name) + && $node->hasAttribute('endLine') + ) { + $this->insertAtLine = (int) $node->getAttribute('endLine') + 1; + } + } + + public function leaveNode(Node $node): void + { + if ($this->imported) { + return; + } + + if (!$node instanceof Node\Stmt\Namespace_) { + return; + } + + $this->alias = $this->className; + + if ($this->importWithSameNameExists) { + $this->alias = 'Purgatory'.$this->alias; + } + } + + public function isImported(): bool + { + return $this->imported; + } + + public function importWithSameNameExists(): bool + { + return $this->importWithSameNameExists; + } + + public function getAlias(): ?string + { + return $this->alias; + } + + public function getInsertAtLine(): ?int + { + return $this->insertAtLine; + } + + public function getFqcn(): string + { + return $this->fqcn; + } + + private function processUseItem(Node\UseItem $node, bool $checkFqcn): void + { + if ($this->imported) { + return; + } + + $importName = $checkFqcn ? $this->fqcn : $this->className; + + if ($importName !== $node->name->name) { + if ($this->className === ($node->alias?->name ?? Str::getShortClassName($node->name->name))) { + $this->importWithSameNameExists = true; + } + + return; + } + + $this->imported = true; + $this->insertAtLine = null; + + /* + * This covers: + * use Sofascore\PurgatoryBundle\Attribute\PurgeOn as Alias; + */ + $this->alias = $node->alias?->name ?? $this->className; + } +} diff --git a/tests/Maker/Util/PurgeOnAttributeBuilderTest.php b/tests/Maker/Util/PurgeOnAttributeBuilderTest.php new file mode 100644 index 0000000..8fbd65a --- /dev/null +++ b/tests/Maker/Util/PurgeOnAttributeBuilderTest.php @@ -0,0 +1,148 @@ +generate(), + ); + } + + #[DataProvider('purgeOnProvider')] + public function testFullPurgeOn( + string|array|ForGroups $target, + array $routeParams, + string $route, + string|array $actions, + string $expected, + ): void { + $builder = new PurgeOnAttributeBuilder( + purgeOnAlias: 'PurgeOn', + entityAlias: 'Foo', + ); + + $this->setupDefaultAliases($builder); + + $builder->addTarget($target) + ->addRouteParams($routeParams) + ->includeIf() + ->addRoute($route) + ->addActions($actions); + + self::assertSame($expected, $builder->generate()); + } + + public static function purgeOnProvider(): iterable + { + $expected = <<<'EOD' + #[PurgeOn(Foo::class, + target: 'property', + routeParams: [ + 'param1' => '', + 'param2' => new RawValues(), + 'param3' => new EnumValues(), + 'param4' => new DynamicValues(), + ], + if: '', + route: 'app_foo_route', + actions: Action::Create, + )] + EOD; + + yield [ + 'target' => 'property', + 'routeParams' => [ + 'param1' => PropertyValues::class, + 'param2' => RawValues::class, + 'param3' => EnumValues::class, + 'param4' => DynamicValues::class, + ], + 'route' => 'app_foo_route', + 'actions' => 'Create', + 'expected' => $expected, + ]; + + $expected = <<<'EOD' + #[PurgeOn(Foo::class, + target: ['prop1', 'prop2'], + routeParams: [ + 'param1' => '', + 'param2' => new RawValues(), + 'param3' => new EnumValues(), + 'param4' => new DynamicValues(), + ], + if: '', + route: 'app_foo_route', + actions: [Action::Create, Action::Update], + )] + EOD; + + yield [ + 'target' => ['prop1', 'prop2'], + 'routeParams' => [ + 'param1' => PropertyValues::class, + 'param2' => RawValues::class, + 'param3' => EnumValues::class, + 'param4' => DynamicValues::class, + ], + 'route' => 'app_foo_route', + 'actions' => ['Create', 'Update'], + 'expected' => $expected, + ]; + + $expected = <<<'EOD' + #[PurgeOn(Foo::class, + target: new ForGroups(['group1', 'group2']), + routeParams: [ + 'param1' => '', + 'param2' => '', + ], + if: '', + route: 'app_foo_route', + actions: [Action::Create, Action::Update, Action::Delete], + )] + EOD; + + yield [ + 'target' => new ForGroups(['group1', 'group2']), + 'routeParams' => [ + 'param1' => PropertyValues::class, + 'param2' => PropertyValues::class, + ], + 'route' => 'app_foo_route', + 'actions' => ['Create', 'Update', 'Delete'], + 'expected' => $expected, + ]; + } + + private function setupDefaultAliases(PurgeOnAttributeBuilder $builder): void + { + $builder->setActionsAlias('Action'); + $builder->setRawValuesAlias('RawValues'); + $builder->setDynamicValuesAlias('DynamicValues'); + $builder->setEnumValuesAlias('EnumValues'); + $builder->setForGroupsAlias('ForGroups'); + } +} diff --git a/tests/Maker/Util/PurgeOnYamlBuilderTest.php b/tests/Maker/Util/PurgeOnYamlBuilderTest.php new file mode 100644 index 0000000..c45929d --- /dev/null +++ b/tests/Maker/Util/PurgeOnYamlBuilderTest.php @@ -0,0 +1,20 @@ +