Caution: You are browsing the legacy symfony 1.x part of this website.
Cover of the book Symfony 5: The Fast Track

Symfony 5: The Fast Track is the best book to learn modern Symfony development, from zero to production. +300 pages in full color showing how to combine Symfony with Docker, APIs, queues & async tasks, Webpack, Single-Page Applications, etc.

Buy printed version

Ngày 6: Tìm hiểu thêm về Model

Tóm tắt

Hôm qua là một ngày tuyệt vời. Chúng ta đã học cách tạo một URL dễ nhìn và cách dùng symfony framework để tự động làm nhiều việc cho chúng ta.

Hôm nay, chúng ta sẽ chi tiết thêm Jobeet website bằng cách chỉnh sửa code đã có. Trong quá trình đó, bạn sẽ được học thêm về những tính năng chúng tôi đã giới thiệu trong tuần này.

The Propel Criteria Object

Đây là yêu cầu đề ra trong ngày 2:

"Khi người dùng vào trang Jobeet, anh ta sẽ thấy danh sách các công việc mới nhất."

Nhưng hiện tại tất cả các công việc đều được hiển thị:

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

Một active job là một công việc được đưa lên trong 30 ngày gần nhất. Ở đây, chúng ta đã lấy tất cả các công việc trong cơ sở dữ liệu.

Ta cần sửa lại để chỉ lấy các active job:

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

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

Debug cho Propel từ câu SQL được sinh ra

Chúng ta không trực tiếp viết câu lệnh SQL, Propel sẽ sinh ra câu SQL phù hợp với database engines mà chúng ta đã chọn trong cấu hình. Đôi khi, chúng ta muốn xem câu SQL được tạo ra bởi Propel; ví dụ, khi chúng ta muốn debug một câu truy vấn làm việc không như mong muốn. Trong môi trường dev, symfony lưu lại những câu truy vấn này (và nhiều thứ khác) trong thư mục log/. Mỗi file log ứng với một application trong một môi trường nào đó. File chúng ta cần tìm có tên 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).

note

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 .

Xem từ file log khá tiên lợi, nhưng có một chút bất tiện khi phải chuyển qua lại giữa trình duyệt, IDE, và file log mỗi khi bạn cần kiểm tra sự thay đổi. Nhờ có symfony web debug toolbar, bạn có thể xem tất cả các thông tin cần thiết này từ trình duyệt:

SQL statements in the web debug toolbar

Object Serialization

Mặc dù code trên hoạt đông, nhưng nó vẫn chưa đúng là những gì chúng ta muốn:

"Người dùng có thể kích hoạt lại hoặc tăng thời hạn cho công việc thêm 30 ngày nữa..."

Điều này là không thể thực hiện với code ở trên, do giá trị created_at chỉ được xác định một lần khi tạo job mới.

Hãy nhớ lại database schema, chúng ta có một cột expires_at. Hiện tại giá trị này để trống. Khi một công việc được tạo, nó phải được thiết lập giá trị là ngày cách ngày hiện tại 30 ngày. Để thực hiện một việc gì đó trước khi một đối tượng Propel được đưa vào database, bạn cần override phương thức save() :

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

Phương thức isNew() trả về true nếu đối tượng chưa được ghi vào database, và false trong trường hợp ngược lại.

Sửa lại câu truy vấn trong action sử dụng cột expires_at:

public function executeIndex(sfWebRequest $request)
{
  $criteria = new Criteria();
  $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN);
 
  $this->jobs = JobeetJobPeer::doSelect($criteria);
}

Chúng ta đã truy vấn để chỉ lấy các jobs còn thời hạn.

More with Fixtures

Refresh lại trang chủ bạn sẽ thấy không có gì thay đổi vì các job trong database chỉ mới đưa lên vài ngày trước. Hãy thêm một công việc đã hết hạn:

# 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:        [email protected]

Ngay cả khi giá trị cột created_at được thêm tự động bởi Propel, bạn vẫn có thể override nó. Load lại file fixtures và refresh lại trình duyệt bạn sẽ thấy rằng công việc quá hạn không được hiển thị:

$ php symfony propel:data-load

Custom Configuration

Trong phương thức JobeetJob::save(), chúng ta đã để cố định cho số ngày mà một công việc trở thành quá hạn. Ta nên để số ngày này có thể cấu hình được. Symfony framework cung cấp sẵn file cho các cấu hình của application:app.yml. File YAML này có thể chứa bất kì cấu hình nào bạn muốn:

# apps/frontend/config/app.yml
all:
  active_days: 30

Trong application, những thiết lập này có thể được truy cập thông qua global sfConfig class:

sfConfig::get('app_active_days')

Tên của setting được gán app_ đằng trước vì lớp sfConfig có thể truy cập đến tất cả các symfony settings mà chúng ta sẽ thấy ở phần sau.

Hãy sửa lại code với setting vừa tạo:

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

File app.yml là nơi lý tưởng để chứa các global settings của application.

Refactoring

Mặc dù code chúng ta đã viết hoạt động tốt, nhưng nó vẫn còn vài vấn đề. Bạn có thể chỉ ra được những vấn đề đó?

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

Bây giờ, ở action ta gọi phương thức vừa tạo để nhận các active job.

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

Việc refactoring này có vài lợi ích so với code trước:

  • Việc xử lý để nhận các active job bây giờ đã nằm đúng chỗ của nó trong Model
  • Code trong controller trở nên dễ đọc hơn
  • Phương thức getActiveJobs() có thể được dùng lại (ví dụ trong action khác)
  • Model code bây giờ có thể unit test

Hãy sắp xếp các công việc theo 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).

Categories on the Homepage

Từ yêu cầu ở ngày 2:

"Công việc được hiển thị theo category và xếp theo ngày public (công việc mới ở trên)."

Hiện tại, chúng ta chưa nhóm các công việc theo category. Đầu tiên, chúng ta cần lấy tất cả các category có ít nhất một 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).

Sửa lại action index:

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

Trong template, chúng ta cần duyệt qua tất cả các categories và hiển thị các active job:

// 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="">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

Để hiển thị tên của category trong template, chúng ta viết echo $category. Điều này có vẻ bất thường? $category là một đối tượng, làm thế nào mà echo lại hiển thị ra được tên của category? Câu trả lời đã có trong ngày 3 khi chúng ta dùng magic method __toString() trong tất cả các 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

Vẫn còn một yêu cầu cần thực hiện trong trang chủ:

"Với mỗi category, ta chỉ list 10 công việc đầu tiên và có một link cho phép xem tất cả các công việc của category."

Điều này có thể thực hiện đơn giản bằng cách thêm tham số vào phương thức 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);
}

Giá trị trong mệnh đề LIMIT có thể cấu hình được. Sửa lại template để lấy số công việc được cấu hình trong app.yml:

<?php foreach ($category->getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')) as $i => $job): ?>

và thêm setting vào file app.yml:

all:
  active_days:          30
  max_jobs_on_homepage: 10

Dynamic Fixtures

Trừ khi bạn sửa giá trị của max_jobs_on_homepage thành 1, nếu không bạn sẽ thấy trang chủ không có gì thay đổi. Chúng ta cần thêm một số job nữa vào file fixtures. Bạn có thể phải copy hàng chục lần những job đã có ... nhưng có một cách tốt hơn.Nên tránh việc lặp lại, kể cả trong file fixture!

File YAML trong symfony có thể chứa code PHP code, nó sẽ được dịch ra trước khi parse. Thêm đoạn code sau vào cuối file fixtures:

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:        [email protected]
 
<?php endfor; ?>

Hãy chú ý các khoảng lùi đầu dòng.Làm theo những chỉ dẫn đơn giản sau khi bạn thêm code PHP vào file YAML:

  • <?php ?> phải để ở đầu dòng hoặc để trong một giá trị.
  • Nếu <?php ?> kết thúc một dòng, bạn cần output thêm kí tự xuống dòng ("\n").

Secure the Job Page

Khi một công việc hết hạn, nếu bạn biết URL, bạn vẫn có thể truy cập nó. Hãy thử truy cập vào một công việc hết hạn theo URL (thay id bằng id trong database của bạn):

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

Thay vì hiển thị công việc, chúng ta cần chuyển người dùng đến trang 404. Làm thế nào chúng ta có thể làm được điều này khi job được nhận tự động bởi 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);
}

Bây giờ, nếu bạn truy cập một công việc hết hạn, bạn sẽ được chuyển sang trang 404.

Link to the Category Page

Bây giờ, hãy thêm một link tới trang category ở trang chủ và tạo trang category.

Nhưng hãy đợi chút. Hôm nay là thứ 7, vì thế chúng ta sẽ không làm quá nhiều. Và bạn cũng đủ kiến thức để làm việc này! Hãy tự làm nó và chúng ta sẽ kiểm tra lại vào ngày mai.

Hẹn gặp lại ngày mai

Hãy tự bổ sung những thứ trên cho Jobeet project của mình. Sử dụng API documentationdocumentation khi bạn cần giúp đỡ.

Chúc may mắn!

Bạn có thể checkout mã nguồn ngày hôm nay tại:

http://svn.jobeet.org/tags/release_day_06/