From b7a54deee3a208c9a12b10ca3a6bad26e712816f Mon Sep 17 00:00:00 2001 From: Lennart Carstens-Behrens Date: Mon, 17 May 2021 23:13:45 +0200 Subject: [PATCH] [8.x] Add one-of-many relationship (inner join) (#37362) * added one-of-many to has-one * Apply fixes from StyleCI * fixed getResults * added query methods to forwardToOneOfManyQuery * Apply fixes from StyleCI * improvements & tests * Apply fixes from StyleCI * use where or having * Apply fixes from StyleCI * join * wip * wip * fixes style * updated contract * multiple aggregastes * Apply fixes from StyleCI * formatting * formatting * Apply fixes from StyleCI * formatting * rename class * add file * rename array key * add of-many to morph-one * Apply fixes from StyleCI * fixed pivot test * Apply fixes from StyleCI * fixed return type * formatting * add shortcut methods * move test * multiple columns in shortcut * Apply fixes from StyleCI * add key when missing * Apply fixes from StyleCI * use collections * fail for invalid aggregates * Apply fixes from StyleCI * formatting Co-authored-by: Taylor Otwell --- .../Eloquent/SupportsPartialRelations.php | 25 ++ .../Relations/Concerns/CanBeOneOfMany.php | 237 ++++++++++ .../Concerns/ComparesRelatedModels.php | 11 +- .../Database/Eloquent/Relations/HasOne.php | 65 ++- .../Database/Eloquent/Relations/MorphOne.php | 59 ++- .../DatabaseEloquentHasOneOfManyTest.php | 417 ++++++++++++++++++ .../DatabaseEloquentMorphOneOfManyTest.php | 159 +++++++ tests/Database/DatabaseEloquentPivotTest.php | 8 + .../Database/EloquentHasOneOfManyTest.php | 74 ++++ 9 files changed, 1050 insertions(+), 5 deletions(-) create mode 100644 src/Illuminate/Contracts/Database/Eloquent/SupportsPartialRelations.php create mode 100644 src/Illuminate/Database/Eloquent/Relations/Concerns/CanBeOneOfMany.php create mode 100755 tests/Database/DatabaseEloquentHasOneOfManyTest.php create mode 100644 tests/Database/DatabaseEloquentMorphOneOfManyTest.php create mode 100644 tests/Integration/Database/EloquentHasOneOfManyTest.php diff --git a/src/Illuminate/Contracts/Database/Eloquent/SupportsPartialRelations.php b/src/Illuminate/Contracts/Database/Eloquent/SupportsPartialRelations.php new file mode 100644 index 000000000000..a54b74d29b4a --- /dev/null +++ b/src/Illuminate/Contracts/Database/Eloquent/SupportsPartialRelations.php @@ -0,0 +1,25 @@ +isOneOfMany = true; + + $this->relationName = $relation ?: $this->guessRelationship(); + + $keyName = $this->query->getModel()->getKeyName(); + + $columns = is_string($columns = $column) ? [ + $column => $aggregate, + $keyName => $aggregate, + ] : $column; + + if (! array_key_exists($keyName, $columns)) { + $columns[$keyName] = 'MAX'; + } + + if ($aggregate instanceof Closure) { + $closure = $aggregate; + } + + foreach ($columns as $column => $aggregate) { + if (! in_array(strtolower($aggregate), ['min', 'max'])) { + throw new InvalidArgumentException("Invalid aggregate [{$aggregate}] used within ofMany relation. Available aggregates: MIN, MAX"); + } + + $subQuery = $this->newSubQuery( + isset($previous) ? $previous['column'] : $this->getOneOfManySubQuerySelectColumns(), + $column, $aggregate + ); + + if (isset($previous)) { + $this->addJoinSub($subQuery, $previous['subQuery'], $previous['column']); + } elseif (isset($closure)) { + $closure($subQuery); + } + + if (array_key_last($columns) == $column) { + $this->addJoinSub($this->query, $subQuery, $column); + } + + $previous = [ + 'subQuery' => $subQuery, + 'column' => $column, + ]; + } + + return $this; + } + + /** + * Indicate that the relation is the latest single result of a larger one-to-many relationship. + * + * @param string|array|null $column + * @param string|Closure|null $aggregate + * @param string|null $relation + * @return $this + */ + public function latestOfMany($column = 'id', $relation = null) + { + return $this->ofMany(collect(Arr::wrap($column))->mapWithKeys(function ($column) { + return [$column => 'MAX']; + })->all(), 'MAX', $relation ?: $this->guessRelationship()); + } + + /** + * Indicate that the relation is the oldest single result of a larger one-to-many relationship. + * + * @param string|array|null $column + * @param string|Closure|null $aggregate + * @param string|null $relation + * @return $this + */ + public function oldestOfMany($column = 'id', $relation = null) + { + return $this->ofMany(collect(Arr::wrap($column))->mapWithKeys(function ($column) { + return [$column => 'MIN']; + })->all(), 'MIN', $relation ?: $this->guessRelationship()); + } + + /** + * Get a new query for the related model, grouping the query by the given column, often the foreign key of the relationship. + * + * @param string|array $groupBy + * @param string|null $column + * @param string|null $aggregate + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function newSubQuery($groupBy, $column = null, $aggregate = null) + { + $subQuery = $this->query->getModel() + ->newQuery(); + + foreach (Arr::wrap($groupBy) as $group) { + $subQuery->groupBy($this->qualifyRelatedColumn($group)); + } + + if (! is_null($column)) { + $subQuery->selectRaw($aggregate.'('.$column.') as '.$column); + } + + $this->addOneOfManySubQueryConstraints($subQuery, $groupBy, $column, $aggregate); + + return $subQuery; + } + + /** + * Add the join subquery to the given query on the given column and the relationship's foreign key. + * + * @param \Illuminate\Database\Eloquent\Builder $parent + * @param \Illuminate\Database\Eloquent\Builder $subQuery + * @param string $on + * @return void + */ + protected function addJoinSub(Builder $parent, Builder $subQuery, $on) + { + $parent->joinSub($subQuery, $this->relationName, function ($join) use ($on) { + $join->on($this->qualifySubSelectColumn($on), '=', $this->qualifyRelatedColumn($on)); + + $this->addOneOfManyJoinSubQueryConstraints($join, $on); + }); + } + + /** + * Get the qualified column name for the one-of-many relationship using the subselect join query's alias. + * + * @param string $column + * @return string + */ + public function qualifySubSelectColumn($column) + { + return $this->getRelationName().'.'.last(explode('.', $column)); + } + + /** + * Qualify related column using the related table name if it is not already qualified. + * + * @param string $column + * @return string + */ + protected function qualifyRelatedColumn($column) + { + return Str::contains($column, '.') ? $column : $this->query->getModel()->getTable().'.'.$column; + } + + /** + * Guess the "hasOne" relationship's name via backtrace. + * + * @return string + */ + protected function guessRelationship() + { + return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; + } + + /** + * Determine whether the relationship is a one-of-many relationship. + * + * @return bool + */ + public function isOneOfMany() + { + return $this->isOneOfMany; + } + + /** + * Get the name of the relationship. + * + * @return string + */ + public function getRelationName() + { + return $this->relationName; + } +} diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/ComparesRelatedModels.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/ComparesRelatedModels.php index 50ec4f03e337..ca06698875e8 100644 --- a/src/Illuminate/Database/Eloquent/Relations/Concerns/ComparesRelatedModels.php +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/ComparesRelatedModels.php @@ -2,6 +2,7 @@ namespace Illuminate\Database\Eloquent\Relations\Concerns; +use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations; use Illuminate\Database\Eloquent\Model; trait ComparesRelatedModels @@ -14,10 +15,18 @@ trait ComparesRelatedModels */ public function is($model) { - return ! is_null($model) && + $match = ! is_null($model) && $this->compareKeys($this->getParentKey(), $this->getRelatedKeyFrom($model)) && $this->related->getTable() === $model->getTable() && $this->related->getConnectionName() === $model->getConnectionName(); + + if ($match && $this instanceof SupportsPartialRelations && $this->isOneOfMany()) { + return $this->query + ->whereKey($model->getKey()) + ->exists(); + } + + return $match; } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOne.php b/src/Illuminate/Database/Eloquent/Relations/HasOne.php index 81ca9bb441cf..dc4ee3fd338c 100644 --- a/src/Illuminate/Database/Eloquent/Relations/HasOne.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOne.php @@ -2,14 +2,18 @@ namespace Illuminate\Database\Eloquent\Relations; +use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Concerns\CanBeOneOfMany; use Illuminate\Database\Eloquent\Relations\Concerns\ComparesRelatedModels; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; +use Illuminate\Database\Query\JoinClause; -class HasOne extends HasOneOrMany +class HasOne extends HasOneOrMany implements SupportsPartialRelations { - use ComparesRelatedModels, SupportsDefaultModels; + use ComparesRelatedModels, CanBeOneOfMany, SupportsDefaultModels; /** * Get the results of the relationship. @@ -54,6 +58,63 @@ public function match(array $models, Collection $results, $relation) return $this->matchOne($models, $results, $relation); } + /** + * Add the constraints for an internal relationship existence query. + * + * Essentially, these queries compare on column names like "whereColumn". + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $parentQuery + * @param array|mixed $columns + * @return \Illuminate\Database\Eloquent\Builder + */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) + { + if (! $this->isOneOfMany()) { + return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + } + + $query->getQuery()->joins = $this->query->getQuery()->joins; + + return $query->select($columns)->whereColumn( + $this->getQualifiedParentKeyName(), '=', $this->getExistenceCompareKey() + ); + } + + /** + * Add constraints for inner join subselect for one of many relationships. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|null $column + * @param string|null $aggregate + * @return void + */ + public function addOneOfManySubQueryConstraints(Builder $query, $column = null, $aggregate = null) + { + $query->addSelect($this->foreignKey); + } + + /** + * Get the columns that should be selected by the one of many subquery. + * + * @return array|string + */ + public function getOneOfManySubQuerySelectColumns() + { + return $this->foreignKey; + } + + /** + * Add join query constraints for one of many relationships. + * + * @param \Illuminate\Database\Eloquent\JoinClause $join + * @return void + */ + public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) + { + $join->on($this->qualifySubSelectColumn($this->foreignKey), '=', $this->qualifyRelatedColumn($this->foreignKey)); + } + /** * Make a new related instance for the given model. * diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphOne.php b/src/Illuminate/Database/Eloquent/Relations/MorphOne.php index a874cdaec8d6..7a3353cbe498 100644 --- a/src/Illuminate/Database/Eloquent/Relations/MorphOne.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphOne.php @@ -2,14 +2,18 @@ namespace Illuminate\Database\Eloquent\Relations; +use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Concerns\CanBeOneOfMany; use Illuminate\Database\Eloquent\Relations\Concerns\ComparesRelatedModels; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; +use Illuminate\Database\Query\JoinClause; -class MorphOne extends MorphOneOrMany +class MorphOne extends MorphOneOrMany implements SupportsPartialRelations { - use ComparesRelatedModels, SupportsDefaultModels; + use CanBeOneOfMany, ComparesRelatedModels, SupportsDefaultModels; /** * Get the results of the relationship. @@ -54,6 +58,57 @@ public function match(array $models, Collection $results, $relation) return $this->matchOne($models, $results, $relation); } + /** + * Get the relationship query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $parentQuery + * @param array|mixed $columns + * @return \Illuminate\Database\Eloquent\Builder + */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) + { + $query->getQuery()->joins = $this->query->getQuery()->joins; + + return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + } + + /** + * Add constraints for inner join subselect for one of many relationships. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|null $column + * @param string|null $aggregate + * @return void + */ + public function addOneOfManySubQueryConstraints(Builder $query, $column = null, $aggregate = null) + { + $query->addSelect($this->foreignKey, $this->morphType); + } + + /** + * Get the columns that should be selected by the one of many subquery. + * + * @return array|string + */ + public function getOneOfManySubQuerySelectColumns() + { + return [$this->foreignKey, $this->morphType]; + } + + /** + * Add join query constraints for one of many relationships. + * + * @param \Illuminate\Database\Eloquent\JoinClause $join + * @return void + */ + public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) + { + $join + ->on($this->qualifySubSelectColumn($this->morphType), '=', $this->qualifyRelatedColumn($this->morphType)) + ->on($this->qualifySubSelectColumn($this->foreignKey), '=', $this->qualifyRelatedColumn($this->foreignKey)); + } + /** * Make a new related instance for the given model. * diff --git a/tests/Database/DatabaseEloquentHasOneOfManyTest.php b/tests/Database/DatabaseEloquentHasOneOfManyTest.php new file mode 100755 index 000000000000..37d5925f382d --- /dev/null +++ b/tests/Database/DatabaseEloquentHasOneOfManyTest.php @@ -0,0 +1,417 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->increments('id'); + }); + + $this->schema()->create('logins', function ($table) { + $table->increments('id'); + $table->foreignId('user_id'); + }); + + $this->schema()->create('states', function ($table) { + $table->increments('id'); + $table->string('state'); + $table->string('type'); + $table->foreignId('user_id'); + }); + + $this->schema()->create('prices', function ($table) { + $table->increments('id'); + $table->dateTime('published_at'); + $table->foreignId('user_id'); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('logins'); + } + + public function testItGuessesRelationName() + { + $user = HasOneOfManyTestUser::create(); + $this->assertSame('latest_login', $user->latest_login()->getRelationName()); + } + + // public function testRelationNameCanBeSet() + // { + // $user = HasOneOfManyTestUser::create(); + // $this->assertSame('foo', $user->latest_login_with_other_name()->getRelationName()); + // } + + public function testQualifyingSubSelectColumn() + { + $user = HasOneOfManyTestUser::create(); + $this->assertSame('latest_login.id', $user->latest_login()->qualifySubSelectColumn('id')); + } + + public function testItFailsWhenUsingInvalidAggregate() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid aggregate [count] used within ofMany relation. Available aggregates: MIN, MAX'); + $user = HasOneOfManyTestUser::make(); + $user->latest_login_with_invalid_aggregate(); + } + + public function testItGetsCorrectResults() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $result = $user->latest_login()->getResults(); + $this->assertNotNull($result); + $this->assertSame($latestLogin->id, $result->id); + } + + public function testItGetsCorrectResultsUsingShortcutMethod() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $result = $user->latest_login_with_shortcut()->getResults(); + $this->assertNotNull($result); + $this->assertSame($latestLogin->id, $result->id); + } + + public function testItGetsCorrectResultsUsingShortcutReceivingMultipleColumnsMethod() + { + $user = HasOneOfManyTestUser::create(); + $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $price = $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + + $result = $user->price_with_shortcut()->getResults(); + $this->assertNotNull($result); + $this->assertSame($price->id, $result->id); + } + + public function testKeyIsAddedToAggregatesWhenMissing() + { + $user = HasOneOfManyTestUser::create(); + $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $price = $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + + $result = $user->price_without_key_in_aggregates()->getResults(); + $this->assertNotNull($result); + $this->assertSame($price->id, $result->id); + } + + public function testItGetsWithConstraintsCorrectResults() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $user->logins()->create(); + + $result = $user->latest_login()->whereKey($previousLogin->getKey())->getResults(); + $this->assertNull($result); + } + + public function testItEagerLoadsCorrectModels() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $user = HasOneOfManyTestUser::with('latest_login')->first(); + + $this->assertTrue($user->relationLoaded('latest_login')); + $this->assertSame($latestLogin->id, $user->latest_login->id); + } + + public function testHasNested() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $found = HasOneOfManyTestUser::whereHas('latest_login', function ($query) use ($latestLogin) { + $query->where('logins.id', $latestLogin->id); + })->exists(); + $this->assertTrue($found); + + $found = HasOneOfManyTestUser::whereHas('latest_login', function ($query) use ($previousLogin) { + $query->where('logins.id', $previousLogin->id); + })->exists(); + $this->assertFalse($found); + } + + public function testHasCount() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + $user->logins()->create(); + + $user = HasOneOfManyTestUser::withCount('latest_login')->first(); + $this->assertEquals(1, $user->latest_login_count); + } + + public function testExists() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $this->assertFalse($user->latest_login()->whereKey($previousLogin->getKey())->exists()); + $this->assertTrue($user->latest_login()->whereKey($latestLogin->getKey())->exists()); + } + + public function testIsMethod() + { + $user = HasOneOfManyTestUser::create(); + $login1 = $user->latest_login()->create(); + $login2 = $user->latest_login()->create(); + + $this->assertFalse($user->latest_login()->is($login1)); + $this->assertTrue($user->latest_login()->is($login2)); + } + + public function testIsNotMethod() + { + $user = HasOneOfManyTestUser::create(); + $login1 = $user->latest_login()->create(); + $login2 = $user->latest_login()->create(); + + $this->assertTrue($user->latest_login()->isNot($login1)); + $this->assertFalse($user->latest_login()->isNot($login2)); + } + + /** + * @group fail + */ + public function testGet() + { + $user = HasOneOfManyTestUser::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $latestLogins = $user->latest_login()->get(); + $this->assertCount(1, $latestLogins); + $this->assertSame($latestLogin->id, $latestLogins->first()->id); + + $latestLogins = $user->latest_login()->whereKey($previousLogin->getKey())->get(); + $this->assertCount(0, $latestLogins); + } + + public function testCount() + { + $user = HasOneOfManyTestUser::create(); + $user->logins()->create(); + $user->logins()->create(); + + $this->assertSame(1, $user->latest_login()->count()); + } + + public function testAggregate() + { + $user = HasOneOfManyTestUser::create(); + $firstLogin = $user->logins()->create(); + $user->logins()->create(); + + $user = HasOneOfManyTestUser::first(); + $this->assertSame($firstLogin->id, $user->first_login->id); + } + + public function testJoinConstraints() + { + $user = HasOneOfManyTestUser::create(); + $user->states()->create([ + 'type' => 'foo', + 'state' => 'draft', + ]); + $currentForState = $user->states()->create([ + 'type' => 'foo', + 'state' => 'active', + ]); + $user->states()->create([ + 'type' => 'bar', + 'state' => 'baz', + ]); + + $user = HasOneOfManyTestUser::first(); + $this->assertSame($currentForState->id, $user->foo_state->id); + } + + public function testMultipleAggregates() + { + $user = HasOneOfManyTestUser::create(); + + $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + $price = $user->prices()->create([ + 'published_at' => '2021-05-01 00:00:00', + ]); + + $user = HasOneOfManyTestUser::first(); + $this->assertSame($price->id, $user->price->id); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class HasOneOfManyTestUser extends Eloquent +{ + protected $table = 'users'; + protected $guarded = []; + public $timestamps = false; + + public function logins() + { + return $this->hasMany(HasOneOfManyTestLogin::class, 'user_id'); + } + + public function latest_login() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->ofMany(); + } + + public function latest_login_with_shortcut() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->latestOfMany(); + } + + public function latest_login_with_invalid_aggregate() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->ofMany('id', 'count'); + } + + public function first_login() + { + return $this->hasOne(HasOneOfManyTestLogin::class, 'user_id')->ofMany('id', 'min'); + } + + public function states() + { + return $this->hasMany(HasOneOfManyTestState::class, 'user_id'); + } + + public function foo_state() + { + return $this->hasOne(HasOneOfManyTestState::class, 'user_id')->ofMany( + ['id' => 'max'], + function ($q) { + $q->where('type', 'foo'); + } + ); + } + + public function prices() + { + return $this->hasMany(HasOneOfManyTestPrice::class, 'user_id'); + } + + public function price() + { + return $this->hasOne(HasOneOfManyTestPrice::class, 'user_id')->ofMany([ + 'published_at' => 'max', + 'id' => 'max', + ], function ($q) { + $q->where('published_at', '<', now()); + }); + } + + public function price_without_key_in_aggregates() + { + return $this->hasOne(HasOneOfManyTestPrice::class, 'user_id')->ofMany(['published_at' => 'MAX']); + } + + public function price_with_shortcut() + { + return $this->hasOne(HasOneOfManyTestPrice::class, 'user_id')->latestOfMany(['published_at', 'id']); + } +} + +class HasOneOfManyTestLogin extends Eloquent +{ + protected $table = 'logins'; + protected $guarded = []; + public $timestamps = false; +} + +class HasOneOfManyTestState extends Eloquent +{ + protected $table = 'states'; + protected $guarded = []; + public $timestamps = false; + protected $fillable = ['type', 'state']; +} + +class HasOneOfManyTestPrice extends Eloquent +{ + protected $table = 'prices'; + protected $guarded = []; + public $timestamps = false; + protected $fillable = ['published_at']; + protected $casts = ['published_at' => 'datetime']; +} diff --git a/tests/Database/DatabaseEloquentMorphOneOfManyTest.php b/tests/Database/DatabaseEloquentMorphOneOfManyTest.php new file mode 100644 index 000000000000..85ab66d8074d --- /dev/null +++ b/tests/Database/DatabaseEloquentMorphOneOfManyTest.php @@ -0,0 +1,159 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('products', function ($table) { + $table->increments('id'); + }); + + $this->schema()->create('states', function ($table) { + $table->increments('id'); + $table->morphs('stateful'); + $table->string('state'); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('products'); + $this->schema()->drop('states'); + } + + public function testReceivingModel() + { + $product = MorphOneOfManyTestProduct::create(); + $product->states()->create([ + 'state' => 'draft', + ]); + $product->states()->create([ + 'state' => 'active', + ]); + + $this->assertNotNull($product->current_state); + $this->assertSame('active', $product->current_state->state); + } + + public function testMorphType() + { + $product = MorphOneOfManyTestProduct::create(); + $product->states()->create([ + 'state' => 'draft', + ]); + $product->states()->create([ + 'state' => 'active', + ]); + $state = $product->states()->make([ + 'state' => 'foo', + ]); + $state->stateful_type = 'bar'; + $state->save(); + + $this->assertNotNull($product->current_state); + $this->assertSame('active', $product->current_state->state); + } + + public function testExists() + { + $product = MorphOneOfManyTestProduct::create(); + $previousState = $product->states()->create([ + 'state' => 'draft', + ]); + $currentState = $product->states()->create([ + 'state' => 'active', + ]); + + $exists = MorphOneOfManyTestProduct::whereHas('current_state', function ($q) use ($previousState) { + $q->whereKey($previousState->getKey()); + })->exists(); + $this->assertFalse($exists); + + $exists = MorphOneOfManyTestProduct::whereHas('current_state', function ($q) use ($currentState) { + $q->whereKey($currentState->getKey()); + })->exists(); + $this->assertTrue($exists); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection() + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class MorphOneOfManyTestProduct extends Eloquent +{ + protected $table = 'products'; + protected $guarded = []; + public $timestamps = false; + + public function states() + { + return $this->morphMany(MorphOneOfManyTestState::class, 'stateful'); + } + + public function current_state() + { + return $this->morphOne(MorphOneOfManyTestState::class, 'stateful')->ofMany(); + } +} + +class MorphOneOfManyTestState extends Eloquent +{ + protected $table = 'states'; + protected $guarded = []; + public $timestamps = false; + protected $fillable = ['state']; +} diff --git a/tests/Database/DatabaseEloquentPivotTest.php b/tests/Database/DatabaseEloquentPivotTest.php index 57e0a457e483..ad51118ec5db 100644 --- a/tests/Database/DatabaseEloquentPivotTest.php +++ b/tests/Database/DatabaseEloquentPivotTest.php @@ -2,8 +2,12 @@ namespace Illuminate\Tests\Database; +use Illuminate\Database\Connection; +use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Pivot; +use Illuminate\Database\Query\Grammars\Grammar; +use Illuminate\Database\Query\Processors\Processor; use Mockery as m; use PHPUnit\Framework\TestCase; use stdClass; @@ -19,6 +23,10 @@ public function testPropertiesAreSetCorrectly() { $parent = m::mock(Model::class.'[getConnectionName]'); $parent->shouldReceive('getConnectionName')->twice()->andReturn('connection'); + $parent->setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); + $resolver->shouldReceive('connection')->andReturn($connection = m::mock(Connection::class)); + $connection->shouldReceive('getQueryGrammar')->andReturn($grammar = m::mock(Grammar::class)); + $connection->shouldReceive('getPostProcessor')->andReturn($processor = m::mock(Processor::class)); $parent->getConnection()->getQueryGrammar()->shouldReceive('getDateFormat')->andReturn('Y-m-d H:i:s'); $parent->setDateFormat('Y-m-d H:i:s'); $pivot = Pivot::fromAttributes($parent, ['foo' => 'bar', 'created_at' => '2015-09-12'], 'table', true); diff --git a/tests/Integration/Database/EloquentHasOneOfManyTest.php b/tests/Integration/Database/EloquentHasOneOfManyTest.php new file mode 100644 index 000000000000..96c81eb27f76 --- /dev/null +++ b/tests/Integration/Database/EloquentHasOneOfManyTest.php @@ -0,0 +1,74 @@ +id(); + }); + + Schema::create('logins', function ($table) { + $table->id(); + $table->foreignId('user_id'); + }); + } + + protected function getEnvironmentSetUp($app) + { + parent::getEnvironmentSetUp($app); + $app['config']->set('app.debug', 'true'); + } + + public function testItOnlyEagerLoadsRequiredModels() + { + $this->retrievedLogins = 0; + User::getEventDispatcher()->listen('eloquent.retrieved:*', function ($event, $models) { + foreach ($models as $model) { + if (get_class($model) == Login::class) { + $this->retrievedLogins++; + } + } + }); + + $user = User::create(); + $user->latest_login()->create(); + $user->latest_login()->create(); + $user = User::create(); + $user->latest_login()->create(); + $user->latest_login()->create(); + + User::with('latest_login')->get(); + + $this->assertSame(2, $this->retrievedLogins); + } +} + +class User extends Model +{ + protected $guarded = []; + public $timestamps = false; + + public function latest_login() + { + return $this->hasOne(Login::class)->ofMany(); + } +} + +class Login extends Model +{ + protected $guarded = []; + public $timestamps = false; +}