Commit 266305ca authored by Eric Tucker's avatar Eric Tucker

add `whereHas()` support for hybrid relations

parent 3276bac2
<?php namespace Jenssegers\Mongodb\Eloquent; <?php namespace Jenssegers\Mongodb\Eloquent;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Relations\Relation; use Jenssegers\Mongodb\Helpers\QueriesRelationships;
use MongoDB\Driver\Cursor; use MongoDB\Driver\Cursor;
use MongoDB\Model\BSONDocument; use MongoDB\Model\BSONDocument;
class Builder extends EloquentBuilder class Builder extends EloquentBuilder
{ {
use QueriesRelationships;
/** /**
* The methods that should be returned from query builder. * The methods that should be returned from query builder.
* *
...@@ -139,54 +140,6 @@ class Builder extends EloquentBuilder ...@@ -139,54 +140,6 @@ class Builder extends EloquentBuilder
return parent::decrement($column, $amount, $extra); return parent::decrement($column, $amount, $extra);
} }
/**
* @inheritdoc
*/
protected function addHasWhere(EloquentBuilder $hasQuery, Relation $relation, $operator, $count, $boolean)
{
$query = $hasQuery->getQuery();
// Get the number of related objects for each possible parent.
$relations = $query->pluck($relation->getHasCompareKey());
$relationCount = array_count_values(array_map(function ($id) {
return (string) $id; // Convert Back ObjectIds to Strings
}, is_array($relations) ? $relations : $relations->flatten()->toArray()));
// Remove unwanted related objects based on the operator and count.
$relationCount = array_filter($relationCount, function ($counted) use ($count, $operator) {
// If we are comparing to 0, we always need all results.
if ($count == 0) {
return true;
}
switch ($operator) {
case '>=':
case '<':
return $counted >= $count;
case '>':
case '<=':
return $counted > $count;
case '=':
case '!=':
return $counted == $count;
}
});
// If the operator is <, <= or !=, we will use whereNotIn.
$not = in_array($operator, ['<', '<=', '!=']);
// If we are comparing to 0, we need an additional $not flip.
if ($count == 0) {
$not = ! $not;
}
// All related ids.
$relatedIds = array_keys($relationCount);
// Add whereIn to the query.
return $this->whereIn($this->model->getKeyName(), $relatedIds, $boolean, $not);
}
/** /**
* @inheritdoc * @inheritdoc
*/ */
...@@ -198,14 +151,16 @@ class Builder extends EloquentBuilder ...@@ -198,14 +151,16 @@ class Builder extends EloquentBuilder
// Convert MongoCursor results to a collection of models. // Convert MongoCursor results to a collection of models.
if ($results instanceof Cursor) { if ($results instanceof Cursor) {
$results = iterator_to_array($results, false); $results = iterator_to_array($results, false);
return $this->model->hydrate($results); return $this->model->hydrate($results);
} // Convert Mongo BSONDocument to a single object. } // Convert Mongo BSONDocument to a single object.
elseif ($results instanceof BSONDocument) { elseif ($results instanceof BSONDocument) {
$results = $results->getArrayCopy(); $results = $results->getArrayCopy();
return $this->model->newFromBuilder((array) $results);
return $this->model->newFromBuilder((array)$results);
} // The result is a single object. } // The result is a single object.
elseif (is_array($results) and array_key_exists('_id', $results)) { elseif (is_array($results) and array_key_exists('_id', $results)) {
return $this->model->newFromBuilder((array) $results); return $this->model->newFromBuilder((array)$results);
} }
return $results; return $results;
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Jenssegers\Mongodb\Helpers\EloquentBuilder;
use Jenssegers\Mongodb\Relations\BelongsTo; use Jenssegers\Mongodb\Relations\BelongsTo;
use Jenssegers\Mongodb\Relations\BelongsToMany; use Jenssegers\Mongodb\Relations\BelongsToMany;
use Jenssegers\Mongodb\Relations\HasMany; use Jenssegers\Mongodb\Relations\HasMany;
...@@ -265,4 +266,12 @@ trait HybridRelations ...@@ -265,4 +266,12 @@ trait HybridRelations
return parent::guessBelongsToManyRelation(); return parent::guessBelongsToManyRelation();
} }
/**
* @inheritdoc
*/
public function newEloquentBuilder($query)
{
return new EloquentBuilder($query);
}
} }
<?php
namespace Jenssegers\Mongodb\Helpers;
use Illuminate\Database\Eloquent\Builder;
class EloquentBuilder extends Builder
{
use QueriesRelationships;
}
\ No newline at end of file
<?php
namespace Jenssegers\Mongodb\Helpers;
use Closure;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
trait QueriesRelationships
{
/**
* Add a relationship count / exists condition to the query.
*
* @param string $relation
* @param string $operator
* @param int $count
* @param string $boolean
* @param \Closure|null $callback
* @return \Illuminate\Database\Eloquent\Builder|static
*/
public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', Closure $callback = null)
{
if (strpos($relation, '.') !== false) {
return $this->hasNested($relation, $operator, $count, $boolean, $callback);
}
$relation = $this->getRelationWithoutConstraints($relation);
// If this is a hybrid relation then we can not use an existence query
// We need to use a `whereIn` query
if ($relation->getParent()->getConnectionName() !== $relation->getRelated()->getConnectionName()) {
return $this->addHybridHas($relation, $operator, $count, $boolean, $callback);
}
// If we only need to check for the existence of the relation, then we can optimize
// the subquery to only run a "where exists" clause instead of this full "count"
// clause. This will make these queries run much faster compared with a count.
$method = $this->canUseExistsForExistenceCheck($operator, $count)
? 'getRelationExistenceQuery'
: 'getRelationExistenceCountQuery';
$hasQuery = $relation->{$method}(
$relation->getRelated()->newQuery(), $this
);
// Next we will call any given callback as an "anonymous" scope so they can get the
// proper logical grouping of the where clauses if needed by this Eloquent query
// builder. Then, we will be ready to finalize and return this query instance.
if ($callback) {
$hasQuery->callScope($callback);
}
return $this->addHasWhere(
$hasQuery, $relation, $operator, $count, $boolean
);
}
/**
* Compare across databases
* @param $relation
* @param string $operator
* @param int $count
* @param string $boolean
* @param Closure|null $callback
* @return mixed
*/
public function addHybridHas($relation, $operator = '>=', $count = 1, $boolean = 'and', Closure $callback = null)
{
$hasQuery = $relation->getQuery();
if ($callback) {
$hasQuery->callScope($callback);
}
$relations = $hasQuery->pluck($this->getHasCompareKey($relation));
$constraintKey = $this->getRelatedConstraintKey($relation);
return $this->addRelatedCountConstraint($constraintKey, $relations, $operator, $count, $boolean);
}
/**
* Returns key we are constraining this parent model's query witth
* @param $relation
* @return string
* @throws \Exception
*/
protected function getRelatedConstraintKey($relation)
{
if ($relation instanceof HasOneOrMany) {
return $relation->getQualifiedParentKeyName();
}
if ($relation instanceof BelongsTo) {
return $relation->getForeignKey();
}
throw new \Exception(class_basename($relation).' Is Not supported for hybrid query constraints!');
}
/**
* @param $relation
* @return string
*/
protected function getHasCompareKey($relation)
{
if ($relation instanceof HasOneOrMany) {
return $relation->getForeignKeyName();
}
$keyMethods = ['getOwnerKey', 'getHasCompareKey'];
foreach ($keyMethods as $method) {
if (method_exists($relation, $method)) {
return $relation->$method();
}
}
}
/**
* Add the "has" condition where clause to the query.
*
* @param \Illuminate\Database\Eloquent\Builder $hasQuery
* @param \Illuminate\Database\Eloquent\Relations\Relation $relation
* @param string $operator
* @param int $count
* @param string $boolean
* @return \Illuminate\Database\Eloquent\Builder|static
*/
protected function addHasWhere(EloquentBuilder $hasQuery, Relation $relation, $operator, $count, $boolean)
{
$query = $hasQuery->getQuery();
// Get the number of related objects for each possible parent.
$relations = $query->pluck($relation->getHasCompareKey());
return $this->addRelatedCountConstraint($this->model->getKeyName(), $relations, $operator, $count, $boolean);
}
/**
* Consta
* @param $key
* @param $relations
* @param $operator
* @param $count
* @param $boolean
* @return mixed
*/
protected function addRelatedCountConstraint($key, $relations, $operator, $count, $boolean)
{
$relationCount = array_count_values(array_map(function ($id) {
return (string)$id; // Convert Back ObjectIds to Strings
}, is_array($relations) ? $relations : $relations->flatten()->toArray()));
// Remove unwanted related objects based on the operator and count.
$relationCount = array_filter($relationCount, function ($counted) use ($count, $operator) {
// If we are comparing to 0, we always need all results.
if ($count == 0) {
return true;
}
switch ($operator) {
case '>=':
case '<':
return $counted >= $count;
case '>':
case '<=':
return $counted > $count;
case '=':
case '!=':
return $counted == $count;
}
});
// If the operator is <, <= or !=, we will use whereNotIn.
$not = in_array($operator, ['<', '<=', '!=']);
// If we are comparing to 0, we need an additional $not flip.
if ($count == 0) {
$not = ! $not;
}
// All related ids.
$relatedIds = array_keys($relationCount);
// Add whereIn to the query.
return $this->whereIn($key, $relatedIds, $boolean, $not);
}
}
\ No newline at end of file
...@@ -4,6 +4,16 @@ use Illuminate\Database\Eloquent\Builder; ...@@ -4,6 +4,16 @@ use Illuminate\Database\Eloquent\Builder;
class BelongsTo extends \Illuminate\Database\Eloquent\Relations\BelongsTo class BelongsTo extends \Illuminate\Database\Eloquent\Relations\BelongsTo
{ {
/**
* Get the key for comparing against the parent key in "has" query.
*
* @return string
*/
public function getHasCompareKey()
{
return $this->getOwnerKey();
}
/** /**
* @inheritdoc * @inheritdoc
*/ */
......
<?php <?php
class MysqlRelationsTest extends TestCase class HybridRelationsTest extends TestCase
{ {
public function setUp() public function setUp()
{ {
...@@ -74,4 +74,59 @@ class MysqlRelationsTest extends TestCase ...@@ -74,4 +74,59 @@ class MysqlRelationsTest extends TestCase
$role = $user->mysqlRole()->first(); // refetch $role = $user->mysqlRole()->first(); // refetch
$this->assertEquals('John Doe', $role->user->name); $this->assertEquals('John Doe', $role->user->name);
} }
public function testRelationConstraints()
{
$user = new MysqlUser;
$otherUser = new MysqlUser;
$this->assertInstanceOf('MysqlUser', $user);
$this->assertInstanceOf('Illuminate\Database\MySqlConnection', $user->getConnection());
$this->assertInstanceOf('MysqlUser', $otherUser);
$this->assertInstanceOf('Illuminate\Database\MySqlConnection', $otherUser->getConnection());
//MySql User
$user->name = "John Doe";
$user->id = 2;
$user->save();
// Other user
$otherUser->name = 'Other User';
$otherUser->id = 3;
$otherUser->save();
// Make sure they are created
$this->assertTrue(is_int($user->id));
$this->assertTrue(is_int($otherUser->id));
// Clear to start
$user->books()->truncate();
$otherUser->books()->truncate();
// Create books
$otherUser->books()->saveMany([
new Book(['title' => 'Harry Plants']),
new Book(['title' => 'Harveys']),
]);
// SQL has many
$user->books()->saveMany([
new Book(['title' => 'Game of Thrones']),
new Book(['title' => 'Harry Potter']),
new Book(['title' => 'Harry Planter']),
]);
$users = MysqlUser::whereHas('books', function ($query) {
return $query->where('title', 'LIKE', 'Har%');
})->get();
$this->assertEquals(2, $users->count());
$users = MysqlUser::whereHas('books', function ($query) {
return $query->where('title', 'LIKE', 'Harry%');
}, '>=', 2)->get();
$this->assertEquals(1, $users->count());
$books = Book::whereHas('mysqlAuthor', function ($query) {
return $query->where('name', 'LIKE', 'Other%');
})->get();
$this->assertEquals(2, $books->count());
}
} }
...@@ -443,6 +443,7 @@ class RelationsTest extends TestCase ...@@ -443,6 +443,7 @@ class RelationsTest extends TestCase
Role::create(['title' => 'Customer']); Role::create(['title' => 'Customer']);
$users = User::has('role')->get(); $users = User::has('role')->get();
$this->assertCount(2, $users); $this->assertCount(2, $users);
$this->assertEquals('John Doe', $users[0]->name); $this->assertEquals('John Doe', $users[0]->name);
$this->assertEquals('Jane Doe', $users[1]->name); $this->assertEquals('Jane Doe', $users[1]->name);
......
...@@ -8,15 +8,15 @@ return [ ...@@ -8,15 +8,15 @@ return [
'name' => 'mongodb', 'name' => 'mongodb',
'driver' => 'mongodb', 'driver' => 'mongodb',
'host' => '127.0.0.1', 'host' => '127.0.0.1',
'database' => 'unittest', 'database' => 'mongo',
], ],
'mysql' => [ 'mysql' => [
'driver' => 'mysql', 'driver' => 'mysql',
'host' => '127.0.0.1', 'host' => '127.0.0.1',
'database' => 'unittest', 'database' => 'mongo',
'username' => 'travis', 'username' => 'homestead',
'password' => '', 'password' => 'secret',
'charset' => 'utf8', 'charset' => 'utf8',
'collation' => 'utf8_unicode_ci', 'collation' => 'utf8_unicode_ci',
'prefix' => '', 'prefix' => '',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment