Skip to content
Caution: You are browsing the legacy symfony 1.x part of this website.

第六天:更多模型知识

Language
ORM

昨天是很棒的一天。你学习了如何生成友好URL,和如何使用symfony框架自动完成一些 工作。

今天,我们要通过整合分散的代码,增强Jobeet网站功能。这个过程中,你会学到更多本周介绍的特性。

Propel Criteria对象

第2天时我们描述这样的需求:

“当用户浏览Jobeet网站时,她看到一个已激活的招聘信息列表”。

但是现在,没有激活的工作也显示在网页上:

// apps/frontend/modules/job/actions/actions.class.php
class jobActions extends sfActions
{
  public function executeIndex(sfWebRequest $request)
  {
    $this->jobeet_job_list = JobeetJobPeer::doSelect(new Criteria());
  }
 
  // ...
}

激活的招聘信息必须是30天之内发布的,否则该信息将成为过期信息。我们看到index动作中 的doSelect()方法将Criteria对象作为参数,Criteria用来生成(SQL查询语句中的)条件子句。 这里使用空Criteria对象,表示从数据库中取回所有记录。

我们把它改成只获取激活的招聘信息:

public function executeIndex(sfWebRequest $request)
{
  $criteria = new Criteria();
  $criteria->add(JobeetJobPeer::CREATED_AT, time() - 86400 * 30, Criteria::GREATER_THAN);
 
  $this->jobeet_job_list = JobeetJobPeer::doSelect($criteria);
}

Criteria::add()方法将一个WHERE子句添加到SQL语句中。在这我们添加一个限制条件, 只选择不早于30天的招聘信息。Criteria::add()具有许多不同的比较运算符,这里是 一些常用的:

  • 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

调试Propel生成的SQL语句

即使你不写任何SQL语句,Propel仍会处理好各种数据库引擎直接的兼容,并为你选择的数据库引擎 (如MySQL)自动生成优化的SQL语句。有时这些SQL语句会很有用。例如,调试出问题的查询语句。 在开发环境中,这些查询语句(和其它东西一起)生成日志文件,保存在log/目录下。在这个目录下, symfony会为每种环境中每个程序都生成一个单独的日志文件,我们找的是frontend_dev.logdev环境下的frontend程序的日志文件):

# 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

看一下Propel已经为created_at字段生成了一个WHERE子句 (WHERE jobeet_job.CREATED_AT > :p1)。

note

查询语句中:p1表示Propel生成中间语句(prepared statements)。:p1的实际值(上例中为’2008-11-06 15:47:12′) 经过数据库引擎严格的转义后执行。使用中间语句可以明显减少SQL注入(SQL injection)攻击

日志文件真的不错,但麻烦的是每次需要查看的时候,你都要重新打开一次日志文件 (因为用记事本打开的文件是不会自动刷新的)。还好我们有替代方法——WEB调试工具栏, 你可以直接通过浏览器查看全部信息:

SQL statements in the web debug toolbar

对象入库

index动作中的代码虽然可以得到我们想要的结果,但却不是我们应该使用的方式。 因为需求还要求:

”用户可以重新激活招聘信息并扩展30天有效期限…”。

因为created_at字段一旦生成无法改变,所以上面的代码无法实现这个需求,我们 需要重新修改代码。

你应该还记得数据库中有一个expires_at字段。现在这个值为空。当添加一条记录时, 这个字段应该被设置成30天后的日期(过期日期)。

你可以修改save()方法,在Propel对象被存储到数据库前设置expires_at字段的值:

// 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.

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 executeIndex(sfWebRequest $request)
{
  $criteria = new Criteria();
  $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN);
 
  $this->jobeet_job_list = JobeetJobPeer::doSelect($criteria);
}

我们限制查询条件,只选择expires_at日期没到的记录。

导入更多数据

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

note

Be careful when you copy and paste code in a fixture file to not break the indentation. The expired_job must only have two spaces before it.

As you can see in the job we have added in the fixture file, the created_at column value can be defined even if it is automatically filled by Propel. The defined value will override the default one. Reload the fixtures and refresh your browser to ensure that the old job does not show up:

$ php symfony propel:data-load

You can also execute the following query to make sure that the expires_at column is automatically filled by the save() method, based on the created_at value:

SELECT `position`, `created_at`, `expires_at` FROM `jobeet_job`;

Custom Configuration

In the JobeetJob::save() method, we have hardcoded the number of days for the job to expire. 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.

Last, if you need project-wide settings, just create a new app.yml file in the config folder at the root of your symfony project.

重构(Refactoring)

虽然现在代码工作正常,但并不是非常好。你能看出问题吗?

Criteria代码不属于动作(Controller)而是属于模型层(Model layer)。因为这些代码的 作用是从数据库返回对象(涉及到数据库所以属于模型层),我们在JobeetJobPeer类中创建 一个方法:

// 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);
  }
}

现在动作可以用这个方法调用激活的招聘信息了。

public function executeIndex(sfWebRequest $request)
{
  $this->jobeet_job_list = JobeetJobPeer::getActiveJobs();
}

This refactoring has several benefits over the previous code:

重构(refactoring)后的代码有很多优点:

  • 将获取招聘信息的逻辑放入Model层
  • 控制层的代码更加易读
  • getActiveJobs()方法可以被其他动作重用
  • 模型代码现在可以进行单元测试了

现在用expires_at字段进行排序:

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).

首页中的信息分类

第2天提出的需求:

”招聘信息按类别显示,每个分类中的信息按发布日期排序(最新的放在第一个)”。

直到现在,我们还没有考虑信息分类。按要求,首页必须按分类显示招聘信息, 我们需要让每个分类至少包含一个已激活的招聘信息。

打开JobeetCategoryPeer类添加getWithJobs()方法:

// 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).

Criteria::addJoin()方法给SQL语句添加了JOIN子句。默认情况下,连接条件会增加到 SQL语句的WHERE子句中。你也可以通过设置第三个参数 (Criteria::LEFT_JOIN, Criteria::RIGHT_JOIN, and Criteria::INNER_JOIN) 改变连接操作。

相应地修改index动作:

// apps/frontend/modules/job/actions/actions.class.php
public function executeIndex(sfWebRequest $request)
{
  $this->categories = JobeetCategoryPeer::getWithJobs();
}

我们在模板中通过迭代,显示所以分类及该分类下已激活的招聘信息:

// apps/frontend/modules/job/templates/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="">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 class="location">
              <?php echo $job->getLocation() ?>
            </td>
            <td class="position">
              <?php echo link_to($job->getPosition(), 'job_show_user', $job) ?>
            </td>
            <td class="company">
              <?php echo $job->getCompany() ?>
            </td>
          </tr>
        <?php endforeach; ?>
      </table>
    </div>
  <?php endforeach; ?>
</div>

note

我们使用echo $category显示分类名,听起来很奇怪?$category是一个对象,如何象 字符串一样显示分类名?答案就是我们第3天给所有model类定义魔术方法__toString()。 这个方法返回设置好的字符串。当需要将对象作为字符串使用的时候,就会调用这个方法。

为了让上面的代码工作,我们在JobeetCategory类中添加getActiveJobs()方法:

// lib/model/JobeetCategory.php
public function getActiveJobs()
{
  $criteria = new Criteria();
  $criteria->add(JobeetJobPeer::CATEGORY_ID, $this->getId());
 
  return JobeetJobPeer::getActiveJobs($criteria);
}

add()的第3个参数为默认值Criteria::EQUAL时,我们可以将它省略。

The JobeetCategory::getActiveJobs() method uses the JobeetJobPeer::getActiveJobs() method to retrieve the active jobs for the given category.

调用JobeetJobPeer::getActiveJobs()时,有时需要传递当前的Criteria对象,而不必 新建一个Criteria实例。

修改getActiveJobs()。因为Criteria是个对象,这非常容易办到:

// 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);
}

限制结果

首页信息列表还有一个要求:

“首页每个分类只能显示前10条信息,进入分类页面允许显示改分类的所有工作”。

这太容易了,修改getActiveJobs()方法:

// 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);
}

现在,LIMIT子句使用的了硬编码(hard-coded),我们将最大值变量添加到app.yml中, 模板从配置文件中读取该值:

<!-- apps/frontend/modules/job/templates/indexSuccess.php -->
<?php foreach ($category->getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')) as $i => $job): ?>

app.yml中添加新设置:

all:
  active_days:          30
  max_jobs_on_homepage: 10

Homepage sorted by category

动态导入数据

现在,除非你将max_jobs_on_homepage设置为1,否则你不会看到修改后代码的效果 (job的数量不够)。我们需要导入很多招聘信息到数据库中。你可以将已有的job复制 粘贴十几二十遍,不过即使是在数据导入文件(fixture)中代码重复仍是很糟糕的事情。 我们有更好的方法。

symfony的YAML文件中可以包含PHP代码,它们将在YAML代码被分析之前执行。打开 020_jobs.yml,添加下列代码到尾部:

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; ?>

小心,如果你的缩进没有对齐,YAML会出错,记住用空格代替Tab。当你添加PHP代码到YAML文件时, 记住下面两点:

  • 语句必须占一行,或嵌入到一个值中
  • 如果 在一行的结尾,需要输出一个新行(”\n”)

You can now reload the fixtures with the propel:data-load task and see if only 10 jobs are displayed on the homepage for the Programming category. In the following screenshot, we have changed the maximum number of jobs to five to make the image smaller:

Pagination

保护信息页面

招聘信息过期后,便不能通过网页访问。即使你知道URL,也不会看到相关的内容。 试试这个URL(id换为实际值)- SELECT id, token FROM jobeet_job WHERE expires_at < NOW()):

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

我们仍能看到信息页面,但我们希望显示的是404页面。我们可不可以在招聘信息被 自动取回的同时,通过路由实现这个功能呢?

答案是肯定的,默认情况下sfPropelRoute类使用标准的doSelectOne()方法 取回一个对象,但你也可以在路由文件中用~method_for_criteria~选项设置:

# 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+
    sf_method: [GET]

当路由规则被匹配时,doSelectActive()方法会被调用,并且会带有一个由路由构建的 Criteria对象作为参数:

// lib/model/JobeetJobPeer.php
class JobeetJobPeer extends BaseJobeetJobPeer
{
  static public function doSelectActive(Criteria $criteria)
  {
    $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN);
 
    return self::doSelectOne($criteria);
  }
 
  // ...
}

现在,如果你再尝试浏览过期信息,会被带到404页面。

404 for expired job

链接到分类页面

现在,给首页的分类页面添加链接,并创建分类页面。

不过今天的时间不够了,还是等到明天吧,不过你可以自己尝试一下。

明天见

在做Jobeet项目的同时,阅读一下在线API文档 和其它文档 会对你有很大帮助。

Good luck!

This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.