Previously on Jobeet
Yesterday was a great day. You learned how to create pretty URLs and how to use the symfony framework to automate a lot of things for you.
Today, we will enhance the Jobeet website by tweaking the code here and there. In the process, you will learn more about all the features we have introduced this week.
The Propel Criteria Object
From day 2 requirements:
"When a user comes to the Jobeet website, she sees a list of active jobs."
But as of now, all jobs are displayed, whether they are active or not:
class jobActions extends sfActions { public function executeIndex(sfWebRequest $request) { $this->jobs = JobeetJobPeer::doSelect(new Criteria()); } // ... }
An active job is one that was posted less than 30 days ago. The
doSelect()
method takes a Criteria
object that describes the request to
make to the database. In the code above, an empty Criteria
is passed,
which means that all the records are retrieved from the database.
Let's change it to only select active jobs:
public function executeIndex(sfWebRequest $request) { $criteria = new Criteria(); $criteria->add(JobeetJobPeer::CREATED_AT, time() - 86400 * 30, Criteria::GREATER_THAN); $this->jobs = JobeetJobPeer::doSelect($criteria); }
The Criteria::add()
method adds a WHERE
clause to the generated SQL. Here,
we restrict the criteria to only select jobs that are no older than 30 days.
This method has a lot of different comparison operators; here are the most
common ones:
Criteria::EQUAL
Criteria::NOT_EQUAL
Criteria::GREATER_THAN
,Criteria::GREATER_EQUAL
Criteria::LESS_THAN
,Criteria::LESS_EQUAL
Criteria::LIKE
,Criteria::NOT_LIKE
Criteria::CUSTOM
Criteria::IN
,Criteria::NOT_IN
Criteria::ISNULL
,Criteria::ISNOTNULL
Criteria::CURRENT_DATE
,Criteria::CURRENT_TIME
,Criteria::CURRENT_TIMESTAMP
Debugging Propel generated SQL
As you don't write the SQL statements by hand, Propel will take care of the
differences between database engines and will generate SQL statements
optimized for the database engine you choose during day 3. But sometimes, it
is of great help to see the SQL generated by Propel; for instance, to debug
a Criteria
that does not work as expected. In the dev
environment, symfony
logs these queries (along with much more) in the log/
directory. There is
one log file for every combination of an application and an environment. The
file we are looking for is named frontend_dev.log
:
# log/frontend_dev.log
Dec 6 15:47:12 symfony [debug] {sfPropelLogger} exec: SET NAMES 'utf8'
Dec 6 15:47:12 symfony [debug] {sfPropelLogger} prepare: SELECT jobeet_job.ID, jobeet_job.CATEGORY_ID, jobeet_job.TYPE, jobeet_job.COMPANY, jobeet_job.LOGO, jobeet_job.URL, jobeet_job.POSITION, jobeet_job.LOCATION, jobeet_job.DESCRIPTION, jobeet_job.HOW_TO_APPLY, jobeet_job.TOKEN, jobeet_job.IS_PUBLIC, jobeet_job.CREATED_AT, jobeet_job.UPDATED_AT FROM `jobeet_job` WHERE jobeet_job.CREATED_AT>:p1
Dec 6 15:47:12 symfony [debug] {sfPropelLogger} Binding '2008-11-06 15:47:12' at position :p1 w/ PDO type PDO::PARAM_STR
You can see for yourself that Propel has generated a where clause for the
created_at
column (WHERE jobeet_job.CREATED_AT > :p1
).
The
:p1
string in the query indicates that Propel generates prepared statements. The actual value of:p1
('2008-11-06 15:47:12' in the example above) is passed during the execution of the query and properly escaped by the database engine. The use of prepared statements dramatically reduces your exposure to SQL injection attacks.
This is good, but it's a bit annoying to have to switch between the browser, the IDE, and the log file every time you need to test a change. Thanks to the symfony web debug toolbar, all the information you need is also available within the comfort of your browser:
Object Serialization
Even if the above code works, it is not the way to go. As our day 2 requirements state:
"A user can come back to re-activate or extend the validity of the job ad for an extra 30 days..."
This is not possible with the above code, as the created_at
value should
not be changed once the record is created.
If you remember the database schema, we have an expires_at
column. Currently
this value is always empty. When a job is created, it must be set to 30 days
after the current date. To do something before a Propel object is serialized
to the database, you can override the save()
method:
// lib/model/JobeetJob.php class JobeetJob extends BaseJobeetJob { public function save(PropelPDO $con = null) { if ($this->isNew() && !$this->getExpiresAt()) { $now = $this->getCreatedAt() ? $this->getCreatedAt('U') : time(); $this->setExpiresAt($now + 86400 * 30); } return parent::save($con); } // ... }
The isNew()
method returns true
when the object has not been serialized
yet in the database, and false
otherwise.
Let's change the action to use the expires_at
column:
public function executeIndex(sfWebRequest $request) { $criteria = new Criteria(); $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN); $this->jobs = JobeetJobPeer::doSelect($criteria); }
We restrict the criteria to only select jobs with the expires_at
date in
the future.
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:
# data/fixtures/020_jobs.yml JobeetJob: # other jobs expired_job: category_id: programming company: Sensio Labs position: Web Developer location: Paris, France description: Lorem ipsum dolor sit amet, consectetur adipisicing elit. how_to_apply: Send your resume to lorem.ipsum [at] dolor.sit is_public: true is_activated: true created_at: 2005-12-01 token: job_expired email: job@example.com
Even if the created_at
column is automatically filled by Propel, you can
override it. Reload the fixtures and refresh your browser to ensure that the
old job does not show up:
$ php symfony propel:data-load
Custom Configuration
In the JobeetJob::save()
method, we have hardcoded the number of days for
the job to become inactive. It would have been better to make the 30 days
configurable. The symfony framework provides a built-in configuration file for
application specific settings, the app.yml
file. This YAML file can contain
any setting you want:
# apps/frontend/config/app.yml all: active_days: 30
In the application, these settings are available through the global sfConfig
class:
sfConfig::get('app_active_days')
The setting has been prefixed by app_
because the sfConfig
class also
provides access to symfony settings as we will see later on.
Let's update the code to take this new setting into account:
public function save(PropelPDO $con = null) { if ($this->isNew() && !$this->getExpiresAt()) { $now = $this->getCreatedAt() ? $this->getCreatedAt('U') : time(); $this->setExpiresAt($now + 86400 * sfConfig::get('app_active_days')); } return parent::save($con); }
The app.yml
configuration file is a great way to centralize global settings
for your application.
Refactoring
Although the code we have written works fine, it's not quite right yet. Can you spot the problem?
The Criteria
code does not belong to the action, it belongs to the
Model layer. As the code returns jobs, let's create a method in the
JobeetJobPeer
class:
// lib/model/JobeetJobPeer.php class JobeetJobPeer extends BaseJobeetJobPeer { static public function getActiveJobs() { $criteria = new Criteria(); $criteria->add(self::EXPIRES_AT, time(), Criteria::GREATER_THAN); return self::doSelect($criteria); } }
Notice that the method is static
. The action code can now use this new method:
public function executeIndex(sfWebRequest $request) { $this->jobs = JobeetJobPeer::getActiveJobs(); }
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 much more readable
- The
getActiveJobs()
method is re-usable (for instance in another action) - The model code is now unit testable
Let's sort the jobs by the expires_at
column:
static public function getActiveJobs() { $criteria = new Criteria(); $criteria->add(self::EXPIRES_AT, time(), Criteria::GREATER_THAN); $criteria->addDescendingOrderByColumn(self::EXPIRES_AT); return self::doSelect($criteria); }
The addDescendingOrderByColumn()
method adds an ORDER BY
clause to the
generated SQL (addAscendingOrderByColumn()
also exists).
Categories on the Homepage
From day 2 requirements:
"The jobs are sorted by category and then by publication date (newer jobs first)."
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. Open the JobeetCategoryPeer
class and add a getWithJobs()
method:
// lib/model/JobeetCategoryPeer.php class JobeetCategoryPeer extends BaseJobeetCategoryPeer { static public function getWithJobs() { $criteria = new Criteria(); $criteria->addJoin(self::ID, JobeetJobPeer::CATEGORY_ID); $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN); $criteria->setDistinct(); return self::doSelect($criteria); } }
The Criteria::addJoin()
method adds a JOIN
clause to the generated SQL.
By default, the join condition is added to the WHERE
clause. You can also
change the join operator by adding a third argument (Criteria::LEFT_JOIN
,
Criteria::RIGHT_JOIN
, and Criteria::INNER_JOIN
).
Change the index
action accordingly:
// apps/frontend/modules/job/actions/actions.class.php public function executeIndex(sfWebRequest $request) { $this->categories = JobeetCategoryPeer::getWithJobs(); }
In the template, we need to iterate through all categories and display the active jobs:
// apps/frontend/modules/job/indexSuccess.php <?php use_stylesheet('jobs.css') ?> <div id="jobs"> <?php foreach ($categories as $category): ?> <div class="category_<?php echo Jobeet::slugify($category->getName()) ?>"> <div class="category"> <div class="feed"> <a href="">RSS feed</a> </div> <h1><?php echo $category ?></h1> </div> <table class="jobs"> <?php foreach ($category->getActiveJobs() as $i => $job): ?> <tr class="<?php echo fmod($i, 2) ? 'even' : 'odd' ?>"> <td><?php echo $job->getLocation() ?></td> <td><?php echo link_to($job->getPosition(), 'job_show_user', $job) ?></td> <td><?php echo $job->getCompany() ?></td> </tr> <?php endforeach; ?> </table> </div> <?php endforeach; ?> </div>
To display the category name in the template, we have used
echo $category
. Does this sound weird?$category
is an object, how canecho
magically display the category name? The answer was given during day 3 when we have defined the magic__toString()
method for all the model classes.
For this to work, we need to add the getActiveJobs()
method to the
JobeetCategory
class:
// lib/model/JobeetCategory.php public function getActiveJobs() { $criteria = new Criteria(); $criteria->add(JobeetJobPeer::CATEGORY_ID, $this->getId()); return JobeetJobPeer::getActiveJobs($criteria); }
In the add()
call, we have omitted the third argument as Criteria::EQUAL
is the default value.
When calling the JobeetJobPeer::getActiveJobs()
, we need to pass the current
Criteria
object. So, the getActiveJobs()
needs to merge it with its own
criteria. As the Criteria
is an object, this is quite simple:
// lib/model/JobeetJobPeer.php static public function getActiveJobs(Criteria $criteria = null) { if (is_null($criteria)) { $criteria = new Criteria(); } $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN); $criteria->addDescendingOrderByColumn(self::EXPIRES_AT); return self::doSelect($criteria); }
Limit the Results
There is still one requirement to implement for the homepage job list:
"For each category, the list only shows the first 10 jobs and a link allows to list all the jobs for a given category."
That's simple enough to add to the getActiveJobs()
method:
// lib/model/JobeetCategory.php public function getActiveJobs($max = 10) { $criteria = new Criteria(); $criteria->add(JobeetJobPeer::CATEGORY_ID, $this->getId()); $criteria->setLimit($max); return JobeetJobPeer::getActiveJobs($criteria); }
The appropriate LIMIT
clause is now hard-coded into the Model, but it is
better for this value to be configurable. Change the template to pass a
maximum number of jobs set in app.yml
:
<?php foreach ($category->getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')) as $i => $job): ?>
and add a new setting in app.yml
:
all: active_days: 30 max_jobs_on_homepage: 10
Dynamic Fixtures
Unless you lower the max_jobs_on_homepage
setting to one, you won't see
any difference. We need to add a bunch of jobs to the fixtures. 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.
symfony to the rescue! YAML files in symfony can contain PHP code
that will be evaluated just before the parsing of the file. Edit the
020_jobs.yml
fixtures file and add the following code at the end:
JobeetJob: # Starts at the beginning of the line (no whitespace before) <?php for ($i = 100; $i <= 130; $i++): ?> job_<?php echo $i ?>: category_id: programming company: Company <?php echo $i."\n" ?> position: Web Developer location: Paris, France description: Lorem ipsum dolor sit amet, consectetur adipisicing elit. how_to_apply: | Send your resume to lorem.ipsum [at] company_<?php echo $i ?>.sit is_public: true is_activated: true token: job_<?php echo $i."\n" ?> email: job@example.com <?php endfor; ?>
Be careful, the YAML parser won't like you if you mess up with indentation. Keep in mind the following simple tips when adding PHP code to a YAML file:
- The
<?php ?>
statements must always start the line or be embedded in a value. - If a
<?php ?>
statement ends a line, you need to explicly output a new line ("\n").
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):
/frontend_dev.php/job/sensio-labs/paris-france/4/web-developer-expired
Instead of displaying the job, we need to forward the user to a 404 page. But how can we do this as the job is retrieved automatically by the route?
By default, the sfPropelRoute
uses the standard doSelectOne()
method to
retrieve the object, but you can change it by providing a
method_for_criteria
option in the route configuration:
# apps/frontend/config/routing.yml job_show_user: url: /job/:company_slug/:location_slug/:id/:position_slug class: sfPropelRoute options: model: JobeetJob type: object method_for_criteria: doSelectActive param: { module: job, action: show } requirements: id: \d+
The doSelectActive()
method will receive the Criteria
object built by the
route:
// lib/model/JobeetJobPeer.php static public function doSelectActive(Criteria $criteria) { $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN); return self::doSelectOne($criteria); }
Now, if you try to get an expired job, you will be forwarded to a 404 page.
Link to the Category Page
Now, let's add a link to the category page on the homepage and create the category page.
But, wait a minute. It's Saturday, the hour is not yet over and we haven't worked that much. So, you have plenty of free time and enough knowledge to implement this all by yourself! Let's make an exercise of it. Check back tomorrow for our implementation.
See you Tomorrow
Do work on an implementation on your local Jobeet project. Please, abuse the online API documentation and all the free documentation available on the symfony website to help you out. We'll see you again tomorrow with our take on this implementation.
Good luck!
As always, you can checkout the source code of Jobeet as of today:
http://svn.jobeet.org/tags/release_day_06/
Hi,
I have a question and report two typos:
Q1: Can I check the runtime of each sql like with the debug toolbar of symfony1.0 ?
Typo1: In the JobeetJob->save() method at the "Object Serialization" section:
$now = $this->getCreatedAt() ? $this->getCreatedAt() : time(); -> $now = $this->getCreatedAt() ? $this->getCreatedAt('U') : time();
Typo2: In the same method at the "Custom Configuration" section:
The "$now" variable is missing. So the test data "expired_job" added at the "More with Fixtures" section doesn't work any more.
Have a nice weekend :)
I would replace :
sfConfig::get('app_active_days')
by
sfConfig::get('app_active_days', 1)
Thanks again for this great work :)
Hello, Thank at all for the great tuto. I notice a small typo mistake about the last routing rule. It is not for job but for "job_show_user:".
Hi, thanks for this tutorial... it's good to get back to symfony A question first : the view "indexSuccess" generates database calls ($category->getActiveJobs()), does this break MVC paradigm ?
Finally, we can notice that a single call to the home page of the app generates 1 + 1 per job category SQL select requests... that's my proposition to generate a single call to the database (using "JobeetJobPeer::doSelectJoinJobeetCategory") :
Model : // lib/model/JobeetJobPeer.php public static function getActiveJobsJoinCategory(Criteria $criteria = null) { if (is_null($criteria)) { $criteria = new Criteria(); }
$criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN); $criteria->addAscendingOrderByColumn(JobeetCategoryPeer::NAME); $criteria->addDescendingOrderByColumn(self::EXPIRES_AT);
return self::doSelectJoinJobeetCategory($criteria); }
Controller : // apps/frontend/modules/job/actions/actions.class.php public function executeIndex(sfWebRequest $request) { $this->jobs = JobeetJobPeer::getActiveJobsJoinCategory(); }
View : // apps/frontend/modules/job/indexSuccess.php
I am not sure if I overlooked something in past days, but for me it works only if I change in actionx.class.php -> class jobActions extends sfActions -> public function executeIndex(sfWebRequest $request) the "$this->jobs = JobeetJobPeer::doSelect(new Criteria());" to this one: "$this->jobeet_job_list = JobeetJobPeer::doSelect(new Criteria());"
Ups, sorry! It should be
$this->jobeet_job_list = JobeetJobPeer::doSelect($criteria); (not "$this->jobeet_job_list = JobeetJobPeer::doSelect(new Criteria());" as I wrote before)