01/25/2016
Doctrine: joining entities with no association
Doctrine normally joins along a mapped association — ->join('a.user', 'u'). When there is no association between the two entities, you can
still join them by naming the class and giving the condition yourself:
use Doctrine\ORM\Query\Expr\Join;
public function findHistoryFor(User $user): array
{
return $this->createQueryBuilder('h')
->addSelect('u')
->leftJoin(
User::class,
'u',
Join::WITH,
'h.userId = u.id',
)
->where('u = :user')
->setParameter('user', $user)
->orderBy('h.createdAt', 'DESC')
->getQuery()
->getResult();
}
Join::WITH is the part doing the work: it says "join this class on this
condition" rather than "follow this mapping". User::class is worth
preferring over the string 'User\Entity\User' — a typo in a class
constant is a fatal error at the point you wrote it, and a typo in a
string is a runtime exception somewhere else.
What the query gives you back, which is the part that surprises people
Selecting two aliases does not hand back History objects with a user
property filled in. There is no association, so there is nowhere to put
one. You get an array of arrays:
foreach ($rows as $row) {
$history = $row[0]; // the History entity
$user = $row[1]; // the User entity
}
That is why a template doing {{ row.user.name }} on these results fails
with a confusing message: row is an array of two entities, not one entity
with a relation. Either unpack it in the repository and return something
shaped for the caller, or select only the root alias and look the users up
separately.
If you drop addSelect('u') entirely, the join still filters correctly and
you get plain History objects back — which is often what you actually
wanted, and is cheaper.
When to fix the mapping instead
This technique earns its place against a schema you do not control: a
legacy table, another team's database, a column that holds an id without a
foreign key behind it. On a schema you own, an unassociated join is usually
a missing #[ORM\ManyToOne] wearing a disguise, and mapping it properly
gets you lazy loading, cascades and ->join('h.user', 'u') for free.
A useful tell: if you find yourself writing the same Join::WITH condition
in three repositories, the relationship is real and the mapping is what is
missing.
Questions this keeps raising
Why is my result an array instead of an entity?
Because the query selects two aliases. Doctrine returns one row per result with each selected alias as a numeric element, so $row[0] is the root entity and $row[1] the joined one. Dropping addSelect on the joined alias returns plain root entities and still filters by the join.
What is the difference between Join::WITH and Join::ON?
WITH adds your condition to the association's own join condition; ON replaces it. With no association in play there is nothing to add to, so WITH is the one to use and is what Doctrine's own examples show.
Can I do this with a plain DQL string?
Yes - "LEFT JOIN App\\Entity\\User u WITH h.userId = u.id" is the same query. The builder is preferable when parts of the query are conditional, and a DQL string is easier to read when it is not.
Should I add the association instead?
On a schema you own, usually yes. An unassociated join often means a missing ManyToOne, and mapping it gives you lazy loading and simpler queries. Keep the unassociated join for schemas you cannot change - legacy tables, or a database another team owns.