From e69b679938e53ce6065b58e4a947276ace14079a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 11 Mar 2025 15:08:48 +0100 Subject: [PATCH] Add Rector Rule to inject normalization methods into export classes This update introduces a Rector rule to automatically add `normalizeFormData`, `denormalizeFormData`, and `getNormalizationVersion` methods to export-related classes implementing specific interfaces. It ensures consistency and reduces manual work by leveraging traits and default implementations for normalizing form data. Test fixtures and configurations are included to validate and support this functionality. --- rector.php | 2 - ...eAddNormalizationMethodsOnExportRector.php | 342 ++++++++++++++++++ ...NormalizationMethodsOnExportRectorTest.php | 40 ++ .../Fixture/add-method-simple-field.php.inc | 126 +++++++ .../Fixture/skip-existing-method.php.inc | 55 +++ .../Fixture/skip-no-filter-class.php.inc | 9 + .../config/config.php | 14 + 7 files changed, 586 insertions(+), 2 deletions(-) create mode 100644 utils/rector/src/Rector/ChillBundleAddNormalizationMethodsOnExportRector.php create mode 100644 utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/ChillBundleAddNormalizationMethodsOnExportRectorTest.php create mode 100644 utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/Fixture/add-method-simple-field.php.inc create mode 100644 utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/Fixture/skip-existing-method.php.inc create mode 100644 utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/Fixture/skip-no-filter-class.php.inc create mode 100644 utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/config/config.php diff --git a/rector.php b/rector.php index 2f9b04be1..32538b3dc 100644 --- a/rector.php +++ b/rector.php @@ -85,10 +85,8 @@ return static function (RectorConfig $rectorConfig): void { new \Rector\Php80\ValueObject\AnnotationToAttribute('Chill\MainBundle\Validator\Constraints\Entity\UserCircleConsistency'), new \Rector\Php80\ValueObject\AnnotationToAttribute('Chill\MainBundle\Workflow\Validator\EntityWorkflowCreation'), ]); -*/ // upgrade config for rector $rectorConfig->rules([\Rector\Transform\Rector\FileWithoutNamespace\RectorConfigBuilderRector::class]); - // upgrade of chill exports }; diff --git a/utils/rector/src/Rector/ChillBundleAddNormalizationMethodsOnExportRector.php b/utils/rector/src/Rector/ChillBundleAddNormalizationMethodsOnExportRector.php new file mode 100644 index 000000000..3410ee543 --- /dev/null +++ b/utils/rector/src/Rector/ChillBundleAddNormalizationMethodsOnExportRector.php @@ -0,0 +1,342 @@ +classAnalyzer->hasImplements($node, FilterInterface::class) + && !$this->classAnalyzer->hasImplements($node, AggregatorInterface::class) + && !$this->classAnalyzer->hasImplements($node, ExportInterface::class) + && !$this->classAnalyzer->hasImplements($node, DirectExportInterface::class) + && !$this->classAnalyzer->hasImplements($node, ListInterface::class) + && !$this->classAnalyzer->hasImplements($node, FormatterInterface::class) + ) { + return null; + } + + $hasDenormalizeMethod = false; + $hasNormalizedMethod = false; + $hasGetVersionMethod = false; + $buildFormStmtIndex = null; + $buildForm = null; + $hasTraitHelper = false; + + foreach ($node->stmts as $k => $stmt) { + if ($stmt instanceof Node\Stmt\TraitUse) { + if (ExportDataNormalizerTrait::class === $stmt->traits[0]->toString()) { + $hasTraitHelper = true; + } + } elseif (!$stmt instanceof Node\Stmt\ClassMethod) { + continue; + } + + if ('buildForm' === $stmt->name->name) { + $buildForm = $stmt; + $buildFormStmtIndex = $k; + } elseif ('normalizeFormData' === $stmt->name->name) { + $hasNormalizedMethod = true; + } elseif ('denormalizeFormData' === $stmt->name->name) { + $hasDenormalizeMethod = true; + } elseif ('getNormalizationVersion' === $stmt->name->name) { + $hasGetVersionMethod = true; + } + } + + $toAdd = []; + + $stmtBefore = array_slice($node->stmts, 0, $buildFormStmtIndex, false); + $stmtAfter = array_slice($node->stmts, $buildFormStmtIndex + 1); + $propertiesOnForm = $this->getPropertiesOnForm($buildForm); + + // if the trait is not present, we add it into the statements + if (!$hasTraitHelper && array_reduce( + $propertiesOnForm, + function (bool $carry, string $item): bool { + if ('entity' === $item || 'date' === $item) { + return true; + } + + return $carry; + }, + false, + )) { + array_unshift($stmtBefore, new Node\Stmt\TraitUse([ + new Node\Name('\\'.ExportDataNormalizerTrait::class), + ])); + } + + // if we do not have the `getNormalizerVersion` method + if (!$hasGetVersionMethod) { + $toAdd[] = $this->buildGetNormalizationVersionMethod(); + } + + // if we do not have the `normalizeFormData` method + if (!$hasNormalizedMethod) { + $toAdd[] = $this->buildNormalizeFormDataMethod($propertiesOnForm); + } + + if (!$hasDenormalizeMethod) { + $toAdd[] = $this->buildDenormalizeFormDataMethod($propertiesOnForm); + } + + $node->stmts = [ + ...array_values($stmtBefore), + $buildForm, + ...array_values($toAdd), + ...array_values($stmtAfter), + ]; + + return $node; + } + + private function buildDenormalizeFormDataMethod(array $propertiesOnForm): Node\Stmt\ClassMethod + { + $array = new Node\Expr\Array_([]); + + foreach ($propertiesOnForm as $name => $kind) { + $arrayDimFetch = new Node\Expr\ArrayDimFetch( + new Node\Expr\Variable('formData'), + new Node\Scalar\String_($name), + ); + + $array->items[] = new Node\Expr\ArrayItem( + match ($kind) { + 'entity' => new Node\Expr\MethodCall( + new Node\Expr\Variable('this'), + new Node\Identifier('denormalizeDoctrineEntity'), + [ + new Node\Arg($arrayDimFetch), + new Node\Arg( + new PropertyFetch( + new Node\Expr\Variable('this'), + 'someRepository', + ), + ), + ], + ), + 'rolling_date' => new Node\Expr\StaticCall( + new Node\Name\FullyQualified(RollingDate::class), + 'fromNormalized', + [new Node\Arg($arrayDimFetch)], + ), + 'date' => new Node\Expr\MethodCall( + new Node\Expr\Variable('this'), + new Node\Identifier('denormalizeDate'), + [ + new Node\Arg($arrayDimFetch), + ], + ), + 'scalar' => $arrayDimFetch, + default => $arrayDimFetch, + }, + new Node\Scalar\String_($name), + ); + } + + return new Node\Stmt\ClassMethod( + 'denormalizeFormData', + [ + 'flags' => Node\Stmt\Class_::MODIFIER_PUBLIC, + 'returnType' => new Node\Identifier('array'), + 'params' => [ + new Node\Param(new Node\Expr\Variable('formData'), type: new Node\Identifier('array')), + new Node\Param(new Node\Expr\Variable('fromVersion'), type: new Node\Identifier('int')), + ], + 'stmts' => [ + new Node\Stmt\Return_($array), + ], + ], + ); + } + + private function buildNormalizeFormDataMethod(array $propertiesOnForm): Node\Stmt\ClassMethod + { + $array = new Node\Expr\Array_([]); + + foreach ($propertiesOnForm as $name => $kind) { + $arrayDimFetch = new Node\Expr\ArrayDimFetch( + new Node\Expr\Variable('formData'), + new Node\Scalar\String_($name), + ); + + $array->items[] = new Node\Expr\ArrayItem( + match ($kind) { + 'entity' => new Node\Expr\MethodCall( + new Node\Expr\Variable('this'), + new Node\Identifier('normalizeDoctrineEntity'), + [ + new Node\Arg($arrayDimFetch), + ], + ), + 'rolling_date' => new Node\Expr\MethodCall( + $arrayDimFetch, + new Node\Identifier('normalize'), + ), + 'date' => new Node\Expr\MethodCall( + new Node\Expr\Variable('this'), + new Node\Identifier('normalizeDate'), + [ + new Node\Arg($arrayDimFetch), + ], + ), + 'scalar' => $arrayDimFetch, + default => $arrayDimFetch, + }, + new Node\Scalar\String_($name), + ); + } + + return new Node\Stmt\ClassMethod( + 'normalizeFormData', + [ + 'flags' => Node\Stmt\Class_::MODIFIER_PUBLIC, + 'returnType' => new Node\Identifier('array'), + 'params' => [ + new Node\Param(new Node\Expr\Variable('formData'), type: new Node\Identifier('array')), + ], + 'stmts' => [ + new Node\Stmt\Return_($array), + ], + ], + ); + } + + private function buildGetNormalizationVersionMethod(): Node\Stmt\ClassMethod + { + return new Node\Stmt\ClassMethod( + 'getNormalizationVersion', + [ + 'flags' => Node\Stmt\Class_::MODIFIER_PUBLIC, + 'returnType' => new Node\Identifier('int'), + 'stmts' => [ + new Node\Stmt\Return_( + new Node\Scalar\LNumber(1) + ), + ], + ], + ); + } + + /** + * @return array + */ + private function getPropertiesOnForm(Node\Stmt\ClassMethod $buildFormMethod): array + { + $builderName = $buildFormMethod->params[0]->var->name; + $values = []; + + foreach ($buildFormMethod->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Expression + // it must be a method call + && $stmt->expr instanceof Node\Expr\MethodCall + && 'add' === $stmt->expr->name->toString() + ) { + $newValues = $this->handleMethodCallAdd($stmt->expr, $builderName); + + if (false === $newValues) { + continue; + } + + $values = [...$values, ...$newValues]; + } + } + + return $values; + } + + private function handleMethodCallAdd(Node\Expr\MethodCall $call, string $builderName): array|false + { + if ($call->var instanceof Node\Expr\Variable) { + // in this case, the call is done on the form builder: $formBuilder->add + // or the last method call on the form builder, for instance $formBuilder->add( )->add( ) + if ($builderName !== $call->var->name) { + return false; + } + } elseif ($call->var instanceof Node\Expr\MethodCall && 'add' === $call->var->name->toString()) { + // in this case, we have a chained method call: something like $formbuilder->add()->add(). + // we have to go deeper into the call to get the information from them + $previous = $this->handleMethodCallAdd($call->var, $builderName); + + if (false === $previous) { + return false; + } + } + + $arg0 = $call->args[0] ?? null; + + if (null === $arg0) { + throw new \UnexpectedValueException("The first argument of an 'add' call method is empty"); + } + if (!$arg0->value instanceof Node\Scalar\String_) { + throw new \UnexpectedValueException("The first argument of an 'add' call is not a string"); + } + + $key = $arg0->value->value; + /** @var Node\Expr\ClassConstFetch $argType */ + $argType = $call->args[1]->value; + + return [ + ...$previous ?? [], + $key => match($argType->class->toString()) { + EntityType::class, PickUserDynamicType::class => 'entity', + PickRollingDateType::class => 'rolling_date', + ChillDateType::class, ChillDateTimeType::class, DateTimeType::class, DateType::class => 'date', + default => 'scalar', + }, + ]; + } +} diff --git a/utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/ChillBundleAddNormalizationMethodsOnExportRectorTest.php b/utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/ChillBundleAddNormalizationMethodsOnExportRectorTest.php new file mode 100644 index 000000000..81d3655a6 --- /dev/null +++ b/utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/ChillBundleAddNormalizationMethodsOnExportRectorTest.php @@ -0,0 +1,40 @@ +doTestFile($file); + } + + public static function provideData(): \Iterator + { + return self::yieldFilesFromDirectory(__DIR__.'/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__.'/config/config.php'; + } +} diff --git a/utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/Fixture/add-method-simple-field.php.inc b/utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/Fixture/add-method-simple-field.php.inc new file mode 100644 index 000000000..3a31f1ac2 --- /dev/null +++ b/utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/Fixture/add-method-simple-field.php.inc @@ -0,0 +1,126 @@ +add('field', ChoiceType::class, [ + 'choices' => ['one', 'two', 'three', 'four', 'five'], + 'multiple' => false, + ]); + + $builder->add('entity', EntityType::class, []); + + $builder->add('user', \Chill\MainBundle\Form\Type\PickUserDynamicType::class, []); + + $builder->add('rolling_date', \Chill\MainBundle\Form\Type\PickRollingDateType::class); + + $builder->add('date', \Chill\MainBundle\Form\Type\ChillDateType::class, []); + } + + public function getFormDefaultData(): array + { + return []; + } + + public function describeAction($data, $format = 'string') + { + return []; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(\Doctrine\ORM\QueryBuilder $qb, $data, \Chill\MainBundle\Export\ExportGenerationContext $exportGenerationContext) + { + // nothing to add here + } + + public function applyOn() + { + return ['something']; + } +} + +?> +----- +add('field', ChoiceType::class, [ + 'choices' => ['one', 'two', 'three', 'four', 'five'], + 'multiple' => false, + ]); + + $builder->add('entity', EntityType::class, []); + + $builder->add('user', \Chill\MainBundle\Form\Type\PickUserDynamicType::class, []); + + $builder->add('rolling_date', \Chill\MainBundle\Form\Type\PickRollingDateType::class); + + $builder->add('date', \Chill\MainBundle\Form\Type\ChillDateType::class, []); + } + public function getNormalizationVersion(): int + { + return 1; + } + public function normalizeFormData(array $formData): array + { + return ['field' => $formData['field'], 'entity' => $this->normalizeDoctrineEntity($formData['entity']), 'user' => $this->normalizeDoctrineEntity($formData['user']), 'rolling_date' => $formData['rolling_date']->normalize(), 'date' => $this->normalizeDate($formData['date'])]; + } + public function denormalizeFormData(array $formData, int $fromVersion): array + { + return ['field' => $formData['field'], 'entity' => $this->denormalizeDoctrineEntity($formData['entity'], $this->someRepository), 'user' => $this->denormalizeDoctrineEntity($formData['user'], $this->someRepository), 'rolling_date' => \Chill\MainBundle\Service\RollingDate\RollingDate::fromNormalized($formData['rolling_date']), 'date' => $this->denormalizeDate($formData['date'])]; + } + + public function getFormDefaultData(): array + { + return []; + } + + public function describeAction($data, $format = 'string') + { + return []; + } + + public function addRole(): ?string + { + return null; + } + + public function alterQuery(\Doctrine\ORM\QueryBuilder $qb, $data, \Chill\MainBundle\Export\ExportGenerationContext $exportGenerationContext) + { + // nothing to add here + } + + public function applyOn() + { + return ['something']; + } +} + +?> diff --git a/utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/Fixture/skip-existing-method.php.inc b/utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/Fixture/skip-existing-method.php.inc new file mode 100644 index 000000000..25e843a9a --- /dev/null +++ b/utils/rector/tests/ChillBundleAddNormalizationMethodsOnExportRector/Fixture/skip-existing-method.php.inc @@ -0,0 +1,55 @@ +rule(Chill\Utils\Rector\Rector\ChillBundleAddNormalizationMethodsOnExportRector::class); +};