Files
chill-bundles/docs/source/development/normalizing-for-doc-gen.md

6.4 KiB

Normalizing for DocGen

In Chill, some entities can be normalized in the docgen format, which is specifically used for document generation.

The docgen Format Requirements

This format has specific requirements regarding null values. When serializing a null value, it must not be serialized as a literal null. Instead, it must be serialized as an object (or array) containing all the keys that would be present if the object were not null.

Each key must have, as value:

  • An empty string value (for scalars).
  • A boolean
  • An empty array (for collections).
  • Or, if the expected type is another object, it must contain all the keys for that object, recursively.

This ensures that the document generator always finds the expected keys, even if the data is missing.

Additionally, every normalized form must include an isNull key (boolean). This helps the document template distinguish between an actual object and its "null" representation.

Attribute-Based Normalization

The simplest way to support docgen normalization is to use Symfony Serializer attributes.

  • Use the group docgen:read on properties that should be included.
  • For translatable fields (stored as JSON/array of translations), you must also add the context is-translatable => true.

Example: Country.php

The Country entity demonstrates this approach:

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;

#[ORM\Entity]
class Country
{
    #[Groups(['read', 'docgen:read'])]
    #[SerializedName('code')]
    #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 3)]
    private string $countryCode = '';

    #[Groups(['read', 'docgen:read'])]
    #[ORM\Id]
    #[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)]
    private ?int $id = null;

    #[Groups(['read', 'docgen:read'])]
    #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON)]
    #[Context(['is-translatable' => true], groups: ['docgen:read'])]
    private array $name = [];

    // ...
}

For working properly, the property or return type must be properly set.

Custom Normalizer

For more complex entities, you may need to implement a custom normalizer.

Requirements for docgen Normalizers

  1. Handle null in supportsNormalization: The normalizer must return true if the data is null AND the context contains a docgen:expects key matching the class the normalizer handles.

    public function supportsNormalization($data, $format = null, array $context = []): bool
    {
        if ('docgen' === $format) {
            return $data instanceof MyEntity
                || (null === $data && MyEntity::class === ($context['docgen:expects'] ?? null));
        }
        return false;
    }
    
  2. Implementation of getSupportedTypes: To avoid side effects and optimize performance, you must return '*' => false along with the specific class.

    public function getSupportedTypes(?string $format): array
    {
        if ('docgen' === $format) {
            return [
                MyEntity::class => true,
                '*' => false,
            ];
        }
        return [];
    }
    
  3. Handle null in normalize: The normalize method must detect when the input is null and return the "empty" structure with all keys.

Using NormalizeNullValueHelper

To help with normalizing null values recursively, you can use the \Chill\DocGeneratorBundle\Serializer\Helper\NormalizeNullValueHelper class.

This helper takes an array defining the keys and their expected types. If a type is a class-string, it will call the serializer again to normalize a null value for that class.

Example: AddressNormalizer.php

The AddressNormalizer is a good example of a custom normalizer handling docgen:

class AddressNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
    use NormalizerAwareTrait;

    private const array NULL_VALUE = [
        'address_id' => 'int',
        'text' => 'string',
        'street' => 'string',
        // ...
        'validFrom' => \DateTimeInterface::class,
        'postcode' => PostalCode::class,
    ];

    public function normalize($address, $format = null, array $context = []): array
    {
        if ($address instanceof Address) {
            // ... normal normalization logic
            if ('docgen' === $format) {
                $data['postcode'] = $this->normalizer->normalize(
                    $address->getPostcode(),
                    $format,
                    [...$context, 'docgen:expects' => PostalCode::class]
                );
            }
            return $data;
        }

        if (null === $address && 'docgen' === $format) {
            $helper = new NormalizeNullValueHelper($this->normalizer);
            return $helper->normalize(self::NULL_VALUE, $format, $context);
        }

        // ...
    }

    // ... supportsNormalization and getSupportedTypes as described above
}

Testing docgen Normalization

To ensure that your normalizer strictly follows the docgen requirements, you should extend \Chill\DocGeneratorBundle\Test\DocGenNormalizerTestAbstract.

This abstract test class performs several critical checks:

  • It ensures that the normalized form of a non-null object and a null value have exactly the same keys (same array shape).
  • It verifies that the isNull key is present and correctly set (false for objects, true for null).
  • It recursively checks that sub-objects also have consistent keys.
  • It ensures that null values are never returned (they should be empty strings, empty arrays, etc.).

Example Test: AddressDocGenNormalizerTest.php

You need to implement two methods: provideNotNullObject() and provideDocGenExpectClass().

namespace Chill\MainBundle\Tests\Serializer\Normalizer;

use Chill\DocGeneratorBundle\Test\DocGenNormalizerTestAbstract;
use Chill\MainBundle\Entity\Address;

class AddressDocGenNormalizerTest extends DocGenNormalizerTestAbstract
{
    public function provideNotNullObject(): object
    {
        // Return a fully populated instance of the entity
        return new Address()
            ->setStreet('Rue de la Loi')
            // ...
            ;
    }

    public function provideDocGenExpectClass(): string
    {
        return Address::class;
    }
}