DEV Community

Cover image for Answer to the Iterator question
Damnjan Jovanovic
Damnjan Jovanovic

Posted on

Answer to the Iterator question

I got this question

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 a array_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.

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
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;
    }
}
Enter fullscreen mode Exit fullscreen mode
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;
    }
}
Enter fullscreen mode Exit fullscreen mode
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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}

Enter fullscreen mode Exit fullscreen mode

Top comments (3)

Collapse
 
shuvadeep8719 profile image
shuvadeep • Edited

@damnjan traversing through collection how could one skip one collection item based on some logic. Sir can you guide how to write this implementation with collection class. if possible can you write a surveyQuestion scenario
where we should iterate a questionSet for a particular survey.

Collapse
 
godsgood33 profile image
Ryan P

This is really helpful! I really appreciate it. I didn't think about using a translation array. Thanks for your thoughts and time into this!

Collapse
 
damnjan profile image
Damnjan Jovanovic

You are welcome. Thank you for your real-life question, I really enjoyed writing this solution :)