Writable Collection via the PropertyAccessor
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeTo see what's going on here, head to the mapper: UserApiToEntityMapper
. The patch()
request will take this data, populate it onto UserApi
... then we map it back to the entity in this mapper.
And... the reason the test fails is pretty obvious: we're not mapping the dragonTreasures
property from the DTO to the entity!
Let's dump($dto)
so we can see what it looks like after deserializing the data.
// ... lines 1 - 12 | |
class UserApiToEntityMapper implements MapperInterface | |
{ | |
// ... lines 15 - 34 | |
public function populate(object $from, object $to, array $context): object | |
{ | |
// ... lines 37 - 46 | |
dump($dto); | |
// ... lines 48 - 50 | |
} | |
} |
Run the test again:
symfony php bin/phpunit --filter=testTreasuresCanBeRemoved
And... whoa. The dragonTreasures
in the DTO is still the original two. This tells me that this field is being completely ignored: it's not being deserialized. And I bet you know the reason. Inside UserApi
, the $dragonTreasures
property isn't writable
! But it is pretty cool to see writable: false
doing its job.
// ... lines 1 - 42 | |
class UserApi | |
{ | |
// ... lines 45 - 61 | |
/** | |
* @var array<int, DragonTreasureApi> | |
*/ | |
public array $dragonTreasures = []; | |
// ... lines 66 - 68 | |
} |
When we run the test again, you'll see the difference.
symfony php bin/phpunit --filter=testTreasuresCanBeRemoved
Yup! Still two treasures but the IDs are "1" and "3". So UserApi
looks correct.
Going from DragonTreasureApi -> DragonTreasure
Now, we need to take this array of DragonTreasureApi
objects and map them to DragonTreasure
entity objects so we can set them onto the User
entity. Once again, we need micro mapper!
You know the drill: add private MicroMapperInterface $microMapper
... and back down here... start with $dragonTreasureEntities = []
. I'm going to keep this simple and use a good old-fashioned foreach
. Loop over $dto->dragonTreasures
as $dragonTreasureApi
. Then say $dragonTreasureEntities[]
equals $this->microMapper->map()
, passing $dragonTreasureApi
and DragonTreasure::class
. And as you may have already guessed, we're going to pass MicroMapperInterface::MAX_DEPTH
set to 0
.
// ... lines 1 - 11 | |
use Symfonycasts\MicroMapper\MicroMapperInterface; | |
// ... lines 13 - 14 | |
class UserApiToEntityMapper implements MapperInterface | |
{ | |
public function __construct( | |
// ... lines 18 - 19 | |
private MicroMapperInterface $microMapper, | |
) | |
{ | |
} | |
// ... lines 24 - 37 | |
public function populate(object $from, object $to, array $context): object | |
{ | |
// ... lines 40 - 50 | |
$dragonTreasureEntities = []; | |
foreach ($dto->dragonTreasures as $dragonTreasureApi) { | |
$dragonTreasureEntities[] = $this->microMapper->map($dragonTreasureApi, DragonTreasure::class, [ | |
MicroMapperInterface::MAX_DEPTH => 0, | |
]); | |
} | |
// ... lines 57 - 59 | |
} | |
} |
0
is fine here because we just need to make sure that the dragon treasure mapper queries for the correct DragonTreasure
entity. If it has a relation, like owner
, we don't care if that object is fully mapped & populated. Down here, dd($dragonTreasureEntities)
.
// ... lines 1 - 11 | |
use Symfonycasts\MicroMapper\MicroMapperInterface; | |
// ... lines 13 - 14 | |
class UserApiToEntityMapper implements MapperInterface | |
{ | |
public function __construct( | |
// ... lines 18 - 19 | |
private MicroMapperInterface $microMapper, | |
) | |
{ | |
} | |
// ... lines 24 - 37 | |
public function populate(object $from, object $to, array $context): object | |
{ | |
// ... lines 40 - 50 | |
$dragonTreasureEntities = []; | |
foreach ($dto->dragonTreasures as $dragonTreasureApi) { | |
$dragonTreasureEntities[] = $this->microMapper->map($dragonTreasureApi, DragonTreasure::class, [ | |
MicroMapperInterface::MAX_DEPTH => 0, | |
]); | |
} | |
dd($dragonTreasureEntities); | |
// ... lines 58 - 59 | |
} | |
} |
Try it out!
symfony php bin/phpunit --filter=testTreasuresCanBeRemoved
And... looks good! We have 2 treasures with id: 1
... and way down here id: 3
.
Calling the Adder/Remover Methods
So all we need to do now is set that onto the User
object. Say $entity->set
... but... uh oh. We don't have a setDragonTreasures()
method! And that's on purpose! Look inside the User
entity. It has a getDragonTreasures()
method, but no setDragonTreasures()
. Instead, it has addDragonTreasure()
and removeDragonTreasure()
.
I won't dive too deeply into why we can't have a setter, but it relates to the fact that we need to set the owning side of the Doctrine relationship. We talk about this in our Doctrine relations tutorial.
The point is, if we were able to just call ->setDragonTreasures()
, it wouldn't save correctly. We need to call the adder and remover methods.
And this is tricky! We need to look at $dragonTreasureEntities
, compare that with the current dragonTreasures
property, then call the correct adders and removers for which ever treasures are new or removed. In our case, we need to call removeDragonTreasure()
for the middle one and addDragonTreasure()
for this third one.
Writing this code sounds... annoying... and complicated. Fortunately, Symfony has something that does this! It's a service called the "Property Accessor".
Head up here... and add private PropertyAccessorInterface $propertyAccessor
.
// ... lines 1 - 9 | |
use Symfony\Component\PropertyAccess\PropertyAccessorInterface; | |
// ... lines 11 - 15 | |
class UserApiToEntityMapper implements MapperInterface | |
{ | |
public function __construct( | |
// ... lines 19 - 21 | |
private PropertyAccessorInterface $propertyAccessor, | |
) | |
{ | |
} | |
// ... lines 26 - 63 | |
} |
Property Accessor is good at setting properties. It can detect if a property is public... or if it has a setter method... or even adder, or remover methods. To use it, say $this->propertyAccessor->setValue()
passing the object that we're setting data onto - the User
$entity
, the property we're setting - dragonTreasures
- and finally, the value: $dragonTreasureEntities
.
Down here, let's dd($entity)
so we can see how it looks.
// ... lines 1 - 9 | |
use Symfony\Component\PropertyAccess\PropertyAccessorInterface; | |
// ... lines 11 - 15 | |
class UserApiToEntityMapper implements MapperInterface | |
{ | |
public function __construct( | |
// ... lines 19 - 21 | |
private PropertyAccessorInterface $propertyAccessor, | |
) | |
{ | |
} | |
// ... lines 26 - 39 | |
public function populate(object $from, object $to, array $context): object | |
{ | |
// ... lines 42 - 58 | |
$this->propertyAccessor->setValue($entity, 'dragonTreasures', $dragonTreasureEntities); | |
dd($entity); | |
// ... lines 61 - 62 | |
} | |
} |
Deep breath. Try it:
symfony php bin/phpunit --filter=testTreasuresCanBeRemoved
Scroll up... to the User
object. Look at dragonTreasures
! It has two items with id: 1
and id: 3
! It correctly updated the dragonTreasures
property! How the heck did it do that? By calling addDragonTreasure()
for id 3 and removeDragonTreasure()
for id 2.
I can prove it. Down here, add dump('Removing treasure'.$treasure->getId())
.
When we run the test again...
symfony php bin/phpunit --filter=testTreasuresCanBeRemoved
There it is! Removing treasure 2! Life is good. Remove this dump()
... as well as the other one over here.
Let's see some green. Run the test one last time... hopefully:
symfony php bin/phpunit --filter=testTreasuresCanBeRemoved
It passes! The final response contains treasures 1
and 3
. What happened to treasure 2
? It was actually deleted from the database entirely. Behind the scenes, its owner was set to null
. Then, thanks to orphanRemoval
, any time the owner of one of these dragonTreasures
is set to null
, it gets deleted. That's something we talked about in a previous tutorial.
Before we move on, we need to clean up the test. Remove the part where we are stealing $dragonTreasure3
. We'll get rid of that object there, the part where we set it down here, change the length to 1
, and just test that one. So this now truly is a test for removing a treasure.
Celebrate by removing this ->dump()
.
// ... lines 1 - 10 | |
class UserResourceTest extends ApiTestCase | |
{ | |
// ... lines 13 - 56 | |
public function testTreasuresCanBeRemoved(): void | |
{ | |
$user = UserFactory::createOne(); | |
$otherUser = UserFactory::createOne(); | |
$dragonTreasure = DragonTreasureFactory::createOne(['owner' => $user]); | |
DragonTreasureFactory::createOne(['owner' => $user]); | |
$this->browser() | |
->actingAs($user) | |
->patch('/api/users/' . $user->getId(), [ | |
'json' => [ | |
'dragonTreasures' => [ | |
'/api/treasures/' . $dragonTreasure->getId(), | |
], | |
], | |
'headers' => ['Content-Type' => 'application/merge-patch+json'] | |
]) | |
->assertStatus(200) | |
->get('/api/users/' . $user->getId()) | |
->assertJsonMatches('length("dragonTreasures")', 1) | |
->assertJsonMatches('dragonTreasures[0]', '/api/treasures/' . $dragonTreasure->getId()) | |
; | |
} | |
// ... lines 80 - 113 | |
} |
But... treasures can still be stolen, which is lame. Let's fix the validator for this... but also make it a lot simpler, thanks to the DTO system, next.
One more question, patch() should update specific data, no? But it seems to replace those two data in the database ( and remove that second one ) but one gets removed (the second one), I mean why should it get removed due to a patch() request? just because we didn't send it during the update
So, is it removing from the database because when deserializing, it's owner gets set to null, because of the data that's sent ( i.e. dragonTreasures => [ '/api/treasures/1', 'api/treasures/2' ] ) does not get deserialized, and as a result, it's owner is missing ?