- The Doctrine Query Object
- Отладка SQL сгенерированного при помощи Doctrine
- Сериализация
- Больше тестовых данных.
- Конфигурирование.
- Рефакторинг
- Категории на главной странице.
- Ограничение результатов (LIMIT)
- Динамические тестовые данные.
- Обезопасим страницу вакансии.
- Ссылка на страницу категории.
- Увидимся завтра!
Вчера был великий день. Мы изучили как использовать произвольные URL и Symfony в-целом для автоматизации многих ваших задач.
Сегодня мы улучшим сайт Jobeet, дополняя код в разных местах. В процессе работы Вы узнаете больше об особенностях, которые были представлены в первых пяти днях этого руководства.
The Doctrine Query Object
Из требований (день 2):
"Когда пользователь заходит на Jobeet, он видит список активных вакансий."
Но на данный момент все вакансии отбражаются независимо от того, активные они или нет:
// apps/frontend/modules/job/actions/actions.class.php class jobActions extends sfActions { public function executeIndex(sfWebRequest $request) { $this->jobeet_jobs = Doctrine::getTable('JobeetJob') ->createQuery('a') ->execute(); } // ... }
Активной считается вакансия, созданная не более 30 дней назад. Метод
Doctrine_Query::execute()
создает запрос к базе. В коде, приведенном выше,
мы не определили ни одного условия where , это означает что из базы должны быть
получены все записи.
Давайте изменим код, чтобы он получал только активные вакансии:
public function executeIndex(sfWebRequest $request) { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.created_at > ?', date('Y-m-d h:i:s', time() - 86400 * 30)); $this->jobeet_jobs = $q->execute(); }
Отладка SQL сгенерированного при помощи Doctrine
Поскольку Вы не пишете SQL инструкции вручную, Doctrine берет заботу на себя
о различиях между базами и генерирует SQL инструкции,
оптимизированные для той СУБД, которую Вы используете. Но иногда, было бы
очень полезно посмотреть SQL который генерируется Doctrine; например, чтобы
отладить запрос который работает не так, как ожидается. В окружении dev
, symfony
логирует такие запросы (как и много другой информации) в каталоге log/
. Для каждой
комбинации приложения и окружения существует отдельный лог. Файл, который нас интересует,
называется frontend_dev.log
:
# log/frontend_dev.log Dec 04 13:58:33 symfony [info] {sfDoctrineLogger} executeQuery : SELECT j.id AS j__id, j.category_id AS j__category_id, j.type AS j__type, j.company AS j__company, j.logo AS j__logo, j.url AS j__url, j.position AS j__position, j.location AS j__location, j.description AS j__description, j.how_to_apply AS j__how_to_apply, j.token AS j__token, j.is_public AS j__is_public, j.is_activated AS j__is_activated, j.email AS j__email, j.expires_at AS j__expires_at, j.created_at AS j__created_at, j.updated_at AS j__updated_at FROM jobeet_job j WHERE j.created_at > ? (2008-11-08 01:13:35)
В нашем случае мы видим, что Doctrine сгенерировало условие where для
колонки created_at
(WHERE j.created_at > ?
).
note
Строка ?
в запросе свидетельствует о том что Doctrine генерирует предкомпилированные
запросы (prepared statements). Реальное значение ?
('2008-11-08 01:13:35' в примере
выше) подставляется во время выполнения запроса и экранируется встроенными средствами базы данных.
Использование prepared statements практически сводит на нет угрозу
атаки через SQL injection
Это все хорошо, но немного неудобно переключаться между браузером, IDE и файлом лога каждый раз, когда Вам нужно проверить изменения. Благодаря symfony web debug toolbar, вся информация которая Вам нужна удобно доступна через браузер:
Сериализация
Наш код работает, но он еще далек от совершенства, так как не выполняет некоторые требования из дня 2:
"Пользователь может вернуться, чтобы активировать вакансию заново или продлить ее еще на 30 дней..."
Наша проверка основана только на значении created_at
, и поскольку эта
колонка содержит дату создания, мы не можем удовлетворить указанное требование.
Но если Вы вспомните структуру базы, которую мы обсудили в день 3, мы
определили колонку expires_at
. На данный момент она всегда пустая
потому что мы не указали для нее значение. Но при создании вакансии, она может
автоматически принимать значение на 30 дней больше текущей даты.
Когда Вам нужно автоматически сделать что-нибудь перед тем как Doctrine сохранит
объект в базу, Вы должны переопределить метод save()
соответсвующего класса:
// lib/model/doctrine/JobeetJob.class.php class JobeetJob extends BaseJobeetJob { public function save(Doctrine_Connection $conn = null) { if ($this->isNew() && !$this->getExpiresAt()) { $now = $this->getCreatedAt() ? $this->getDateTimeObject('created_at')->format('U') : time(); $this->setExpiresAt(date('Y-m-d h:i:s', $now + 86400 * 30)); } return parent::save($conn); } // ... }
Метод isNew()
возвращает true
, если объект еще ни разу не сохранялся
в базе, и false
в противном случае.
Теперь давайте изменим поведение, чтобы использовалась колонка expires_at
вместо
created_at
для получения активных вакансий:
public function executeIndex(sfWebRequest $request) { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.expires_at > ?', date('Y-m-d h:i:s', time())); $this->jobeet_jobs = $q->execute(); }
Мы ввели ограничение в запросе - выбирать вакансии, у которых expires_at
находится в будущем.
Больше тестовых данных.
Обновив главную страницу Jobeet, Вы увидите, что ничего не изменилось, потому что вакансии в базе были созданы всего несколько дней назад. Давайте изменим fixtures, добавив вакансию, которая просрочена:
# data/fixtures/jobs.yml JobeetJob: # other jobs expired_job: JobeetCategory: 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 00:00:00' token: job_expired email: job@example.com
note
Будьте аккуратны, когда вставляете код в файл тестовых данных, не сломайте отступы.
Перед expired_job
должно стоять ровно 2 пробела.
В вакансии, которую мы добавили в файл, поле created_at
может содержать значение, даже если оно автоматически заполняется через Doctrine.
Определенное здесь значение перекроет значение по умолчанию. Перезагрузите тестовые данные и обновите
браузер, чтобы убедиться что старые вакансии не отображаются:
$ php symfony doctrine: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(Doctrine_Connection $conn = null) { if ($this->isNew() && !$this->getExpiresAt()) { $now = $this->getCreatedAt() ? $this->getDateTimeObject('created_at')->format('U') : time(); $this->setExpiresAt(date('Y-m-d h:i:s', $now + 86400 * sfConfig::get('app_active_days'))); } return parent::save($conn); }
Конфигурационный файл app.yml
- отличный способ хранить глобальные настройки
вашего приложения.
Рефакторинг
Несмотря на то, что написанный нами код неплохо работает, он пока еще не совсем правильный. Можете ли Вы найти в нем проблему?
Код Doctrine_Query
не должен принадлежать слою Контроллера, он
относится к слою Модели. В модели MVC, Модель определяет всю
бизнес-логику, а Контроллер только вызывает Модель, чтобы получить данные.
Поскольку наш код возвращает коллекцию вакансий, давайте перенесем его в
класс JobeetJobTable
и создадим метод getActiveJobs()
:
// lib/model/doctrine/JobeetJobTable.class.php class JobeetJobTable extends Doctrine_Table { public function getActiveJobs() { $q = $this->createQuery('j') ->where('j.expires_at > ?', date('Y-m-d h:i:s', time())); return $q->execute(); } }
Теперь код контроллера может использовать этот новый метод для получения активных вакансий.
public function executeIndex(sfWebRequest $request) { $this->jobeet_jobs = Doctrine_Core::getTable('JobeetJob')->getActiveJobs(); }
Проведенный рефакторинг имеет много преимуществ по сравнению со старым кодом:
- Логика для получения активных вакансий находится в Модели, там где ей и положено быть.
- Код в Контроллере более читаемый.
- Метод
getActiveJobs()
повторно используемый (например, в другом обработчике событий) - Код модели теперь можно покрыть юнит-тестами.
Давайте отсортируем вакансии по колонке expires_at
:
public function getActiveJobs() { $q = $this->createQuery('j') ->where('j.expires_at > ?', date('Y-m-d h:i:s', time())) ->orderBy('j.expires_at DESC'); return $q->execute(); }
Метод orderBy
добавляет конструкцию ORDER BY
в
итоговый SQL (так же есть метод addOrderBy()
).
Категории на главной странице.
Требование (день 2):
"Вакансии отсортированы по категории и затем по дате создания (Новые вакансии вначале)."
До сих пор мы не рассматривали категории вакансий. Согласно требованиям, главная страница должна выводить вакансии по категориям. Для начала нам нужно получить все категории, в которых есть хотя бы одна активная вакансия.
Откройте класс JobeetCategoryTable
и добавьте метод getWithJobs()
:
// lib/model/doctrine/JobeetCategoryTable.class.php class JobeetCategoryTable extends Doctrine_Table { public function getWithJobs() { $q = $this->createQuery('c') ->leftJoin('c.JobeetJobs j') ->where('j.expires_at > ?', date('Y-m-d h:i:s', time())); return $q->execute(); } }
Соответственно изменим index
action:
// apps/frontend/modules/job/actions/actions.class.php public function executeIndex(sfWebRequest $request) { $this->categories = Doctrine_Core::getTable('JobeetCategory')->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/doctrine/JobeetCategory.class.php public function getActiveJobs() { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.category_id = ?', $this->getId()); return Doctrine_Core::getTable('JobeetJob')->getActiveJobs($q); }
Метод JobeetCategory::getActiveJobs()
использует
Doctrine::getTable('JobeetJob')->getActiveJobs()
для получения активных
вакансий в данной категории.
Когда мы вызываем Doctrine::getTable('JobeetJob')->getActiveJobs()
, мы хотим
ограничить условие выборки, передавая категорию. Вместо того чтобы передавать
объект category, мы решили передавать объект Doctrine_Query
,
потому что это лучший способ инкапсулировать произвольное условие.
Метод getActiveJobs()
должен объединить переданный ему объект Doctrine_Query
с
остальным запросом. Делается это весьма просто:
// lib/model/doctrine/JobeetJobTable.class.php public function getActiveJobs(Doctrine_Query $q = null) { if (is_null($q)) { $q = Doctrine_Query::create() ->from('JobeetJob j'); } $q->andWhere('j.expires_at > ?', date('Y-m-d h:i:s', time())) ->addOrderBy('j.expires_at DESC'); return $q->execute(); }
Ограничение результатов (LIMIT)
Нужно реализовать еще одно требование, которому удолжен удовлетворять список вакансий на главной странице:
"Для каждой категории, в списке отображаются первые 10 вакансий и ссылка, которая позволяет просмотреть все вакансии в данной категории."
Реализуется достаточно просто в методе getActiveJobs()
:
public function getActiveJobs($max = 10) { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.category_id = ?', $this->getId()) ->limit($max); return Doctrine_Core::getTable('JobeetJob')->getActiveJobs($q); }
Соответствующая конструкция 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 код,
который будет выполнен перед загрузкой информации из файла. Откройте файл
jobs.yml
и добавьте следующий код в конец:
JobeetJob: # Starts at the beginning of the line (no whitespace before) <?php for ($i = 100; $i <= 130; $i++): ?> job_<?php echo $i ?>: JobeetCategory: 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").
Теперь Вы можете перезагрузить тестовые данные, введя команду doctrine: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. Но как это сделать, ведь вакансия загружается автоматически через настройки маршрутов?
# apps/frontend/config/routing.yml job_show_user: url: /job/:company_slug/:location_slug/:id/:position_slug class: sfDoctrineRoute options: model: JobeetJob type: object method_for_query: retrieveActiveJob param: { module: job, action: show } requirements: id: \d+ sf_method: [GET]
Метод retrieveActiveJob()
получает объект Doctrine_Query
сформированный
данным маршрутом:
// lib/model/doctrine/JobeetJobTable.class.php class JobeetJobTable extends Doctrine_Table { public function retrieveActiveJob(Doctrine_Query $q) { $q->andWhere('a.expires_at > ?', date('Y-m-d h:i:s', time())); return $q->fetchOne(); } // ... }
Теперь, если Вы попытаетесь открыть просроченную вакансию, Вы будете перенаправлены на страницу 404.
Ссылка на страницу категории.
Теперь давайте сделаем названия категорий ссылками, ведущими на соответствующие страницы.
Но постойте-ка, час еще не закончился, а мы еще как следует не поработали! Пожалуй, у Вас достаточно свободного времени и знаний, чтбы сделать эту задачу самостоятельно! Пусть это будет ваше домашнее задание. Завтра проверим вашу реализацию.
Увидимся завтра!
Сделайте задачу в вашем локальном проекте. В вашем распоряжении есть онлайн API документация и вся свободная докуменация, доступная на сайте symfony. Завтра мы увидимся и рассмотрим наш вариант реализации этой задачи.
Good luck!
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.