Как сделать ORM на чистом PHP. Инструкция по созданию простенького ORM для PHP
lang_of_article_differ
want_proper_trans
ORM - это технология, которая делает меппинг строк (кортежей) в бд в обьекты в вашей программе. Таким образом, вы можете оперировать над строками в вашей бд через абстракцию обьектов.
Писать сырые запросы круто. Круто пока ваш проэкт влазит в пару тройку страниц формата A-4.
Когда ваш проэкт ростет, лучше сразу автоматизировать работу с sql-запросами.
Не смотря на то что ORM
сложное и тяжолое приложение которое может сделать ваше приложение более медленным , оно также привносит большую гибкость: возможность автоматической проверки безопасности генерируемых запросов, очистка входящих данных, обработка связей между обьектами в бд и другие.
Начнем
Нам нужно каким-то образом сделать привязку строки в таблице в соответствие обьекту в php программе.
Для удобного оперирования сущностями мы создадим клас который будет аналогом таблицы в бд:
class Post {
// ... другий код
public function save() { // этот метод будет сохранять обьект в бд
// ... код метода будет потом
}
}
этот класc будет отвечать таблице post
в бд. Такая же структура была бы у класса User
или Comment
.
Честно говоря, нам нужен метод save
(и возможно еще какие-то функции) на каждом orm-классе. Поэтому создадим абстрактный класс Entity
который будет держать в себе весь обобщенный функционал для работы с бд:
abstract class Entity {
public function save() {
// этот метод будет "конструировать" запрос update для обьекта
}
}
Мы уже написали реализацию метода save
в предыдущем уроке, поэтому мы можем просто вставить его сюда, немного изменив его:
public function save() {
$class = new \ReflectionClass($this);
// название таблицы соответствует названию класса, но это можно изменить
$tableName = strtolower($class->getShortName());
$propsToImplode = [];
// собираем поля класса, которые мы будем использовать в запросе
foreach ($class->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { // собираем информацию только о полях класса которые имеют модификатор `public`
$propertyName = $property->getName();
$propsToImplode[] = '`'.$propertyName.'` = "'.$this->{$propertyName}.'"';
}
$setClause = implode(',',$propsToImplode); // записываем в наш генериреумый запрос все поля
$sqlQuery = '';
// если айди нашего обьекта не 0, тогда обьект существует и нам нужно обновить его в бд
if ($this->id > 0) {
$sqlQuery = 'UPDATE `'.$tableName.'` SET '.$setClause.' WHERE id = '.$this->id;
} else { // иначе нам нужно вставить новый объект в бд
$sqlQuery = 'INSERT INTO `'.$tableName.'` SET '.$setClause.', id = '.$this->id;
}
$result = self::$db->exec($sqlQuery);
// обрабатываем ошибки
if (self::$db->errorCode()) {
throw new \Exception(self::$db->errorInfo()[2]);
}
return $result;
}
В примере выше мы используем название класса как название таблицы. Это не слишком удобно. Чтобы сделать это более гибким образом давайте добавим поле, которое будет содержать название таблицы:
abstract class Entity {
protected $tableName;
// ...
потом, в методе save
мы проверим, установлено ли значение этого поля. Если да - тогда будем использовать значение этого поля в качестве названия таблицы:
public function save() {
$class = new \ReflectionClass($this);
$tableName = '';
if ($this->tableName != '') {
$tableName = $this->tableName;
} else {
$tableName = strtolower($class->getShortName());
}
// ...
В противном случае - будем использовать старую схему: назание таблицы это название класса.
Для того, чтобы мы могли исполнять запросы к бд нам нужно соединение с базой. Мы будем использовать PDO
для этих целей, более детально я смогу описать работу с ним в другой статье. Будем держать соединение с базой в защищенном поле класса $db
в классе Entity
:
/**
*
* @var PDO
*/
protected $db;
public function __construct() {
try {
// параметры соединения
$this->db = new \PDO('mysql:host=localhost;dbname=blog','user_name', 'user_password');
} catch (\Exception $e) {
throw new \Exception('Error creating a database connection ');
}
}
В этом примере я сделал подключение к бд прямо в конструкторе абстрактного класса Entity
лишь для примера.
В реальных системах этого делать категорически не нужно . Потому что у вас будет соединение с базой в каждом обьекте (Представьте что выняли из бд 1000 строк, для каждой с которых будет создан обьект). Нужно использовать dependency injection
или контейнер зависимостей (service container
) в таком случае. Об этом можно будет поговорить в другой публикации.
Связываем поля объекта к столбикам в бд
Мы будем использовать самый простой из доступных методов привязки - публичные поля класса. Некоторые супер программисты будут вам говорить что такой подход ламает принцыпы инкапсуляции и так далее, но это полный бред как по мне. Использование гетеров и сетеров в скриптовых языках коем является php очень пагубно влияет на продуктивность. Конечно они правы в чем-то, но использование такой схемы немного разгружает сложность технологии orm.
Давайте посмотрим на класс который реализует функии о которых мы уже говорили выше:
class Post extends Entity {
protected $tableName = 'posts';
// список полей которые мы хотим обрабатывать
public $id;
public $title;
public $body;
public $author_id;
public $date;
public $views;
public $finished;
}
Выглядит чудесно, разве нет ?
Теперь самое время получить значение столбцов в поля класса из бд. Для этого создадим в классе Entity
метод morph
который будет конвертировать масив полей который возвращает pdo fetch в интересующий нас обьект. Рассмотрим самую простую реализацию такого метода:
/**
*
* @return Entity
*/
public static function morph(array $object) {
// создаем обект результирующего класса
$class = new \ReflectionClass(get_called_class()); // этот метод статический, поэтому мы узнаем имя класса на котором он был вызван при помощи get_called_class
$entity = $class->newInstance();
foreach($class->getProperties(\ReflectionProperty::PUBLIC) as $prop) {
if (isset($object[$prop->getName()])) {
$prop->setValue($entity,$object[$prop->getName()]);
}
}
$entity->initialize(); // магия инициализации обьекта
return $entity;
}
Это очень простая схема как я уже сказал и много чего можно сюда добавить по желанию: приведение в корректные типы данных, валидация и т.д. Возможно, мы розберем приведение типов в следующей публикации.
В конце morph
я вызываю метод initialize
текущего обьекта. Как я уже написал в комментарии - это своего рода магия: каждый класс который расширяет класс Entity
может реализовать метод initialize
который будет вызван при создании каждого обьекта такого типа. Это позволит подготавливать обьекты к работе, если у вас есть какая-то дополнительная логика при работе с ним
Создание и сохранение обьектов
На текущий момент мы можем создать строку в бд без единой написаной строки сырого SQL
. Рассмотрим пример использования абстракции которую мы рассмотрели в разделах выше:
$post = new Post(); // это создает обьект типа публикация
$post->title = 'как готовить пиццу';
$post->date = time();
$post->finished = false;
$post->save(); // этот метод создаст SQL запрос и выполнит его на бд
После того как мы сохранили его, мы можем обратиться к идентификатору созданой в бд строки в поле $id
классаPost
(Почему это так - смотрите предыдущий пост):
echo "new post id: ".$post->id;
Поиск по таблице, используя наш orm
Для удобства нам нужно создать как минимум два метода которые позволят нам доставать из бд один и много обьектов которые подходят под наши критерии поиска. Давайте создадим статический метод find
в классе Entity
:
/**
*
* @return Entity[]
*/
public static function find ($options = []) {
$result = [];
$query = '';
$whereClause = '';
$whereConditions = [];
if (!empty($options)) {
foreach ($options as $key => $value) {
$whereConditions[] = '`'.$key.'` = "'.$value.'"';
}
$whereClause = " WHERE ".implode(' AND ',$whereConditions);
}
$raw = self::$db->query($query);
if (self::$db->errorCode()) {
throw new \Exception(self::$db->errorInfo()[2]);
}
foreach ($raw as $rawRow) {
$result[] = self::morph($rawRow);
}
return $result;
}
Для простоты примера я рассмотрел только ситуацию когда вы передаете в этот метод масив, ключами которого будут названия полей в бд а значениями - значения этих полей (рекурсивно немного, моглашусь). Рассмотрим пример, в котором мы попробуем достать из бд запись в блоге с названием (title
) Как приготовить заливного коня
:
$posts = Post::find([
'title' => 'Как приготовить заливного коня'
]);
Мы можем расширить такую функциональность добавив возможность передачи части сырого запроса как параметр к find
функции. Тогда, если вам нужно выполнить выборку данных по "не простому запросу" где вы проверяете данные на полное соответствие, вы можете передать в метод поиска кусок запроса SQL:
$posts = Post::find('id IN (1,5,10)');
Для этого нам нужно немного подправить метод find
:
/**
*
* @return Entity[]
*/
public static function find ($options = []) {
$result = [];
$query = '';
if (is_array($options)) {
// ... старый код этого метода
} elseif (is_stirng($options)) {
$query = 'WHERE '.$options;
} else {
throw new \Exception('Wrong parameter type of options');
}
$raw = $this->database->execute($query);
foreach ($raw as $rawRow) {
$result[] = self::morph($rawRow);
}
return $result;
}
Идем еще дальше
Я забыл сделать возможность указывать количество елементов в результате (sql limit
) и порядок сортировки (sql sort by
) но думаю вы и сами уже сможете это сделать :)
Дам подсказку: мы должны разбить параметр $options
на подпараметры со своими ключами. Параметры из примера выше будут выглядеть следующим образом:
[
'condtions' => [
'title' => 'Some title'
]
]
Теперь мы можем передать и другие ключи limit
и/или order
как подпараметры к find
:
$posts = Post::find([
'conditions' => 'id IN (1,2,5)',
'order' => 'id DESC'
]);
Пока что все. Если у вас есть вопросы или пожелания к данной статье - пишите в комментариях внизу и я обязательно дам ответ