Commit 39f6516a authored by Jens Segers's avatar Jens Segers Committed by GitHub

Merge pull request #1195 from Tucker-Eric/master

Add Hybrid `has` and `whereHas` functionality
parents c4a5264b ea3be777
......@@ -25,8 +25,7 @@
},
"autoload": {
"psr-0": {
"Jenssegers\\Mongodb": "src/",
"Jenssegers\\Eloquent": "src/"
"Jenssegers\\Mongodb": "src/"
}
},
"autoload-dev": {
......
<?php namespace Jenssegers\Mongodb\Eloquent;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Jenssegers\Mongodb\Helpers\QueriesRelationships;
use MongoDB\Driver\Cursor;
use MongoDB\Model\BSONDocument;
class Builder extends EloquentBuilder
{
use QueriesRelationships;
/**
* The methods that should be returned from query builder.
*
......@@ -139,54 +140,6 @@ class Builder extends EloquentBuilder
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
*/
......@@ -198,10 +151,12 @@ class Builder extends EloquentBuilder
// Convert MongoCursor results to a collection of models.
if ($results instanceof Cursor) {
$results = iterator_to_array($results, false);
return $this->model->hydrate($results);
} // Convert Mongo BSONDocument to a single object.
elseif ($results instanceof BSONDocument) {
$results = $results->getArrayCopy();
return $this->model->newFromBuilder((array) $results);
} // The result is a single object.
elseif (is_array($results) and array_key_exists('_id', $results)) {
......
......@@ -3,6 +3,7 @@
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Support\Str;
use Jenssegers\Mongodb\Helpers\EloquentBuilder;
use Jenssegers\Mongodb\Relations\BelongsTo;
use Jenssegers\Mongodb\Relations\BelongsToMany;
use Jenssegers\Mongodb\Relations\HasMany;
......@@ -265,4 +266,12 @@ trait HybridRelations
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;
}
<?php
namespace Jenssegers\Mongodb\Helpers;
use Closure;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
use Jenssegers\Mongodb\Eloquent\Model;
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 a normal whereExists() query that relies on a subquery
// We need to use a `whereIn` query
if ($this->getModel() instanceof Model || $this->isAcrossConnections($relation)) {
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
);
}
/**
* @param $relation
* @return bool
*/
protected function isAcrossConnections($relation)
{
return $relation->getParent()->getConnectionName() !== $relation->getRelated()->getConnectionName();
}
/**
* Compare across databases
* @param $relation
* @param string $operator
* @param int $count
* @param string $boolean
* @param Closure|null $callback
* @return mixed
* @throws \Exception
*/
public function addHybridHas($relation, $operator = '>=', $count = 1, $boolean = 'and', Closure $callback = null)
{
$hasQuery = $relation->getQuery();
if ($callback) {
$hasQuery->callScope($callback);
}
// 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;
}
$relations = $hasQuery->pluck($this->getHasCompareKey($relation));
$relatedIds = $this->getConstrainedRelatedIds($relations, $operator, $count);
return $this->whereIn($this->getRelatedConstraintKey($relation), $relatedIds, $boolean, $not);
}
/**
* Returns key we are constraining this parent model's query with
* @param $relation
* @return string
* @throws \Exception
*/
protected function getRelatedConstraintKey($relation)
{
if ($relation instanceof HasOneOrMany) {
return $this->model->getKeyName();
}
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 (method_exists($relation, 'getHasCompareKey')) {
return $relation->getHasCompareKey();
}
return $relation instanceof HasOneOrMany ? $relation->getForeignKeyName() : $relation->getOwnerKey();
}
/**
* @param $relations
* @param $operator
* @param $count
* @return array
*/
protected function getConstrainedRelatedIds($relations, $operator, $count)
{
$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;
}
});
// All related ids.
return array_keys($relationCount);
}
}
......@@ -4,6 +4,16 @@ use Illuminate\Database\Eloquent\Builder;
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
*/
......
<?php
class MysqlRelationsTest extends TestCase
class HybridRelationsTest extends TestCase
{
public function setUp()
{
......@@ -74,4 +74,120 @@ class MysqlRelationsTest extends TestCase
$role = $user->mysqlRole()->first(); // refetch
$this->assertEquals('John Doe', $role->user->name);
}
public function testHybridWhereHas()
{
$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());
}
public function testHybridWith()
{
$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
Book::truncate();
MysqlBook::truncate();
// Create books
// Mysql relation
$user->mysqlBooks()->saveMany([
new MysqlBook(['title' => 'Game of Thrones']),
new MysqlBook(['title' => 'Harry Potter']),
]);
$otherUser->mysqlBooks()->saveMany([
new MysqlBook(['title' => 'Harry Plants']),
new MysqlBook(['title' => 'Harveys']),
new MysqlBook(['title' => 'Harry Planter']),
]);
// SQL has many Hybrid
$user->books()->saveMany([
new Book(['title' => 'Game of Thrones']),
new Book(['title' => 'Harry Potter']),
]);
$otherUser->books()->saveMany([
new Book(['title' => 'Harry Plants']),
new Book(['title' => 'Harveys']),
new Book(['title' => 'Harry Planter']),
]);
MysqlUser::with('books')->get()
->each(function ($user) {
$this->assertEquals($user->id, $user->books->count());
});
MysqlUser::whereHas('mysqlBooks', function ($query) {
return $query->where('title', 'LIKE', 'Harry%');
})
->with('books')
->get()
->each(function ($user) {
$this->assertEquals($user->id, $user->books->count());
});
}
}
......@@ -443,6 +443,7 @@ class RelationsTest extends TestCase
Role::create(['title' => 'Customer']);
$users = User::has('role')->get();
$this->assertCount(2, $users);
$this->assertEquals('John Doe', $users[0]->name);
$this->assertEquals('Jane Doe', $users[1]->name);
......
......@@ -27,7 +27,8 @@ class MysqlBook extends Eloquent
if (!$schema->hasTable('books')) {
Schema::connection('mysql')->create('books', function ($table) {
$table->string('title');
$table->string('author_id');
$table->string('author_id')->nullable();
$table->integer('mysql_user_id')->unsigned()->nullable();
$table->timestamps();
});
}
......
......@@ -21,6 +21,11 @@ class MysqlUser extends Eloquent
return $this->hasOne('Role');
}
public function mysqlBooks()
{
return $this->hasMany(MysqlBook::class);
}
/**
* Check if we need to run the schema.
*/
......
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