Writing to a Collection Relation
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 SubscribeWe are so close to completely re-implementing our API using these custom classes. So excited!
Let's run every test to see where we stand.
symfony php bin/phpunit
And... everything passes except one. This trouble-maker test is UserResourceTest::testTreasuresCannotBeStolen. Let's go check it out!
Open tests/Functional/UserResourceTest.php and search for testTreasuresCannotBeStolen(). Here it is.
| // ... lines 1 - 10 | |
| class UserResourceTest extends ApiTestCase | |
| { | |
| // ... lines 13 - 56 | |
| public function testTreasuresCannotBeStolen(): void | |
| { | |
| $user = UserFactory::createOne(); | |
| $otherUser = UserFactory::createOne(); | |
| $dragonTreasure = DragonTreasureFactory::createOne(['owner' => $otherUser]); | |
| $this->browser() | |
| ->actingAs($user) | |
| ->patch('/api/users/' . $user->getId(), [ | |
| 'json' => [ | |
| 'username' => 'changed', | |
| 'dragonTreasures' => [ | |
| '/api/treasures/' . $dragonTreasure->getId(), | |
| ], | |
| ], | |
| 'headers' => ['Content-Type' => 'application/merge-patch+json'] | |
| ]) | |
| ->assertStatus(422); | |
| } | |
| // ... lines 76 - 89 | |
| } |
Let's read the story. We update a user and attempt to change its dragonTreasures property to contain a treasure owned by someone else. The test looks for a 422 status code - because we want to prevent stealing treasures - but the test fails with a 200.
But apart from the whole stealing thing, this is the first test that we've seen that writes to a collection relation field. And that is an interesting topic all on its own.
Avoid Writable Collection Fields?
First, if you can, I'd recommend against allowing collection relationship fields like this to be writable. I mean, you absolutely can... but it adds complexity. For example, like this test shows, we need to worry about how setting the dragonTreasures property changes the owner on that treasure. And there's already a different way to do this: make a patch() request to this treasure and... change the owner. Simple!
But, if you still want to allow your collection relation to be writable in your DTO system, fine, here's how to do it. I'm kidding - it's not too bad.
Testing the Collection Write
Start by duplicating this test. Rename it to testTreasuresCanBeRemoved. I totally typo'ed that - mine says cannot, which is the opposite of what I want to test - so make sure you get that right in your code.
| // ... lines 1 - 10 | |
| class UserResourceTest extends ApiTestCase | |
| { | |
| // ... lines 13 - 56 | |
| public function testTreasuresCanBeRemoved(): void | |
| { | |
| // ... lines 59 - 74 | |
| } | |
| // ... lines 76 - 109 | |
| } |
Now we can dress this up a bit. Make the first $dragonTreasure owned by $user. Then create a second $dragonTreasure also owned by $user, but we won't need a variable for it... you'll see. Finally, add a third $dragonTreasure called $dragonTreasure3 that's owned by $otherUser.
| // ... 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]); | |
| $dragonTreasure3 = DragonTreasureFactory::createOne(['owner' => $otherUser]); | |
| // ... lines 64 - 82 | |
| } | |
| // ... lines 84 - 117 | |
| } |
So we have three dragonTreasures, two owned by $user, and one by $otherUser. Down here, we patch to modify $user. Remove username - we don't care about that - then send two dragonTreasures: the first and the third: /api/treasures/ $dragonTreasure3->getId().
| // ... 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]); | |
| $dragonTreasure3 = DragonTreasureFactory::createOne(['owner' => $otherUser]); | |
| $this->browser() | |
| ->actingAs($user) | |
| ->patch('/api/users/' . $user->getId(), [ | |
| 'json' => [ | |
| 'dragonTreasures' => [ | |
| '/api/treasures/' . $dragonTreasure->getId(), | |
| '/api/treasures/' . $dragonTreasure3->getId(), | |
| ], | |
| ], | |
| 'headers' => ['Content-Type' => 'application/merge-patch+json'] | |
| ]) | |
| // ... lines 76 - 81 | |
| ; | |
| } | |
| // ... lines 84 - 117 | |
| } |
We're going to test for two things. First, that the second treasure is removed from this user. Think about it: $user started with these two treasures... and the fact that this second treasure's IRI is not sent means that we want it to be removed from $user.
Second, I added $dragonTreasure3 temporarily to prove that treasures can be stolen. This is currently owned by $otherUser, but we pass it to dragonTreasures... and we're going to verify that the owner of $dragonTreasure3 changes from $otherUser to $user. That's not the end behavior we want, but it'll help us get all the relation writing working. Then we'll worry about preventing that.
Down here, ->assertStatus(200) then extend the test by saying ->get('/api/users/' . $user->getId()) and ->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]); | |
| $dragonTreasure3 = DragonTreasureFactory::createOne(['owner' => $otherUser]); | |
| $this->browser() | |
| ->actingAs($user) | |
| ->patch('/api/users/' . $user->getId(), [ | |
| 'json' => [ | |
| 'dragonTreasures' => [ | |
| '/api/treasures/' . $dragonTreasure->getId(), | |
| '/api/treasures/' . $dragonTreasure3->getId(), | |
| ], | |
| ], | |
| 'headers' => ['Content-Type' => 'application/merge-patch+json'] | |
| ]) | |
| ->assertStatus(200) | |
| ->get('/api/users/' . $user->getId()) | |
| ->dump() | |
| // ... lines 79 - 81 | |
| ; | |
| } | |
| // ... lines 84 - 117 | |
| } |
I want to see what the user looks like after the update. Finally, assert that the length of the dragonTreasures field - I need quotes on that - is 2, for treasures 1 and 3. Then assert that dragonTreasures[0] is equal to '/api/treasures/'., followed by $dragonTreasure->getId(). Copy that, paste, and assert that the 1 key is $dragonTreasure3.
| // ... 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]); | |
| $dragonTreasure3 = DragonTreasureFactory::createOne(['owner' => $otherUser]); | |
| $this->browser() | |
| ->actingAs($user) | |
| ->patch('/api/users/' . $user->getId(), [ | |
| 'json' => [ | |
| 'dragonTreasures' => [ | |
| '/api/treasures/' . $dragonTreasure->getId(), | |
| '/api/treasures/' . $dragonTreasure3->getId(), | |
| ], | |
| ], | |
| 'headers' => ['Content-Type' => 'application/merge-patch+json'] | |
| ]) | |
| ->assertStatus(200) | |
| ->get('/api/users/' . $user->getId()) | |
| ->dump() | |
| ->assertJsonMatches('length("dragonTreasures")', 2) | |
| ->assertJsonMatches('dragonTreasures[0]', '/api/treasures/' . $dragonTreasure->getId()) | |
| ->assertJsonMatches('dragonTreasures[1]', '/api/treasures/' . $dragonTreasure3->getId()) | |
| ; | |
| } | |
| // ... lines 84 - 117 | |
| } |
Lovely! That test took some work, but it'll be super useful. Let's... just run it and see what happens! Copy the method name and, over at your terminal, run:
symfony php bin/phpunit --filter=testTreasuresCanBeRemoved
And by "cannot be removed", I, of course, mean that it can be removed. That was some good 'ol copy/paste madness right there. There we go. And... it fails, on line 81. This means that the request was successful... but the dragonTreasures are still the original two: /api/treasures/2 instead of /api/treasures/3. No changes were made to the treasures.
Why? Let's find out next and leverage the property accessor component to make sure the changes save correctly.
12 Comments
Hey Ryan,
you might say it already but i can't find the reference in your course, if i execute a patch request with a collection of embed objects (not IRI) , there is a way to use the EntityClassDtoStateProcessor or should i use a custom processor class ?
or should i use the serialization group in ApiResource class like we used to do it entity class ?
Hey @Joel-L!
You're doing something like PATCH:
Right? Without thinking about this too hard, yes, the answer should be to use serialization groups. This all comes down to the serializer: we want it to "accept" the serialization groups and return them into DTO objects. Then, by the time the code gets to our state processor, we should have (to use my example here) a
ProductDtowith aspecsproperty full ofProductSpecDtoobjects. Then, transforming that into entity objects and saving them shouldn't be any different.The biggest bummer for me when allowing embedded objects with this setup is that you suddenly do need to use serialization groups. The system simplified life so that we didn't need them anymore... but if you want to accept embedded data, then you do need them.
Cheers!
Thanks Ryan
in that direction i'm facing a new problem :
There is a Product and a (child) Item entity class.
I've created a ProductApi and ItemApi resource class everything is fine except for Patch operation who i got the error above.
It seems happening from the mapper
$this->propertyAccessor->setValue($entity, 'items', $importItemEntities);where $entity is ProductApi and items is ItemApi Collection.
Any idea ?
Hey @Joel-L!
On your
Productclass, how is the$itemsproperty accessible? You would typically have anaddItem()andremoveItem()method, and the property accessor is smart enough to call those. Do you have them (they're added by MakerBundle if you generated the relationship viamake:entityand said that you wanted to map the "inverse" side of the relation),Cheers!
yes i have them :
getItems()
addItem()
removeItem()
Hmm, then that just doesn't make sense. The error specifically is:
So this means that PropertyAccess component is being asked to read or write the "items" property on the
Productentity. And being able to usegetItems()(in the case of reading) oraddItem()/removeItem()is core to the property accessor component https://symfony.com/doc/current/components/property_access.html#writing-to-array-propertiesHmm, unless there is something weird happening because
itemson your entity is aCollectionand on your DTO it is an array. You could try making youritemson your DTO anArrayCollectionfrom Doctrine to test that theory (so that both sides are a type of "collection" object).Cheers!
below the source code i'm using , jus tin case you have time :
Product entity
Item entity
ProductApi resource
ItemApi resource
ProductApiToEntityMapper
It all looks fine. My best guess, of something to try - just to see if it makes a difference - would be to make the
itemsproperty inProductApian array, instead of anArrayCollection. That shouldn't make any difference, but your entity looks fine. So the idea is that: perhaps the property accessor is getting confused by theArrayCollection(not realizing it is a "collection") and so is not looking for an adder/remove and is instead only looking for a setter.Cheers!
thanks for your time.
But unfortunately i got same error message.
I will still searching
i found the issue it was mispelling items name
fix now with Collection everywhere
thks againe for your time
Phew! Fantastic - glad you got it sorted.
Happy holidays!
"Houston: no signs of life"
Start the conversation!