|
| 1 | + |
1 | 2 | # Nullable Embeddable Bundle |
2 | 3 | #### Symfony Bundle - [AndanteProject](https://github.com/andanteproject) |
| 4 | +[](https://github.com/andanteproject/nullable-embeddable-bundle/releases) |
| 5 | + |
| 6 | + |
| 7 | + |
| 8 | + |
3 | 9 |
|
4 | | -A Symfony Bundle to handle nullable embeddables with Doctrine. |
| 10 | +A Symfony Bundle that extends [Doctrine Embeddables](https://www.doctrine-project.org/projects/doctrine-orm/en/3.5/tutorials/embeddables.html) to allow them to be nullable with **custom business logic** to precisely determine their null state, **handling null** and **uninitialized properties**, addressing a common limitation in Doctrine ORM. |
| 11 | + |
| 12 | +## Introduction |
| 13 | + |
| 14 | +[Doctrine Embeddables](https://www.doctrine-project.org/projects/doctrine-orm/en/3.5/tutorials/embeddables.html) are powerful for encapsulating value objects, but they inherently cannot be null. This bundle provides a flexible solution to this limitation by introducing the `#[NullableEmbeddable]` attribute. This attribute allows you to define custom logic, either through a dedicated [processor class](#processor-interface) or a [static anonymous function (PHP 8.5+)](#anonymous-function-processor-php-85), to determine when an embeddable object should be considered null. This enables precise control over the null state, even handling uninitialized properties safely. |
| 15 | + |
| 16 | +The bundle works seamlessly with multiple levels of embedded objects, processing from the deepest leaf embeddable up to the root entity. |
| 17 | + |
| 18 | +For example, a `Country` embeddable can be marked as nullable based on an uninitialized property: |
| 19 | + |
| 20 | +```php |
| 21 | +<?php |
| 22 | +// ... use statements |
| 23 | +use Andante\NullableEmbeddableBundle\Attribute\NullableEmbeddable; |
| 24 | +use Andante\NullableEmbeddableBundle\PropertyAccess\PropertyAccessor; |
| 25 | +use Andante\NullableEmbeddableBundle\Result; |
| 26 | +use Doctrine\DBAL\Types\Types; |
| 27 | +use Doctrine\ORM\Mapping as ORM; |
| 28 | + |
| 29 | +#[ORM\Embeddable] |
| 30 | +#[NullableEmbeddable(processor: static function (PropertyAccessor $propertyAccessor, object $embeddableObject): Result { |
| 31 | + // The bundle ensures $embeddableObject is of the expected type. |
| 32 | + // We check if the 'code' property is uninitialized. |
| 33 | + if ($propertyAccessor->isUninitialized($embeddableObject, 'code')) { |
| 34 | + return Result::SHOULD_BE_NULL; |
| 35 | + } |
| 36 | + return Result::KEEP_INITIALIZED; |
| 37 | +})] |
| 38 | +class Country |
| 39 | +{ |
| 40 | + public function __construct( |
| 41 | + #[ORM\Column(type: Types::STRING, length: 2, nullable: true)] |
| 42 | + private string $code, |
| 43 | + ) { |
| 44 | + } |
| 45 | + // ... getters and setters |
| 46 | +} |
| 47 | +``` |
5 | 48 |
|
6 | 49 | ## Requirements |
7 | | -Symfony 5.x-7.x and PHP 8.2. |
| 50 | +* Symfony 5.x-7.x |
| 51 | +* PHP 8.1+ (PHP 8.5+ for anonymous function processors) |
| 52 | +* [Doctrine ORM](https://www.doctrine-project.org/projects/doctrine-orm/en/3.5/tutorials/embeddables.html) |
8 | 53 |
|
9 | 54 | ## Install |
10 | 55 | Via [Composer](https://getcomposer.org/): |
11 | 56 | ```bash |
12 | 57 | $ composer require andanteproject/nullable-embeddable-bundle |
13 | 58 | ``` |
14 | 59 |
|
15 | | -## Features |
16 | | -- Automatically handles nullable embeddables in Doctrine entities. |
17 | | - |
18 | | -## Basic usage |
19 | | -After [install](#install), make sure you have the bundle registered in your symfony bundles list (`config/bundles.php`): |
| 60 | +After installation, make sure you have the bundle registered in your Symfony bundles list (`config/bundles.php`): |
20 | 61 | ```php |
21 | 62 | return [ |
22 | | - /// bundles... |
| 63 | + // ... |
23 | 64 | Andante\NullableEmbeddableBundle\AndanteNullableEmbeddableBundle::class => ['all' => true], |
24 | | - /// bundles... |
| 65 | + // ... |
25 | 66 | ]; |
26 | 67 | ``` |
27 | | -This should have been done automagically if you are using [Symfony Flex](https://flex.symfony.com). Otherwise, just register it by yourself. |
| 68 | +This should be done automatically if you are using [Symfony Flex](https://flex.symfony.com). Otherwise, register it manually. |
| 69 | + |
| 70 | +## Usage |
| 71 | + |
| 72 | +The core of this bundle is the `#[NullableEmbeddable]` attribute, which you place on your Doctrine Embeddable classes alongside `#[ORM\Embeddable]`. This attribute requires a `processor` argument, which can be either a class implementing [ProcessorInterface](#processor-interface) or a [static anonymous function (PHP 8.5+)](#anonymous-function-processor-php-85). |
| 73 | + |
| 74 | +### Processor Interface |
| 75 | + |
| 76 | +For older PHP versions or more complex logic that warrants a dedicated class, you can implement the `ProcessorInterface`. |
| 77 | + |
| 78 | +```php |
| 79 | +<?php |
| 80 | + |
| 81 | +declare(strict_types=1); |
| 82 | + |
| 83 | +namespace Andante\NullableEmbeddableBundle; |
| 84 | + |
| 85 | +use Andante\NullableEmbeddableBundle\Exception\UnexpectedEmbeddableClassException; |
| 86 | +use Andante\NullableEmbeddableBundle\PropertyAccess\PropertyAccessor; |
| 87 | +use Symfony\Component\PropertyAccess\PropertyPathInterface; |
| 88 | + |
| 89 | +interface ProcessorInterface |
| 90 | +{ |
| 91 | + /** |
| 92 | + * @throws UnexpectedEmbeddableClassException |
| 93 | + */ |
| 94 | + public function analyze(PropertyAccessor $propertyAccessor, object $embeddableObject, PropertyPathInterface $propertyPath, object $rootEntity, mixed $embeddedConfig): Result; |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +Your processor class must implement this interface. |
| 99 | + |
| 100 | +**Example: Address Embeddable with Class Processor** |
| 101 | + |
| 102 | +```php |
| 103 | +<?php |
| 104 | + |
| 105 | +declare(strict_types=1); |
| 106 | + |
| 107 | +namespace App\Entity; |
| 108 | + |
| 109 | +use Andante\NullableEmbeddableBundle\Attribute\NullableEmbeddable; |
| 110 | +use Doctrine\ORM\Mapping as ORM; |
| 111 | +use App\Processor\AddressEmbeddableProcessor; // Your custom processor |
| 112 | + |
| 113 | +#[ORM\Embeddable] |
| 114 | +#[NullableEmbeddable(processor: AddressEmbeddableProcessor::class)] |
| 115 | +class Address |
| 116 | +{ |
| 117 | + // ... properties, getters, setters |
| 118 | +} |
| 119 | +``` |
| 120 | + |
| 121 | +And the corresponding `AddressEmbeddableProcessor` class: |
| 122 | + |
| 123 | +```php |
| 124 | +<?php |
| 125 | + |
| 126 | +declare(strict_types=1); |
| 127 | + |
| 128 | +namespace App\Processor; |
| 129 | + |
| 130 | +use Andante\NullableEmbeddableBundle\ProcessorInterface; |
| 131 | +use Andante\NullableEmbeddableBundle\PropertyAccess\PropertyAccessor; |
| 132 | +use Andante\NullableEmbeddableBundle\Result; |
| 133 | +use Andante\NullableEmbeddableBundle\Exception\UnexpectedEmbeddableClassException; |
| 134 | +use App\Entity\Address; |
| 135 | +use Symfony\Component\PropertyAccess\PropertyPathInterface; |
| 136 | + |
| 137 | +class AddressEmbeddableProcessor implements ProcessorInterface |
| 138 | +{ |
| 139 | + public function analyze(PropertyAccessor $propertyAccessor, object $embeddableObject, PropertyPathInterface $propertyPath, object $rootEntity, mixed $embeddedConfig): Result |
| 140 | + { |
| 141 | + if (!$embeddableObject instanceof Address) { |
| 142 | + throw UnexpectedEmbeddableClassException::create(Address::class, $embeddableObject); |
| 143 | + } |
| 144 | + |
| 145 | + if ( |
| 146 | + null === $propertyAccessor->getValue($embeddableObject, 'street') |
| 147 | + && null === $propertyAccessor->getValue($embeddableObject, 'city') |
| 148 | + && null === $propertyAccessor->getValue($embeddableObject, 'country') |
| 149 | + ) { |
| 150 | + return Result::SHOULD_BE_NULL; |
| 151 | + } |
| 152 | + |
| 153 | + return Result::KEEP_INITIALIZED; |
| 154 | + } |
| 155 | +} |
| 156 | +``` |
| 157 | + |
| 158 | +### Anonymous Function Processor (PHP 8.5+) |
| 159 | + |
| 160 | +For projects running on PHP 8.5 or newer, the most convenient way to define your nullability logic is using a static anonymous function directly within the `#[NullableEmbeddable]` attribute. This keeps your business logic co-located with the embeddable definition, avoiding the need for separate processor classes. |
| 161 | + |
| 162 | +**Example: Address Embeddable with Anonymous Function Processor** |
| 163 | + |
| 164 | +Consider an `Address` embeddable that should be considered null if all its properties (`street`, `city`, `country`) are null. |
| 165 | + |
| 166 | +```php |
| 167 | +<?php |
| 168 | + |
| 169 | +declare(strict_types=1); |
| 170 | + |
| 171 | +namespace App\Entity; |
| 172 | + |
| 173 | +use Andante\NullableEmbeddableBundle\Attribute\NullableEmbeddable; |
| 174 | +use Andante\NullableEmbeddableBundle\Exception\UnexpectedEmbeddableClassException; |
| 175 | +use Andante\NullableEmbeddableBundle\PropertyAccess\PropertyAccessor; |
| 176 | +use Andante\NullableEmbeddableBundle\Result; |
| 177 | +use Doctrine\DBAL\Types\Types; |
| 178 | +use Doctrine\ORM\Mapping as ORM; |
| 179 | + |
| 180 | +#[ORM\Embeddable] |
| 181 | +#[NullableEmbeddable(processor: static function (PropertyAccessor $propertyAccessor, object $embeddableObject): Result { |
| 182 | + if (!$embeddableObject instanceof Address) { |
| 183 | + throw UnexpectedEmbeddableClassException::create(Address::class, $embeddableObject); |
| 184 | + } |
| 185 | + if ( |
| 186 | + null === $propertyAccessor->getValue($embeddableObject, 'street') |
| 187 | + && null === $propertyAccessor->getValue($embeddableObject, 'city') |
| 188 | + && null === $propertyAccessor->getValue($embeddableObject, 'country') |
| 189 | + ) { |
| 190 | + return Result::SHOULD_BE_NULL; |
| 191 | + } |
| 192 | + |
| 193 | + return Result::KEEP_INITIALIZED; |
| 194 | +})] |
| 195 | +class Address |
| 196 | +{ |
| 197 | + #[ORM\Column(type: Types::STRING, nullable: true)] |
| 198 | + private ?string $street = null; |
| 199 | + |
| 200 | + #[ORM\Column(type: Types::STRING, nullable: true)] |
| 201 | + private ?string $city = null; |
| 202 | + |
| 203 | + #[ORM\Embedded(class: Country::class, columnPrefix: 'country_')] |
| 204 | + private ?Country $country = null; |
| 205 | + |
| 206 | + // ... getters and setters |
| 207 | +} |
| 208 | +``` |
| 209 | + |
| 210 | +In this example, the anonymous function receives a [`PropertyAccessor`](#the-propertyaccessor) and the `$embeddableObject`. The [`PropertyAccessor`](#the-propertyaccessor) is crucial as it allows you to safely check for uninitialized properties without triggering PHP fatal errors, even with `declare(strict_types=1)`. The function must return a [`Result`](#the-result-enum) enum (`Result::SHOULD_BE_NULL` or `Result::KEEP_INITIALIZED`). |
| 211 | + |
| 212 | +**Example: Country Embeddable with Anonymous Function Processor** |
| 213 | + |
| 214 | +A nested embeddable like `Country` can also use this approach. Here, `Country` is considered null if its `code` property is uninitialized (meaning it was never set, often indicating a new, empty object). |
| 215 | + |
| 216 | +```php |
| 217 | +<?php |
| 218 | + |
| 219 | +declare(strict_types=1); |
| 220 | + |
| 221 | +namespace App\Entity; |
| 222 | + |
| 223 | +use Andante\NullableEmbeddableBundle\Attribute\NullableEmbeddable; |
| 224 | +use Andante\NullableEmbeddableBundle\Exception\UnexpectedEmbeddableClassException; |
| 225 | +use Andante\NullableEmbeddableBundle\PropertyAccess\PropertyAccessor; |
| 226 | +use Andante\NullableEmbeddableBundle\Result; |
| 227 | +use Doctrine\DBAL\Types\Types; |
| 228 | +use Doctrine\ORM\Mapping as ORM; |
| 229 | + |
| 230 | +#[ORM\Embeddable] |
| 231 | +#[NullableEmbeddable(processor: static function (PropertyAccessor $propertyAccessor, object $embeddableObject): Result { |
| 232 | + if (!$embeddableObject instanceof Country) { |
| 233 | + throw UnexpectedEmbeddableClassException::create(Country::class, $embeddableObject); |
| 234 | + } |
| 235 | + |
| 236 | + if ($propertyAccessor->isUninitialized($embeddableObject, 'code')) { |
| 237 | + return Result::SHOULD_BE_NULL; |
| 238 | + } |
| 239 | + |
| 240 | + return Result::KEEP_INITIALIZED; |
| 241 | +})] |
| 242 | +class Country |
| 243 | +{ |
| 244 | + #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] |
| 245 | + private ?string $name = null; |
| 246 | + |
| 247 | + public function __construct( |
| 248 | + #[ORM\Column(type: Types::STRING, length: 2, nullable: true)] |
| 249 | + private string $code, |
| 250 | + ) { |
| 251 | + } |
| 252 | + |
| 253 | + // ... getters and setters |
| 254 | +} |
| 255 | +``` |
| 256 | + |
| 257 | +### The `PropertyAccessor` |
| 258 | + |
| 259 | +The `PropertyAccessor` provided to your processor (or anonymous function) is a specialized tool that allows you to inspect the state of embeddable properties, including whether they are uninitialized. This is particularly useful for non-nullable properties that might not have been set when an object is retrieved from the database or instantiated. |
| 260 | + |
| 261 | +* `$propertyAccessor->getValue($embeddableObject, 'propertyName')`: Safely retrieves the value of a property. |
| 262 | +* `$propertyAccessor->isUninitialized($embeddableObject, 'propertyName')`: Checks if a property is uninitialized. |
| 263 | + |
| 264 | +### The `Result` Enum |
| 265 | + |
| 266 | +The `analyze` method of your processor must return one of two values from the `Result` enum: |
| 267 | + |
| 268 | +* `Result::SHOULD_BE_NULL`: Indicates that the embeddable object should be treated as null. Note that "should" is used because the parent entity might have the embeddable class defined as not nullable. There is no guarantee the parent class accepts `null` as a value; this depends on database consistency and the user's data model. |
| 269 | +* `Result::KEEP_INITIALIZED`: Indicates that the embeddable object should remain initialized. |
| 270 | + |
| 271 | +## Configuration |
| 272 | + |
| 273 | +The bundle provides a configuration option to enable a cache warmer for improved performance in production environments. |
| 274 | + |
| 275 | +```yaml |
| 276 | +# config/packages/prod/andante_nullable_embeddable.yaml |
| 277 | +andante_nullable_embeddable: |
| 278 | + metadata_cache_warmer_enabled: true |
| 279 | +``` |
| 280 | +
|
| 281 | +Alternatively, using PHP: |
| 282 | +
|
| 283 | +```php |
| 284 | +<?php |
| 285 | +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; |
| 286 | + |
| 287 | +return static function (ContainerConfigurator $containerConfigurator): void { |
| 288 | + if ('prod' === $containerConfigurator->env()) { |
| 289 | + $containerConfigurator->extension('andante_nullable_embeddable', [ |
| 290 | + 'metadata_cache_warmer_enabled' => true, |
| 291 | + ]); |
| 292 | + } |
| 293 | +}; |
| 294 | +``` |
| 295 | + |
| 296 | +* `metadata_cache_warmer_enabled` (default: `false`): When set to `true`, the bundle will read all `#[NullableEmbeddable]` attributes during Symfony's cache warmup process. This can speed up subsequent requests by pre-populating the metadata cache. It is recommended to enable this only in your production environment. |
| 297 | + |
| 298 | +## Nested Embeddables |
| 299 | + |
| 300 | +This bundle fully supports nested embeddables (e.g., an `Address` embeddable containing a `Country` embeddable). The processing logic correctly traverses the embeddable tree, starting from the deepest nested embeddable and working its way up to the root entity. |
28 | 301 |
|
29 | 302 | Built with love ❤️ by [AndanteProject](https://github.com/andanteproject) team. |
0 commit comments