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.
This commit is contained in:
Julien Fastré 2025-03-11 15:08:48 +01:00
parent 229f9b7125
commit e69b679938
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
7 changed files with 586 additions and 2 deletions

View File

@ -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\Validator\Constraints\Entity\UserCircleConsistency'),
new \Rector\Php80\ValueObject\AnnotationToAttribute('Chill\MainBundle\Workflow\Validator\EntityWorkflowCreation'), new \Rector\Php80\ValueObject\AnnotationToAttribute('Chill\MainBundle\Workflow\Validator\EntityWorkflowCreation'),
]); ]);
*/
// upgrade config for rector // upgrade config for rector
$rectorConfig->rules([\Rector\Transform\Rector\FileWithoutNamespace\RectorConfigBuilderRector::class]); $rectorConfig->rules([\Rector\Transform\Rector\FileWithoutNamespace\RectorConfigBuilderRector::class]);
// upgrade of chill exports
}; };

View File

@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Utils\Rector\Rector;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\MainBundle\Export\DirectExportInterface;
use Chill\MainBundle\Export\ExportDataNormalizerTrait;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\ListInterface;
use Chill\MainBundle\Form\Type\ChillDateTimeType;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use PhpParser\Node;
use PhpParser\Node\Expr\PropertyFetch;
use Rector\Rector\AbstractRector;
use Rector\Symfony\NodeAnalyzer\ClassAnalyzer;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
class ChillBundleAddNormalizationMethodsOnExportRector extends AbstractRector
{
public function __construct(
private ClassAnalyzer $classAnalyzer,
) {}
public function getRuleDefinition(): RuleDefinition
{
return new RuleDefinition(
'Add methods `normalizeFormData` and `denormalizeFormData` on Export, Filter, Aggregator, and Formatter',
[]
);
}
public function getNodeTypes(): array
{
return [Node\Stmt\Class_::class];
}
public function refactor(Node $node)
{
if (!$node instanceof Node\Stmt\Class_) {
return null;
}
if (
!$this->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<string, 'entity'|'scalar'|'rolling_date'|'date'>
*/
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',
},
];
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Utils\Rector\Tests\ChillBundleAddNormalizationMethodsOnExportRector;
use Rector\Testing\PHPUnit\AbstractRectorTestCase;
/**
* @internal
*
* @coversNothing
*/
class ChillBundleAddNormalizationMethodsOnExportRectorTest extends AbstractRectorTestCase
{
/**
* @dataProvider provideData
*/
public function test(string $file): void
{
$this->doTestFile($file);
}
public static function provideData(): \Iterator
{
return self::yieldFilesFromDirectory(__DIR__.'/Fixture');
}
public function provideConfigFilePath(): string
{
return __DIR__.'/config/config.php';
}
}

View File

@ -0,0 +1,126 @@
<?php
use Chill\MainBundle\Export\FilterInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
class MyFilter implements FilterInterface
{
public function getTitle()
{
return "some title";
}
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder)
{
$builder->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'];
}
}
?>
-----
<?php
use Chill\MainBundle\Export\FilterInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
class MyFilter implements FilterInterface
{
use \Chill\MainBundle\Export\ExportDataNormalizerTrait;
public function getTitle()
{
return "some title";
}
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder)
{
$builder->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'];
}
}
?>

View File

@ -0,0 +1,55 @@
<?php
use Chill\MainBundle\Export\FilterInterface;
class MyFilterUpdated implements FilterInterface
{
public function getTitle()
{
return "some title";
}
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder)
{
}
public function getFormDefaultData(): array
{
return [];
}
public function normalizeFormData(array $formData): array
{
return $formData;
}
public function denormalizeFormData(array $formData, int $fromVersion): array
{
return $formData;
}
public function getNormalizationVersion(): int
{
return 1;
}
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'];
}
}

View File

@ -0,0 +1,9 @@
<?php
class SomeClass
{
public function doSomething(): void
{
// dummy
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
return static function (Rector\Config\RectorConfig $rectorConfig): void {
$rectorConfig->rule(Chill\Utils\Rector\Rector\ChillBundleAddNormalizationMethodsOnExportRector::class);
};