Another major feature we implement in the Blog sample will be the tag cloud. The tag cloud appears on the Home page. The tag cloud contains most popular tags, and tag's font size varies depending on popularity of the tag: most popular tags appear larger than less popular ones. Clicking the tag in the tag cloud results in filtering posts by this tag.
For example of what we are trying to achieve, please look at the right side of the figure 12.11 below:
For this feature, we need the following things:
PostRepository
custom entity repository class that would encapsulate the complex logic
of filtering posts by tag;PostManager
and add functionality for calculating font sizes for the tag cloud;Earlier we mentioned that by default Doctrine uses the Doctrine\ORM\EntityRepository
as the
default repository class. Custom repository is a class extended from EntityRepository
class.
It is typically used when you need to encapsulate complex DQL queries and search logic in a single place in your code.
It is also possible to put the DQL queries to controller class, but that would make controllers "fat". Since we use MVC pattern, we strive to avoid that.
DQL is similar to SQL in sense that it allows to write and execute queries to database, but the result of a query is an array of objects rather than an array of table rows. For more information on DQL and its usage examples, please refer to this page.
For our Blog sample web application, we need a custom repository which allows to find published
posts having at least one tag (to calculate total count of tagged posts), and, to find published
posts filtered by particular tag. We plan to encapsulate this search logic into the custom PostRepository
repository.
Doctrine works with custom repositories transparently. This means, that you retrieve the repository from
EntityManager
as usual and still can use itsfindBy()
,findOneBy()
and other methods.
Create the PostRepository.php file inside the Repository directory under the module's source directory.
Below, you can find the code of PostRepository
class that has two public methods:
findPostsHavingAnyTag()
method is designed to select all posts that have status Published
and have one or more tags assigned;findPostsByTag()
method is designed to return all published posts that have the particular tag
assigned (to filter posts by the given tag).<?php
namespace Application\Repository;
use Doctrine\ORM\EntityRepository;
use Application\Entity\Post;
// This is the custom repository class for Post entity.
class PostRepository extends EntityRepository
{
// Finds all published posts having any tag.
public function findPostsHavingAnyTag()
{
$entityManager = $this->getEntityManager();
$queryBuilder = $entityManager->createQueryBuilder();
$queryBuilder->select('p')
->from(Post::class, 'p')
->join('p.tags', 't')
->where('p.status = ?1')
->orderBy('p.dateCreated', 'DESC')
->setParameter('1', Post::STATUS_PUBLISHED);
$posts = $queryBuilder->getQuery()->getResult();
return $posts;
}
// Finds all published posts having the given tag.
public function findPostsByTag($tagName)
{
$entityManager = $this->getEntityManager();
$queryBuilder = $entityManager->createQueryBuilder();
$queryBuilder->select('p')
->from(Post::class, 'p')
->join('p.tags', 't')
->where('p.status = ?1')
->andWhere('t.name = ?2')
->orderBy('p.dateCreated', 'DESC')
->setParameter('1', Post::STATUS_PUBLISHED)
->setParameter('2', $tagName);
$posts = $queryBuilder->getQuery()->getResult();
return $posts;
}
}
In the code above, we use the query builder to conveniently create complex DQL queries.
In lines 17-22, we create a query which selects all published posts ordering them by date created in descending order. Because we join posts with tags, here we only select posts which have at least one tag. In line 24, we execute the query. If you are curious what DQL the query builder creates, here it is:
SELECT p FROM \Application\Entity\Post p JOIN p.tags t
WHERE p.status=?1 ORDER BY p.dateCreated DESC
In lines 36-43, we create a query that filters posts by tag name. An analogous DQL is presented below:
SELECT p FROM \Application\Entity\Post p JOIN p.tags t
WHERE p.status=?1 AND t.name=?2 ORDER BY p.dateCreated DESC
To learn more about Doctrine query builder, please refer to this page.
To let Doctrine know that it should use the custom repository for Post
entity,
modify the Post
entity's annotation as follows:
<?php
//...
/**
* This class represents a single post in a blog.
* @ORM\Entity(repositoryClass="\Application\Repository\PostRepository")
* @ORM\Table(name="post")
*/
class Post
{
//...
}
Above, in line 6, we use the repositoryClass
parameter of the @ORM\Entity
tag to tell Doctrine
that it should use PostRepository
repository.
Business logic for the tag cloud feature will be stored inside of the PostManager::getTagCloud()
method,
as follows:
<?php
//...
class PostManager
{
//...
// Calculates frequencies of tag usage.
public function getTagCloud()
{
$tagCloud = [];
$posts = $this->entityManager->getRepository(Post::class)
->findPostsHavingAnyTag();
$totalPostCount = count($posts);
$tags = $this->entityManager->getRepository(Tag::class)
->findAll();
foreach ($tags as $tag) {
$postsByTag = $this->entityManager->getRepository(Post::class)
->findPostsByTag($tag->getName());
$postCount = count($postsByTag);
if ($postCount > 0) {
$tagCloud[$tag->getName()] = $postCount;
}
}
$normalizedTagCloud = [];
// Normalize
foreach ($tagCloud as $name=>$postCount) {
$normalizedTagCloud[$name] = $postCount/$totalPostCount;
}
return $normalizedTagCloud;
}
}
In the code above, we have the getTagCloud()
method that selects all post having at least
one tag attached and calculates the "frequency" of each available tag (how often the tag appears).
Then it normalizes the frequency values (makes them to be between 0 and 1.0).
Here we will modify the IndexController
to implement tag filter.
<?php
//...
class IndexController extends AbstractActionController
{
/**
* Post manager.
* @var Application\Service\PostManager
*/
private $postManager;
// Constructor is used for injecting dependencies into the controller.
public function __construct($entityManager, $postManager)
{
$this->entityManager = $entityManager;
$this->postManager = $postManager;
}
public function indexAction()
{
$tagFilter = $this->params()->fromQuery('tag', null);
if ($tagFilter) {
// Filter posts by tag
$posts = $this->entityManager->getRepository(Post::class)
->findPostsByTag($tagFilter);
} else {
// Get recent posts
$posts = $this->entityManager->getRepository(Post::class)
->findBy(['status'=>Post::STATUS_PUBLISHED],
['dateCreated'=>'DESC']);
}
// Get popular tags.
$tagCloud = $this->postManager->getTagCloud();
// Render the view template.
return new ViewModel([
'posts' => $posts,
'postManager' => $this->postManager,
'tagCloud' => $tagCloud
]);
}
}
The action method will retrieve the tag from the GET variable tag
if the variable doesn't
present in HTTP request, all posts are retrieved as usual. If the variable present, we use our
custom repository's findPostsByTag()
method to filter posts.
In line 36, we call the PostManager::getTagCloud()
that returns array of tags and their frequencies.
We use this information for rendering the cloud.
Please note that we are now using the
PostManager
service in our controller and have to inject it into the constructor. Do not forget to modify the controller factory to do that.
Finally, modify the index.phtml file to make it look like follows:
<h1>Posts</h1>
<div class="row">
<div class="col-md-8">
<?php foreach($posts as $post): ?>
<h3>
<a href="<?= $this->url('posts', ['action'=>'view', 'id'=>$post->getId()]); ?>">
<?= $this->escapeHtml($post->getTitle()); ?>
</a>
</h3>
<p>
Published: <?= $this->escapeHtml(date('jS \of F Y', strtotime($post->getDateCreated()))); ?>
| Tags: <?= $this->escapeHtml($postManager->convertTagsToString($post)); ?>
</p>
<p class="comments-header">
<?= $this->escapeHtml($postManager->getCommentCountStr($post)); ?> |
<a href="<?= $this->url('posts', ['action'=>'view', 'id'=>$post->getId()],
['fragment'=>'comment']); ?>">
Add new comment
</a>
</p>
<p>
<?= $this->escapeHtml($post->getContent()); ?>
</p>
<?php endforeach; ?>
</div>
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Popular Tags</h3>
</div>
<div class="panel-body">
<?php foreach($this->tagCloud as $tagName=>$frequency): ?>
<a href="<?= $this->url('application', ['action'=>'index'],
['query'=>['tag'=>$tagName]]); ?>">
<span style="font-size:<?= $this->escapeHtml(0.9 + $frequency*3) ?>em">
<?= $this->escapeHtml($tagName); ?>
</span>
</a>
<?php endforeach; ?>
</div>
</div>
</div>
</div>