Refactor address and postal code serialization: enhance normalization logic, add docgen:read groups and tests, and improve null handling.

This commit is contained in:
2026-01-09 15:58:40 +01:00
parent 1dfebc0297
commit f5e923ca39
9 changed files with 369 additions and 27 deletions

View File

@@ -14,9 +14,13 @@ namespace Chill\DocGeneratorBundle\Serializer\Helper;
use Symfony\Component\Serializer\Mapping\ClassMetadataInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class NormalizeNullValueHelper
final readonly class NormalizeNullValueHelper
{
public function __construct(private readonly NormalizerInterface $normalizer, private readonly ?string $discriminatorType = null, private readonly ?string $discriminatorValue = null) {}
public function __construct(
private NormalizerInterface $normalizer,
private ?string $discriminatorType = null,
private ?string $discriminatorValue = null,
) {}
public function normalize(array $attributes, string $format = 'docgen', ?array $context = [], ?ClassMetadataInterface $classMetadata = null)
{

View File

@@ -42,10 +42,11 @@ abstract class DocGenNormalizerTestAbstract extends KernelTestCase
self::assertIsArray($normalizedObject);
self::assertIsArray($nullNormalizedObject);
self::assertEqualsCanonicalizing(array_keys($normalizedObject), array_keys($nullNormalizedObject));
self::assertEqualsCanonicalizing(array_keys($normalizedObject), array_keys($nullNormalizedObject), "Compare not-null-objects's keys (expected) with null-object keys (actual)");
self::assertArrayHasKey('isNull', $nullNormalizedObject, 'each object must have an "isNull" key');
self::assertTrue($nullNormalizedObject['isNull'], 'isNull key must be true for null objects');
self::assertFalse($normalizedObject['isNull'], 'isNull key must be false for null objects');
self::assertGreaterThan(1, count(array_keys($normalizedObject)), 'Test that there are more than one key (if fails, maybe there is only the isNull key)');
foreach ($normalizedObject as $key => $value) {
if (in_array($key, ['isNull', 'type'])) {

View File

@@ -11,11 +11,18 @@ declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\Model;
use Symfony\Component\Serializer\Attribute\Groups;
class Point implements \JsonSerializable
{
public static string $SRID = '4326';
private function __construct(private readonly ?float $lon, private readonly ?float $lat) {}
private function __construct(
#[Groups(['read', 'docgen:read'])]
private readonly ?float $lon,
#[Groups(['read', 'docgen:read'])]
private readonly ?float $lat,
) {}
public static function fromArrayGeoJson(array $array): self
{

View File

@@ -14,6 +14,7 @@ namespace Chill\MainBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
/**
* Country.
@@ -25,8 +26,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
class Country
{
#[Groups(['read', 'docgen:read'])]
#[SerializedName('code')]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 3)]
#[Context(['is-translatable' => true], groups: ['docgen:read'])]
private string $countryCode = '';
#[Groups(['read', 'docgen:read'])]

View File

@@ -45,28 +45,28 @@ class PostalCode implements TrackUpdateInterface, TrackCreationInterface
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])]
private string $canonical = '';
#[Groups(['read'])]
#[Groups(['read', 'docgen:read'])]
#[ORM\Column(type: 'point', nullable: true)]
private ?Point $center = null;
#[Groups(['write', 'read'])]
#[Groups(['write', 'read', 'docgen:read'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 100)]
private ?string $code = null;
#[Groups(['write', 'read'])]
#[Groups(['write', 'read', 'docgen:read'])]
#[ORM\ManyToOne(targetEntity: Country::class)]
private ?Country $country = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])]
private ?\DateTimeImmutable $deletedAt = null;
#[Groups(['write', 'read'])]
#[Groups(['write', 'read', 'docgen:read'])]
#[ORM\Id]
#[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
private ?int $id = null;
#[Groups(['write', 'read'])]
#[Groups(['write', 'read', 'docgen:read'])]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255, name: 'label')]
private ?string $name = null;

View File

@@ -13,6 +13,8 @@ namespace Chill\MainBundle\Serializer\Normalizer;
use Chill\DocGeneratorBundle\Serializer\Helper\NormalizeNullValueHelper;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\Country;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Templating\Entity\AddressRender;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
@@ -60,17 +62,6 @@ class AddressNormalizer implements \Symfony\Component\Serializer\Normalizer\Norm
'text' => $this->addressRender->renderString($address, []),
'street' => $address->getStreet(),
'streetNumber' => $address->getStreetNumber(),
'postcode' => [
'id' => $address->getPostCode()->getId(),
'name' => $address->getPostCode()->getName(),
'code' => $address->getPostCode()->getCode(),
'center' => $address->getPostcode()->getCenter(),
],
'country' => [
'id' => $address->getPostCode()->getCountry()->getId(),
'name' => $address->getPostCode()->getCountry()->getName(),
'code' => $address->getPostCode()->getCountry()->getCountryCode(),
],
'floor' => $address->getFloor(),
'corridor' => $address->getCorridor(),
'steps' => $address->getSteps(),
@@ -80,6 +71,7 @@ class AddressNormalizer implements \Symfony\Component\Serializer\Normalizer\Norm
'extra' => $address->getExtra(),
'confidential' => $address->getConfidential(),
'lines' => $this->addressRender->renderLines($address),
'isNoAddress' => $address->isNoAddress(),
];
if ('json' === $format) {
@@ -88,33 +80,42 @@ class AddressNormalizer implements \Symfony\Component\Serializer\Normalizer\Norm
$format,
[AbstractNormalizer::GROUPS => ['read']]
);
$data['validFrom'] = $address->getValidFrom();
$data['validTo'] = $address->getValidTo();
$data['validFrom'] = $this->normalizer->normalize($address->getValidFrom(), $format, $context);
$data['validTo'] = $this->normalizer->normalize($address->getValidTo(), $format, $context);
$data['refStatus'] = $address->getRefStatus();
$data['point'] = $this->normalizer->normalize(
$address->getPoint(),
$format,
[AbstractNormalizer::GROUPS => ['read']]
);
$data['isNoAddress'] = $address->isNoAddress();
$data['postcode'] = $this->normalizer->normalize($address->getPostcode(), $format, [AbstractNormalizer::GROUPS => ['read']]);
$data['country'] = $this->normalizer->normalize($address->getPostcode()?->getCountry(), $format, [AbstractNormalizer::GROUPS => ['read']]);
} elseif ('docgen' === $format) {
$dateContext = array_merge($context, ['docgen:expects' => \DateTimeInterface::class]);
$data['validFrom'] = $this->normalizer->normalize($address->getValidFrom(), $format, $dateContext);
$data['validTo'] = $this->normalizer->normalize($address->getValidTo(), $format, $dateContext);
$data['postcode'] = $this->normalizer->normalize($address->getPostcode(), $format, [...$context, 'docgen:expects' => PostalCode::class]);
$data['country'] = $this->normalizer->normalize($address->getPostcode()?->getCountry(), $format, [...$context, 'docgen:expects' => Country::class]);
$data['isNull'] = false;
}
return $data;
}
if (null === $address) {
if ('json' === $format) {
return null;
}
$helper = new NormalizeNullValueHelper($this->normalizer);
return array_merge(
$helper->normalize(self::NULL_VALUE, $format, $context),
[
'postcode' => $helper->normalize(self::NULL_POSTCODE_COUNTRY, $format, $context),
'country' => $helper->normalize(self::NULL_POSTCODE_COUNTRY, $format, $context),
'postcode' => $this->normalizer->normalize(null, $format, [...$context, 'docgen:expects' => PostalCode::class]),
'country' => $this->normalizer->normalize(null, $format, [...$context, 'docgen:expects' => Country::class]),
'lines' => [],
'isNoAddress' => true,
]
);
}
@@ -147,7 +148,8 @@ class AddressNormalizer implements \Symfony\Component\Serializer\Normalizer\Norm
if ('docgen' === $format) {
return [
Address::class => false,
Address::class => true,
'*' => false,
];
}

View File

@@ -0,0 +1,168 @@
<?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\MainBundle\Tests\Serializer\Normalizer;
use Chill\DocGeneratorBundle\Test\DocGenNormalizerTestAbstract;
use Chill\MainBundle\Doctrine\Model\Point;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\Country;
use Chill\MainBundle\Entity\PostalCode;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
/**
* @internal
*
* @coversNothing
*/
class AddressDocGenNormalizerTest extends DocGenNormalizerTestAbstract
{
public function provideNotNullObject(): object
{
return new Address()
->setStreet('Rue de Rosée')
->setStreetNumber('15')
->setFloor('2')
->setCorridor('A')
->setSteps('3')
->setFlat('B')
->setBuildingName('Le Palais')
->setDistribution('Boite 1')
->setExtra('Près de la fontaine')
->setConfidential(true)
->setValidFrom(new \DateTime('2024-01-01'))
->setValidTo(new \DateTime('2024-12-31'))
->setPostcode(
new PostalCode()
->setCode('4020')
->setName('Liège')
->setCountry(
new Country()
->setName(['fr' => 'Belgique'])
->setCountryCode('BE')
)
);
}
public function provideDocGenExpectClass(): string
{
return Address::class;
}
public function testNormalizeDocGen(): void
{
$address = new Address();
$address->setStreet('Rue de la Loi');
$address->setStreetNumber('16');
$address->setFloor('5');
$address->setCorridor('B');
$address->setSteps('12');
$address->setFlat('A');
$address->setBuildingName('Berlaymont');
$address->setDistribution('Boite 5');
$address->setExtra('Extra info');
$address->setConfidential(false);
$address->setValidFrom(new \DateTime('2023-01-01'));
$address->setValidTo(new \DateTime('2023-12-31'));
$address->setIsNoAddress(false);
$postalCode = new PostalCode();
$postalCode->setCode('1000');
$postalCode->setName('Bruxelles');
$country = new Country();
$country->setCountryCode('BE');
$country->setName(['fr' => 'Belgique']);
$postalCode->setCountry($country);
$postalCode->setCenter(Point::fromLonLat(5.0, 50.0));
$address->setPostcode($postalCode);
$address->setPoint(Point::fromLonLat(5.0, 50.0));
$result = $this->normalizer->normalize($address, 'docgen', [AbstractNormalizer::GROUPS => ['docgen:read']]);
$this->assertIsArray($result);
// Check main keys
$this->assertArrayHasKey('address_id', $result);
$this->assertArrayHasKey('text', $result);
$this->assertArrayHasKey('street', $result);
$this->assertArrayHasKey('streetNumber', $result);
$this->assertArrayHasKey('postcode', $result);
$this->assertArrayHasKey('country', $result);
$this->assertArrayHasKey('floor', $result);
$this->assertArrayHasKey('corridor', $result);
$this->assertArrayHasKey('steps', $result);
$this->assertArrayHasKey('flat', $result);
$this->assertArrayHasKey('buildingName', $result);
$this->assertArrayHasKey('distribution', $result);
$this->assertArrayHasKey('extra', $result);
$this->assertArrayHasKey('confidential', $result);
$this->assertArrayHasKey('lines', $result);
$this->assertArrayHasKey('validFrom', $result);
$this->assertArrayHasKey('validTo', $result);
$this->assertArrayHasKey('isNoAddress', $result);
$this->assertArrayNotHasKey('addressReference', $result);
// Check postcode subkeys. This is because until the time of writing, those keys are in used
$this->assertIsArray($result['postcode']);
$this->assertArrayHasKey('id', $result['postcode']);
$this->assertArrayHasKey('name', $result['postcode']);
$this->assertArrayHasKey('code', $result['postcode']);
$this->assertArrayHasKey('center', $result['postcode']);
// Check postcode subkeys. This is because until the time of writing, those keys are in used
$this->assertIsArray($result['country']);
$this->assertArrayHasKey('id', $result['country']);
$this->assertArrayHasKey('name', $result['country']);
$this->assertArrayHasKey('code', $result['country']);
}
public function testNormalizeDocGenNullValue(): void
{
$result = $this->normalizer->normalize(null, 'docgen', [AbstractNormalizer::GROUPS => ['docgen:read'], 'docgen:expects' => Address::class]);
$this->assertIsArray($result);
// Check main keys
$this->assertArrayHasKey('address_id', $result);
$this->assertArrayHasKey('text', $result);
$this->assertArrayHasKey('street', $result);
$this->assertArrayHasKey('streetNumber', $result);
$this->assertArrayHasKey('postcode', $result);
$this->assertArrayHasKey('country', $result);
$this->assertArrayHasKey('floor', $result);
$this->assertArrayHasKey('corridor', $result);
$this->assertArrayHasKey('steps', $result);
$this->assertArrayHasKey('flat', $result);
$this->assertArrayHasKey('buildingName', $result);
$this->assertArrayHasKey('distribution', $result);
$this->assertArrayHasKey('extra', $result);
$this->assertArrayHasKey('confidential', $result);
$this->assertArrayHasKey('lines', $result);
$this->assertArrayHasKey('validFrom', $result);
$this->assertArrayHasKey('validTo', $result);
$this->assertArrayHasKey('isNoAddress', $result);
$this->assertArrayNotHasKey('addressReference', $result);
// Check postcode subkeys. This is because until the time of writing, those keys are in used
$this->assertIsArray($result['postcode']);
$this->assertArrayHasKey('id', $result['postcode']);
$this->assertArrayHasKey('name', $result['postcode']);
$this->assertArrayHasKey('code', $result['postcode']);
$this->assertArrayHasKey('center', $result['postcode']);
// Check postcode subkeys. This is because until the time of writing, those keys are in used
$this->assertIsArray($result['country']);
$this->assertArrayHasKey('id', $result['country']);
$this->assertArrayHasKey('name', $result['country']);
$this->assertArrayHasKey('code', $result['country']);
}
}

View File

@@ -0,0 +1,118 @@
<?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\MainBundle\Tests\Serializer\Normalizer;
use Chill\MainBundle\Doctrine\Model\Point;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\AddressReference;
use Chill\MainBundle\Entity\Country;
use Chill\MainBundle\Entity\PostalCode;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* @internal
*
* @coversNothing
*/
class AddressNormalizerTest extends KernelTestCase
{
private NormalizerInterface $normalizer;
protected function setUp(): void
{
self::bootKernel();
$this->normalizer = self::getContainer()->get(NormalizerInterface::class);
}
public function testNormalizeJson(): void
{
$address = new Address();
$address->setStreet('Rue de la Loi');
$address->setStreetNumber('16');
$address->setFloor('5');
$address->setCorridor('B');
$address->setSteps('12');
$address->setFlat('A');
$address->setBuildingName('Berlaymont');
$address->setDistribution('Boite 5');
$address->setExtra('Extra info');
$address->setConfidential(false);
$address->setValidFrom(new \DateTime('2023-01-01'));
$address->setValidTo(new \DateTime('2023-12-31'));
$address->setIsNoAddress(false);
$postalCode = new PostalCode();
$postalCode->setCode('1000');
$postalCode->setName('Bruxelles');
$country = new Country();
$country->setCountryCode('BE');
$country->setName(['fr' => 'Belgique']);
$postalCode->setCountry($country);
$point = Point::fromLonLat(5.0, 50.0);
$postalCode->setCenter($point);
$address->setPostcode($postalCode);
$address->setPoint($point);
$address->setAddressReference(new AddressReference());
$result = $this->normalizer->normalize($address, 'json', ['groups' => ['read']]);
$this->assertIsArray($result);
// Check main keys
$this->assertArrayHasKey('address_id', $result);
$this->assertArrayHasKey('text', $result);
$this->assertArrayHasKey('street', $result);
$this->assertArrayHasKey('streetNumber', $result);
$this->assertArrayHasKey('postcode', $result);
$this->assertArrayHasKey('country', $result);
$this->assertArrayHasKey('floor', $result);
$this->assertArrayHasKey('corridor', $result);
$this->assertArrayHasKey('steps', $result);
$this->assertArrayHasKey('flat', $result);
$this->assertArrayHasKey('buildingName', $result);
$this->assertArrayHasKey('distribution', $result);
$this->assertArrayHasKey('extra', $result);
$this->assertArrayHasKey('confidential', $result);
$this->assertArrayHasKey('lines', $result);
$this->assertArrayHasKey('addressReference', $result);
$this->assertArrayHasKey('validFrom', $result);
$this->assertArrayHasKey('validTo', $result);
$this->assertArrayHasKey('refStatus', $result);
$this->assertArrayHasKey('point', $result);
$this->assertArrayHasKey('isNoAddress', $result);
// Check postcode subkeys
$this->assertIsArray($result['postcode']);
$this->assertArrayHasKey('id', $result['postcode']);
$this->assertArrayHasKey('name', $result['postcode']);
$this->assertArrayHasKey('code', $result['postcode']);
$this->assertArrayHasKey('center', $result['postcode']);
// Check country subkeys
$this->assertIsArray($result['country']);
$this->assertArrayHasKey('id', $result['country']);
$this->assertArrayHasKey('name', $result['country']);
$this->assertArrayHasKey('code', $result['country']);
}
public function testNormalizeNullJson(): void
{
$result = $this->normalizer->normalize(null, 'json', ['groups' => ['read']]);
self::assertNull($result);
}
}

View File

@@ -0,0 +1,41 @@
<?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\MainBundle\Tests\Serializer\Normalizer;
use Chill\DocGeneratorBundle\Test\DocGenNormalizerTestAbstract;
use Chill\MainBundle\Entity\Country;
use Chill\MainBundle\Entity\PostalCode;
/**
* @internal
*
* @coversNothing
*/
class PostalCodeDocGenNormalizerTest extends DocGenNormalizerTestAbstract
{
public function provideNotNullObject(): object
{
return new PostalCode()
->setCode('4020')
->setName('Liège')
->setCountry(
new Country()
->setName(['fr' => 'Belgique'])
->setCountryCode('BE')
);
}
public function provideDocGenExpectClass(): string
{
return PostalCode::class;
}
}