Skip to content

Commit

Permalink
implement eager loading for has many relationships - #25
Browse files Browse the repository at this point in the history
  • Loading branch information
Jared King committed Jan 28, 2018
1 parent 3f39074 commit 70cb280
Show file tree
Hide file tree
Showing 7 changed files with 181 additions and 59 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Expand Up @@ -9,7 +9,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Added a `foreign_key` setting on model properties for overriding the default foreign key on relationships.
- Added a `pivot_tablename` setting on model properties for overriding the default pivot table name on belongs-to-many relationships.
- Added a Collection class to represent a collection of models and provide functionality around managing that collection.
- Implemented eager loading for has-one relationships.
- Implemented eager loading for has-one and has-many relationships.

## Changed
- Reduce a loadModel call by caching the values after a save.
Expand Down
17 changes: 17 additions & 0 deletions src/Model.php
Expand Up @@ -1389,6 +1389,23 @@ public function setRelation($k, self $model)
return $this;
}

/**
* @deprecated
*
* Sets the model for a one-to-many relationship
*
* @param string $k
* @param iterable $models
*
* @return $this
*/
public function setRelationCollection($k, iterable $models)
{
$this->_relationships[$k] = $models;

return $this;
}

/**
* Sets the model for a one-to-one relationship (has-one or belongs-to) as null.
*
Expand Down
45 changes: 31 additions & 14 deletions src/Query.php
Expand Up @@ -275,18 +275,14 @@ public function getWith()
*/
public function execute()
{
$models = [];
$model = $this->model;
$driver = $model::getDriver();

$ids = [];
$eagerLoadedProperties = [];
foreach ($this->eagerLoaded as $k) {
$ids[$k] = [];
$eagerLoadedProperties[$k] = $model::getProperty($k);
}
$ids = array_fill_keys($this->eagerLoaded, []);

// fetch the models matching the query
$driver = $model::getDriver();
$models = [];
foreach ($driver->queryModels($this) as $row) {
// get the model's ID
$id = [];
Expand All @@ -297,8 +293,11 @@ public function execute()
// create the model and cache the loaded values
$models[] = new $model($id, $row);
foreach ($this->eagerLoaded as $k) {
$property = $eagerLoadedProperties[$k];
$localKey = $property['local_key'];
if (!isset($eagerLoadedProperties[$k])) {
$eagerLoadedProperties[$k] = $model::getProperty($k);
}

$localKey = $eagerLoadedProperties[$k]['local_key'];
if ($row[$localKey]) {
$ids[$k][] = $row[$localKey];
}
Expand All @@ -311,15 +310,15 @@ public function execute()
$relationModelClass = $property['relation'];

if (Model::RELATIONSHIP_BELONGS_TO == $property['relation_type']) {
$relationships = $this->fetchRelationships($relationModelClass, $ids[$k], $property['foreign_key']);
$relationships = $this->fetchRelationships($relationModelClass, $ids[$k], $property['foreign_key'], false);

foreach ($ids[$k] as $j => $id) {
if (isset($relationships[$id])) {
$models[$j]->setRelation($k, $relationships[$id]);
}
}
} elseif (Model::RELATIONSHIP_HAS_ONE == $property['relation_type']) {
$relationships = $this->fetchRelationships($relationModelClass, $ids[$k], $property['foreign_key']);
$relationships = $this->fetchRelationships($relationModelClass, $ids[$k], $property['foreign_key'], false);

foreach ($ids[$k] as $j => $id) {
if (isset($relationships[$id])) {
Expand All @@ -332,6 +331,16 @@ public function execute()
$models[$j]->clearRelation($k);
}
}
} elseif (Model::RELATIONSHIP_HAS_MANY == $property['relation_type']) {
$relationships = $this->fetchRelationships($relationModelClass, $ids[$k], $property['foreign_key'], true);

foreach ($ids[$k] as $j => $id) {
if (isset($relationships[$id])) {
$models[$j]->setRelationCollection($k, $relationships[$id]);
} else {
$models[$j]->setRelationCollection($k, []);
}
}
}
}

Expand Down Expand Up @@ -492,15 +501,16 @@ public function delete()
}

/**
* Hydrates the eager-loaded relationships for a given set of models.
* Hydrates the eager-loaded relationships for a given set of IDs.
*
* @param string $modelClass
* @param array $ids
* @param string $foreignKey
* @param bool $multiple when true will condense
*
* @return array
*/
private function fetchRelationships($modelClass, array $ids, $foreignKey)
private function fetchRelationships($modelClass, array $ids, $foreignKey, $multiple)
{
$uniqueIds = array_unique($ids);
if (0 === count($uniqueIds)) {
Expand All @@ -513,7 +523,14 @@ private function fetchRelationships($modelClass, array $ids, $foreignKey)

$result = [];
foreach ($models as $model) {
$result[$model->$foreignKey] = $model;
if ($multiple) {
if (!isset($result[$model->$foreignKey])) {
$result[$model->$foreignKey] = [];
}
$result[$model->$foreignKey][] = $model;
} else {
$result[$model->$foreignKey] = $model;
}
}

return $result;
Expand Down
6 changes: 3 additions & 3 deletions tests/ModelTest.php
Expand Up @@ -323,13 +323,13 @@ public function testPropertiesSoftDelete()
'required' => false,
'validate' => 'timestamp|db_timestamp',
],
'car' => [
'garage' => [
'type' => null,
'mutable' => Model::MUTABLE,
'null' => false,
'unique' => false,
'required' => false,
'relation' => 'Car',
'relation' => 'Garage',
'relation_type' => 'has_one',
'foreign_key' => 'person_id',
'local_key' => 'id',
Expand Down Expand Up @@ -621,7 +621,7 @@ public function testToArrayWithRelationship()
'id' => 10,
'name' => 'Bob Loblaw',
'email' => 'bob@example.com',
'car' => null,
'garage' => null,
'deleted_at' => null,
],
];
Expand Down
87 changes: 78 additions & 9 deletions tests/QueryTest.php
Expand Up @@ -218,13 +218,13 @@ public function testExecuteEagerLoadingBelongsTo()
public function testExecuteEagerLoadingHasOne()
{
$query = new Query(Person::class);
$query->with('car');
$query->with('garage');

$driver = Mockery::mock(DriverInterface::class);

$driver->shouldReceive('queryModels')
->andReturnUsing(function ($query) {
if ($query->getModel() instanceof Car && $query->getWhere() == ['person_id IN (1,2,3)']) {
if ($query->getModel() instanceof Garage && $query->getWhere() == ['person_id IN (1,2,3)']) {
return [
[
'id' => 100,
Expand Down Expand Up @@ -260,13 +260,82 @@ public function testExecuteEagerLoadingHasOne()

$this->assertCount(3, $result);

$car1 = $result[0]->relation('car');
$this->assertInstanceOf(Car::class, $car1);
$this->assertEquals(100, $car1->id());
$car2 = $result[1]->relation('car');
$this->assertInstanceOf(Car::class, $car2);
$this->assertEquals(101, $car2->id());
$this->assertNull($result[2]->relation('car'));
$garage1 = $result[0]->relation('garage');
$this->assertInstanceOf(Garage::class, $garage1);
$this->assertEquals(100, $garage1->id());
$garage2 = $result[1]->relation('garage');
$this->assertInstanceOf(Garage::class, $garage2);
$this->assertEquals(101, $garage2->id());
$this->assertNull($result[2]->relation('garage'));
}

public function testExecuteEagerLoadingHasMany()
{
$query = new Query(Category::class);
$query->with('posts');

$driver = Mockery::mock(DriverInterface::class);

$driver->shouldReceive('queryModels')
->andReturnUsing(function ($query) {
if ($query->getModel() instanceof Post && $query->getWhere() == ['category_id IN (1,2,3)']) {
return [
[
'id' => 100,
'category_id' => 1,
],
[
'id' => 101,
'category_id' => 2,
],
[
'id' => 102,
'category_id' => 2,
],
[
'id' => 103,
'category_id' => 2,
],
];
} elseif (Category::class == $query->getModel()) {
return [
[
'id' => 1,
],
[
'id' => 2,
],
[
'id' => 3,
],
];
}
});

Category::setDriver($driver);

$result = $query->execute();

$this->assertCount(3, $result);

$posts1 = $result[0]->relation('posts');
$this->assertCount(1, $posts1);
foreach ($posts1 as $post) {
$this->assertInstanceOf(Post::class, $post);
}
$this->assertEquals(100, $posts1[0]->id());

$posts2 = $result[1]->relation('posts');
$this->assertCount(3, $posts2);
foreach ($posts2 as $post) {
$this->assertInstanceOf(Post::class, $post);
}
$this->assertEquals(101, $posts2[0]->id());
$this->assertEquals(102, $posts2[1]->id());
$this->assertEquals(103, $posts2[2]->id());

$posts3 = $result[2]->relation('posts');
$this->assertCount(0, $posts3);
}

public function testExecuteEagerLoadingNoRelations()
Expand Down

0 comments on commit 70cb280

Please sign in to comment.