Damnjan Jovanovic
Posted on December 4, 2018
I got this question
I found it very interesting, so I decide to give it a whole post to answer it, even there is an 80% similar code base like in the original post. I decided to approach this problem from Collection perspective since Iterator has nothing to do with the search for Id function Ryan want's to implement. Still, Iterating abilities remains same :) so everybody should be happy. Please pay strong attention to Tests I wrote, they remain the same as in the original article, just with added functionalities
The original post is here:
NOTE
If you read this question and this blog post, and you think you have better, smarter, more optimal ar cleaners solution, please feel free to contribute, I'll be happy to see other approaches.
User class
User class is just an example of a class which returns Id, we gonna mock it all the time in our test, so you can implement your own
// Example class with getId method
class User
{
public function getId()
{
return rand(1, 1000000);
}
}
UserIterator
Than we have unchanged Iterator
class UserIterator implements \Iterator
{
/** @var int */
private $position = 0;
/** @var UsersCollection */
private $usersCollection;
public function __construct(UsersCollection $userCollection)
{
$this->usersCollection = $userCollection;
}
public function current() : User
{
return $this->usersCollection->getUser($this->position);
}
public function next()
{
$this->position++;
}
public function key() : int
{
return $this->position;
}
public function valid() : bool
{
return !is_null($this->usersCollection->getUser($this->position));
}
public function rewind()
{
$this->position = 0;
}
}
UsersCollection
And Finally Collection is here with new public methods getUserById, removeById and updateUserById. Also, note that during appending new user to the collection we also append new id to array userIds. That array contains userId as a key and an iterator position as a value. More info about how things work down in the test section
class UsersCollection implements \IteratorAggregate
{
/** @var array */
private $users = [];
private $userIds = [];
public function getIterator() : UserIterator
{
return new UserIterator($this);
}
public function getUser($position)
{
if (isset($this->users[$position])) {
return $this->users[$position];
}
return null;
}
public function getUserById($userId)
{
if (isset($this->userIds[$userId])) {
return $this->getUser($this->userIds[$userId]);
}
return null;
}
public function count() : int
{
return count($this->users);
}
public function removeById($userId)
{
if (isset($this->userIds[$userId])) {
unset($this->userIds[$userId]);
}
}
public function updateUserById($userId, User $user)
{
if ($user->getId() !== $userId) {
throw new Exception('User Id mismatch');
}
if (isset($this->userIds[$userId])) {
$position = $this->userIds[$userId];
$this->users[$position] = $user;
}
}
public function addUser(User $user)
{
$this->users[] = $user;
$this->setUserId($user);
}
private function setUserId(User $user)
{
$userId = $user->getId();
$currentPosition = count($this->users) - 1;
$this->userIds[$userId] = $currentPosition;
}
}
UserIteratorTest
Tests for iterator pretty much the same as in the original article, I just modified it to have actual User mock implemented.
class UserIteratorTest extends MockClass
{
/**
* @covers UserIterator
*/
public function testCurrent()
{
$iterator = $this->getIterator();
$current = $iterator->current();
$this->assertEquals($this->getUserMock(), $current);
}
/**
* @covers UserIterator
*/
public function testNext()
{
$iterator = $this->getIterator();
$iterator->next();
$this->assertEquals(1, $iterator->key());
}
/**
* @covers UserIterator
*/
public function testKey()
{
$iterator = $this->getIterator();
$iterator->next();
$iterator->next();
$this->assertEquals(2, $iterator->key());
}
/**
* @covers UserIterator
*/
public function testValidIfItemInvalid()
{
$iterator = $this->getIterator();
$iterator->next();
$iterator->next();
$iterator->next();
$this->assertEquals(false, $iterator->valid());
}
/**
* @covers UserIterator
*/
public function testValidIfItemIsValid()
{
$iterator = $this->getIterator();
$iterator->next();
$this->assertEquals(true, $iterator->valid());
}
/**
* @covers UserIterator
*/
public function testRewind()
{
$iterator = $this->getIterator();
$iterator->rewind();
$this->assertEquals(0, $iterator->key());
}
private function getIterator() : UserIterator
{
return new UserIterator($this->getCollection());
}
private function getCollection() : UsersCollection
{
$userItems[] = $this->getUserMock();
$userItems[] = $this->getUserMock();
$usersCollection = new UsersCollection();
foreach ($userItems as $user) {
$usersCollection->addUser($user);
}
return $usersCollection;
}
/**
* @return \PHPUnit\Framework\MockObject\MockObject | User
*/
private function getUserMock()
{
$userMock = $this->getMockBuilder(User::class)->getMock();
return $userMock;
}
}
UsersCollectionTest
And tests for Collection now testing for two new "get" cases, get User by existing Id and if user id does not exist return null. Also, there is the test for removing user and two cases updating an existing one.
class UsersCollectionTest extends MockClass
{
/**
* @covers UsersCollection
*/
public function testUsersCollectionShouldReturnNullForNotExistingUserPosition()
{
$usersCollection = new UsersCollection();
$this->assertEquals(null, $usersCollection->getUser(1));
}
/**
* @covers UsersCollection
*/
public function testEmptyUsersCollection()
{
$usersCollection = new UsersCollection();
$this->assertEquals(new UserIterator($usersCollection), $usersCollection->getIterator());
$this->assertEquals(0, $usersCollection->count());
}
/**
* @covers UsersCollection
*/
public function testUsersCollectionWithUserElements()
{
$usersCollection = new UsersCollection();
$usersCollection->addUser($this->getUserMock());
$usersCollection->addUser($this->getUserMock());
$this->assertEquals(new UserIterator($usersCollection), $usersCollection->getIterator());
$this->assertEquals($this->getUserMock(), $usersCollection->getUser(1));
$this->assertEquals(2, $usersCollection->count());
}
/**
* @covers UsersCollection
*/
public function testSearchForUserByIdShouldReturnUserWithGivenId()
{
$user1 = $this->getUserMock();
$user2 = $this->getUserMock();
$user3 = $this->getUserMock();
$user1->expects($this->once())
->method('getId')
->willReturn(123);
$user2->expects($this->once())
->method('getId')
->willReturn(111);
$user3->expects($this->once())
->method('getId')
->willReturn(345);
$usersCollection = new UsersCollection();
$usersCollection->addUser($user1);
$usersCollection->addUser($user2);
$usersCollection->addUser($user3);
$this->assertEquals($user3, $usersCollection->getUserById(345));
$this->assertEquals($user2, $usersCollection->getUserById(111));
$this->assertEquals($user1, $usersCollection->getUserById(123));
}
/**
* @covers UsersCollection
*/
public function testSearchForUserByIdWhichNotExistShouldReturnNull()
{
$user1 = $this->getUserMock();
$user2 = $this->getUserMock();
$user1->expects($this->once())
->method('getId')
->willReturn(1);
$user2->expects($this->once())
->method('getId')
->willReturn(2);
$usersCollection = new UsersCollection();
$usersCollection->addUser($user1);
$usersCollection->addUser($user2);
$this->assertEquals(null, $usersCollection->getUserById(4));
$this->assertEquals(null, $usersCollection->getUserById(100));
}
/**
* @covers UsersCollection
*/
public funCtion testIfOneUserIsRemovedFromCollectionSearchOnUserIdShouldReturnNull()
{
$user1 = $this->getUserMock();
$user2 = $this->getUserMock();
$user3 = $this->getUserMock();
$user1->expects($this->once())
->method('getId')
->willReturn(123);
$user2->expects($this->once())
->method('getId')
->willReturn(111);
$user3->expects($this->once())
->method('getId')
->willReturn(345);
$usersCollection = new UsersCollection();
$usersCollection->addUser($user1);
$usersCollection->addUser($user2);
$usersCollection->addUser($user3);
$usersCollection->removeById(111);
$this->assertEquals($user3, $usersCollection->getUserById(345));
$this->assertEquals(null, $usersCollection->getUserById(111));
$this->assertEquals($user1, $usersCollection->getUserById(123));
}
/**
* @covers UsersCollection
*/
public funCtion testUpdateUserByIdShouldReplaceUserObjectOnThisPosition()
{
$user1 = $this->getUserMock();
$user2 = $this->getUserMock();
$user3 = $this->getUserMock();
$user4 = $this->getUserMock();
// this property is set to ensure that object is different than $user1
$user4->property = 4;
$user1->expects($this->once())
->method('getId')
->willReturn(123);
$user2->expects($this->once())
->method('getId')
->willReturn(111);
$user3->expects($this->once())
->method('getId')
->willReturn(345);
$user4->expects($this->once())
->method('getId')
->willReturn(123);
$usersCollection = new UsersCollection();
$usersCollection->addUser($user1);
$usersCollection->addUser($user2);
$usersCollection->addUser($user3);
$usersCollection->updateUserById(123, $user4);
$this->assertEquals($user4, $usersCollection->getUserById(123));
}
/**
* @expectedExceptionMessage User Id mismatch
* @expectedException \Exception
* @covers UsersCollection
*/
public funCtion testUpdateUserWhenUserIdAndGivenIdMismatchShouldThrowException()
{
$user1 = $this->getUserMock();
$user2 = $this->getUserMock();
$user1->expects($this->once())
->method('getId')
->willReturn(123);
$user2->expects($this->once())
->method('getId')
->willReturn(444);
$usersCollection = new UsersCollection();
$usersCollection->addUser($user1);
$usersCollection->updateUserById(123, $user2);
}
/**
* @return \PHPUnit\Framework\MockObject\MockObject | User
*/
private function getUserMock()
{
$userMock = $this->getMockBuilder(User::class)->getMock();
return $userMock;
}
}
Posted on December 4, 2018
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
How would you implement a solution where the collection indexes didn't start at 0? In the project I'm working on, there are several places where traversal of a large array takes too long, so I specify the index with a known number. Then I can do a
isset($arr[$obj->id])
to find, retrieve, delete, or overwrite the object at that position. I tried to implement this adapting your code but it creates a problem with the$position
variable in the iterator. In PHP 7.3 they added aarray_key_first()
method to more easily get the first key, but I'm operating in 7.2. I've tried a foreach hack, but it creates an internal loop in the iterator.