Вчера был великий день. Мы изучили как использовать произвольные URL и Symfony в-целом для автоматизации многих ваших задач.
Сегодня мы улучшим сайт Jobeet, дополняя код в разных местах. В процессе работы Вы узнаете больше об особенностях, которые были представлены в первых пяти днях этого руководства.
The Propel Criteria Object
Из требований (день 2):
"Когда пользователь заходит на Jobeet, он видит список активных вакансий."
Но на данный момент все вакансии отбражаются независимо от того, активные они или нет:
// apps/frontend/modules/job/actions/actions.class.php class jobActions extends sfActions { public function executeIndex(sfWebRequest $request) { $this->jobeet_jobs = JobeetJobPeer::doSelect(new Criteria()); } // ... }
Активной считается вакансия, созданная не более 30 дней назад. Метод
doSelect()
принимает объект Criteria
, который описывает запрос к
базе. В коде, приведенном выше, мы передаем пустой объект Criteria
- это
означает что все записи должны быть получены из базы.
Давайте изменим код, чтобы он получал только активные вакансии:
public function executeIndex(sfWebRequest $request) { $criteria = new Criteria(); $criteria->add(JobeetJobPeer::CREATED_AT, time() - 86400 * 30, Criteria::GREATER_THAN); $this->jobeet_jobs = JobeetJobPeer::doSelect($criteria); }
Метод Criteria::add()
добавляет конструкцию WHERE
в генерируемый SQL. Здесь
мы ограничим условие - получать записи не старше 30 дней.
Метод 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
Отладка SQL сгенерированного при помощи Propel
Поскольку Вы не пишете SQL инструкции вручную, Propel берет заботу на себя
о различиях между базами и генерирует SQL инструкции,
оптимизированные для той СУБД, которую Вы используете. Но иногда, было бы
очень полезно посмотреть SQL который генерируется Propel; например, чтобы
отладить запрос который работает не так, как ожидается. В окружении dev
, symfony
логирует такие запросы (как и много другой информации) в каталоге log/
. Для каждой
комбинации приложения и окружения существует отдельный лог. Файл, который нас интересует,
называется 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
В нашем случае мы видим, что Propel создал условие where для колонки
created_at
(WHERE jobeet_job.CREATED_AT > :p1
).
note
Строка :p1
в запросе свидетельствует о том что Propel генерирует предкомпилированные
запросы (prepared statements). Актуальное значение :p1
('2008-11-06 15:47:12
' в примере
выше) подставляется во время выполнения запроса и экранируется встроенными средствами
базы данных. Использование prepared statements практически сводит на нет угрозу
атаки через SQL injection
Это все хорошо, но немного неудобно переключаться между браузером, IDE и файлом лога каждый раз, когда Вам нужно проверить изменения. Благодаря symfony web debug toolbar, вся информация которая Вам нужна удобно доступна через браузер:
Сериализация
Наш код работает, но он еще далек от совершенства, так как не выполняет некоторые требования из дня 2:
"Пользователь может вернуться, чтобы активировать вакансию заново или продлить ее еще на 30 дней..."
Наша проверка основана только на значении created_at
, и поскольку эта
колонка содержит дату создания, мы не можем удовлетворить указанное требование.
Но если Вы вспомните структуру базы, которую мы обсудили в день 3, мы
определили колонку expires_at
. На данный момент она всегда пустая
потому что мы не указали для нее значение. Но при создании вакансии, она может
автоматически принимать значение на 30 дней больше текущей даты.
Когда Вам нужно автоматически сделать что-нибудь перед тем как Propel сохранит
объект в базу, Вы должны переопределить метод 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); } // ... }
Метод isNew()
возвращает true
, если объект еще ни разу не сохранялся
в базе, и false
в противном случае.
Теперь давайте изменим поведение, чтобы использовалась колонка expires_at
вместо
created_at
для получения активных вакансий:
public function executeIndex(sfWebRequest $request) { $criteria = new Criteria(); $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN); $this->jobeet_jobs = JobeetJobPeer::doSelect($criteria); }
Мы ввели ограничение в запросе - выбирать вакансии, у которых expires_at
находится в будущем.
Больше тестовых данных.
Обновив главную страницу Jobeet, Вы увидите, что ничего не изменилось, потому что вакансии в базе были созданы всего несколько дней назад. Давайте изменим fixtures, добавив вакансию, которая просрочена:
# 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
Будьте аккуратны, когда вставляете код в файл тестовых данных, не сломайте отступы.
Перед expired_job
должно стоять ровно 2 пробела.
В вакансии, которую мы добавили в файл, поле created_at
может содержать значение, даже если оно автоматически заполняется через Propel.
Определенное здесь значение перекроет значение по умолчанию. Перезагрузите тестовые данные и обновите
браузер, чтобы убедиться что старые вакансии не отображаются:
$ php symfony propel:data-load
Вы можете выполнить следующий запрос, чтобы убедиться в том что поле expires_at
автоматически заполняется методом save()
на основании значения
created_at
:
SELECT `position`, `created_at`, `expires_at` FROM `jobeet_job`;
Конфигурирование.
В методе JobeetJob::save()
мы жестко задали количество дней, через которое
вакансия становится просроченой. Неплохо было бы сделать это значение конфигурируемым.
symfony framework предоставляет встроенный конфигурационный файл для настроек
приложения - app.yml
. В этом YAML файле Вы можете хранить любые свои настройки:
# apps/frontend/config/app.yml all: active_days: 30
В приложении эти настройки доступны через класс sfConfig
:
sfConfig::get('app_active_days')
Параметр имеет префикс app_
потому что класс sfConfig
также
предоставляет доступ к настройкам symfony, которые будут рассмотрены позже.
Давайте допишем код, чтобы использовать этот новый параметр:
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); }
Конфигурационный файл app.yml
- отличный способ хранить глобальные настройки
вашего приложения.
Рефакторинг
Несмотря на то, что написанный нами код неплохо работает, он пока еще не совсем правильный. Можете ли Вы найти в нем проблему?
Код Criteria
не должен принадлежать слою Контроллера, он
относится к слою Модели. В модели MVC, Модель определяет всю
бизнес-логику, а Контроллер только вызывает Модель, чтобы получить данные.
Поскольку наш код возвращает коллекцию вакансий, давайте перенесем его в
класс JobeetJobPeer
и создадим метод getActiveJobs()
:
// 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_jobs = JobeetJobPeer::getActiveJobs(); }
Проведенный рефакторинг имеет много преимуществ по сравнению со старым кодом:
- Логика для получения активных вакансий находится в Модели, там где ей и положено быть.
- Код в Контроллере более читаемый.
- Метод
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); }
Метод addDescendingOrderByColumn()
добавляет конструкцию ORDER BY
в
итоговый SQL (так же есть метод addAscendingOrderByColumn()
).
Категории на главной странице.
Требование (день 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); } }
Метод Criteria::addJoin()
добавляет конструкцию JOIN
в итоговый SQL.
По умолчанию, условие join добавляется в конструкцию WHERE
. Вы так же можете
указать тип оператора join, добавив третий аргумент (Criteria::LEFT_JOIN
,
Criteria::RIGHT_JOIN
и Criteria::INNER_JOIN
).
Соответственно изменим index
action:
// apps/frontend/modules/job/actions/actions.class.php public function executeIndex(sfWebRequest $request) { $this->categories = JobeetCategoryPeer::getWithJobs(); }
В шаблоне мы должны пройтись по всем категориям и вывести активные вакансии:
// 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
Чтобы вывести категории в шаблоне, мы используем echo $category
.
Не выглядит ли это странно? $category
является объектом, как echo
волшебным образом
выводит название категории? Ответ кроется в Дне 3, когда мы
определили magic метод __toString()
для всех классов модели.
Для того чтобы все это работало, мы должны добавить метод getActiveJobs()
в класс JobeetCategory
, который возвращает активные вакансии в данной категории:
// lib/model/JobeetCategory.php public function getActiveJobs() { $criteria = new Criteria(); $criteria->add(JobeetJobPeer::CATEGORY_ID, $this->getId()); return JobeetJobPeer::getActiveJobs($criteria); }
В вызове add()
мы пропустили третий аргумент, потому что по умолчанию он имеет значение
Criteria::EQUAL
.
Метод JobeetCategory::getActiveJobs()
использует метод
JobeetJobPeer::getActiveJobs()
чтобы получить активные вакансси для данной категории.
Вызывая JobeetJobPeer::getActiveJobs()
, мы хотим ввести ограничение,
передавая в метод категорию. Вместо того чтобы передавать объект category,
мы решили передавать объект 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); }
Ограничение результатов (LIMIT)
Нужно реализовать еще одно требование, которому удолжен удовлетворять список вакансий на главной странице:
"Для каждой категории, в списке отображаются первые 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
теперь жестко задана в Модели, но было бы
лучше сделать это значение конфигурируемым. Измените шаблон, чтобы в нем передавалось
максимальное количество вакансий заданное в app.yml
:
<!-- apps/frontend/modules/job/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
Динамические тестовые данные.
До тех пор пока Вы не установите max_jobs_on_homepage
равным единице, Вы не увидите
никаких изменений. Нам нужно больше вакансий в тестовых данных. Вы можете скопировать
и вставить существующую вакансию десять или двадцать раз вручную... но есть лучший способ.
Дублирование это плохо, даже в тестовых файлах.
Нас спасает symfony! Файлы YAML в symfony могут содержать PHP код,
который будет выполнен перед загрузкой информации из файла. Откройте файл
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 не понравится, если Вы напутаете с отступами. Помните эти простые советы, когда добавляете код PHP в файл YAML:
- Тэги
<?php ?>
должны быть в самом начале строки, либо быть частью значения - Если
<?php ?>
расположен в конце строки, Вы должны явно вывести перевод строки ("\n").
Теперь Вы можете перезагрузить тестовые данные, введя команду propel:data-load
и проверить
отображается ли ровно 10
вакансий на главной странице в категории Programming
.
На скриншоте мы уменьшили количество вакансий до 5, чтобы сделать картинку меньше:
Обезопасим страницу вакансии.
Когда вакансия истекает, она не должна быть доступна, даже если Вы попытаетесь
открыть ее по прямой ссылке. Откройте url просроченой вакансии (замените значение id
на актуальное из вашей базы - SELECT
id,
tokenFROM
jobeet_jobWHERE
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.
Ссылка на страницу категории.
Теперь давайте сделаем названия категорий ссылками, ведущими на соответствующие страницы.
Но постойте-ка, час еще не закончился, а мы еще как следует не поработали! Пожалуй, у Вас достаточно свободного времени и знаний, чтбы сделать эту задачу самостоятельно! Пусть это будет ваше домашнее задание. Завтра проверим вашу реализацию.
Увидимся завтра!
Сделайте задачу в вашем локальном проекте. В вашем распоряжении есть онлайн API документация и вся свободная докуменация, доступная на сайте symfony. Завтра мы увидимся и рассмотрим наш вариант реализации этой задачи.
Good luck!
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.