9 minutes to read, 22.27K views since 2019.10.10 Read in english

Как сделать ORM на чистом PHP. Инструкция по созданию простенького ORM для PHP

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' 
]);

Пока что все. Если у вас есть вопросы или пожелания к данной статье - пишите в комментариях внизу и я обязательно дам ответ

Read next article Adding extra flexibility to php programs via PhpDoc comments in course Reflection in PHP