Skip to content

[12.x] feat: add CanBeOneOfMany support to HasOneThrough #54759

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,6 @@ public function ofMany($column = 'id', $aggregate = 'MAX', $relation = null)
];
}

$this->addConstraints();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this removed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not needed and adds double constraints:

For example, on existing relations:

image

On the HasOneThrough relation in particular it added a double join clause that prevented the query from even functioning.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removal of this constraint causes all rows in the database to be loaded and than filtered while before the query was more narrow and only returned the required results. I have opened an issue for this, I can create a PR reverting this change but I don't know what the impact is on the feature that was added in this PR.

Copy link
Contributor Author

@calebdw calebdw Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BertvanHoekelen, the constraints should have already been added by the time this was called (in the Relation constructor)---see the tests, this was adding double constraints.

I'll take a look, there must be a path where the constraints aren't added but we didn't have a test case for it


$columns = $this->query->getQuery()->columns;

if (is_null($columns) || $columns === ['*']) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public function one()
$this->farParent,
$this->throughParent,
$this->getFirstKeyName(),
$this->secondKey,
$this->getForeignKeyName(),
$this->getLocalKeyName(),
$this->getSecondLocalKeyName(),
));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ public function addEagerConstraints(array $models)
$this->whereInEager(
$whereIn,
$this->getQualifiedFirstKeyName(),
$this->getKeys($models, $this->localKey)
$this->getKeys($models, $this->localKey),
$this->getRelationQuery(),
);
}

Expand Down
55 changes: 53 additions & 2 deletions src/Illuminate/Database/Eloquent/Relations/HasOneThrough.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@

namespace Illuminate\Database\Eloquent\Relations;

use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
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\InteractsWithDictionary;
use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels;
use Illuminate\Database\Query\JoinClause;

/**
* @template TRelatedModel of \Illuminate\Database\Eloquent\Model
Expand All @@ -14,13 +19,17 @@
*
* @extends \Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough<TRelatedModel, TIntermediateModel, TDeclaringModel, ?TRelatedModel>
*/
class HasOneThrough extends HasOneOrManyThrough
class HasOneThrough extends HasOneOrManyThrough implements SupportsPartialRelations
{
use InteractsWithDictionary, SupportsDefaultModels;
use ComparesRelatedModels, CanBeOneOfMany, InteractsWithDictionary, SupportsDefaultModels;

/** @inheritDoc */
public function getResults()
{
if (is_null($this->getParentKey())) {
return $this->getDefaultFor($this->farParent);
}

return $this->first() ?: $this->getDefaultFor($this->farParent);
}

Expand Down Expand Up @@ -54,6 +63,36 @@ public function match(array $models, EloquentCollection $results, $relation)
return $models;
}

/** @inheritDoc */
public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*'])
{
if ($this->isOneOfMany()) {
$this->mergeOneOfManyJoinsTo($query);
}

return parent::getRelationExistenceQuery($query, $parentQuery, $columns);
}

/** @inheritDoc */
public function addOneOfManySubQueryConstraints(Builder $query, $column = null, $aggregate = null)
{
$query->addSelect([$this->getQualifiedFirstKeyName()]);

$this->performJoin($query);
}

/** @inheritDoc */
public function getOneOfManySubQuerySelectColumns()
{
return [$this->getQualifiedFirstKeyName()];
}

/** @inheritDoc */
public function addOneOfManyJoinSubQueryConstraints(JoinClause $join)
{
$join->on($this->qualifySubSelectColumn($this->firstKey), '=', $this->getQualifiedFirstKeyName());
}

/**
* Make a new related instance for the given model.
*
Expand All @@ -64,4 +103,16 @@ public function newRelatedInstanceFor(Model $parent)
{
return $this->related->newInstance();
}

/** @inheritDoc */
protected function getRelatedKeyFrom(Model $model)
{
return $model->getAttribute($this->getForeignKeyName());
}

/** @inheritDoc */
public function getParentKey()
{
return $this->farParent->getAttribute($this->localKey);
}
}
6 changes: 3 additions & 3 deletions tests/Database/DatabaseEloquentHasOneOfManyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ public function testEagerLoadingAppliesConstraintsToInnerJoinSubQuery()
$user = HasOneOfManyTestUser::create();
$relation = $user->latest_login();
$relation->addEagerConstraints([$user]);
$this->assertSame('select MAX("logins"."id") as "id_aggregate", "logins"."user_id" from "logins" where "logins"."user_id" = ? and "logins"."user_id" is not null and "logins"."user_id" in (1) group by "logins"."user_id"', $relation->getOneOfManySubQuery()->toSql());
$this->assertSame('select MAX("logins"."id") as "id_aggregate", "logins"."user_id" from "logins" where "logins"."user_id" in (1) group by "logins"."user_id"', $relation->getOneOfManySubQuery()->toSql());
}

public function testGlobalScopeIsNotAppliedWhenRelationIsDefinedWithoutGlobalScope()
Expand All @@ -116,7 +116,7 @@ public function testGlobalScopeIsNotAppliedWhenRelationIsDefinedWithoutGlobalSco
$user = HasOneOfManyTestUser::create();
$relation = $user->latest_login_without_global_scope();
$relation->addEagerConstraints([$user]);
$this->assertSame('select "logins".* from "logins" inner join (select MAX("logins"."id") as "id_aggregate", "logins"."user_id" from "logins" where "logins"."user_id" = ? and "logins"."user_id" is not null and "logins"."user_id" in (1) group by "logins"."user_id") as "latestOfMany" on "latestOfMany"."id_aggregate" = "logins"."id" and "latestOfMany"."user_id" = "logins"."user_id" where "logins"."user_id" = ? and "logins"."user_id" is not null', $relation->getQuery()->toSql());
$this->assertSame('select "logins".* from "logins" inner join (select MAX("logins"."id") as "id_aggregate", "logins"."user_id" from "logins" where "logins"."user_id" in (1) group by "logins"."user_id") as "latestOfMany" on "latestOfMany"."id_aggregate" = "logins"."id" and "latestOfMany"."user_id" = "logins"."user_id" where "logins"."user_id" = ? and "logins"."user_id" is not null', $relation->getQuery()->toSql());

HasOneOfManyTestLogin::addGlobalScope('test', function ($query) {
});
Expand All @@ -130,7 +130,7 @@ public function testGlobalScopeIsNotAppliedWhenRelationIsDefinedWithoutGlobalSco

$user = HasOneOfManyTestUser::create();
$relation = $user->price_without_global_scope();
$this->assertSame('select "prices".* from "prices" inner join (select max("prices"."id") as "id_aggregate", min("prices"."published_at") as "published_at_aggregate", "prices"."user_id" from "prices" inner join (select max("prices"."published_at") as "published_at_aggregate", "prices"."user_id" from "prices" where "published_at" < ? and "prices"."user_id" = ? and "prices"."user_id" is not null group by "prices"."user_id") as "price_without_global_scope" on "price_without_global_scope"."published_at_aggregate" = "prices"."published_at" and "price_without_global_scope"."user_id" = "prices"."user_id" where "published_at" < ? group by "prices"."user_id") as "price_without_global_scope" on "price_without_global_scope"."id_aggregate" = "prices"."id" and "price_without_global_scope"."published_at_aggregate" = "prices"."published_at" and "price_without_global_scope"."user_id" = "prices"."user_id" where "prices"."user_id" = ? and "prices"."user_id" is not null', $relation->getQuery()->toSql());
$this->assertSame('select "prices".* from "prices" inner join (select max("prices"."id") as "id_aggregate", min("prices"."published_at") as "published_at_aggregate", "prices"."user_id" from "prices" inner join (select max("prices"."published_at") as "published_at_aggregate", "prices"."user_id" from "prices" where "published_at" < ? group by "prices"."user_id") as "price_without_global_scope" on "price_without_global_scope"."published_at_aggregate" = "prices"."published_at" and "price_without_global_scope"."user_id" = "prices"."user_id" where "published_at" < ? group by "prices"."user_id") as "price_without_global_scope" on "price_without_global_scope"."id_aggregate" = "prices"."id" and "price_without_global_scope"."published_at_aggregate" = "prices"."published_at" and "price_without_global_scope"."user_id" = "prices"."user_id" where "prices"."user_id" = ? and "prices"."user_id" is not null', $relation->getQuery()->toSql());

HasOneOfManyTestPrice::addGlobalScope('test', function ($query) {
});
Expand Down
Loading
Loading