Collection Magic with Criteria
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOf course, if we wanted to remove any of the deleted comments from this collection, we could loop over all of the comments, check if each is deleted, and return an array of only the ones left. Heck, the collection object even has a filter()
method to make this easier!
But... there's a problem. If we did this, Doctrine would query for all of the comments, even though we don't need all of the comments. If your collection is pretty small, no big deal: querying for a few extra comments is probably fine. But if you have a large collection, like 200 comments, and you want to return a small sub-set, like only 10 of them, that would be super, super wasteful!
Hello Criteria
To solve this, Doctrine has a super powerful and amazing feature... and yet, somehow, almost nobody knows about it! Time to change that! Once you're an expert on this feature, it'll be your job to tell the world!
The system is called "Criteria". Instead of looping over all the data, add $criteria = Criteria
- the one from Doctrine - Criteria::create()
:
// ... lines 1 - 6 | |
use Doctrine\Common\Collections\Criteria; | |
// ... lines 8 - 14 | |
class Article | |
{ | |
// ... lines 17 - 183 | |
public function getNonDeletedComments(): Collection | |
{ | |
$criteria = Criteria::create() | |
// ... lines 187 - 191 | |
} | |
// ... lines 193 - 215 | |
} |
Then, you can chain off of this. The Criteria
object is similar to the QueryBuilder
we're used to, but with a slightly different, well, slightly more confusing syntax. Add andWhere()
, but instead of a string, use Criteria::expr()
. Then, there are a bunch of methods to help create the where clause, like eq()
for equals, gt()
for greater than, gte()
for greater than or equal, and so on. It's a little object-oriented builder for the WHERE expression.
In this case, we need eq()
so we can say that isDeleted
equals false
:
// ... lines 1 - 6 | |
use Doctrine\Common\Collections\Criteria; | |
// ... lines 8 - 14 | |
class Article | |
{ | |
// ... lines 17 - 183 | |
public function getNonDeletedComments(): Collection | |
{ | |
$criteria = Criteria::create() | |
->andWhere(Criteria::expr()->eq('isDeleted', false)) | |
// ... lines 188 - 191 | |
} | |
// ... lines 193 - 215 | |
} |
Then, add orderBy
, with createdAt => 'DESC'
to keep the sorting we want:
// ... lines 1 - 6 | |
use Doctrine\Common\Collections\Criteria; | |
// ... lines 8 - 14 | |
class Article | |
{ | |
// ... lines 17 - 183 | |
public function getNonDeletedComments(): Collection | |
{ | |
$criteria = Criteria::create() | |
->andWhere(Criteria::expr()->eq('isDeleted', false)) | |
->orderBy(['createdAt' => 'DESC']) | |
; | |
// ... lines 190 - 191 | |
} | |
// ... lines 193 - 215 | |
} |
Creating the Criteria object doesn't actually do anything yet - it's like creating a query builder. But now we can say return $this->comments->matching()
and pass $criteria
:
// ... lines 1 - 6 | |
use Doctrine\Common\Collections\Criteria; | |
// ... lines 8 - 14 | |
class Article | |
{ | |
// ... lines 17 - 183 | |
public function getNonDeletedComments(): Collection | |
{ | |
$criteria = Criteria::create() | |
->andWhere(Criteria::expr()->eq('isDeleted', false)) | |
->orderBy(['createdAt' => 'DESC']) | |
; | |
return $this->comments->matching($criteria); | |
} | |
// ... lines 193 - 215 | |
} |
Because, remember, even though we think of the $comments
property as an array, it's not! This Collection
return type is an interface from Doctrine, and our property will always be some object that implements that. That's a long way of saying that, while the $comments
property will look and feel like an array, it is actually an object that has some extra helper methods on it.
The Super-Intelligent Criteria Queries
Anyways, ready to try this? Move over and refresh. Check it out: the 8 comments went down to 7! And the deleted comment is gone. But you haven't seen the best part yet! Click to open the profiler for Doctrine. Check out the last query: it's perfect. It no longer queries for all of the comments for this article. Nope, instead, Doctrine executed a super-smart query that finds all comments where the article matches this article and where isDeleted
is false, or zero. It even did the same for the count query!
Doctrine, that's crazy cool! So, by using Criteria
, we get super efficient filtering. Of course, it's not always necessary. You could just loop over all of the comments and filter manually. If you are removing only a small percentage of the results, the performance difference is minor. The Criteria
system is better than manually filtering, but, remember! Do not prematurely optimize. Get your app to production, then check for issues. But if you have a big collection and need to return only a small number of results, you should use Criteria
immediately.
Organizing the Criteria into the Repository
One thing I don't like about the Criteria
system is that I do not like having query logic inside my entity. And this is important! To keep my app sane, I want to have 100% of my query logic inside my repository. No worries: we can move it there!
In ArticleRepository
, create a public static function
called createNonDeletedCriteria()
that will return a Criteria
object:
// ... lines 1 - 6 | |
use Doctrine\Common\Collections\Criteria; | |
// ... lines 8 - 16 | |
class ArticleRepository extends ServiceEntityRepository | |
{ | |
// ... lines 19 - 35 | |
public static function createNonDeletedCriteria(): Criteria | |
{ | |
// ... lines 38 - 41 | |
} | |
// ... lines 43 - 65 | |
} |
In Article
, copy the Criteria
code, paste it here, and return:
// ... lines 1 - 6 | |
use Doctrine\Common\Collections\Criteria; | |
// ... lines 8 - 16 | |
class ArticleRepository extends ServiceEntityRepository | |
{ | |
// ... lines 19 - 35 | |
public static function createNonDeletedCriteria(): Criteria | |
{ | |
return Criteria::create() | |
->andWhere(Criteria::expr()->eq('isDeleted', false)) | |
->orderBy(['createdAt' => 'DESC']) | |
; | |
} | |
// ... lines 43 - 65 | |
} |
These are the only static methods that you should ever have in your repository. It needs to be static simply so that we can use it from inside Article
. That's because entity classes don't have access to services.
Use it with $criteria = ArticleRepository::createNonDeletedCriteria()
:
// ... lines 1 - 4 | |
use App\Repository\ArticleRepository; | |
// ... lines 6 - 15 | |
class Article | |
{ | |
// ... lines 18 - 186 | |
public function getNonDeletedComments(): Collection | |
{ | |
$criteria = ArticleRepository::createNonDeletedCriteria(); | |
return $this->comments->matching($criteria); | |
} | |
// ... lines 193 - 215 | |
} |
Side note: we could have also put this method into the CommentRepository
. When you start working with related entities, sometimes, it's not clear exactly which repository class should hold some logic. No worries: do your best and don't over-think it. You can always move code around later.
Ok, go back to your browser, close the profiler and, refresh. Awesome: it still works great!
Using the Criteria in a QueryBuilder
Oh, and bonus! in ArticleRepository
, what if in the future, we need to create a QueryBuilder
and want to re-use the logic from the Criteria? Is that possible? Totally! Just use ->addCriteria()
then, in this case, self::createNonDeletedCriteria()
:
class ArticleRepository extends ServiceEntityRepository
{
public function findAllPublishedOrderedByNewest()
{
$this->createQueryBuilder('a')
->addCriteria(self::createNonDeletedCriteria());
return $this->addIsPublishedQueryBuilder()
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult()
;
}
}
These Criteria
are reusable.
Updating the Homepage
To finish this feature, go back to the homepage. These comment numbers are still including deleted comments. No problem! Open homepage.html.twig
, find where we're printing that number, and use article.nonDeletedComments
:
// ... lines 1 - 2 | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<!-- Article List --> | |
<div class="col-sm-12 col-md-8"> | |
// ... lines 10 - 18 | |
<!-- Supporting Articles --> | |
{% for article in articles %} | |
<div class="article-container my-1"> | |
<a href="{{ path('article_show', {slug: article.slug}) }}"> | |
// ... line 24 | |
<div class="article-title d-inline-block pl-3 align-middle"> | |
// ... line 26 | |
<small>({{ article.nonDeletedComments|length }} comments)</small> | |
// ... lines 28 - 30 | |
</div> | |
</a> | |
</div> | |
{% endfor %} | |
</div> | |
// ... lines 36 - 55 | |
</div> | |
</div> | |
{% endblock %} |
Ok, go back. We have 10, 13 & 7. Refresh! Nice! Now it's 5, 9 and 5.
Next, let's take a quick detour and leverage some Twig techniques to reduce duplication in our templates.
Very good advice imo not overthink and not prematurely optimize. When you want to look good programmers, you might start thinking long before making best solution, but you end up not being good in relation to the cost and missing deadlines if you overthink and prematurely optimize. Plus prematurely optimizing can make code more complex, which again takes more time to understnad, and easier to create bugs in it.
Btw I am surprised I learned so much new things in this course. I had been working with doctrine for a while and watched some videos already, and I thought probably I will not learn a lot new from this. Of course I could live with what I know, otherwise I would have learned earlier, but maybe will find where to use those new things, at least some of them.