Доступ к базам данных из php. Часть 8

December 20, 2007

Это последняя часть в серии посвященной методам работы с базами данных из php. В прошлый раз я начал рассказ о библиотеке propel и паттерне Data Mapper. Я рассказал о слабых и сильных сторонах этого подхода, описал модель базы данных (таблицы и их связи) на которой мы будем практиковаться, так что осталось завершить пример и немного попрограммировать: попробовать искать записи на основании сложных и не очень условий, добавлять новые, редактировать и удалять записи.

Прошлую статью мы закончили на том, что создали два конфигурационных файла с параметрами подключения с БД. Первый из них (build.properties) использовался для автоматической генерации классов php повторяющих структуру таблиц БД. На основании этого файла я, используя creole и phing (вспомните, что такое “цели” в терминологии phing и какие они бывают), выполнил генерацию классов php. Второй же конфигурационный файл (runtime-conf.xml) использовался для подключения к БД на стадии работы программы, использующей propel. В самом конце прошлой статьи я привел краткий пример кода такого файла, однако он нуждается в корректировках. Прежде всего, я не указал информацию об используемой кодировке при обмене данными между propel и СУБД (без этого я не смогу корректно работать с русскоязычными текстами). Кодировка задается с помощью тега “cp1251”. Далее, строка подключения к СУБД должна быть в формате PDO, так что сведения об имени и пароле должны быть записаны отдельно от остальных частей DSN. Одним словом, вот полный пример конфигурационного файла runtime-conf.xml. Не забудьте только после изменения файла запустить цель “propel-gen ./” – чтобы обновить все нужные для работы propel объекты.
  1. <?xml version="1.0" encoding="utf-8"?>
  2. <config> 
  3.   <log>
  4.     <ident>propel-firmaproject</ident>
  5.     <level>7</level>
  6.     <type>display</type>
  7.  </log> 
  8.  <propel>
  9.   <datasources default="firma01">
  10.   <datasource id="firma01">
  11.   <adapter>mysql</adapter>
  12.   <connection>
  13.     <dsn>mysql:dbname=firma01;host=localhost</dsn>
  14.     <user>root</user>
  15.     <password></password>
  16.     <attributes> 
  17.       <option id="ATTR_EMULATE_PREPARES">true</option>
  18.     </attributes>
  19.     <settings> 
  20.       <setting id="charset">cp1251</setting> 
  21.     </settings>
  22.   </connection>
  23.  </datasource>
  24.  </datasources>
  25.  </propel>
  26. </config>
А теперь, давайте напишем немного кода, который подключается к базе и добавляет, ищет, и изменяет записи в одной из таблиц, например, Users. Начнем с того, что создадим файл php, первой строкой которого подключим файлы библиотеки propel (propel/Propel.php). Затем, используя вызов Propel::init, укажем путь к конфигурационному файлу runtime-conf.xml. Последний шаг настройки – это подключить файлы всех файлов со сгенерированными классами. К слову сказать, попробуйте открыть исходный код одного из этих файлов (например, BaseUsers.php) и вы будете приятно удивлены – при генерации кода класса были также созданы и подробные комментарии, описывающие, что делают те или иные методы класса и для чего служат те или иные поля этого класса. Для удобства работы с propel лучше всего добавить его в pear хранилище. Если же этого не сделать, то могут возникать проблемы с поиском нужных для работы propel файлов. Так чтобы этого избежать я добавил к конфигурационной переменной include_path путь к тому месту, где у меня была расположена библиотека propel. Кроме того, я добавил в строку поиска и ссылку на каталог “build/classes/”, иначе при подключении файла “firmaproject/Users.php” будут возникать ошибки. Обратите внимание на использование константы PATH_SEPARATOR – она нужна, для того чтобы сконструировать путь к файлам, который бы работал как под windows, так и под linux.
  1. // подключаем файлы библиотеки propel
  2. $pathtolib = dirname(__FILE__) . "/propel-1.3.0beta2/runtime/classes/";
  3. $pathtomyclasses = dirname(__FILE__) . "/gopropel/build/classes/";
  4.  
  5. ini_set('include_path' , ini_get('include_path') . PATH_SEPARATOR . 
  6. $pathtolib . PATH_SEPARATOR . $pathtomyclasses);
  7.  
  8. require_once 'propel/Propel.php';
  9. // инициализируем библиотеку указывая путь к файлу конфигурации "времени исполнения"
  10. Propel::init('./gopropel/build/conf/firmaproject-conf.php');
  11. // подключаем все файлы классов, которые были сгенерированы библиотекой на основании базы данных
  12. require_once('firmaproject/Users.php');
  13.  
  14. // создаем запись и наполняем ее информацией
  15. $bill = new Users();
  16. $bill->setUsername ("Вилли Тапкин");
  17. $bill->setSex("m");
  18. $bill->setBirthday("2007-12-31");
  19.  
  20. // добавляем запись
  21. if ($bill->save()){
  22.   print 'ok append record';
  23.   // изменяем значения полей
  24.   $bill->setSex("f");
  25.   // и обновляем запись
  26.   if ($bill->save())
  27.      print 'ok update exists record';
  28. }
  29. else  
  30.    print 'not saved';
Давайте теперь критично рассмотрим пример кода выше и спросим себя, есть хоть что-нибудь такое, чем propel лучше реализованной в adodb функции activerecord? Что-то такое, что заставило бы нас тратить больше времени на подготовительные действия установки библиотек pear и настройки самого propel? Первое, теперь значения полей устанавливаются с помощью вызовов специальных методов, таких как, setBirthday, а прямой доступ к переменным, где, собственно, и хранится информация, закрыт с помощью модификатора protected. Это приятное улучшение – когда я писал код под adodb, то несколько раз ловил себя на том, что ошибался в записи имен полей и мог написать нечто вроде $vasya->birtday вместо $vasya->birthday. Adodb никак не реагировал на такие опечатки: значение неправильно названного поля в базу не помещалось, а я ломал голову что же не так. Плюс, если вы пользуетесь для набора кода интеллектуальной IDE, например, zend, то увидите такие всплывающие подсказки см. рис. 1.



Второе, при сохранении полей типа дата-время можно указать значение, как в виде строки, так и в виде unix timestamp – именно этот тип данных возвращает большинство функций в php работающих с датами.
  1. $bill->setBirthday(mktime(0,0,0, 3, 8,2007));
Теперь пару слов об умном сохранении. То, что при вызове метода Save сохранение записи в базу данных идет либо с помощью команды sql INSERT (вставить новую запись) или команды UPDATE (обновить существующую), вас уже не удивляет (это было еще со времен adodb). Но вот то, что propel умеет корректно обрабатывать и сохранять связанные записи (например, запись “отдел” содержит перечень привязанных к нему записей “сотрудников”). И более того, операция сохранения взаимосвязанных записей помещается внутрь транзакции, которая “откатается” вся целиком, если при сохранении хотя бы одной из записей произошла ошибка – вот за это хочется сказать: “молодцы!”. Для пробы я добавил таблице users ограничение на значение поля “дата рождения”, запрещающее данному полю быть пустым. А в следующем примере создаются два объекта: отдел и сотрудник в составе этого отдела (для привязки записей друг к другу я использовал метод setDepartments). Обратите внимание, что у меня только один раз вызывается функция сохранения записи (для сотрудника). Однако PDO сохранит две записи. А в том случае, когда я специально допустил ошибку и не указал значение ”даты рождения”, то не сохраняются ни запись “сотрудник” ни запись “отдел”.
  1. $managers = new Departments();
  2. $managers->setDepartmentname('managers');
  3. // создаем запись и наполняем ее информацией
  4. $bill = new Users();
  5. $bill->setUsername ("Марк Клавдий");
  6. $bill->setSex('m');
  7. $bill->setBirthday(null);// тут будет ошибка
  8. $bill->setDepartments($managers);
  9. // добавляем запись
  10. if ($bill->save()){
  11.   print 'ok save graph';
  12. }else 
  13.   print 'not saved';
Транзакциями можно управлять и самому, для этого вы получаете ссылку на объект подключение к СУБД (в основе propel лежит библиотека PDO) а затем используете стандартные методы PDO: beginTransaction, commit, rollback. Обратите внимание на то, что в качестве параметра к getConnection следует указать имя вашей БД – имя подключения:
  1. $pdo = Propel::getConnection(BaseDepartmentsPeer::DATABASE_NAME);
  2.  $pdo->beginTransaction();
  3.  try {
  4.   // некоторые действия изменяющие БД
  5.   $pdo->commit();
  6.  } 
  7.  catch (Exception $e) {
  8.    $pdo->rollback();
  9.    throw $e; 
  10.  }
Давайте, теперь попробуем искать информацию по некоторым критериям. Для этого в составе propel введен механизм “конструктора запросов”. Фактически вам не нужно знать sql чтобы написать код ищущий сотрудников, у которых, например, фамилия начинается на слово “Ива” и дата рождения в отрезке от 1.1.1970 до 1.1.1980. Начнем же мы с чего-то попроще. Первый способ получить из базы некоторую запись – указать значение ее идентификатора (первичного ключа), например, так:
  1. $managers =DepartmentsPeer::retrieveByPK(6);
  2. print_r($managers);
Возможно, задать сразу несколько значений первичного ключа тех записей, которые вы хотите найти, в этом случае вам будет возвращен массив объектов:
  1. $managers =DepartmentsPeer::retrieveByPKs(array (4,5,6));
Предусмотрена возможность поиска записей и в том случае, если ваш первичный ключ состоит из нескольких полей (надо сказать что я не сторонник такого подхода и всегда стремлюсь к тому, чтобы роль первичного ключа играло какое-либо “суррогатное” значение, например, auto_increment). В этом случае просто передайте эти несколько значений внутрь метода retrieveByPK.
  1. $myObject = MultiColPKExamplePeer::retrieveByPK(1,2);
Для сложных запросов в составе библиотеки propel предусмотрен специальный класс Criteria. Его методы позволяют вам управлять всеми нюансами генерации текста запроса. Вы можете сказать, что хотите найти те записи, для которых некоторые поля равны или не равны чему-то, больше или меньше чем некоторые значения.
  1. $c = new Criteria();
  2. $c->add(UsersPeer::USERNAME , "Марк", Criteria::EQUAL);
  3. $c->add(UsersPeer::SEX , "m", Criteria::NOT_EQUAL);
  4. $users = UsersPeer::doSelect($c);
  5. print_r($users);
После создания объекта Criteria вы заполняете его набором условий. Метод add должен получить три параметра: имя поля, его значение и признак того, как поле будет связано со своим значением. Будет ли оно ему равно (Criteria::EQUAL), или не равно (Criteria::NOT_EQUAL). Фактически на сервер отправилась вот такая команда:
  1. SELECT users.USERID, users.USERNAME, users.BIRTHDAY, users.SEX, users.DEPARTMENTID FROM `users` 
  2. WHERE users.USERNAME=? AND users.SEX<>?
Естественно, что кроме операций равно-неравно есть и другие отношения:
  1. $c->add(UsersPeer::BIRTHDAY , "2000.1.1", Criteria::LESS_THAN); // строго меньше чем
  2. $c->add(UsersPeer::BIRTHDAY , "2000.2.1", Criteria::LESS_EQUAL);// меньше либо равно чем
  3. $c->add(UsersPeer::BIRTHDAY , "2000.3.1", Criteria::GREATER_THAN);// строго больше чем
  4. $c->add(UsersPeer::BIRTHDAY , "2000.4.1", Criteria::GREATER_EQUAL );// больше или равно чем
Найдется и аналог для sql команды IN. В этом случае задайте второй параметр метода add как массив значений, а в качестве третьего параметра укажите Criteria::IN. Любая СУБД содержит методы для поиска информации по частичному совпадению строк. Самым простым способом будет использование оператора LIKE, если сказать что “fio like ‘%Вася%’ ” то будут найдены все записи, у которых значение поля fio содержит в любой своей части слово “Вася”. Как ожидалось, у Criteria найдется метод и на этот случай:
  1. $c->add(UsersPeer::USERNAME , "%Марк%", Criteria::LIKE);
И как ожидалось, поддержки специализированных механизмов поиска (например, особенностью mysql является возможность поиска, когда строка-шаблон задается с помощью регулярных выражений) нет.

Мы можем задать сколь угодно много критериев. И все они будут связаны через оператор AND – будет требоваться, чтобы все условия выполнялись. Если запись удовлетворяет только одному критерию, то она найдена не будет. Есть ли способ это изменить? Способ найдется, но прежде всего маленький фокус. Как думаете, какая команда будет послана на сервер вот для такого запроса:
  1. $c->add(UsersPeer::BIRTHDAY , "2007.4.1", Criteria::LESS_EQUAL);
  2. $c->add(UsersPeer::BIRTHDAY , "2000.4.1", Criteria::GREATER_EQUAL);
Думаете, propel найдет тех сотрудников, которые родились в отрезке от 2000.4.1 до 2007.4.1? Как бы не так. На самом деле на сервер отправится вот такой запрос:
  1. SELECT users.USERID, users.USERNAME, users.BIRTHDAY, users.SEX, users.DEPARTMENTID FROM `users` WHERE users.BIRTHDAY>=?
Давайте разберемся почему. На самом деле объект класса Criteria является хранилищем других объектов: Criterion. Каждый раз, когда я вызываю функцию add, то внутри ее создается объект Criterion и именно в нем хранится условие отбора. Так вот особенность создания этих Criterion-ов в том, что если вы добавляете к объекту Criteria два условия по одному и тому же полю, то первое условие будет утеряно. Для того чтобы этого избежать нам придется создавать эти Criterion самостоятельно. Например, так:
  1. $c = new Criteria();
  2. $c1 = $c->getNewCriterion(UsersPeer::BIRTHDAY , "2007.4.1", Criteria::LESS_EQUAL  );
  3. $c2 = $c->getNewCriterion(UsersPeer::BIRTHDAY , "2000.4.1", Criteria::GREATER_EQUAL );
  4. $c1->addAnd($c2);
  5. $c->add($c1);
  6. $users = UsersPeer::doSelect($c);
Объекты Criterion-ы создаются вызовом метода getNewCriterion, параметры которого идентичны тем, что мы видели ранее у метода add. Хитрость в другом: созданные criterion-ы необходимо скомбинировать. Для этого служит метод addAnd – он присоединяет второй criterion к первому с помощью оператора AND. Так что теперь запрос на сервер будет отправлен уже в правильном виде:
  1. SELECT users.USERID, users.USERNAME, users.BIRTHDAY, users.SEX, users.DEPARTMENTID 
  2. FROM `users` WHERE (users.BIRTHDAY<=? AND users.BIRTHDAY>=?)
Как бонус знакомства с criterion-ами возможность комбинировать их с помощью оператора OR (соответственно, метод addOr). Есть и упрощенный вариант синтаксиса:
  1. $c = new Criteria();
  2. $c->add(UsersPeer::BIRTHDAY , "2007.4.1", Criteria::LESS_EQUAL  );
  3. $c->addAnd(UsersPeer::BIRTHDAY , "2000.4.1", Criteria::GREATER_EQUAL );
Вот только очевидно, что его можно использовать лишь, когда у нас нет вложенных условий. Если же нужно построить сложное дерево условий(например, найти мужчин родившихся до 1970.1.1 и женщин до 1980.1.1), то … Попробуйте для тренировки записать такое условие самостоятельно. Ну как, получилось? Вот только код, мягко говоря, не ахти: громоздок, не удобочитаем – все-таки старый добрый sql был гораздо компактнее. О плюсах и минусах подобного отхода от стандартизированного (ох, если бы так было на самом деле) и удобочитаемого sql-кодирования я говорил еще в прошлой статье. К счастью propel умеет совмещать два подхода и позволяет нам писать условия отбора и на sql. Наиболее часто такая функциональность нужна при использовании подзапросов, например, в следующем примере я хочу найти мужчин и женщин, возраст которых больше чем средний среди всех лиц принадлежащих к их полу.
  1. define ('DATABASE_NAME', 'firma01');
  2. $con = Propel::getConnection(DATABASE_NAME);
  3. $sql = "SELECT * FROM users a WHERE birthday > (select avg(birthday) from users b where a.sex = b.sex)";
  4. $stmt = $con->prepare($sql);
  5. $stmt->execute();
  6. $users = UsersPeer::populateObjects($stmt);
  7. print_r($users);
Функциональность класса Criteria достаточно велика. В нем есть механизмы позволяющие управлять сортировками отбираемых записей, можно объединять отбираемые записи в группы с одинаковыми значениями некоторых полей и считать для таких записей статистические функции. Кроме условий отбора на основании отдельных записей можно задать условия отбора и для групп записей – аналог команды HAVING. Фактически можно целую статью посвятить описанию возможностей Criteria и Criterion-ов. Но все же я пойду дальше и расскажу о поддержке связанных записей (внешних ключей). В следующем примере я получаю список всех отделов (в качестве параметра методу doSelect передается “пустой” Criteria). Затем я организую цикл по всем найденным записям и вызываю метод getUserss, который вернет мне список людей числящихся в данном отделе. Можно передать необязательный параметр методу getUserss – задать дополнительное условие отбора сотрудников.
  1. $deps = DepartmentsPeer::doSelect(new Criteria());
  2. for ($i = 0; $i < count($deps); $i++){
  3.   print 'department: ' . $deps[$i]->getDepartmentName () . '<br />';
  4.   print 'users: ' . var_export($deps[$i]->getUserss (), true) . '<br />';
  5. }
Найденные записи можно редактировать, для сохранения их используется функция Save (также как и для сохранения только что добавленной записи), возможно записи удалять – за это отвечает метод delete. Но все же последнее что мы сделаем с помощью propel – разберемся как включить механизм журналирования, так чтобы видеть и понимать как конструирует запросы к СУБД propel и в случае необходимости вмешаться и исправить ошибки. Помните тот самый файл runtime-conf.xml, о котором я говорил в начале статьи? Так вот, в нем есть специальный тег log, как раз и отвечающий за уровень подробности журналирования (задается внутри тега level) а также место, куда будут выводиться сообщения, возникающие в ходе работы propel. В примере я написал значение тега type равным слову display – сообщения выводятся на экран. Возможны еще варианты:
  1. <type>file</type>
  2. <name>./propel.log</name>
В этом случае в текущем каталоге (там, где находится php-файл пользующийся propel) будет создан файл propel.log в который и будут выводиться все команды посылаемые на сервер. Можно отправлять команды и в syslog (системный журнал событий).

На этом все. О возможностях propel можно рассказывать еще очень долго, но они достаточно специфичны и необходимость в них у вас возникнет, скорее всего, еще не скоро.