Skip to content

Commit 1dea68e

Browse files
committed
[Live] Hydration serializer opt in
1 parent 5188aaf commit 1dea68e

18 files changed

+695
-970
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
public User $user;
1111
```
1212

13+
- [BC BREAK]: `LiveProp` values are no longer automatically (de)hydrated
14+
through Symfony's serializer. Use `LiveProp(useSerializerForHydration: true)`
15+
to activate this. Also, a `serializationContext` option was added to
16+
`LiveProp`.
17+
1318
- [BC BREAK]: Child components are no longer automatically re-rendered when
1419
a parent component re-renders and the value of one of the props passed to
1520
the child has changed. Pass `acceptUpdatesFromParent: true` to any `LiveProp`
@@ -30,6 +35,9 @@ public User $user;
3035
`value` attribute, then the associated `LiveProp` will be set to a boolean
3136
when the input is checked/unchecked.
3237

38+
- A `format` option was added to `LiveProp` to control how `DateTime`
39+
properties are (de)hydrated.
40+
3341
- Added support for setting `writable` to a property that is an object
3442
(previously, only scalar values were supported). The object is passed
3543
through the serializer.

doc/index.rst

Lines changed: 116 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,15 @@ exceptions being properties that hold services (these don't need to be
245245
stateful because they will be autowired each time before the component
246246
is rendered).
247247

248+
LiveProp Data Types
249+
~~~~~~~~~~~~~~~~~~~
250+
251+
LiveProps must be a value that can be sent to JavaScript. Supported values
252+
are scalars (int, float, string, bool, null), arrays (of scalar values), enums,
253+
DateTime objects & Doctrine entity objects.
254+
255+
See :ref:`hydration` for handling more complex data.
256+
248257
Data Binding
249258
------------
250259

@@ -404,11 +413,16 @@ update correctly because JavaScript doesn't trigger the normal
404413
JavaScript library and set the model directly (or trigger a
405414
``change`` event on the ``data-model`` field). See :ref:`working-in-javascript`.
406415

416+
.. _hydration:
417+
407418
LiveProp for Entities & More Complex Data
408419
-----------------------------------------
409420

410-
You can also add the ``LiveProp`` attribute above more complex data,
411-
like entities or other objects. For example::
421+
``LiveProp`` data must be simple scalar values, with a few exception,
422+
like ``DateTime`` objects, enums & Doctrine entity objects. When ``LiveProp``s
423+
are sent to the frontend, they are "dehydrated". When Ajax requests are sent
424+
to the frontend, the dehydrated data is then "hydrated" back into the original.
425+
Doctrine entity objects are a special case for ``LiveProp``::
412426

413427
use App\Entity\Post;
414428

@@ -419,25 +433,24 @@ like entities or other objects. For example::
419433
public Post $post;
420434
}
421435

422-
To send it to the frontend, the ``Post`` object is "dehydrated" to a scalar value.
423-
For persisted entities, the data is dehydrated to its ``id``. For unsaved entities -
424-
or any other objects - the value is passed through Symfony's serializer.
425-
426-
When Ajax requests are sent to the frontend, the dehydrated data is then *hydrated*
427-
back into the original values.
436+
If the ``Post`` object is persisted, its dehydrated to the entity's ``id`` and then
437+
hydrated back by querying the database. If the object is unpersisted, it's dehydrated
438+
to an empty array, then hydrated back by creating an *empty* object
439+
(i.e. ``new Post()``).
428440

429-
.. caution::
441+
Arrays of Doctrine entities and other "simple" values like ``DateTime`` are also
442+
supported, as long as the ``LiveProp`` has proper PHPDoc that LiveComponents
443+
can read::
430444

431-
Dehydrated data is passed to the frontend so it's readable by the user.
432-
If your object is dehydrated via the serializer, be sure no sensitive
433-
data is exposed.
445+
/** @var Product[] */
446+
public $products = [];
434447

435448
Writable Object Properties or Array Keys
436449
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
437450

438-
By default, the user can't change the *properties* of an object ``LiveProp``
439-
(or the keys of an array). But, you can allow this by setting
440-
``writable`` to property names that *should* be writable::
451+
By default, the user can't change the *properties* of an entity ``LiveProp``
452+
You can allow this by setting ``writable`` to property names that *should* be writable.
453+
This also works as a way to make only *some* keys of an array writable::
441454

442455
use App\Entity\Post;
443456

@@ -549,6 +562,26 @@ single value or an array of values::
549562
<option value="sushi">Sushi</option>
550563
</select>
551564

565+
LiveProp Date Formats
566+
~~~~~~~~~~~~~~~~~~~~~
567+
568+
.. versionadded:: 2.8
569+
570+
The ``format`` option was introduced in Live Components 2.8.
571+
572+
If you have a writable ``LiveProp`` that is some sort of ``DateTime`` instance,
573+
you can control the format of the model on the frontend with the ``format``
574+
option::
575+
576+
#[LiveProp(writable: true, format: 'Y-m-d')]
577+
public ?\DateTime $publishOn = null;
578+
579+
Now you can bind this to a field on the frontend that uses that same format:
580+
581+
.. code-block:: twig
582+
583+
<input type="date" data-model="publishOn">
584+
552585
Allowing an Entity to be Changed to Another
553586
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
554587

@@ -596,36 +629,37 @@ Note that being able to change the "identity" of an object is something
596629
that works only for objects that are dehydrated to a scalar value (like
597630
persisted entities, which dehydrate to an ``id``).
598631

599-
More Control Over Dehydration & Hydration
600-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
632+
Hydration, DTO's & the Serializer
633+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
601634

602-
The ``LiveProp`` attribute uses the normalizer to dehydrate values before
603-
sending them to the frontend. Then it uses the denormalizer to hydrate values
604-
when they are sent back to the backend.
635+
If you try to use a ``LiveProp`` for some unsupported type (e.g.a DTO object),
636+
it will fail. A best practice is to use simple data.
605637

606-
If needed, you can control the normalization or denormalization context using
607-
the ``Context`` attribute from Symfony's serializer::
638+
But there are two options to make this work:
608639

609-
use App\Entity\Post;
640+
1) Hydrating with the Serializer
641+
................................
610642

611-
#[AsLiveComponent('edit_post')]
612-
class EditPostComponent
643+
.. versionadded:: 2.8
644+
645+
The ``useSerializerForHydration`` option was added in LiveComponent 2.8.
646+
647+
To hydrate/dehydrate through Symfony's serializer, use the ``useSerializerForHydration``
648+
option::
649+
650+
class ComponentWithAddressDto
613651
{
614-
#[LiveProp]
615-
#[Context(groups: ['my_group'])]
616-
public Post $post;
652+
#[LiveProp(useSerializerForHydration: true)]
653+
public AddressDto $addressDto;
617654
}
618655

619-
.. note::
656+
You can also set a ``serializationContext`` option on the ``LiveProp``.
620657

621-
If your property has writable paths, those will be normalized/denormalized
622-
using the same `Context` set on the property itself.
658+
2) Hydrating with Methods: hydrateWith & dehydrateWith
659+
......................................................
623660

624-
Using the serializer isn't meant to work out-of-the-box in every possible situation
625-
and it's always simpler to use scalar `LiveProp` values instead of complex objects.
626-
If you're having (de)hydrating a complex object, you can take full control by
627-
setting the ``hydrateWith`` and ``dehydrateWith`` options on ``LiveProp``. For
628-
example::
661+
You can take full control of the hydration process by setting the ``hydrateWith``
662+
and ``dehydrateWith`` options on ``LiveProp``::
629663

630664
class ComponentWithAddressDto
631665
{
@@ -641,12 +675,55 @@ example::
641675
];
642676
}
643677

644-
public function hydrateMyDto($data): MyDto
678+
public function hydrateAddress($data): AddressDto
645679
{
646-
return new MyDto($data['street'], $data['city'], $data['state']);
680+
return new AddressDto($data['street'], $data['city'], $data['state']);
647681
}
648682
}
649683

684+
Hydration Extensions
685+
~~~~~~~~~~~~~~~~~~~~
686+
687+
.. versionadded:: 2.8
688+
689+
The ``HydrationExtensionInterface`` system was added in LiveComponents 2.8.
690+
691+
If you frequently hydrate/dehydrate the same type of object, you can create a custom
692+
hydration extension to make this easier. For example, if you frequently hydrate
693+
a custom ``Food`` object, a hydration extension might look like this::
694+
695+
use App\Model\Food;
696+
use Symfony\UX\LiveComponent\Hydration\HydrationExtensionInterface;
697+
698+
class FoodHydrationExtension implements HydrationExtensionInterface
699+
{
700+
public function supports(string $className): bool
701+
{
702+
return is_subclass_of($className, Food::class);
703+
}
704+
705+
public function hydrate($value)
706+
{
707+
return new Food($value['name'], $value['isCooked']);
708+
}
709+
710+
public function dehydrate(object $object): mixed
711+
{
712+
return [
713+
'name' => $object->getName(),
714+
'isCooked' => $object->isCooked(),
715+
];
716+
}
717+
}
718+
719+
If you're using autoconfiguration, you're done! Otherwise, tag the service
720+
with ``live_component.hydration_extension``.
721+
722+
.. tip::
723+
724+
Internally, Doctrine entity objects use the ``DoctrineEntityHydrationExtension``
725+
to control the custom (de)hydration of entity objects.
726+
650727
Updating a Model Manually
651728
-------------------------
652729

@@ -791,9 +868,7 @@ Or, to *hide* an element while the component is loading:
791868
.. code-block:: html+twig
792869

793870
<!-- hide when the component is loading -->
794-
<span
795-
data-loading="hide"
796-
>Saved!</span>
871+
<span data-loading="hide">Saved!</span>
797872

798873
Adding and Removing Classes or Attributes
799874
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

src/Attribute/LiveProp.php

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ final class LiveProp
3030

3131
private ?string $dehydrateWith;
3232

33+
private bool $useSerializerForHydration;
34+
35+
private array $serializationContext;
36+
3337
/**
3438
* The "frontend" field name that should be used for this property.
3539
*
@@ -44,27 +48,43 @@ final class LiveProp
4448
private bool $acceptUpdatesFromParent;
4549

4650
/**
47-
* @param bool|array $writable If true, this property can be changed by the frontend.
48-
* Or set to an array of paths within this object/array
49-
* that are writable.
50-
* @param bool $updateFromParent if true, while a parent component is re-rendering,
51-
* if the parent passes in this prop and it changed
52-
* from the value used when originally rendering
53-
* this child, the value in the child will be updated
54-
* to match the new value and the child will be re-rendered
51+
* @param bool|array $writable If true, this property can be changed by the frontend.
52+
* Or set to an array of paths within this object/array
53+
* that are writable.
54+
* @param bool $useSerializerForHydration If true, the serializer will be used to
55+
* dehydrate then hydrate this property.
56+
* Incompatible with hydrateWith and dehydrateWith.
57+
* @param string|null $format The format to be used if the value is a DateTime of some sort.
58+
* For example: 'Y-m-d H:i:s'. If this property is writable, set this
59+
* to the format that your frontend field will use/set.
60+
* @param bool $updateFromParent if true, while a parent component is re-rendering,
61+
* if the parent passes in this prop and it changed
62+
* from the value used when originally rendering
63+
* this child, the value in the child will be updated
64+
* to match the new value and the child will be re-rendered
5565
*/
5666
public function __construct(
5767
bool|array $writable = false,
5868
?string $hydrateWith = null,
5969
?string $dehydrateWith = null,
70+
bool $useSerializerForHydration = false,
71+
array $serializationContext = [],
6072
?string $fieldName = null,
73+
?string $format = null,
6174
bool $updateFromParent = false
6275
) {
6376
$this->writable = $writable;
6477
$this->hydrateWith = $hydrateWith;
6578
$this->dehydrateWith = $dehydrateWith;
79+
$this->useSerializerForHydration = $useSerializerForHydration;
80+
$this->serializationContext = $serializationContext;
6681
$this->fieldName = $fieldName;
82+
$this->format = $format;
6783
$this->acceptUpdatesFromParent = $updateFromParent;
84+
85+
if ($this->useSerializerForHydration && ($this->hydrateWith || $this->dehydrateWith)) {
86+
throw new \InvalidArgumentException('Cannot use useSerializerForHydration with hydrateWith or dehydrateWith.');
87+
}
6888
}
6989

7090
/**
@@ -109,6 +129,22 @@ public function dehydrateMethod(): ?string
109129
return $this->dehydrateWith ? trim($this->dehydrateWith, '()') : null;
110130
}
111131

132+
/**
133+
* @internal
134+
*/
135+
public function useSerializerForHydration(): bool
136+
{
137+
return $this->useSerializerForHydration;
138+
}
139+
140+
/**
141+
* @internal
142+
*/
143+
public function serializationContext(): array
144+
{
145+
return $this->serializationContext;
146+
}
147+
112148
/**
113149
* @internal
114150
*/
@@ -125,6 +161,11 @@ public function calculateFieldName(object $component, string $fallback): string
125161
return $this->fieldName;
126162
}
127163

164+
public function format(): ?string
165+
{
166+
return $this->format;
167+
}
168+
128169
public function acceptUpdatesFromParent(): bool
129170
{
130171
return $this->acceptUpdatesFromParent;

src/DependencyInjection/Compiler/OptionalDependencyPass.php

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
77
use Symfony\Component\DependencyInjection\ContainerBuilder;
88
use Symfony\Component\DependencyInjection\Reference;
9-
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
10-
use Symfony\UX\LiveComponent\Normalizer\DoctrineObjectNormalizer;
9+
use Symfony\UX\LiveComponent\Hydration\DoctrineEntityHydrationExtension;
10+
use Symfony\UX\LiveComponent\LiveComponentBundle;
1111

1212
/**
1313
* @author Kevin Bond <[email protected]>
@@ -21,10 +21,9 @@ final class OptionalDependencyPass implements CompilerPassInterface
2121
public function process(ContainerBuilder $container): void
2222
{
2323
if ($container->hasDefinition('doctrine')) {
24-
$container->register('ux.live_component.doctrine_object_normalizer', DoctrineObjectNormalizer::class)
25-
->setArguments([new IteratorArgument([new Reference('doctrine')])]) // todo add other object managers (mongo)
26-
->addTag('container.service_subscriber', ['key' => DenormalizerInterface::class, 'id' => 'serializer'])
27-
->addTag('serializer.normalizer', ['priority' => -100])
24+
$container->register('ux.live_component.doctrine_entity_hydration_extension', DoctrineEntityHydrationExtension::class)
25+
->setArguments([new IteratorArgument([new Reference('doctrine')])]) // TODO: add support for multiple entity managers
26+
->addTag(LiveComponentBundle::HYDRATION_EXTENSION_TAG)
2827
;
2928
}
3029
}

0 commit comments

Comments
 (0)