* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.

The Doctrine Query Object

From the second day’s requirements: “On the homepage, the user sees the latest active jobs”. But as of now, all jobs are displayed, whether they are active or not:

// ...

class JobController extends Controller
{
    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();

        $entities = $em->getRepository('IbwJobeetBundle:Job')->findAll();

        return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
            'entities' => $entities
        ));

 // ...
}

An active job is one that was posted less than 30 days ago. The $entities = $em->getRepository('IbwJobeetBundle')->findAll() method will make a request to the database to get all the jobs. We are not specifying any condition, which means that all the records are retrieved from the database.

Let’s change it to only select active jobs:

public function indexAction()
{
    $em = $this->getDoctrine()->getManager();

    $query = $em->createQuery(
        'SELECT j FROM IbwJobeetBundle:Job j WHERE j.created_at > :date'
    )->setParameter('date', date('Y-m-d H:i:s', time() - 86400 * 30));
    $entities = $query->getResult();

    return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
        'entities' => $entities
    ));
}

Debugging Doctrine generated SQL

Sometimes, it is of great help to see the SQL generated by Doctrine; for instance, to debug a query that does not work as expected. In the dev environment, thanks to the Symfony Web Debug Toolbar, all the information you need is available within the comfort of your browser (http://jobeet.local/app_dev.php):

Day 6 - web debug toolbar

Object Serialization

Even if the code above works, it is far from perfect as it does not take into account some requirements from Day 2: “A user can come back to re-activate or extend the validity of the job for an extra 30 days..”.

But as the above code only relies on the created_at value, and because this column stores the creation date, we cannot satisfy the above requirement.

If you remember the database schema we have described during Day 3, we also have defined an expires_at column. Currently, if this value is not set in fixture file, it remains always empty. But when a job is created, it can be automatically set to 30 days after the current date.

When you need to do something automatically before a Doctrine object is serialized to the database, you can add a new action to the lifecycle callbacks in the file that maps objects to the database, like we did earlier for the created_at column:

# ...
    # ...
    lifecycleCallbacks:
        prePersist: [ setCreatedAtValue, setExpiresAtValue ]
        preUpdate: [ setUpdatedAtValue ]

Now, we have to rebuild the entities classes so Doctrine will add the new function:

php app/console doctrine:generate:entities IbwJobeetBundle

Open the src/Ibw/JobeetBundle/Entity/Job.php file and edit the new added function:

// ...

class Job
{
    // ... 

    public function setExpiresAtValue()
    {
        if(!$this->getExpiresAt()) {
            $now = $this->getCreatedAt() ? $this->getCreatedAt()->format('U') : time();
            $this->expires_at = new DateTime(date('Y-m-d H:i:s', $now + 86400 * 30));
        }
    }
}

Now, let’s change the action to use the expires_at column instead of the created_at one to select the active jobs:

// ...

    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();

        $query = $em->createQuery(
            'SELECT j FROM IbwJobeetBundle:Job j WHERE j.expires_at > :date'
    )->setParameter('date', date('Y-m-d H:i:s', time()));
        $entities = $query->getResult();

        return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
            'entities' => $entities
        ));
    }

// ...

More with Fixtures

Refreshing the Jobeet homepage in your browser won’t change anything, as the jobs in the database have been posted just a few days ago. Let’s change the fixtures to add a job that is already expired:

// ...

    public function load(ObjectManager $em)
    {
        $job_expired = new Job();
        $job_expired->setCategory($em->merge($this->getReference('category-programming')));
        $job_expired->setType('full-time');
        $job_expired->setCompany('Sensio Labs');
        $job_expired->setLogo('sensio-labs.gif');
        $job_expired->setUrl('http://www.sensiolabs.com/');
        $job_expired->setPosition('Web Developer Expired');
        $job_expired->setLocation('Paris, France');
        $job_expired->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit.');
        $job_expired->setHowToApply('Send your resume to lorem.ipsum [at] dolor.sit');
        $job_expired->setIsPublic(true);
        $job_expired->setIsActivated(true);
        $job_expired->setToken('job_expired');
        $job_expired->setEmail('job@example.com');
        $job_expired->setCreatedAt(new DateTime('2005-12-01'));

        // ...

        $em->persist($job_expired);
        // ...
    }

// ...

Reload the fixtures and refresh your browser to ensure that the old job does not show up:

php app/console doctrine:fixtures:load

Refactoring

Although the code we have written works fine, it’s not quite right yet. Can you spot the problem?

The Doctrine query code does not belong to the action (the Controller layer), it belongs to the Model layer. In the MVC model, the Model defines all the business logic, and the Controller only calls the Model to retrieve data from it. As the code returns a collection of jobs, let’s move the code to the model. For that we will need to create a custom repository class for Job entity and to add the query to that class.

Open /src/Ibw/JobeetBundle/Resources/config/doctrine/Job.orm.yml and add the following to it:

IbwJobeetBundleEntityJob:
    type: entity
    repositoryClass: IbwJobeetBundleRepositoryJobRepository
    # ...

Doctrine can generate the repository class for you by running the generate:entities command used earlier:

php app/console doctrine:generate:entities IbwJobeetBundle

Next, add a new method – getActiveJobs() – to the newly generated repository class. This method will query for all of the active Job entities sorted by the expires_at column (and filtered by category, if it receives the $category_id parameter).

namespace IbwJobeetBundleRepository;

use DoctrineORMEntityRepository;

/**
 * JobRepository
 *
 * This class was generated by the Doctrine ORM. Add your own custom
 * repository methods below.
 */
class JobRepository extends EntityRepository
{
    public function getActiveJobs($category_id = null)
    {
        $qb = $this->createQueryBuilder('j')
            ->where('j.expires_at > :date')
            ->setParameter('date', date('Y-m-d H:i:s', time()))
            ->orderBy('j.expires_at', 'DESC');

        if($category_id)
        {
            $qb->andWhere('j.category = :category_id')
                ->setParameter('category_id', $category_id);
        }

        $query = $qb->getQuery();

        return $query->getResult();
    }
}

Now the action code can use this new method to retrieve the active jobs.

// ...

    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();

        $entities = $em->getRepository('IbwJobeetBundle:Job')->getActiveJobs();

        return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
            'entities' => $entities
        ));
    }

// ...

This refactoring has several benefits over the previous code:

  • The logic to get the active jobs is now in the Model, where it belongs
  • The code in the controller is thinner and much more readable
  • The getActiveJobs() method is re-usable (for instance in another action)
  • The model code is now unit testable

Categories on the Homepage

According to the second day’s requirements we need to have jobs sorted by categories. Until now, we have not taken the job category into account. From the requirements, the homepage must display jobs by category. First, we need to get all categories with at least one active job.

Create a repository class for the Category entity like we did for Job:

IbwJobeetBundleEntityCategory:
    type: entity
    repositoryClass: IbwJobeetBundleRepositoryCategoryRepository
    #...

Generate the repository class:

php app/console doctrine:generate:entities IbwJobeetBundle

Open the CategoryRepository class and add a getWithJobs() method:

namespace IbwJobeetBundleRepository;

use DoctrineORMEntityRepository;

/**
 * CategoryRepository
 *
 * This class was generated by the Doctrine ORM. Add your own custom
 * repository methods below.
 */
class CategoryRepository extends EntityRepository
{
    public function getWithJobs()
    {
        $query = $this->getEntityManager()->createQuery(
            'SELECT c FROM IbwJobeetBundle:Category c LEFT JOIN c.jobs j WHERE j.expires_at > :date'
        )->setParameter('date', date('Y-m-d H:i:s', time()));

        return $query->getResult();
    }   
}

Change the index action accordingly:

// ...

    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();

        $categories = $em->getRepository('IbwJobeetBundle:Category')->getWithJobs();

        foreach($categories as $category) {
            $category->setActiveJobs($em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId()));
        }

        return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
            'categories' => $categories
        ));
    }

// ...

For this to work, we have to add a new property to our Category class, the active_jobs:

class Category
{
    // ...

    private $active_jobs;

    // ...

    public function setActiveJobs($jobs)
    {
        $this->active_jobs = $jobs;
    }

    public function getActiveJobs()
    {
        return $this->active_jobs;
    }
}

In the template, we need to iterate through all categories and display the active jobs:

<!-- ... -->
{% block content %}
    <div id="jobs">
        {% for category in categories %}
            <div>
                <div class="category">
                    <div class="feed">
                        <a href="">Feed</a>
                    </div>
                    <h1>{{ category.name }}</h1>
                </div>
                <table class="jobs">
                    {% for entity in category.activejobs %}
                        <tr class="{{ cycle(['even', 'odd'], loop.index) }}">
                            <td class="location">{{ entity.location }}</td>
                            <td class="position">
                                <a href="{{ path('ibw_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug }) }}">
                                    {{ entity.position }}
                                </a>
                            </td>
                             <td class="company">{{ entity.company }}</td>
                        </tr>
                    {% endfor %}
                </table>
            </div>
        {% endfor %}
    </div>
{% endblock %}

Limit the results

There is still one requirement to implement for the homepage job list: we have to limit the job list to 10 items. That’s simple enough to add the $max parameter to the JobRepository::getActiveJobs() method:

    public function getActiveJobs($category_id = null, $max = null)
    {
        $qb = $this->createQueryBuilder('j')
            ->where('j.expires_at > :date')
            ->setParameter('date', date('Y-m-d H:i:s', time()))
            ->orderBy('j.expires_at', 'DESC');

        if($max) {
            $qb->setMaxResults($max);
        }

        if($category_id) {
            $qb->andWhere('j.category = :category_id')
                ->setParameter('category_id', $category_id);
        }

        $query = $qb->getQuery();

        return $query->getResult();
    }

Change the call to getActiveJobs() to include the $max parameter:

// ...

    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();

        $categories = $em->getRepository('IbwJobeetBundle:Category')->getWithJobs();

        foreach($categories as $category)
        {
            $category->setActiveJobs($em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), 10));
        }

        return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
            'categories' => $categories
        ));
    }

// ...

Custom Configuration

In the JobController, indexAction method, we have hardcoded the number of max jobs returned for a category. It would have been better to make the 10 limit configurable. In Symfony, you can define custom parameters for your application in the app/config/config.yml file, under the parameters key (if the parameters key doesn’t exist, create it):

# ...

parameters:
    max_jobs_on_homepage: 10

This can now be accessed from a controller:

// ...

    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();

        $categories = $em->getRepository('IbwJobeetBundle:Category')->getWithJobs();

        foreach($categories as $category) {
            $category->setActiveJobs($em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), $this->container->getParameter('max_jobs_on_homepage')));
        }

        return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
            'categories' => $categories
        ));
    }

// ...

Dinamic Fixtures

For now, you won’t see any difference because we have a very small amount of jobs in our database. We need to add a bunch of jobs to the fixture. So, you can copy and paste an existing job ten or twenty times by hand… but there’s a better way. Duplication is bad, even in fixture files:

// ...

public function load(ObjectManager $em)
{
    // ...

    for($i = 100; $i &lt;= 130; $i++)
    {
        $job = new Job();
        $job-&gt;setCategory($em-&gt;merge($this-&gt;getReference('category-programming')));
        $job-&gt;setType('full-time');
        $job-&gt;setCompany('Company '.$i);
        $job-&gt;setPosition('Web Developer');
        $job-&gt;setLocation('Paris, France');
        $job-&gt;setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit.');
        $job-&gt;setHowToApply('Send your resume to lorem.ipsum [at] dolor.sit');
        $job-&gt;setIsPublic(true);
        $job-&gt;setIsActivated(true);
        $job-&gt;setToken('job_'.$i);
        $job-&gt;setEmail('job@example.com');

        $em-&gt;persist($job);
    }

    // ... 
    $em-&gt;flush();
}

// ...

You can now reload the fixtures with the doctrine:fixtures:load task and see if only 10 jobs are displayed on the homepage for the Programming category:

Day 6 - limited no of jobs

Secure the Job Page

When a job expires, even if you know the URL, it must not be possible to access it anymore. Try the URL for the expired job (replace the id with the actual id in your database – SELECT id, token FROM job WHERE expires_at < NOW()):

/app_dev.php/job/sensio-labs/paris-france/ID/web-developer-expired

Instead of displaying the job, we need to forward the user to a 404 page. For this we will create a new function in the JobRepository:

// ...

    public function getActiveJob($id)
    {
        $query = $this->createQueryBuilder('j')
            ->where('j.id = :id')
            ->setParameter('id', $id)
            ->andWhere('j.expires_at > :date')
            ->setParameter('date', date('Y-m-d H:i:s', time()))
            ->setMaxResults(1)
            ->getQuery();

        try {
            $job = $query->getSingleResult();
        } catch (DoctrineOrmNoResultException $e) {
            $job = null;
        }

        return $job;
    }

The getSingleResult() method throws a DoctrineORMNoResultException exception if no results are returned and a DoctrineORMNonUniqueResultException if more than one result is returned. If you use this method, you may need to wrap it in a try-catch blockand ensure that only one result is returned.

Now change the showAction() from the JobController to use the new repository method:

// ...

$entity = $em->getRepository('IbwJobeetBundle:Job')->getActiveJob($id);

// ...

Now, if you try to get an expired job, you will be forwarded to a 404 page:

Day 6 - no job found

That’s all for today! We will see you again tomorrow, when we’ll be playing with the category page.

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.