diff --git a/lib/Mapper.php b/lib/Mapper.php index 03ae52d..1634a4e 100644 --- a/lib/Mapper.php +++ b/lib/Mapper.php @@ -2,6 +2,7 @@ namespace Spot; use Doctrine\DBAL\Types\Type; +use Spot\Relation\NestedRelation; /** * Base DataMapper @@ -445,6 +446,8 @@ protected function with($collection, $entityName, $with = []) return $collection; } + $relationObjects = []; + foreach ($with as $relationName) { // We only need a single entity from the collection, because we're // going to modify the query to pass in an array of all the @@ -458,6 +461,25 @@ protected function with($collection, $entityName, $with = []) $relationObject = $singleEntity->relation($relationName); + // Handle nested relations + if ($relationObject === false) { + $relationNames = explode('.', $relationName); + if (count($relationNames) > 1) { + $finalRelationName = array_pop($relationNames); + $parentRelationName = implode('.', $relationNames); + if (!isset($relationObjects[$parentRelationName])) { + throw new \InvalidArgumentException("Nested relation exception: parent relation is not loaded:'" . $parentRelationName . "'"); + } + $parentRelation = $relationObjects[$parentRelationName]; + $parentRelationEntity = $parentRelation->entityName(); + if( !isset($parentRelationEntity::relations($this, (new $parentRelationEntity))[$finalRelationName])) { + throw new \InvalidArgumentException("Nested relation exception: target relation '" . $finalRelationName . "' doesn't exist in Entity '" . $parentRelationEntity . "'"); + } + $targetRelation = $parentRelationEntity::relations($this, (new $parentRelationEntity))[$finalRelationName]; + $relationObject = new NestedRelation($targetRelation, $parentRelation); + } + } + // Ensure we have a valid relation name if ($relationObject === false) { throw new Exception("Invalid relation name eager-loaded in 'with' clause: No relation on $entityName with name '$relationName'"); @@ -478,6 +500,8 @@ protected function with($collection, $entityName, $with = []) // Eager-load relation results back to collection $collection = $relationObject->eagerLoadOnCollection($relationName, $collection); + + $relationObjects[$relationName] = $relationObject; } $eventEmitter->emit('afterWith', [$this, $collection, $with]); diff --git a/lib/MapperInterface.php b/lib/MapperInterface.php index d5054bb..c14de57 100644 --- a/lib/MapperInterface.php +++ b/lib/MapperInterface.php @@ -1,6 +1,8 @@ relationObject = $relationObject; + $this->parentRelationObject = $parentRelationObject; + $this->entityName = $relationObject->entityName(); + } + + /** + * Set identity values from given collection + * + * @param \Spot\Entity\Collection + */ + public function identityValuesFromCollection(Collection $collection) + { + throw new \BadMethodCallException("This method is not implemented in NestedRelation class"); + } + + /** + * Build query object + * + */ + protected function buildQuery() + { + throw new \BadMethodCallException("This method is not implemented in NestedRelation class"); + } + + /** + * Map relation results to original collection of entities + * + * @param string Relation name + * @param \Spot\Entity\Collection Collection of original entities to map results of query back to + * + * @return \Spot\Entity\Collection + * + * @throws \Exception + */ + public function eagerLoadOnCollection($relationName, Collection $collection) + { + $relationNames = explode('.', $relationName); + $this->relationName = array_pop($relationNames); + $this->parentRelationName = array_pop($relationNames); + + $this->createRelationCollection($collection); + + $filledRelationCollection = $this->relationObject->eagerLoadOnCollection($this->relationName, $this->relationCollection); + + if (!empty($this->relationCollectionReversedIdentities)) { + $result = []; + foreach ($filledRelationCollection as $ent) { + if (isset($this->relationCollectionReversedIdentities[$ent->primaryKey()])) { + $result[$this->relationCollectionReversedIdentities[$ent->primaryKey()]][] = $ent; + } + } + $filledRelationCollection = $result; + } + + return $this->addFilledCollection($collection, $filledRelationCollection); + } + + /** + * @param $collection + * @param $filledRelationCollection + * + * @return mixed + */ + public function addFilledCollection($collection, $filledRelationCollection) + { + $parentCollection = $collection; + if ($this->parentRelationObject instanceof NestedRelation) { + $parentCollection = $this->parentRelationObject->relationCollection; + } + + foreach($parentCollection as $entity) { + if (isset($filledRelationCollection[$entity->primaryKey()])) { + $entity->relation($this->parentRelationName, $filledRelationCollection[$entity->primaryKey()]); + } + } + + if ($this->parentRelationObject instanceof NestedRelation) { + return $this->parentRelationObject->addFilledCollection($collection, $parentCollection); + } + + return $parentCollection; + } + + /** + * @param Collection $collection + */ + public function createRelationCollection(Collection $collection) + { + if ($this->parentRelationObject instanceof NestedRelation) { + $collection = $this->parentRelationObject->relationCollection; + } + $relationCollection = []; + $resultsIdentities = []; + foreach($collection as $entity) { + $relatedEntity = $entity->relation($this->parentRelationName); + if ($relatedEntity instanceof Collection) { + foreach ($relatedEntity as $childEntity) { + $relationCollection[] = $childEntity; + $resultsIdentities[] = $childEntity->primaryKey(); + $this->relationCollectionReversedIdentities[$childEntity->primaryKey()] = $entity->primaryKey(); + } + } else { + $relationCollection[$entity->primaryKey()] = $relatedEntity; + $resultsIdentities[] = $relatedEntity->primaryKey(); + } + } + $this->relationCollection = new Collection($relationCollection, $resultsIdentities); + $this->relationObject->identityValuesFromCollection($this->relationCollection); + } + + + /** + * Save related entities + * + * @param EntityInterface $entity Entity to save relation from + * @param string $relationName Name of the relation to save + * @param array $options Options to pass to the mappers + * + * @throws \Exception + */ + public function save(EntityInterface $entity, $relationName, $options = []) + { + throw new \BadMethodCallException("This method is not implemented in NestedRelation class"); + } +} diff --git a/tests/CRUD.php b/tests/CRUD.php index 89a41f5..36d54bd 100644 --- a/tests/CRUD.php +++ b/tests/CRUD.php @@ -6,7 +6,7 @@ */ class CRUD extends \PHPUnit_Framework_TestCase { - private static $entities = ['PolymorphicComment', 'PostTag', 'Post\Comment', 'Post', 'Tag', 'Author', 'Setting', 'Event\Search', 'Event']; + private static $entities = ['PolymorphicComment', 'PostTag', 'Post\UserComment', 'Post\Comment', 'Post', 'Tag', 'Author', 'Setting', 'Event\Search', 'Event']; public static function setupBeforeClass() { diff --git a/tests/Entity/Post.php b/tests/Entity/Post.php index 437df08..24df142 100644 --- a/tests/Entity/Post.php +++ b/tests/Entity/Post.php @@ -6,6 +6,7 @@ use Spot\MapperInterface; use Spot\EventEmitter; use Spot\Query; +use SpotTest\Entity\Post\UserComment; /** * Post @@ -38,6 +39,7 @@ public static function relations(MapperInterface $mapper, EntityInterface $entit 'tags' => $mapper->hasManyThrough($entity, 'SpotTest\Entity\Tag', 'SpotTest\Entity\PostTag', 'tag_id', 'post_id'), 'comments' => $mapper->hasMany($entity, 'SpotTest\Entity\Post\Comment', 'post_id')->order(['date_created' => 'ASC']), 'polymorphic_comments' => $mapper->hasMany($entity, 'SpotTest\Entity\PolymorphicComment', 'item_id')->where(['item_type' => 'post']), + 'user_comments' => $mapper->hasMany($entity, 'SpotTest\Entity\Post\UserComment', 'post_id'), 'author' => $mapper->belongsTo($entity, 'SpotTest\Entity\Author', 'author_id') ]; } diff --git a/tests/Entity/Post/UserComment.php b/tests/Entity/Post/UserComment.php new file mode 100644 index 0000000..4b95ca8 --- /dev/null +++ b/tests/Entity/Post/UserComment.php @@ -0,0 +1,37 @@ + ['type' => 'integer', 'primary' => true, 'autoincrement' => true], + 'post_id' => ['type' => 'integer', 'index' => true, 'required' => true], + 'user_id' => ['type' => 'integer', 'index' => true, 'required' => true], + 'body' => ['type' => 'text', 'required' => true], + 'date_created' => ['type' => 'datetime'] + ]; + } + + public static function relations(MapperInterface $mapper, EntityInterface $entity) + { + return [ + 'post' => $mapper->belongsTo($entity, 'SpotTest\Entity\Post', 'post_id'), + 'user' => $mapper->belongsTo($entity, 'SpotTest\Entity\User', 'user_id') + ]; + } +} diff --git a/tests/Entity/User.php b/tests/Entity/User.php new file mode 100644 index 0000000..c336a4e --- /dev/null +++ b/tests/Entity/User.php @@ -0,0 +1,27 @@ + ['type' => 'integer', 'primary' => true, 'autoincrement' => true], + 'email' => ['type' => 'string', 'required' => true, 'unique' => true, + 'validation' => [ + 'email', + 'length' => [4, 255] + ] + ], // Unique + 'password' => ['type' => 'text', 'required' => true], + 'date_created' => ['type' => 'datetime', 'value' => new \DateTime()] + ]; + } +} diff --git a/tests/NestedRelation.php b/tests/NestedRelation.php new file mode 100644 index 0000000..1e14c7b --- /dev/null +++ b/tests/NestedRelation.php @@ -0,0 +1,139 @@ +migrate(); + } + + // Fixtures for this test suite + + // 1 Author + $authorMapper = test_spot_mapper('SpotTest\Entity\Author'); + $author = $authorMapper->create([ + 'id' => 123, + 'email' => 'test123@test.com', + 'password' => 'password123', + 'is_admin' => false + ]); + + // 4 Users + $users = []; + $usersCount = 4; + $userMapper = test_spot_mapper('SpotTest\Entity\User'); + for ($i = 1; $i <= $usersCount; $i++) { + $users[] = $userMapper->create([ + 'email' => "test_$i@test.com", + 'password' => 'password' + ]); + } + + // 10 Posts + $posts = []; + $postsCount = 10; + $mapper = test_spot_mapper('SpotTest\Entity\Post'); + for ($i = 1; $i <= $postsCount; $i++) { + $posts[] = $mapper->create([ + 'title' => "Eager Loading Test Post $i", + 'body' => "Eager Loading Test Post Content Here $i", + 'author_id' => $author->id + ]); + } + + // 10 comments for each post + foreach ($posts as $post) { + $comments = []; + $commentCount = 10; + $commentMapper = test_spot_mapper('SpotTest\Entity\Post\UserComment'); + for ($i = 1; $i <= $commentCount; $i++) { + $comments[] = $commentMapper->create([ + 'post_id' => $post->id, + 'user_id' => $i % $usersCount + 1, + 'body' => "This is a test comment $i. Yay!" + ]); + } + } + } + + public static function tearDownAfterClass() + { + foreach (self::$entities as $entity) { + test_spot_mapper('\SpotTest\Entity\\' . $entity)->dropTable(); + } + } + + /** + * @dataProvider testEagerLoadDataProvider() + */ + public function testEagerLoad($relations, $expectedTotalQueries) + { + $mapper = test_spot_mapper('\SpotTest\Entity\Post'); + + // Set SQL logger + $logger = new \Doctrine\DBAL\Logging\DebugStack(); + $mapper->connection()->getConfiguration()->setSQLLogger($logger); + + $startCount = count($logger->queries); + + $posts = $mapper->all()->with($relations); + foreach ($posts as $post) { + foreach ($post->user_comments as $comment) { + $user = $comment->user; + // Lets check that we have the correct user for each comment + $this->assertEquals($user->id, $comment->user_id); + } + } + $endCount = count($logger->queries); + + // Eager-loaded relation should be only 3 queries + $this->assertEquals($startCount+$expectedTotalQueries, $endCount); + } + + public function testEagerLoadDataProvider() + { + return [ + // without nested relation we will have 102 queries + [ + ['user_comments'], 102 + ], + // with nested relation we will have only 3 queries + [ + ['user_comments', 'user_comments.user'], 3 + ] + ]; + } + + /** + * @dataProvider testWrongRelationNamesDataProvider() + * + * @expectedException \InvalidArgumentException + */ + public function testNestedRelationBadRelationNames($relations) + { + $mapper = test_spot_mapper('\SpotTest\Entity\Post'); + + + $posts = $mapper->all()->with($relations); + foreach ($posts as $post) { + $post->id; + } + } + + public function testWrongRelationNamesDataProvider() + { + return [ + [['user_comments', 'user_commentsss.user']], + [['user_comments.user']], + [['user_comments', 'user_comments.userrr']], + ]; + } + +}