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.
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 ?