Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

JOINing Across Multiple Relationships

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

We decided to change the relationship between Question and Tag from a true ManyToMany to a relationship where we have an entity in between that allows us to add more fields to the join table.

In the database... this didn't change much: we have the same join table and foreign keys as before. But in PHP, it did change things. In Question, instead of a $tags property - which returned a collection of Tag objects - we now have a $questionTags property that returns a collection of QuestionTag objects. This change almost certainly broke our frontend.

We're only rendering the tags on the homepage.... so let's open up that template templates/question/homepage.html.twig. Here it is: for tag in question.tags. That's not going to work anymore because there is no tags property. Though, if you want to be clever, you could create a getTags() method that loops over the questionTags property and returns an array of the related Tag objects.

Or... you can fix it here to use questionTag in questionTags. Then say questionTag.tag to reach across that relationship.

... lines 1 - 9
<div class="container">
... lines 11 - 15
<div class="row">
{% for question in questions %}
<div class="col-12 mb-3">
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container p-4">
<div class="row">
<div class="col-2 text-center">
... lines 23 - 26
{% for questionTag in question.questionTags %}
<span class="badge rounded-pill bg-light text-dark">{{ questionTag.tag.name }}</span>
{% endfor %}
</div>
... lines 31 - 38
</div>
</div>
... lines 41 - 45
</div>
</div>
{% endfor %}
</div>
</div>
... lines 51 - 53

So still fairly straightforward... just a bit more code to go across both relationships.

Let's refresh and see what happens. And... whoa!

Semantical error: near tag WHERE q.askedAt: Class Question has no association named tags.

So... that sounds like a query error... but let's look down the stack trace. Yup! It's coming from QuestionRepository.

Joining Across Two Entities

Go open that up: src/Repository/QuestionRepository.php... here it is. To solve the N+1 problem, we joined directly across the previous q.tags relationship. Now we're going to need two joins to get to the tag table.

No problem: change q.tags to q.questionTags and alias that to question_tag. Then do an inner join from QuestionTag to Tag - ->innerJoin('question_tag.tag') - and alias that to tag.

... lines 1 - 15
class QuestionRepository extends ServiceEntityRepository
{
... lines 18 - 25
public function findAllAskedOrderedByNewest()
{
return $this->addIsAskedQueryBuilder()
->orderBy('q.askedAt', 'DESC')
->leftJoin('q.questionTags', 'question_tag')
->innerJoin('question_tag.tag', 'tag')
->addSelect('tag')
->getQuery()
->getResult()
;
}
... lines 37 - 59
}

Cool! And we're still selecting the tag data... so that looks good to me.

Refresh again and... another error! This one... is even more confusing.

The parent object of entity result with alias tag was not found. The parent alias is question_tag.

This is trying to say that it doesn't like that we're selecting the tag data... but we're not selecting the question_tag data that's between Question and Tag. Doing that is legal in SQL... but it messes up how Doctrine creates the objects, so it doesn't allow it.

The solution is easy enough: select both. You can actually pass an array to addSelect() to select question_tag and tag.

... lines 1 - 15
class QuestionRepository extends ServiceEntityRepository
{
... lines 18 - 25
public function findAllAskedOrderedByNewest()
{
return $this->addIsAskedQueryBuilder()
->orderBy('q.askedAt', 'DESC')
->leftJoin('q.questionTags', 'question_tag')
->innerJoin('question_tag.tag', 'tag')
->addSelect('question_tag', 'tag')
->getQuery()
->getResult()
;
}
... lines 37 - 59
}

Try it now. And... we're back! Woo! Check out what the query looks like... it's this big first one. So cool: we select from question left join to question_tag, inner join over to tag... and grab all of that data.

Okay team: there's just one last topic I want to cover... and, I admit, it's not strictly related to relations. Let's add pagination to our homepage.

Leave a comment!

4
Login or Register to join the conversation
akincer Avatar
akincer Avatar akincer | posted 1 year ago

Finally this makes sense. I've been wondering about this process from soup to nuts ever since way back when Ryan gave a very brief description about this in a previous Doctrine tutorial. Thanks!

1 Reply
davidmintz Avatar
davidmintz Avatar davidmintz | posted 1 year ago | edited

Looks like there is still some of that N+1 thing happening after adding the QuestionTag association class and updating QuestionRepository and the homepage template. FWIW, I got it down to one query by joining Answers as well in our repository's findAllAskedOrderedByNewest method, thus:


return $this->addIsAskedQueryBuilder()
     <b>->join('q.answers','a')->addSelect('a')</b>
     ->join('q.questionTags','qt')
     ->join('qt.tag','t')
     ->addSelect(['qt','t'])
     ->orderBy('q.askedAt', 'DESC')
     ->getQuery()
     ->getResult()

I'll accept my gold star now, thank you (-: ...although I guess it's a trade-off, because I have reduced the number of queries, the SELECT is selecting data that I'm not using. I bet there is a way to tell Doctrine what columns we want to fetch. Excuse me, I mean what <i>object properties</i>... So, can that be done with the QueryBuilder, or do we have to write the DQL directly?

Reply
Marco Avatar
Marco Avatar Marco | davidmintz | posted 1 year ago | edited

Note that this also returns the unapproved answers - which you already noticed. :)
I fixed that by checking the answer status, but for that I had to include the Answer Entity inside the Question Entity, which does not feel right. :|


use App\Entity\Answer;

// ...
            ->leftJoin('q.answers', 'answers')
            ->addSelect('answers')
            ->andWhere('answers.status = :status')
            ->setParameter('status', Answer::STATUS_APPROVED)
Reply

Hey David,

Well done! And thanks for sharing your additional optimization on this spot with others!

Yeah, there's always a way to optimize it further, we don't pay too much attention to Doctrine optimiation in this tutorial because we're teaching different concepts here and optimization would require a separate tutorial, and probably no always needed, depends on your case and your website load :)

And yes, you're right! There's always a trade-off as you said when we're talking about optimization :) Executing less queries probably would mean that the response size will grow that leads to bigger execution time. Ideally, use some tools that helps to "profile" things, e.g. Blackfire.io tool is awesome for this - you can see if your optimization was good in your specific case. Also, we have a course about it here: https://symfonycasts.com/sc...

About fetching specific properties - yeah, it's possible, but it will reduce your DX and may complicate maintenance and increase maintenance time - as you said, there's always trade-off. And yeah, this is only possible with specific queries, i.e. when you specify explicitly what fields you want to fetch from the database. But in this case you won't be able to use "entities", because the response will be just raw array data. But that's up to you, you decide if such optimization worth in your specific case. And as I said, some profiler tools like Blackfire could very help you with this.

however, we always say in our tutorials that you should be careful with pre-optimizations, because sometimes it lead to overoptimizations :)

I hope this helps!

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial also works great for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.3
        "doctrine/doctrine-bundle": "^2.1", // 2.4.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.9.5
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "sensio/framework-extra-bundle": "^6.0", // v6.2.1
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.7
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/framework-bundle": "5.3.*", // v5.3.7
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/validator": "5.3.*", // v5.3.14
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.33.0
        "symfony/var-dumper": "5.3.*", // v5.3.7
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.5
        "zenstruck/foundry": "^1.1" // v1.13.1
    }
}
userVoice