Регистрация и авторизация пользователей в 2024 году. Паттерны

Улучшаем работу с классом User

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

В этой же статье, мы попробуем придать больший смысл всему тому, что было описано и в первой статье, и во второй. Через класс User мы должны с лёгкостью получать информацию о пользователе, сохранять информацию о пользователе, и обновлять информацию о пользователе. Да и не только о пользователе, вообще о чем-либо. То есть наши объекты — экземпляры классов — должны будут, по сути, представлять строки данных из таблиц MySQL.

Многие из вас, да почти все, не захотят читать всю простыню статьи, и желают лишь получить исходный код того, что регистрирует и авторизует. Пожалуйста! Всё ради вас.

Посмотреть на рабочий пример кода можно здесь.

Исходный код файлов можно заполучить по ссылке.

Также особым извращенцам доступен просмотр документации в виде phpDocumentor здесь.

1. Паттерн Active Record

Озвученную задачу нам поможет решить паттерн (шаблон) проектирования Active Record. Если очень сильно упростить принцип работы этого паттерна, прозвучит это примерно так: «каждый экземпляр каждого класса соответствует одной записи таблицы».

То есть, наш класс User соответвует таблице `users` в MySQL. Частично мы это реализовали в предыдущей статье в виде выражений User->login, User->email. Они возвращали нам значения логина и адреса электропочты зарегистрированного пользователя, которые были записаны в таблицу `users`.

Но здесь нам придётся забежать намного вперёд. Применение шаблона Active Record так или иначе знакомит нас понятием Модель данных. В контексте наших статей, моделью данных будет являться та часть сценария, которая будет обеспечивать взаимодействие php-сценария и СУБД — экземпляра класса User и таблицы `users`. Класс User и будет нашей моделью, и, даже, будет переименован в класс User_Model.

Имена всех классов, которые будут представлять данные из таблиц СУБД, будут оканчиваться на _Model. Класс Core ничего такого не делает и не представляет, как и класс Core_Database. Поэтому их имена не изменятся. Но изменится кое-что иное.

2. Фабрики — рабочим

Создать экземпляр класса мы можем так:

                
$oUser = new User();
$oCurrentUser = $oUser->getCurrent();
if (is_null($oCurrentUser))
{
    echo "Вы не авторизованы";
}
                
            

А можно было бы создавать экземпляры классов с помощью фабричных методов. Например, так:

                
$oCurrentUser = Core::factory('User')->getCurrent();
if (is_null($oCurrentUser))
{
    echo "Вы не авторизованы";
}
                
            

К тому же, забегая вперед, нам нужно отделить создание экземпляров классов от экземпяров классов Моделей с помощью фабричных методов. Паттерн Active Record вводит для нас ещё одно новое определение — Entity (сущность). Каждая из Моделей — таблиц в СУБД — может быть представлена множеством сущностей — строками таблиц.

Таким образом, экзепляры того, что является моделями, будет создаваться с помощью фабрики Core_Entity::factory(), для экземпляров других классов — Core::factory(), ну или привычный синтаксис: new ClassName().


    <?php
    class Core_Entity extends Core_ORM
    {
        /**
         * Общая логика для всех моделей данных
         */
        public function __construct($primary_key = NULL)
        {
            /**
             * Логика, общая для всех моделей данных при создании экземпляра класса
             */
        }
    
        public function __get($property)
        {
            /**
             * Общий для всех моделей магический метод __get()
             */
        }
    
        public function __set($property, $value)
        {
            /**
             * Общий для всех моделей магический метод __set()
             */
        }
    
        public function __call($method, $arguments)
        {
            /**
             * Общий для всех моделей магический метод __call()
             */
        }
    }
    
    class User_Model extends Core_Entity
    {
        /**
         * Логика, характерная только лишь для модели данных пользователя сайта
         */
        public function __construct($id = NULL)
        {
            // Вызываем конструктор родительского класса Core_Entity
            parent::__construct($id);
    
            // Делаем что-либо с объектом User
            if (!is_null($id))
            {
                // Устанавливаем какие-то параметры по-умолчанию для пользователя
                $this->setDefaults($id);
            }
        }
    
        /**
         * Изменяет статус активации учетной записи пользователя
         */
        public function changeActive()
        {
            $this->active = !$this->active;
            $this->save();
    
            return $this;
        }
    }
    ?>

ORM — объектно-реляционное отображение

Как и таблицы в СУБД, объекты сценария в PHP могут иметь связи. Если таблица `users` имеет связь с какими-то иными (например, пользователь сайта может оставлять множество комментариев к записям, и в таблицах таких комментариев `comments` может быть поле `user_id`, в котором хранится ключ — идентификатор пользователя `users`.`id`) таблицами, то и объекты (User и Comments) должны иметь возможность строить такие связи. Но, обо всём по порядку.

Фабричные методы

Фабрика должна принимать имя класса, экземпляр которого мы хотим создать (для регистрации пользователя, например), и ещё может получать идентификатор записи, если нам нужна конкретная информация (для авторизации, к примеру). Вот, как-то так...


$oUser = Core_Entity::factory('User', 10);


Чтобы это сработало, нам нужно создать класс Core_Entity.


<?php
// Запрещаем прямой доступ к файлу
defined('MYSITE') || exit('Прямой доступ к файлу запрещен');

class Core_Entity extends Core_ORM
{
    protected function __construct($primary_key = NULL)
    {
        parent::__construct($primary_key);
    }

    public static function factory($modelName, $primary_key = NULL) : Core_ORM
    {
        $className = ucfirst($modelName) . "_Model";

        return new $className($primary_key);
    }
}
?>

Как видите, имена классов моделей оканчиваются на _Model. А значит, учитывая специфику работы нашей автозагрузки классов, код реализации моделей мы должны хранить в каталоге classes/имя_модели/model.php. Конкретно для Userclasses/user/model.php.


    <?php
    // Запрещаем прямой доступ к файлу
    defined('MYSITE') || exit('Прямой доступ к файлу запрещен');

    class User_Model extends Core_Entity
    {
        /**
        * Информация о пользователе (логин, пароль, и т.п.)
        * @var array
        */
        protected $_data = [];

        /**
        * Результируйщий объект запроса к БД
        * @var stdClass|NULL
        */
        protected $_user = NULL;

        /**
        * Разрешенные для чтения и записи поля таблицы пользователей
        * @var array
        */
        protected $_allowedProperties = [
            'id',
            'login',
            'email',
            'registration_date',
            'active'
        ];

        /**
        * Запрещенные для чтения и записи поля таблицы пользователей
        * @var array
        */
        protected $_forbiddenProperties = [
            'password',
            'deleted'
        ];

        /**
        * Имена полей таблицы в БД
        * @var array
        */
        protected $_columnNames = [];

        /**
        * Строка для действия регистрации
        * @param const 
        */
        public const ACTION_SIGNUP = 'sign-up';
        
        /**
        * Строка для действия регистрации
        * @param const 
        */
        public const ACTION_SIGNIN = 'sign-in';

        /**
        * Строка для действия выхода из системы
        * @param const 
        */
        public const ACTION_LOGOUT = 'exit';

        /**
        * Получает данные о пользователе сайте
        * @return mixed self|NULL
        */
        protected function _getUserData()
        {
            $return = NULL;
            
            // Если внутри объекта нет информации о пользователе, пробуем получить её из сессии
            if (is_null($this->_user))
            {
                if (!empty($_SESSION['password']))
                {
                    $sPassword = strval($_SESSION['password']);
                    $sValue = strval((!empty($_SESSION['email'])) ? $_SESSION['email'] : $_SESSION['login'] );

                    $stmt = $this->getByLoginOrEmail($sValue);
                    
                    if (!is_null($stmt))
                    {
                        $this->_user = $stmt->fetch();

                        $return = $this;
                    }
                }
            }
            else
            {
                $return = $this;
            }

            return $return;
        }
        
        /**
        * Проверяет, не был ли авторизован пользователь ранее
        * @param string $value логин или адрес электропочты
        * @return boolean TRUE|FALSE
        */
        protected function _checkCurrent(string $value) : bool
        {
            $bIsAuth = FALSE;

            // Если в сессии сохранены логин или электропочта, а функции переданы значения для проверки, и они совпадают с теми, что хранятся в сессии
            if ((!empty($_SESSION['login']) || !empty($_SESSION['email'])) && ($_SESSION['login'] === $value || $_SESSION['email'] === $value))
            {
                // Пользователь авторизован
                $bIsAuth = TRUE;
            }
            // Если есть попытка подмены данных в сессии
            elseif ((!empty($_SESSION['login']) || !empty($_SESSION['email'])) && $_SESSION['login'] !== $value && $_SESSION['email'] !== $value)
            {
                // Стираем данные из сессии
                unset($_SESSION['login']);
                unset($_SESSION['email']);
                unset($_SESSION['password']);

                // Останавливаем работу скрипта
                die("<p>Несоответствие данных авторизации сессии. Работа остановлена</p>");
            }

            return $bIsAuth;
        }

        /**
        * Конструктор класса
        * @param int $id = 0
        */
        public function __construct($id = NULL)
        {
            // Сразу же из базы данных получаем перечень имен полей таблицы
            $this->getColumnNames();
        }

        /**
        * Получает перечень имен полей таблицы из БД
        * @return self
        */
        public function getColumnNames()
        {
            $oCore_Database = Core_Database::instance();
            
            $this->_columnNames = $oCore_Database->getColumnNames('users');

            return $this;
        }

        /**
        * Получает информацию об авторизованном пользователе
        * @return mixed self | NULL если пользователь не авторизован
        */
        public function getCurrent()
        {
            $return = NULL;

            /**
            * Информация о пользователе, если он авторизован, хранится в сессии
            * Поэтому нужно просто проверить, имеется ли там нужная информация
            * Если в сессии её нет, значит пользователь не авторизован
            */
            (!empty($_SESSION['login']) 
                && !empty($_SESSION['email']) 
                && !empty($_SESSION['password']))
                    && $return = $this->_getUserData();
            
            // Возвращаем результат вызову
            return $return;
        }

        /**
        * Устанавливает в сесии параметры пользователя, прошедшего авторизацию
        * @return object self
        */
        public function setCurrent()
        {
            $_SESSION['login'] = $this->_user->login;
            $_SESSION['email'] = $this->_user->email;
            $_SESSION['password'] = $this->_user->password;

            return $this;
        }

        /**
        * Завершает сеанс пользователя в системе
        * @return object self
        */
        public function unsetCurrent()
        {
            // Уничтожение данных о пользователе в сессии
            unset($_SESSION['login']);
            unset($_SESSION['email']);
            unset($_SESSION['password']);

            header("Refresh:0;"); die();

            return NULL;
        }

        /**
        * Обрабатывает данные, которыми пользователь заполнил форму
        * @param array $post
        */
        public function processUserData(array $post)
        {
            $aReturn = [
                'success' => FALSE,
                'message' => "При обработке формы произошла ошибка",
                'data' => [],
                'type' => static::ACTION_SIGNIN
            ];
            
            // Если не передан массив на обработку, останавливаем работу сценария
            if (empty($post))
            {
                die("<p>Для обработки пользовательских данных формы должен быть передан массив</p>");
            }
            
            // Если в массиве отсутствуют данные о типе заполненной формы, останавливаем работу сценария
            if (empty($post[static::ACTION_SIGNIN]) && empty($post[static::ACTION_SIGNUP]))
            {
                die("<p>Метод <code>User::processUserData()</code> должен вызываться только для обработки данных из форм авторизации или регистрации</p>");
            }

            // Флаг регистрации нового пользователя
            $bRegistrationUser = !empty($post[static::ACTION_SIGNUP]);

            // Логин и пароль у нас должны иметься в обоих случаях
            $sLogin = strval(htmlspecialchars(trim($_POST['login'])));
            $sPassword = strval(htmlspecialchars(trim($_POST['password'])));

            // А вот электропочта и повтор пароля будут только в случае регистрации
            if ($bRegistrationUser)
            {
                $aReturn['type'] = static::ACTION_SIGNUP;

                $sEmail = strval(htmlspecialchars(trim($_POST['email'])));
                $sPassword2 = strval(htmlspecialchars(trim($_POST['password2'])));

                // Проверяем данные на ошибки
                if ($this->validateEmail($sEmail))
                {
                    // Логин и пароли не могут быть пустыми
                    if (empty($sLogin))
                    {
                        $aReturn['message'] = "Поле логина не было заполнено";
                        $aReturn['data'] = $post;
                    }
                    elseif (empty($sPassword))
                    {
                        $aReturn['message'] = "Поле пароля не было заполнено";
                        $aReturn['data'] = $post;
                    }
                    // Пароли должны быть идентичны
                    elseif ($sPassword !== $sPassword2)
                    {
                        $aReturn['message'] = "Введенные пароли не совпадают";
                        $aReturn['data'] = $post;
                    }
                    // Если логин не уникален
                    elseif ($this->isValueExist($sLogin, 'login'))
                    {
                        $aReturn['message'] = "Указанный вами логин ранее уже был зарегистрирован";
                        $aReturn['data'] = $post;
                    }
                    // Если email не уникален
                    elseif ($this->isValueExist($sEmail, 'email'))
                    {
                        $aReturn['message'] = "Указанный вами email ранее уже был зарегистрирован";
                        $aReturn['data'] = $post;
                    }
                    // Если все проверки прошли успешно, можно регистрировать пользователя
                    else
                    {
                        /**
                        * Согласно документации к PHP, мы для подготовки пароля пользователя к сохранению в БД
                        * будем использовать функцию password_hash() https://www.php.net/manual/ru/function.password-hash
                        * Причем, согласно рекомендации, начиная с версии PHP 8.0.0 не нужно указывать соль для пароля. Значит, и не будем
                        */
                        // Хэшируем пароль
                        $sPassword = password_hash($sPassword, PASSWORD_BCRYPT);
                        
                        $this->login = $sLogin;
                        $this->password = $sPassword;
                        $this->email = $sEmail;
                        $this->save();

                        if (Core_Database::instance()->lastInsertId())
                        {
                            $aReturn['success'] = TRUE;
                            $aReturn['message'] = "Пользователь с логином <strong>{$sLogin}</strong> и email <strong>{$sEmail}</strong> успешно зарегистрирован.";
                            $aReturn['data']['user_id'] = Core_Database::instance()->lastInsertId();
                        }
                    }
                }
                else
                {
                    $aReturn['message'] = "Указанное значение адреса электропочты не соответствует формату";
                    $aReturn['data'] = $post;
                }
            }
            // Если пользователь авторизуется
            else
            {
                // Проверяем, не был ли пользователь ранее авторизован
                if ($this->_checkCurrent($sLogin))
                {
                    $aReturn['success'] = TRUE;
                    $aReturn['message'] = "Вы ранее уже авторизовались на сайте";
                }
                // Если авторизации не было
                else 
                {
                    // Если не передан пароль
                    if (empty($sPassword))
                    {
                        $aReturn['message'] = "Поле пароля не было заполнено";
                        $aReturn['data'] = $post;
                    }
                    else 
                    {
                        // Ищем соответствие переданной информации в БД
                        $stmt = $this->getByLoginOrEmail($sLogin);
                        
                        // Если были найдены записи
                        if (!is_null($stmt))
                        {
                            // Получаем объект с данными о пользователе
                            $oUser = $this->_user = Core_Database::instance()->result()->fetch();
                            
                            // Проверяем пароль пользователя
                            // Если хэш пароля совпадает
                            if ($this->checkPassword($sPassword, $oUser->password))
                            {
                                // Авторизуем пользователя
                                $this->setCurrent();

                                $aReturn['success'] = TRUE;
                                $aReturn['message'] = "Вы успешно авторизовались на сайте";
                                $aReturn['data'] = $post;
                                $aReturn['data']['user_id'] = $oUser->id;
                            }
                            else
                            {
                                $aReturn['message'] = "Для учетной записи <strong>{$sLogin}</strong> указан неверный пароль";
                                $aReturn['data'] = $post;
                            }
                        }
                    }
                }
            }

            return $aReturn;
        }

        /**
        * Ищет в БД запись по переданному значению полей login или email
        * @param string $value
        * @return object PDOStatement|NULL
        */
        public function getByLoginOrEmail(string $value) : PDOStatement | NULL
        {
            // Определяем тип авторизации: по логину или адресу электропочты
            $sType = NULL;
            $sType = match($this->validateEmail($value)) {
                        TRUE => 'email',
                        FALSE => 'login'
            };

            // Выполняем запрос SELECT
            $oCore_Database = Core_Database::instance();
            $oCore_Database->select()
                ->from('users')
                ->where($sType, '=', $value)
                ->where('deleted', '=', 0)
                ->where('active', '=', 1);
            
            $stmt = $oCore_Database->execute();

            // Если такой пользователь есть в БД, вернем объект с результатом запроса
            return ($oCore_Database->getRowCount() > 0) ? $stmt : NULL;
        }

        /**
        * Проверяет пароль пользователя, совпадает ли он с хранимым в БД
        * @param string $password пароль пользователя
        * @param string $hash хэш пароля пользователя из БД
        * @return boolean TRUE|FALSE
        */
        public function checkPassword(string $password, string $hash) : bool
        {
            /**
            * Согласно документации к PHP, мы для подготовки пароля пользователя к сохранению в БД
            * мы использовали функцию password_hash() https://www.php.net/manual/ru/function.password-hash
            * Теперь для проверки пароля для авторизации нам нужно использовать функцию password_verify()
            * https://www.php.net/manual/ru/function.password-verify.php
            */
            return password_verify($password, $hash);
        }

        /**
        * Проверяет правильность адреса электронной почты
        * @param string $email
        * @return TRUE | FALSE
        */
        public function validateEmail(string $email) : bool
        {
            return filter_var($email, FILTER_VALIDATE_EMAIL);
        }

        /**
        * Проверяет уникальность логина в системе
        * @param string $value
        * @param string $field
        * @return TRUE | FALSE
        */
        public function isValueExist($value, $field) : bool
        {
            // Подключаемся к СУБД
            $oCore_Database = Core_Database::instance();
            $oCore_Database->clearSelect()
                ->clearWhere()
                ->select()
                ->from('users')
                ->where($field, '=', $value)
                ->where('deleted', '=', 0);

            // Выполняем запрос
            try {
                $stmt = $oCore_Database->execute();
            }
            catch (PDOException $e)
            {
                die("<p><strong>При выполнении запроса произошла ошибка:</strong> {$e->getMessage()}</p>");
            }
            
            // Если логин уникален, в результате запроса не должно быть строк
            return $oCore_Database->getRowCount() !== 0;
        }

        /**
        * Сохраняет информацию о пользователе
        * @return object self
        */
        public function save()
        {
            $oCore_Database = Core_Database::instance();
            $oCore_Database->insert('users')
                ->fields(['login', 'password', 'email'])
                ->values([
                    $this->_data['login'], 
                    $this->_data['password'], 
                    $this->_data['email']
                ]);
            
            $stmt = $oCore_Database->execute();

            return $this;
        }
        
        /**
        * Магический метод для установки значений необъявленных свойств класса
        * @param string $property
        * @param mixed $value
        */
        public function __set(string $property, $value)
        {
            $this->_data[$property] = $value;
        }

        /**
        * Магический метод для получения значения необъявленного свойства класса
        * Вернет значение из запрошенного поля таблицы, если оно разрешено в массиве $_allowedProperties
        * @return mixed string|NULL
        */
        public function __get(string $property) : string | NULL
        {
            return (in_array($property, $this->_allowedProperties) ? $this->_user->$property : NULL);
        }
    }
    ?>

В код класса User_Model практически без изменений перенесен код класса User. Чтобы регистрировать пользователей, нам нужно, чтобы был реализован метод User_Model::save(). Но ведь моделей у нас, как и таблиц в БД, может быть великое множество. Значит все они должны иметь реализацию метода save(), а так как классы моделей являются потомками класса Core_Entity, реализацию эту туда и перенесем.


    <?php
    // Запрещаем прямой доступ к файлу
    defined('MYSITE') || exit('Прямой доступ к файлу запрещен');

    class Core_Entity extends Core_ORM
    {
        protected function __construct($primary_key = NULL)
        {
            parent::__construct($primary_key);
        }

        public static function factory($modelName, $primary_key = NULL) : Core_ORM
        {
            $className = ucfirst($modelName) . "_Model";

            return new $className($primary_key);
        }

        /**
        * Сохраняет информацию о модели в БД
        * @return object self
        */
        public function save()
        {
            $oCore_Database = Core_Database::instance();
            $oCore_Database->insert('users')
                ->fields(['login', 'password', 'email'])
                ->values([
                    $this->_data['login'], 
                    $this->_data['password'], 
                    $this->_data['email']
                ]);
            
            $stmt = $oCore_Database->execute();

            return $this;
        }
    
    }
    ?>

Ерунда какая-то, как мы видим. Реализацию мы-то перенесли, но она всё ещё относится конкретно к полям таблицы `users`. Помните, в прошлой статье у нас был описан метод User::getColumnNames(). Он получал информацию об именах столбцов таблицы. Пусть это и будет ориентиром для метода Core_Entity::save().

  1. Создаем экземпляр класса модели.
  2. Получаем информацию о столбцах.
  3. Сохраняем эту информацию внутри объекта класса Core_Entity.
  4. Если в БД уже имеется запись и её данные, загружаем и их из таблицы.
  5. Если данные для пользователя в ходе выполнения сценария меняются, мы должны иметь возможность это понять, и перезаписать такие данные.
  6. Информацию о модели нужно иметь возможность не только сохранять или перезаписывать, но и удалять из БД.

Но прежде чем это заработает как описано выше, требуется реализовать простую выборку информации из базы данных. Представим, что пользователь уже зарегистрирован, и будто бы он ввел корректные данные в форму авторизации. Мы его авторизовали, и перенаправили на главную страницу, где у нас есть выражения $oCurrentUser->login и $oCurrentUser->email. Как теперь эти данные могут быть получены?

Вдобавок к уже упоминавшимся выше Core_Entity, Core_ORM паттерн Active Record добавляет еще одно определение: Query Builder — построитель запросов. Имеются в виду SQL-запросы к базе данных.

В нашей логике появляются три новых составляющих:

  1. Непосредственно построитель запросов — абстрактный класс Core_Querybuilder.
  2. Подготовленный запрос или результирующий набор — абстрактный класс Core_Querybuilder_Statement.
  3. Дочерние классы Core_Querybuilder_Statement: Core_Querybuilder_Select, Core_Querybuilder_Insert и т.п. Именно они будут работать с СУБД.

Итак. По порядку. Что же именно происходит, когда мы пишем следующее выражение?


    $oCurrentUser->login;


Здесь срабатывает магический метод __get(), который класс User_Model наследует от класса Core_Entity.


    class Core_Entity extends Core_ORM
    {
        // .....................
        /**
        * Магический метод для установки значений необъявленных свойств класса
        * @param string $property
        * @param mixed $value
        */
        public function __set(string $property, $value)
        {
            // Если у таблицы, соответствующей модели, есть столбец с теким именем, 
            // то устанавливаем значение для последующего сохранения
            if (array_key_exists($property, $this->_columns))
            {
                $this->_newData[$property] = $value;
            }

            return $this;
        }

        /**
        * Магический метод для получения значения необъявленного свойства класса
        * Вернет значение из запрошенного поля таблицы, если оно разрешено в массиве $_allowedProperties и есть среди полей таблицы
        * @return mixed string|NULL
        */
        public function __get(string $property) : string | NULL
        {
            return ((array_key_exists($property, $this->_columns) && in_array($property, $this->_allowedProperties)) ? $this->load($property) : NULL);
        }
    }


Как видим, если среди имен столбцов таблицы в БД — в нашем случае это таблица `users` — присутствует запрашиваемое свойство, и если оно разрешено к чтению, мы загружаем его. Здесь же представлен и код магического метода __set(). Все те классы, которые представляют собой модели данных таблиц MySQL, наследуют эти методы от класса Core_Entity, и имеют одинаковое поведение в сценарии. Давайте посмотрим на весь текущий код класса Core_Entity.


    <?php
    // Запрещаем прямой доступ к файлу
    defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
    
    class Core_Entity extends Core_ORM
    {
        /**
         * Конструктор класса объекта модели
         * @param integer|NULL $primary_key
         */
        protected function __construct($primary_key = NULL)
        {
            // Вызываем родительский конструктор класса объекта модели
            parent::__construct($primary_key);
        }
    
        public static function factory($modelName, $primary_key = NULL) : Core_ORM
        {
            $className = ucfirst($modelName) . "_Model";
            
            return new $className($primary_key);
        }
    
        /**
         * Сохраняет информацию о модели в БД
         * @return object self
         */
        public function save()
        {
            // Если массив $this->_newData не пустой, и у модели уже есть соответствующая ей запись в таблице БД
    
    
            return $this;
        }
        
        /**
         * Магический метод для установки значений необъявленных свойств класса
         * @param string $property
         * @param mixed $value
         */
        public function __set(string $property, $value)
        {
            // Если у таблицы, соответствующей модели, есть столбец с теким именем, 
            // то устанавливаем значение для последующего сохранения
            if (array_key_exists($property, $this->_columns))
            {
                $this->_newData[$property] = $value;
            }
    
            return $this;
        }
    
        /**
         * Магический метод для получения значения необъявленного свойства класса
         * Вернет значение из запрошенного поля таблицы, если оно разрешено в массиве $_allowedProperties и есть среди полей таблицы
         * @return mixed string|NULL
         */
        public function __get(string $property) : string | NULL
        {
            return ((array_key_exists($property, $this->_columns) && in_array($property, $this->_allowedProperties)) ? $this->load($property) : NULL);
        }
    }
    ?>

Класс Core_Entity наследует свойства и методы от класса Core_ORM. Посмотрим и на него.


    <?php
    // Запрещаем прямой доступ к файлу
    defined('MYSITE') || exit('Прямой доступ к файлу запрещен');

    class Core_ORM
    {
        /**
        * Тип выполняемого SQL-запроса
        * 0 - SELECT
        * 1 - INSERT
        * 2 - UPDATE
        * 3 - DELETE
        * @var integer
        */
        protected $_queryType = NULL;

        /**
        * Наименование поля таблицы, в которой находится первичный ключ
        * В большинстве случаев это будет поле `id`, но нельзя исключать вероятность 
        * того, что кто-то назовёт поле с первичным ключом иначе
        * @var string
        */
        protected $_primaryKey = 'id';

        /**
        * Имя модели
        * @var string
        */
        protected $_modelName = '';

        /**
        * Имя таблицы в БД, соответствующей модели
        * @var string
        */
        protected $_tableName = '';
        
        /**
        * Статус инициализации объекта модели
        * @var boolean
        */
        protected $_init = FALSE;

        /**
        * Была ли загружена модель из базы данных
        * @var boolean
        */
        protected $_loaded = FALSE;

        /**
        * Объект подключения к СУБД
        * @var object Core_Database
        */
        protected $_database = NULL;

        /**
        * Объект взаимодействия с СУБД
        * @var object PDOStatement
        */
        protected $_queryBuilder = NULL;

        /**
        * Строка подготавливаемого запроса к БД
        * @var string
        */
        protected $_sql = NULL;
        

        /**
        * Строка последнего выполненного запроса к БД
        * @var string
        */
        protected $_lastQuery = NULL;
        
        /**
        * Исходные данные модели из таблицы в БД
        * @var array
        */
        protected $_initialData = [];

        /**
        * Новые данные модели для сохранения в БД
        * @var array
        */
        protected $_newData = [];

        /**
        * Имена столбцов таблицы в БД и их параметры
        * @var array
        */
        protected $_columns = [];

        /**
        * Перечень свойств модели, разрешенных к чтению
        * @var array
        */
        protected $_allowedProperties = [];

        /**
        * Перечень свойств модели, запрещенных к чтению 
        * @var array
        */
        protected $_forbiddenProperties = [];

        /**
        * Инициализирует объект модели
        * Этот метод на текущем этапе не имеет задач, которые должен решать,
        * поэтому просто устанавливаем, что объект инициализирован
        * А ещё загрузим информацию о столбцах таблицы
        */
        protected final function _init()
        {
            // Загружаем информацию о столбцах объекта
            $this->_loadColumns();

            // Устанавливаем, что объект инициализирован
            $this->_init = TRUE;
        }

        /**
        * Загружает информацию о столбцах таблицы модели в БД
        */
        protected function _loadColumns()
        {
            $oCore_Database = Core_Database::instance();
            $this->_columns = $oCore_Database->getColumns($this->getTableName());

            // Определяем, какое из полей таблицы имеет первичный ключ
            $this->findPrimaryKeyFieldName();
        }

        /**
        * Загружает первичную информацию модели
        */
        protected function _load($property = NULL)
        {
            // Создает персональный для объекта экземпляр класса построителя запросов
            $this->queryBuilder();

            // Если был указан идентификатор объекта
            !is_null($property) && !is_null($this->_initialData[$this->_primaryKey])
                // Добавляем его значение в условие отбора
                && $this->queryBuilder()
                        ->where($this->_primaryKey, '=', $this->getPrimaryKey());
            
            if (!is_null($property))
            {
                $this->queryBuilder()
                    ->clearSelect()
                    ->select($property);

                $stmt = $this->queryBuilder()
                            ->query()
                            ->asAssoc()
                            ->result();
                
                $aPDOStatementResult = $stmt->fetch();
                
                $this->queryBuilder()->clearSelect();

                (!empty($aPDOStatementResult)) 
                        && $this->_initialData[$property] = $aPDOStatementResult[$property];
            }
        }

        /**
        * Конструктор класса. Его можно вызвать только изнутри, либо фабричным методом
        */
        protected function __construct($primary_key = NULL)
        {
            // Инициализируем объект модели
            $this->_init();
            
            // Если конструктору было передано значение первичного ключа, сохраняем его в исходных данных объекта
            !is_null($primary_key) && $this->_initialData[$this->_primaryKey] = $primary_key;

            // Загружаем из БД информацию объекта
            $this->_load();
        }

        /**
        * Загружает указанные данные модели
        */
        public function load($property = NULL)
        {
            // Загружаем данные
            $this->_load($property);
            
            return $this->_initialData[$property];
        }

        /**
        * Возвращает значение поля первичного ключа
        * @param mixed $returnedValueIfWasNotFound вернется если не было найдено значение поля первичного ключа в случае, если был задан параметр отличный от NULL
        */
        public function getPrimaryKey($returnedValueIfWasNotFound = NULL)
        {
            return (!empty($this->_initialData[$this->_primaryKey]) ? $this->_initialData[$this->_primaryKey] : $returnedValueIfWasNotFound);
        }

        /**
        * Получает и возвращает имя модели
        * @return string
        */
        public function getModelName() : string
        {
            return $this->_modelName;
        }

        /**
        * Получает и возвращает имя таблицы в БД, соответствующей модели
        * @return string
        */
        public function getTableName() : string
        {
            return $this->_tableName;
        }

        /**
        * Получает имя поля, которое имеет первичный ключ
        */
        public function findPrimaryKeyFieldName()
        {
            try {
                if (is_null($this->getColumns()))
                {
                    throw new Core_Exception("<p>Ошибка " . __METHOD__ . ": информация о полях таблицы ещё не была загружена.</p>");
                }

                foreach ($this->getColumns() as $name => $aField)
                {
                    if (!empty($aField['key']) && $aField['key'] == "PRI")
                    {
                        $this->_primaryKey = $name;

                        break;
                    }
                    else
                    {
                        throw new Core_Exception("<p>Ошибка " . __METHOD__ . ": таблица " . $this->getTableName() . " не имеет полей с первичным клочом.</p>");
                    }
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();
            }
        }

        /**
        * Получает и возвращает перечень загруженных полей таблицы
        * @return array
        */
        public function getColumns() : array
        {
            return $this->_columns;
        }

        /**
        * Взаимодействует с СУБД от лица объекта модели
        */
        public function queryBuilder()
        {
            // Сохраняем в объекте ссылку на соединение с БД
            if (is_null($this->_database))
            {
                $this->_database = Core_Database::instance();
            }

            // Создаем экзепляр объекта построителя запросов, если его ещё нет
            if (is_null($this->_queryBuilder))
            {
                // Запрос типа SELECT
                $this->_queryBuilder = Core_Querybuilder::select();
                // Выбираем данные, которые не были удалены
                $this->_queryBuilder->from($this->getTableName())
                    ->where('deleted', '=', 0);
            }

            return $this->_queryBuilder;
        }

        /**
        * Устанавливает тип запроса SELECT, INSERT и т.п.
        * @param integer $queryType
        * @return object self
        */
        public function setQueryType(int $queryType)
        {
            $this->_queryType = $queryType;

            return $this;
        }

        /**
        * Возвращает тип запроса
        * @return integer
        */
        public function getQueryType()
        {
            return $this->_queryType;
        }
    }
    ?>

Очень многое перекочевало из классов Core_Database и Core_Database_Pdo в класс Core_ORM. В его конструкторе сначала вызывается метод _init(). Этот метод, в свою очередь, вызывает для модели метод _load(). Именно этот метод и обеспечивает получение данных через выражения вроде $oCurrentUser->login.

Сначала в нем вызывается метод queryBuilder().


    /**
     * Взаимодействует с СУБД от лица объекта модели
     */
    public function queryBuilder()
    {
        // Сохраняем в объекте ссылку на соединение с БД
        if (is_null($this->_database))
        {
            $this->_database = Core_Database::instance();
        }

        // Создаем экзепляр объекта построителя запросов, если его ещё нет
        if (is_null($this->_queryBuilder))
        {
            // Запрос типа SELECT
            $this->_queryBuilder = Core_Querybuilder::select();
            // Выбираем данные, которые не были удалены
            $this->_queryBuilder->from($this->getTableName())
                ->where('deleted', '=', 0);
        }

        return $this->_queryBuilder;
    }

Построитель запросов — Query Builder

В контексте Модели класс Core_ORM данные может получать или записывать. Поэтому в методе Core_ORM::queryBuilder() присутствует выражение Core_Querybuilder::select() — статический метод класса Core_Querybuilder.


    <?php
    // Запрещаем прямой доступ к файлу
    defined('MYSITE') || exit('Прямой доступ к файлу запрещен');

    /**
    * Строит запросы к СУБД
    */
    abstract class Core_Querybuilder
    {
        protected function __construct($type)
        {

        }

        public static function factory($type, $arguments)
        {
            $queryBuilderClassName = __CLASS__ . "_" . ucfirst($type);

            return new $queryBuilderClassName($arguments);
        }

        public static function select()
        {
            return Core_Querybuilder::factory('Select', func_get_args());
        }
    }
    ?>

Класс Core_Querybuilder является абстрактным. Через статический метод select() вызывается фабричный метод, который создает экземпляр класса для выполнения запроса к БД. В нашем конкретном случае — запроса SELECT.


    <?php
    // Запрещаем прямой доступ к файлу
    defined('MYSITE') || exit('Прямой доступ к файлу запрещен');

    /**
    * Выполняет запрос типа SELECT к БД
    */
    class Core_Querybuilder_Select extends Core_Querybuilder_Statement
    {
        public function __construct($arguments)
        {
            if (empty($arguments))
            {
                $this->_select[] = "*";
            }
        }
        
        /**
        * Устанавливает перечень полей для запроса SELECT
        * @param string|array $data = "*"
        * @return object self
        */
        public function select($data = "*")
        {
            // Устанавливаем в объекте тип выполняемого запроса как SELECT
            $this->getQueryType() != 0 && $this->setQueryType(0);

            // Если методу не был передан перечень полей, очищаем все возможно установленные ранее поля
            if ($data == "*")
            {
                $this->clearSelect();
            }

            // Сохраняем поля
            try {
                // Если перечень полей был передан в виде строки
                if (is_string($data))
                {
                    // Добавляем их к массиву в объекте
                    $this->_select[] = ($data == "*") ? $data : Core_Database::instance()->quoteColumnNames($data);
                }
                // Если был передан массив, его нужно интерпретировать как указание имени поля и его псевдонима в запросе
                elseif (is_array($data))
                {
                    // Если в переданном массиве не два элемента, это ошибка
                    if (count($data) != 2)
                    {
                        throw new Exception("<p>При передаче массива в качестве аргумента методу " . __METHOD__ . "() число элементов этого массива должно быть равным двум</p>");
                    }
                    // Если элементы переданного массива не являются строками, это ошибка
                    elseif (!is_string($data[0]) || !is_string($data[1]))
                    {
                        throw new Exception("<p>При передаче массива в качестве аргумента методу " . __METHOD__ . "() его элементы должны быть строками</p>");
                    }
                    // Если ошибок нет, сохраняем поля в массиве внутри объекта
                    else
                    {
                        // Имена полей экранируем
                        $this->_select[] = Core_Database::instance()->quoteColumnNames($data[0]) . " AS " . Core_Database::instance()->quoteColumnNames($data[1]);
                    }
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();

                die();
            }
            
            return $this;
        }

        /**
        * Очищает перечень полей для оператора SELECT
        * @return object self
        */
        public function clearSelect()
        {
            $this->_select = [];

            return $this;
        }

        /**
        * Строит предварительную строку запроса из переданных данных
        * @return string
        */
        public function build() : string
        {
            // Пустая строка для SQL-запроса
            $sQuery = "";

            // Строка оператора WHERE
            $sWhere = " WHERE ";

            // Сначала собираем строку для оператора WHERE
            foreach ($this->_where as $index => $sWhereRow)
            {
                // Для каждого из сохраненного массива для оператора WHERE формируем строку
                $sWhere .= (($index) ? " AND" : "") . " " . $sWhereRow;
            }
            
            $sQuery .= "SELECT " . ((!empty($this->_select)) ? implode(", ", $this->_select) : "*") . " FROM {$this->_from}" . $sWhere;

            return $sQuery;
        }

        public function query($query = NULL)
        {
            is_null($query) && $query = $this->build();

            return Core_Database::instance()->query($query);
        }
    }
    ?>

Да что ж такое... ещё один какой-то родительский класс нарисовался — Core_Querybuilder_Statement. Он представляет собой либо подготовленный запрос к БД для выполнения, либо уже результат такого запроса, который был выполнен.


    <?php
    // Запрещаем прямой доступ к файлу
    defined('MYSITE') || exit('Прямой доступ к файлу запрещен');
    
    /**
     * Хранит результат выполнения SQL-запроса или подготавливает его к выполнению
     */
    abstract class Core_Querybuilder_Statement
    {
        /**
         * Перечень полей для запроса SELECT
         * @var array
         */
        protected $_select = [];
    
        /**
         * Имя таблицы для запроса SELECT
         * @var string
         */
        protected $_from = NULL;
        
        /**
         * Перечень полей для запроса INSERT
         * @var array
         */
        protected $_fields = [];
        
        /**
         * Перечень значений для запроса INSERT
         * @var array
         */
        protected $_values = [];
    
        /**
         * Имя таблицы для запроса INSERT
         * @var string
         */
        protected $_tableName = NULL;
    
        /**
         * Перечень условий для оператора WHERE
         * @var array
         */
        protected $_where = [];
        
        /**
         * Тип SQL-запроса:
         * 0 - SELECT
         * 1 - INSERT
         * 2 - UPDATE
         * 3 - DELETE
         */
        protected $_queryType = NULL;
    
        /**
         * Строит предварительную строку запроса из переданных данных
         */
        abstract function build();
    
        /**
         * Устанавливает имя таблицы для оператора SELECT
         * @param string $from
         * @return object self
         */
        public function from(string $from)
        {
            try {
    
                if (!is_string($from))
                {
                    throw new Exception("<p>Методу " . __METHOD__ . "() нужно передать имя таблицы для запроса</p>");
                }
                
                // Экранируем данные
                $this->_from = Core_Database::instance()->quoteColumnNames($from);
            }
            catch (Exception $e)
            {
                print $e->getMessage();
    
                die();
            }
    
            return $this;
        }
    
        /**
         * Очищает массив условий отбора для оператора WHERE
         * @return object self
         */
        public function clearWhere()
        {
            $this->_where = [];
    
            return $this;
        }
    
        /**
         * Сохраняет перечень условий для оператора WHERE в SQL-запросе
         * @param string $field
         * @param string $condition
         * @param string $value
         * @return object self
         */
        public function where(string $field, string $condition, $value)
        {
            try {
                if (empty($field) || empty($condition))
                {
                    throw new Exception("<p>Методу " . __METHOD__ . "() обязательно нужно передать значения имени поля и оператора сравнения</p>");
                }
    
                // Экранируем имена полей и значения, которые будут переданы оператору WHERE
                $this->_where[] = Core_Database::instance()->quoteColumnNames($field) . " " . $condition . " " . Core_Database::instance()->getConnection()->quote($value);
            }
            catch (Exception $e)
            {
                print $e->getMessage();
    
                die();
            }
    
            return $this;
        }
    
        /**
         * Устанавливает имя таблицы для оператора INSERT
         * @param string $tableName
         * @return object self
         */
        public function insert(string $tableName)
        {
            // Экранируем имя таблицы
            $this->_tableName = $this->quoteColumnNames($tableName);
    
            // Устанавливаем тип запроса INSERT
            $this->_queryType = 1;
    
            return $this;
        }
    
        /**
         * Устанавливает перечень полей для оператора INSERT
         * @return object self
         */
        public function fields()
        {
            try {
                // Если не было передано перечня полей
                if (empty(func_get_args()))
                {
                    throw new Exception("Метод " . __METHOD__ . "() нельзя вызывать без параметров. Нужно передать перечень полей либо в виде строки, либо в виде массива");
                }
    
                // Сохраняем перечень полей в переменную
                $mFields = func_get_arg(0);
    
                // Если передан массив
                if (is_array($mFields))
                {
                    // Просто сохраняем его
                    $this->_fields = $mFields;
                }
                // Если передана строка
                elseif (is_string($mFields))
                {
                    // Разбираем её, полученный массив сохраняем
                    $this->_fields = explode(',', $mFields);
                }
                // В ином случае будет ошибка
                else
                {
                    throw new Exception("Метод " . __METHOD__ . "() ожидает перечень полей либо в виде строки, либо в виде массива");
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();
    
                die();
            }
    
            return $this;
        }
    
        /**
         * Устанавливает перечень значений, которые будут переданы оператору INSERT
         * @return object self
         */
        public function values()
        {
            try {
                // Если значения не переданы, это ошибка
                if (empty(func_get_args()))
                {
                    throw new Exception("Метод " . __METHOD__ . "() нельзя вызывать без параметров. Нужно передать перечень значений либо в виде строки, либо в виде массива");
                }
    
                // Сохраняем переденные значения в переменную
                $mValues = func_get_arg(0);
    
                // Если был передан массив
                if (is_array($mValues))
                {
                    // Просто сохраняем его
                    $this->_values[] = $mValues;
                }
                // Если была передана строка
                elseif (is_string($mValues))
                {
                    // Разбираем её, полученный массив сохраняем в объекте
                    $this->_values[] = explode(',', $mValues);
                }
                // В ином случае будет ошибка
                else
                {
                    throw new Exception("Метод " . __METHOD__ . "() ожидает перечень значений либо в виде строки, либо в виде массива");
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();
    
                die();
            }
    
            return $this;
        }
    
        /**
         * Выполняет SQL-запрос к СУБД
         */
        public function execute() : PDOStatement | NULL
        {
            // Результат запроса будет представлен в виде объекта
            $this->_fetchType = PDO::FETCH_OBJ;
    
            // Создаем данные, которые вернем в ответ на вызов
            $return = NULL;
    
            $sQuery = $this->build();
    
            // Пробуем выполнить запрос
            try {
    
                if (!empty($sQuery))
                {
                    // В зависимости от типа запроса
                    switch ($this->getQueryType())
                    {
                        // SELECT
                        case 0:
                            
                            $return = $this->query($sQuery)->result();
    
                        break;
                        
                        // INSERT
                        case 1:
                            
                            $stmt = $this->getConnection()->prepare($sQuery);
                            
                            foreach ($this->_values as $aValues)
                            {
                                for ($i = 1; $i <= count($aValues); ++$i)
                                {
                                    $stmt->bindParam($i, $aValues[$i - 1]);
                                }
    
                                $stmt->execute();
                            }
    
                            $return = $stmt;
    
                        break;
                    }
                
                    // Сохраняем строку запроса в объекте
                    $this->_lastQuery = $sQuery;
                }
                else
                {
                    throw new Exception("Ошибка " . __METHOD__ . ": не удалось сформировать строку для выполения SQL-запроса");
                }
            }
            catch (PDOException $e)
            {
                throw new Exception($e->getMessage());
            }
    
            return $return;
        }
    
        /**
         * Устанавливает тип запроса SELECT, INSERT и т.п.
         * @param integer $queryType
         * @return object self
         */
        public function setQueryType(int $queryType)
        {
            $this->_queryType = $queryType;
    
            return $this;
        }
    
        /**
         * Возвращает тип запроса
         * @return integer
         */
        public function getQueryType()
        {
            return $this->_queryType;
        }
    }
    ?>

В общем, раньше всё бремя взаимодействия с СУБД у нас было возложено на классы Core_Database и Core_Database_Pdo. Теперь же они заняты только непосредственным выполнением запроса к БД и возвратом результата таких запросов.

Итак, выражение $oCurrentUser->login у нас получает информацию о логине пользователя через метод Core_ORM::_load(). Почему так? Почему бы сразу не загрузить информацию о пользователе при создании экземпляра класса User_Entity? Ещё один термин: Lazy Load. Ленивая загрузка. Да, сейчас у нас особо нечем нагружать веб-сервер и MySQL. А что будет, если в базе данных хранятся тысячи строк о пользователях? И мы каким-нибудь итератором — циклом foreach(), например — будем перебирать каждую из них, хотя нам нужно, к примеру, получить только значение поля `id` каждого из пользователей? Да и столбцов в таблице будет не как сейчас, а штук 20-30. Мы загружаем только те данные и только тогда, когда в этом есть необходимость.

Можно будет загрузить и все данные полностью сразу же, мы это реализуем позже.

Теперь давайте посмотрим, как нам зарегистрировать нового пользователя. То есть — создать новую запись в таблице `users`. Выглядеть это будет примерно так:


    $oUser = Core_Entity::factory('User');
    $oUser->login = "demo";
    $oUser->password = "123456";
    $oUser->email = "demo@lezhenkin.ru";
    $oUser->save();


Причем, метод save() должен различать, сохраняем мы информацию о новом пользователе, или обновляем информацию об уже имеющемся. Да, у нас ещё будет и метод update(), который информацию будет исключительно обновлять. Но и для метода save() мы такую логику предусмотрим, во избежание, так сказать.

Когда мы через выражение вроде $oUser->password = "123456" устанавливаем значения полей таблицы, мы вызываем магический метод Core_Entity::__set().


    /**
     * Магический метод для установки значений необъявленных свойств класса
     * @param string $property
     * @param mixed $value
     */
    public function __set(string $property, $value)
    {
        // Если у таблицы, соответствующей модели, есть столбец с теким именем, 
        // то устанавливаем значение для последующего сохранения
        if (array_key_exists($property, $this->_columns))
        {
            $this->_newData[$property] = $value;
        }

        return $this;
    }


Проверим. На время в класс Core_Entity мы добавили метод showNewData(). Он просто вернет массив с информацией, которая подготовлена для сохранения в БД.


    $oUser = Core_Entity::factory('User');
    $oUser->login = "demo";
    $oUser->password = "123456";
    $oUser->email = "demo@lezhenkin.ru";
    
    print "<pre>";
    print_r($oUser->showNewData());
    print "</pre>";
    


Должны получить такой вывод:


    Array
    (
        [login] => demo
        [password] => 123456
        [email] => demo@lezhenkin.ru
    )


Пробуем сохранить через выражение $oUser->save()


    $oUser = Core_Entity::factory('User');
    $oUser->login = "demo";
    $oUser->password = "123456";
    $oUser->email = "demo@lezhenkin.ru";
    $oUser->save();
    
    print "<pre>";
    print_r($oUser->login);
    print "</pre>";
    


Этот код должен вернуть логин нового пользователя, будто бы такой объект уже существовал, а запись в БД уже имелась. Но чтобы это действительно было так, нужно реализовать метод Core_Entity::save().


    <?php
    class Core_Entity extends Core_ORM
    {
        // .....................

        /**
        * Сохраняет информацию о модели в БД
        * @return object self
        */
        public function save()
        {
            // Если массив $this->_newData не пустой, и у модели уже есть соответствующая ей запись в таблице БД
            if (!empty($this->_newData))
            {
                $oCore_Querybuilder_Insert = Core_Querybuilder::insert($this->getTableName());

                // Устанавливаем поля и значения полей для оператора INSERT
                $aFields = [];
                $aValues = [];

                foreach ($this->_newData as $field => $value)
                {
                    $aFields[] = $field;
                    $aValues[] = $value;
                }

                $oCore_Querybuilder_Insert->fields($aFields)
                        ->values($aValues);
                
                $this->_statement = $oCore_Querybuilder_Insert->execute();

                // Если запрос был выполнен успешно, очищаем массив с данными для записи в БД
                if (Core_Database::instance()->getRowCount())
                {
                    // Если это был новый объект, который мы только что сохранили
                    if (is_null($this->getPrimaryKey()))
                    {
                        // Устанавливаем для объекта значение первичного ключа — не для записи в БД
                        $this->_setPrimaryKey(Core_Database::instance()->lastInsertId());
                    }

                    $this->clearEntity();
                }
            }

            /**
            * Мы должны запретить возможность установки значения ключевого поля таблицы. Оно у нас устанавливается как AUTO_INCREMENT
            */
            return $this;
        }
        
        /**
        * Очищает объект от новых пользовательских данных
        * @return object self
        */
        public function clearEntity()
        {
            // Очищаем массив с данными
            $this->_newData = [];

            // Загружаем информацию об объекте из БД
            $this->_load();

            return $this;
        }

        // .....................
    }
    ?>

  1. Сначала метод Core_Entity::save() проверяет, были ли вообще установлены данные для записи в БД. Если их нет, просто возвращается ссылка на текущий объект.
  2. Далее, добавление новой записи в БД будет осуществляться через экземпляр нового класса Core_Querybuilder_Insert.
  3. Формируем массивы с именами полей и значениями для них, устанавливаем их через методы Core_Querybuilder_Insert::fields() и Core_Querybuilder_Insert::values().
  4. Результат выполнения запроса сохраняется в новом для нас свойстве Core_ORM::$_statement.
  5. Если запрос затронул строки — в нашем случае, создал новую запись в таблице `users`, действуем дальше.
  6. Если это новый объект пользователя, который мы сохранили в БД, у него изначально не было значения первичного ключа. Поэтому выражение is_null($this->getPrimaryKey()) должно вернуть NULL.
  7. В этом случае мы берем из экземпляра соединения с СУБД значение последней вставленной записи, и устанавливаем его в качестве значения ключевого поля для объекта. Именно для объекта, а не записи в таблице БД.
  8. Через вызов Core_Entity::clearEntity() загружаем данные в наш новый-старый объект.
  9. Метод Core_Entity::clearEntity() очищает массив, в котором харнились данные для записи в таблицу БД, а затем с помощью Core_Entity::_load() уже четко связывает наш объект с записью в таблице БД.

Пришла пора взглянуть на код класса Core_Querybuilder_Insert.


    <?php
    // Запрещаем прямой доступ к файлу
    defined('MYSITE') || exit('Прямой доступ к файлу запрещен');

    /**
    * Выполняет запрос типа INSERT к БД
    */
    class Core_Querybuilder_Insert extends Core_Querybuilder_Statement
    {
        /**
        * Конструктор класса
        */
        public function __construct($arguments)
        {
            try {
                if (!empty($arguments) && !empty($arguments[0]))
                {
                    $this->_tableName = $arguments[0];
                }
                else
                {
                    throw new Exception("<p>Ошибка " . __METHOD__ . ": не передано имя таблицы для оператора INSERT</p>");
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();

                die();
            }
        }
        
        /**
        * Строит предварительную строку запроса из переданных данных
        * @return string
        */
        public function build() : string
        {
            // Пустая строка для SQL-запроса
            $sQuery = "";
            
            /**
            * Здесь мы воспользуемся механизмом подготовки запроса от PDO
            * https://www.php.net/manual/ru/pdo.prepared-statements.php
            */
            $sPseudoValues = "(";
            $sFields = "(";

            try {
                if (!empty($this->_fields))
                {
                    foreach ($this->_fields as $index => $sField)
                    {
                        $sPseudoValues .= (($index) ? "," : "") . "?";
                        $sFields .= (($index) ? "," : "") . Core_Database::instance()->quoteColumnNames($sField);
                    }

                    $sPseudoValues .= ")";
                    $sFields .= ")";

                    $sQuery .= "INSERT INTO " . Core_Database::instance()->quoteColumnNames($this->_tableName) . " " . $sFields . " VALUES " . $sPseudoValues;
                }
                else
                {
                    throw new Exception("<p>Ошибка " . __METHOD__ . ": не переданы поля для выполнения запроса INSERT</p>");
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();

                die();
            }

            return $sQuery;
        }

        public function execute() : PDOStatement
        {
            $query = $this->build();

            try {
                if (empty($query))
                {
                    throw new Exception("<p>Ошибка " . __METHOD__ . ": не строка запроса для оператора INSERT</p>");
                } 
                elseif (!empty($this->_values))
                {
                    $dbh = Core_Database::instance()->getConnection();
                    $stmt = $dbh->prepare($query);
                    
                    foreach ($this->_values as $aValues)
                    {
                        for ($i = 1; $i <= count($aValues); ++$i)
                        {
                            $stmt->bindParam($i, $aValues[$i - 1]);
                        }
                        
                        $stmt->execute();
                    }
                }
                else
                {
                    throw new Exception("<p>Ошибка " . __METHOD__ . ": не переданы значения полей для выполнения запроса INSERT</p>");
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();

                die();
            }
            
            // Возвращаем реультат запроса
            return $stmt;
        }
    }
    ?>

Работает, но есть один нюанс. Таким образом мы сохраняем пароль в открытом виде. Чтобы сохранять не сам пароль, а его хэш, немного изменим код класса User_Model, добавив в него метод preparePassword().


    <?php
    class User_Model extends Core_Entity
    {
        // ..............................

        /**
        * Хэширует переданное значения пароля
        * @param string $password
        * @return string
        */
        public function preparePassword(string $password) : string
        {
            try {
                if (!empty($password))
                {
                    // Получаем параметры для хэширования пароля
                    $aConfig = Core_Config::instance()->get('core_password');

                    // Если указан метод хэширования и он является функцией
                    if (!empty($aConfig['method']) && is_callable($aConfig['method']))
                    {
                        // Получаем имя функции для хэширования
                        $sMethod = $aConfig['method'];

                        // Удаляем из массива имя фнукции
                        unset($aConfig['method']);
                        
                        /**
                        * Для вызова функции хэширования будем использовать 
                        * встроенную в PHP функцию call_user_func_array
                        * Поэтому готовим массив для передачи данных
                        */
                        $aParams = ['password' => $password] + $aConfig;

                        // Хэшируем пароль
                        $sHash = call_user_func_array($sMethod, $aParams);
                    }
                    // Если не указан метод хэширования
                    else
                    {
                        throw new Exception("<p>Ошибка " . __METHOD__ . ": не передано имя функции для создания хэша</p>");
                    }
                }
                else
                {
                    throw new Exception("<p>Ошибка " . __METHOD__ . ": не передано значение пароля для создания хэша</p>");
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();

                die();
            }

            // Возвращаем хэш пароля вызову
            return $sHash;
        }

        // ..............................
    }
    ?>

И чтобы не вызывать этот метод User_Model::preparePassword() отдельно от самого класса, сделаем так, чтобы он вызывался автоматически при попытке задать значения свойства User_Model::$password. Для этого нам нужно в классе User_Model реализовать его собственный магический метод __set().


    <?php
    class User_Model extends Core_Entity
    {
        // ..........................

        /**
        * Устанавливает значения для необъявленных свойств класса
        * Если устанавливается пароль, перед вызовом логики родительского метода
        * __set() значение пароля будет хэшировано. Для остальных свойств 
        * поведение магического метода будет обычным
        * @param string $property
        * @param string $value
        */
        public function __set($property, $value)
        {
            // Если устанавливается пароль пользователя
            if ($property == 'password')
            {
                $value = $this->preparePassword($value);
            }

            // Вызываем родительский метод __set()
            parent::__set($property, $value);
        }

        // ..........................
    }
    ?>

И вот теперь, если выполнить...


    $oUser = Core_Entity::factory('User');
    $oUser->login = "demo";
    $oUser->password = "123456";
    $oUser->email = "demo@lezhenkin.ru";
    $oUser->save();


...в базу данных будет сохранен хэш пароля. Кстати, в методе User_Model::preparePassword() параметры хэширования пароля заданы в файле classes/core/config/password.php, и они запрашиваются выражением $aConfig = Core_Config::instance()->get('core_password'). А вот содержимое этого файла.


    <?php
    /**
    * Параметры хэширования паролей подобраны в соответствии с рекомендациями на странице документации
    * https://www.php.net/manual/ru/function.password-hash
    */
    return [
        'method' => 'password_hash',
        'algo' => PASSWORD_BCRYPT,
        'options' => [,
            'cost' => 10
        ]
    ];
    ?>

Если хочется использовать иной способ хэширования паролей, просто измените этот файл.

Теперь давайте проработаем возможность изменять информацию о пользователе в базе данных в таблице `users` через свойства объекта User_Model. Например, сдлелаем алгоритм изменение статуса активности учетной записи. Для новых пользователей учетная запись будет неактивна до момента подтверждения email. Должно это происходить примерно так.


    $oUser = Core_Entity::factory('User', 40);

    print "<pre>";
    var_dump($oUser->active);
    print "</pre>";

    $oUser->changeActive();

    print "<pre>";
    var_dump($oUser->active);
    print "</pre>";


Это должно вывести на экран следующее:


    string(1) "1"

    string(1) "0"


Но чтобы это происходило, а не вызывало ошибку о том, что метод User_Model::changeActive(), объявим его.


    <?php
    class User_Model extends Core_Entity
    {
        // ...............
        
        /**
        * Меняет статус активации учетной записи пользователя
        * @return object self
        */
        public function changeActive()
        {
            // Пробуем изменить статус
            try {
                // Если объект заполнен данными
                if (!is_null($this->getPrimaryKey()))
                {
                    // Переключаем значение активации учетной записи
                    $this->active = $this->active ? 0 : 1;
                    $this->save();
                }
                else
                {
                    throw new Exception("<p>Ошибка: " . __METHOD__ . ": управлять активацией учетных записей можно только для существующих пользователей</p>");
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();

                die();
            }

            return $this;
        }

        // ...............
    }
    ?>

Если прямо сейчас запустить этот код, он вызовет ошибку: SQLSTATE[HY000]: General error: 1364 Field 'login' doesn't have a default value.

Всё дело в том, что метод User_Method::save() ещё не умеет обновлять информацию, а может её только записывать в базу данных. Исправим это.


    <?php
    class Core_Entity extends Core_ORM
    {
        // .............................
        
        /**
        * Сохраняет информацию о модели в БД
        * @return object self
        */
        public function save()
        {
            // Если массив $this->_newData не пустой
            if (!empty($this->_newData))
            {
                // Если в объекте есть значение первичного ключа записи таблицы БД, значит информация не добавляется
                // в таблицу, а обновляется для существующей записи
                if (!is_null($this->getPrimaryKey()))
                {
                    // Выполняем метод update()
                    return $this->update();
                }
            
            // ............................
        }

        public function update()
        {
            try {
                // Если массив $this->_newData не пустой, и у модели уже есть соответствующая ей запись в таблице БД
                if (!empty($this->_newData) && !is_null($this->getPrimaryKey()))
                {
                    $oCore_Querybuilder_Update = Core_Querybuilder::update($this->getTableName());

                    // Устанавливаем поля и значения полей для оператора INSERT
                    $aFields = [];
                    $aValues = [];
                    
                    foreach ($this->_newData as $field => $value)
                    {
                        $aFields[] = $field;
                        $aValues[] = $value;
                    }

                    $oCore_Querybuilder_Update->fields($aFields)
                            ->values($aValues)
                            ->where($this->_primaryKey, '=', $this->getPrimaryKey());
                    
                    $this->_statement = $oCore_Querybuilder_Update->execute();

                    // Если запрос был выполнен успешно, очищаем массив с данными для записи в БД
                    if (Core_Database::instance()->getRowCount())
                    {
                        // Перезагружаем объект
                        $this->clearEntity();
                    }
                }
                else 
                {
                    throw new Exception("<p>Ошибка: " . __METHOD__ .  ": обновлять информацию можно только для существующих записей в таблицах БД</p>");
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();

                die();
            }

            return $this;
        }
    
        // .............................
    }
    ?>

Для объектов моделей метод Core_Entity::update() можно будет вызывать напрямую, не прибегая к вызову метода Core_Entity::save().

Здесь у нас появился новый класс: Core_Querybuilder_Update. Код его реализации выглядит так:


    <?php
    // Запрещаем прямой доступ к файлу
    defined('MYSITE') || exit('Прямой доступ к файлу запрещен');

    /**
    * Выполняет запрос типа UPDATE к БД
    */
    class Core_Querybuilder_Update extends Core_Querybuilder_Statement
    {
        /**
        * Конструктор класса
        */
        public function __construct($arguments)
        {
            try {
                if (!empty($arguments) && !empty($arguments[0]))
                {
                    $this->_tableName = $arguments[0];
                }
                else
                {
                    throw new Exception("<p>Ошибка " . __METHOD__ . ": не передано имя таблицы для оператора UPDATE</p>");
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();

                die();
            }
        }
        
        /**
        * Строит предварительную строку запроса из переданных данных
        * @return string
        */
        public function build() : string
        {
            // Пустая строка для SQL-запроса
            $sQuery = "";
            
            /**
            * Здесь мы воспользуемся механизмом подготовки запроса от PDO
            * https://www.php.net/manual/ru/pdo.prepared-statements.php
            */

            
            // Строка оператора WHERE
            $sWhere = " WHERE ";

            // Сначала собираем строку для оператора WHERE
            foreach ($this->_where as $index => $sWhereRow)
            {
                // Для каждого из сохраненного массива для оператора WHERE формируем строку
                $sWhere .= (($index) ? " AND" : "") . " " . $sWhereRow;
            }

            $aSets = [];

            try {
                if (!empty($this->_fields))
                {
                    foreach ($this->_fields as $index => $sField)
                    {
                        $aSets[] = $sField . " = :{$sField}";
                    }

                    $sQuery .= "UPDATE " . Core_Database::instance()->quoteColumnNames($this->_tableName) . " SET " . implode(", ", $aSets) . $sWhere;
                }
                else
                {
                    throw new Exception("<p>Ошибка " . __METHOD__ . ": не переданы поля для выполнения запроса UPDATE</p>");
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();

                die();
            }

            return $sQuery;
        }

        public function execute() : PDOStatement
        {
            $query = $this->build();
            
            try {
                if (empty($query))
                {
                    throw new Exception("<p>Ошибка " . __METHOD__ . ": не строка запроса для оператора UPDATE</p>");
                }
                // Защитим себя от обновления всех записей таблицы вместо требуемых
                elsif (empty($this->_where))
                {
                    throw new Exception("<p>Ошибка " . __METHOD__ . ": для оператора UPDATE следует указать значение для выбора обновляемых данных</p>");
                }  
                elseif (!empty($this->_values))
                {
                    $dbh = Core_Database::instance()->getConnection();
                    $stmt = $dbh->prepare($query);
                    
                    foreach ($this->_fields as $index => $sField)
                    {
                        $stmt->bindParam(":{$sField}", $this->_values[0][$index]);
                    }

                    $stmt->execute();
                }
                else
                {
                    throw new Exception("<p>Ошибка " . __METHOD__ . ": не переданы значения полей для выполнения запроса UPDATE</p>");
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();

                die();
            }
            
            // Возвращаем реультат запроса
            return $stmt;
        }
    }
    ?>

Проверяем, как теперь работает обновление информации о пользователе.


    <?php
    $oUser = Core_Entity::factory('User', 40);
    $result = $oUser->findAll();

    print "<pre>";
    var_dump($result);
    print "</pre>";

    $oUser->login = "demo";
    $oUser->email = "demo@lezhenkin.ru";
    $oUser->save();

    $result = $oUser->findAll();

    print "<pre>";
    var_dump($result);
    print "</pre>";
    ?>

Для того, чтобы код сработал как надо, нам потребовалось внести дополнения в код классов Core_ORM и Core_Querybuilder_Select.


    <?php
    class Core_ORM
    {
        // ...................
        
        /**
        * Получает из базы данных все записи, которые вернет подготовленный запрос в соответствии с условиями
        * @return array
        */
        public function findAll()
        {
            // Переключаемся на выполнение запроса к БД
            $oCore_Querybuilder_Select = $this->queryBuilder()
                                            // Очищаем перечень запрашиваемых полей
                                            ->clearSelect()
                                            // Выбираем из базы данных значений тех полей модели, которые разрешены к чтению
                                            ->select(implode(", ", $this->_allowedProperties))
                                            ->asObject(get_class($this));
            
            /**
            * Если у объекта модели установлено значение первичноо ключа, добавляем его к условиям запроса
            * В ином случае вернется всё множество строк, соответствующих SQL-запросу
            */
            !is_null($this->getPrimaryKey()) 
                && $oCore_Querybuilder_Select->where($this->_primaryKey, '=', $this->getPrimaryKey());

            // Результат запроса сохраняем в объекте
            $this->_statement = $oCore_Querybuilder_Select->query()
                        ->result();
            
            // Возвращаем резльутирующий набор вызову
            return $this->_statement->fetchAll();
        }

        // ...................
    }
    ?>

Чтобы Core_Querybuilder_Select умел обрабатывать поля, перечисленные через запятую, его необходимо дополнить.


    <?php
    class Core_Querybuilder_Select extends Core_Querybuilder_Statement
    {
        // ............................

        public function select($data = "*")
        {
            // Устанавливаем в объекте тип выполняемого запроса как SELECT
            $this->getQueryType() != 0 && $this->setQueryType(0);

            // Если методу не был передан перечень полей, очищаем все возможно установленные ранее поля
            if ($data == "*")
            {
                $this->clearSelect();
            }
            // Если передана строка, в которой поля перечислены через запятую
            elseif (count($aFields = explode(",", $data)))
            {
                $aQuotedFields = [];

                foreach ($aFields as $field)
                {
                    $aQuotedFields[] = Core_Database::instance()->quoteColumnNames(trim($field));
                }

                $data = implode(", ", $aQuotedFields);
            }

            // ............................
        }

        // ................................
    }
    ?>

И вот тут мы остановимся. Остановимся, чтобы поговорить о таком важном аспекте, как производительность.

Сейчас у нас с вами не настоящее боевое приложение, а лишь пример несчастной авторизации и регистрации. А посмотрите, как много ради этого мы уже написали. И это ещё далеко не всё.

Метод Core_ORM::findAll() загружает строку результирующего набора SQL-запроса в класс. В нашем случае это класс User_Model. Согласно документации к предопределенным константам PDO при таком представлени результирующего набора в представленном классе значениями полей таблицы заполняются именованные свойства класса, а если их нет, то вызывается магический метод __set() указанного для PDO класса.

В наших классах моделей данных нет публичных свойств. Свойства — значения — соответствующие полям таблицы мы читаем или устанавливаем через магические методы Core_Entity::__get() и Core_Entity::__set(). Если мы создаем обект модели выражением Core_Entity::factory('User', $id), мы прямо указываем конструктору класса значение первичного ключа объекта в БД. На основе этого в объект загружаются из БД его данные из MySQL.

А что будет происходить при создании таких же экземпляров класса при получении результирующего набора от MySQL или иной СУБД? А ничего не произойдет. Экземпляра объекта класса будет создан. Но в нашей реализации в него ничего не будет загружено, так как MySQL не может передать значение первичного ключа классу. Конкретизируя проблему, у нас будет заполнен массив Core_ORM::$_newData, но будет пустым массив Core_ORM::$_initialData. Из-за этого мы не сможем ничего ни прочитать из объекта, ни записать в него, так как в нем отсутствует значение первичного ключа. И чё делать?

После создания экземпляров класса, наследующих Core_ORM, мы должны ещё раз убедиться в том, что в объект загружена информация о столбцах, и что в него записано значение первичного ключа. Сделать это придется в магическом методе Core_Entity::__set(). Пока у нас всего один такой класс User_Model, а в нем всего несколько полей, в этом нет никакой проблемы.

А что же будет, если, например, это будет класс товаров интернет-магазина, число которых исчисляется несколькими тысячами, а полей в каждом из них 20 или более? Это ощутимый удар по производительности. Вы устанете наращивать вычислительные мощности сервера.

По сути, не важно сколько товаров (пользователей, новостных статей и т.п.) мы загружаем в экземпляры объекта класса. Это же всё один и тот же класс модели, связанный с таблицей в БД. Список полей у них на всех один и тот же. Нам достаточно его получить один раз, сохранить, и затем лишь возвращать его из хранилища, если он там имеется.

Создадим для этих целей класс Core_Cache.


    <?php
    // Запрещаем прямой доступ к файлу
    defined('MYSITE') || exit('Прямой доступ к файлу запрещен');

    /**
    * Сохраняет данные, возвращает их в случае однотипных запросов или выражений, которые должны возвращать данные
    */
    class Core_Cache
    {
        /**
        * Экземпляр статического класса
        * @var object self
        */
        static protected $_instance = NULL;

        /**
        * Кэшированные данные
        * @var array
        */
        protected $_data = [];

        /**
        * Создает и возвращает экземпляр статического класса
        */
        static public function instance()
        {
            if (is_null(static::$_instance))
            {
                static::$_instance = new static();
            }

            return static::$_instance;
        }

        /**
        * Проверяет наличие данных в объекте
        * @param string $name
        * @param mixed string|NULL $key
        * @return boolean 
        */
        public function check($name, $key = NULL) : bool
        {
            $return = FALSE;
    
            if (is_null($key))
            {
                $return = !empty($this->_data[$name]);
            }
            else
            {
                $return = !empty($this->_data[$name][$key]);
            }
    
            return $return;
        }
   
        /**
        * Устанавливает в объект данные для хранения
        * @param string $name
        * @param string $key
        * @param mixed $data
        * @return object self
        */
        public function set($name, $key, $data)
        {
            $this->_data[$name][$key] = $data;

            return $this;
        }

        /**
        * Получает данные из объекта, если они были сохранены
        * @param string $name
        * @param string $key
        * @return mixed 
        */
        public function get($name, $key)
        {
            return ($this->check($name, $key)) ? $this->_data[$name][$key] : NULL;
        }

        /**
        * Очищает объект от хранимых данных
        * @param mixed $name string|NULL
        * @param mixed $key string|NULL
        * @return boolean
        */
        public function clean($name = NULL, $key = NULL) : bool
        {
            $return = FALSE;

            if (is_null($name) && is_null($key))
            {
                $this->_data = [];

                $return = $this->_data === [];
            }
            elseif (!is_null($name) && is_null($key))
            {
                unset($this->_data[$name]);

                $return = !isset($this->_data[$name]);
            }
            elseif (!is_null($name) && !is_null($key))
            {
                unset($this->_data[$name][$key]);

                $return = !isset($this->_data[$name][$key]);
            }

            return $return;
        }

        /**
        * Получает и возвращает все хранимые в объекте данные
        * @return array
        */
        public function getAll() : array
        {
            return $this->_data;
        }
    }
    ?>

Класс простенький, статический, но очень важный. Ещё один нюанс. Не рекомендуется использовать в сценарии глобальные переменные. Но у нас, так или иначе, иногда возникает необходимость передавать данные, хранить их, получать между разными частями сценария. Неплохо бы иметь нечто похожее на хранилище данных, доступное из любой части сценария — из любого отдельного файла на сервере. В этом нам поможет паттерн Registry — Реестр.


    <?php
    // Запрещаем прямой доступ к файлу
    defined('MYSITE') || exit('Прямой доступ к файлу запрещен');

    /**
    * Паттерн Реестр. Безопасная замена глобальным переменным
    */
    class Core_Registry
    {
        /**
        * Экземпляр статического класса
        * @var object self
        */
        static protected $_instance = NULL;

        /**
        * Хранимые данные
        * @var array
        */
        protected $_data = [];

        /**
        * Создает и возвращает экземпляр статического класса
        */
        static public function instance()
        {
            if (is_null(static::$_instance))
            {
                static::$_instance = new static();
            }

            return static::$_instance;
        }

        /**
        * Проверяет наличие данных в объекте
        * @param string $name
        * @return boolean 
        */
        public function check($name) : bool
        {
            return !empty($this->_data[$name]);
        }
    
        /**
        * Устанавливает в объект данные для хранения
        * @param string $name
        * @param mixed $data
        * @return object self
        */
        public function set($name, $data)
        {
            $this->_data[$name] = $data;
    
            return $this;
        }
    
        /**
        * Получает данные из объекта, если они были сохранены
        * @param string $name
        * @return mixed 
        */
        public function get($name)
        {
            return ($this->check($name)) ? $this->_data[$name] : NULL;
        }
    
        /**
        * Очищает объект от хранимых данных
        * @param mixed $name string|NULL
        * @return boolean
        */
        public function clean($name = NULL) : bool
        {
            $return = FALSE;
    
            if (is_null($name))
            {
                $this->_data = [];
    
                $return = $this->_data === [];
            }
            else
            {
                unset($this->_data[$name]);
    
                $return = !isset($this->_data[$name]);
            }
    
            return $return;
        }
        
        /**
        * Получает и возвращает все хранимые в объекте данные
        * @return array
        */
        public function getAll() : array
        {
            return $this->_data;
        }
    }
    ?>

Тоже простенький статический класс, прям точная копия Core_Cache. Это очень важные компоненты приложения.

Посмотрим теперь на код метода Core_ORM::findAll() и иные изменения в нем.


    <?php
    class Core_ORM
    {
        // ...................................
        
        /**
        * Загружает информацию о столбцах таблицы модели в БД
        */
        protected function _loadColumns()
        {
            /**
            * Экземляров объектов класса может быть великое множество. Но все они, 
            * по сути, являются представлением строк одной и той же таблицы. 
            * А значит, у всех их будут одни и те же имена ячеек в результирующем наборе.
            * Можно один раз для модели эти имена считать, сохранить, и затем лишь
            * возвращать эти данные из хранилища
            */
            if (!Core_Cache::instance()->check($this->getModelName(), "columnCache"))
            {
                $oCore_Database = Core_Database::instance();
                $this->_columns = $oCore_Database->getColumns($this->getTableName());
                
                Core_Cache::instance()->set($this->getModelName(), "columnCache", $this->_columns);

                // Определяем, какое из полей таблицы имеет первичный ключ
                $this->findPrimaryKeyFieldName();

                return $this;
            }

            // Возвращаем данные из хранилища
            $this->_columns = Core_Cache::instance()->get($this->getModelName(), "columnCache", $this->_columns);
        }

        /**
        * Загружает первичную информацию модели
        */
        protected function _load($property = NULL)
        {
            // Создает персональный для объекта экземпляр класса построителя запросов
            $this->queryBuilder();
            
            // Если был указан идентификатор объекта
            !is_null($property) && !is_null($this->_initialData[$this->_primaryKey])
                // Добавляем его значение в условие отбора
                && $this->queryBuilder()
                        ->where($this->_primaryKey, '=', $this->getPrimaryKey());
            
            // Если задано имя свойства для загрузки
            if (!is_null($property))
            {
                // Вызываем построитель запросов
                $this->queryBuilder()
                    // Очищаем перечень полей
                    ->clearSelect()
                    // Устанавливаем перечень полей
                    ->select($property);

                $stmt = $this->queryBuilder()
                            // Формируем строку запроса
                            ->query()
                            // Результат получим в виде ассоциативного массива
                            ->asAssoc()
                            // Выполняем запрос
                            ->result();
                
                // Пытаемся получить результирующий набор
                $aPDOStatementResult = $stmt->fetch();

                // Очищаем перечень полей для запроса
                $this->queryBuilder()->clearSelect();

                $sCacheName = $this->_primaryKey . "." . $stmt->queryString;

                // Если результирующий набор не пустой, и если он не был кэширован
                if (!empty($aPDOStatementResult) && !Core_Cache::instance()->check($this->getModelName(), $sCacheName)) 
                {
                    // Сохраняем в объект результирующий набор
                    $this->_initialData[$property] = $aPDOStatementResult[$property];

                    // Кэшируем результирующий набор
                    Core_Cache::instance()->set($this->getModelName(), $sCacheName, $aPDOStatementResult[$property]);
                }
                // Если результирующий набор был кэширован
                elseif (Core_Cache::check($this->getModelName(), $this->_primaryKey . "." . $property))
                {
                    $this->_initialData[$property] = Core_Cache::instance()->get($this->getModelName(), $sCacheName);
                }
            }
        }

        // ...................................
        
        /**
        * Конструктор класса. Его можно вызвать только изнутри, либо фабричным методом
        */
        protected function __construct($primary_key = NULL)
        {
            // Инициализируем объект модели
            $this->_init();
            
            // Если конструктору было передано значение первичного ключа, сохраняем его в исходных данных объекта
            if (!isset($this->_initialData[$this->_primaryKey]))
            {
                $this->_initialData[$this->_primaryKey] = $primary_key;
            }
            
            // Загружаем из БД информацию объекта
            $this->_load();
            
            // Очищаем массив для новый значений свойств
            $this->clear();

            // Объект модели загружен данными
            $this->_loaded = TRUE;
        }

        // ...................................
        
        /**
        * Получает из базы данных все записи, которые вернет подготовленный запрос в соответствии с условиями
        * @return array
        */
        public function findAll()
        {
            // Переключаемся на выполнение запроса к БД
            $oCore_Querybuilder_Select = $this->queryBuilder()
                                            // Очищаем перечень запрашиваемых полей
                                            ->clearSelect()
                                            // Выбираем из базы данных значений тех полей модели, которые разрешены к чтению
                                            ->select(implode(", ", $this->_allowedProperties))
                                            ->asObject(get_class($this));
            
            /**
            * Если у объекта модели установлено значение первичноо ключа, добавляем его к условиям запроса
            * В ином случае вернется всё множество строк, соответствующих SQL-запросу
            */
            !is_null($this->getPrimaryKey()) 
                && $oCore_Querybuilder_Select->where($this->_primaryKey, '=', $this->getPrimaryKey());

            $sCacheKey = $oCore_Querybuilder_Select->build();

            // Если результат такого же запроса уже был кэширован
            if (!Core_Cache::instance()->check($this->getModelName(), $sCacheKey))
            {
                // Результат запроса сохраняем в объекте
                $this->_statement = $oCore_Querybuilder_Select->query()
                                        ->result();

                $return = $this->_statement->fetchAll();

                // Записываем данные в кэш
                Core_Cache::instance()->set($this->getModelName(), $sCacheKey, $return);
            }
            else
            {
                // Получаем данные из кэша
                $return = Core_Cache::instance()->get($this->getModelName(), $sCacheKey);
            }

            // Возвращаем резльутирующий набор вызову
            return $return;
        }

        // ...................................
        
        /**
        * Очищает объект от пользовательских данных
        * @return object self
        */
        public function clear()
        {
            // Если объект был создан результирующим набором СУБД, в него установлены значения полей, 
            // но они установлены с помощью магического метода __set()
            if (!$this->_loaded && !empty($this->_newData))
            {
                $this->_initialData = $this->_newData;
            }

            $this->_newData = [];

            return $this;
        }

        
        /**
        * Магический метод для установки значений необъявленных свойств класса
        * @param string $property
        * @param mixed $value
        */
        public function __set(string $property, $value)
        {
            // Если объект был создан загрузкой данных из результирующего набора, информации о столбцах может не быть
            $this->_loadColumns();
            
            // Если у таблицы, соответствующей модели, есть столбец с теким именем, 
            // то устанавливаем значение для последующего сохранения
            if (array_key_exists($property, $this->_columns))
            {
                $this->_newData[$property] = $value;
            }

            return $this;
        }

        /**
        * Магический метод для получения значения необъявленного свойства класса
        * Вернет значение из запрошенного поля таблицы, если оно разрешено в массиве $_allowedProperties и есть среди полей таблицы
        * @return mixed string|NULL
        */
        public function __get(string $property) : string | NULL
        {
            return ((array_key_exists($property, $this->_columns) && in_array($property, $this->_allowedProperties)) ? $this->load($property) : NULL);
        }
    }
    ?>

Теперь при выполнении одного и того же запроса будут возвращаться данные из кэша, они не будут запрашиваться в БД.

Мы перенесли в класс Core_ORM и магические методы __set() и __get(). Более того, сюда же мы перенесём методы save() и update() из класса Core_Entity. В нынешнем контексте это логичнее. Класс Core_Entity у нас в последующе будет использоваться исключительно для представления данных обекътов моделей пользователю. Но эта тема уже для следующих статей.

Но ведь возможны случаи, в которых между такими вызовами данные претерпят изменения. Через метод Core_Entity::update(), например. Значит в методах Core_Entity::save() и Core_Entity::update() мы добавим очистку данных в кэше.


    <?php
    class Core_ORM
    {
        // .............................

        /**
        * Сохраняет информацию о модели в БД
        * @return object self
        */
        public function save()
        {
            // Если массив $this->_newData не пустой
            if (!empty($this->_newData))
            {
                // Если в объекте есть значение первичного ключа записи таблицы БД, значит информация не добавляется
                // в таблицу, а обновляется для существующей записи
                if (!is_null($this->getPrimaryKey()))
                {
                    // Выполняем метод update()
                    return $this->update();
                }

                // .............................

                // Если запрос был выполнен успешно, очищаем массив с данными для записи в БД
                if (Core_Database::instance()->getRowCount())
                {
                    // Если данные модели ранее были кэшированы
                    if (Core_Cache::instance()->check($this->getModelName()))
                    {
                        // Удаляем их
                        Core_Cache::instance()->clean($this->getModelName());
                    }

                    // .............................
                }
            }
            
            // .............................
        }

        public function update()
        {
            try {
                // Если массив $this->_newData не пустой, и у модели уже есть соответствующая ей запись в таблице БД
                if (!empty($this->_newData) && !is_null($this->getPrimaryKey()))
                {
                    // .............................

                    // Если запрос был выполнен успешно, очищаем массив с данными для записи в БД
                    if (Core_Database::instance()->getRowCount())
                    {
                        // Если данные модели ранее были кэшированы
                        if (Core_Cache::instance()->check($this->getModelName()))
                        {
                            // Удаляем их
                            Core_Cache::instance()->clean($this->getModelName());
                        }
                        
                        // .............................
                    }
                }
                
                // .............................
            }
            
            // .............................
        }
    
        // .............................
    }
    ?>

Итак, вернемся к тому, что же должен делать метод Core_ORM::findAll()


    $oUser = Core_Entity::factory('User', 40);
    $result = $oUser->findAll();

    print "<p>Мы загрузили из базы данных информацию о пользователе с идентификатором 40.</p>";

    print "<pre>";
    var_dump($result);
    print "</pre>";

    print "<hr />";
    print "<p>Изменяем данные о пользователе. Сейчас они такие.</p>";
    echo "<ul>
        <li>Логин: {$result[0]->login}</li>
        <li>Электропочта: {$result[0]->email}</li>
    </ul>";
    
    $oUser->login = "demo_new";
    $oUser->email = "demo_new@lezhenkin.ru";
    $oUser->save();
    
    $result = $oUser->findAll();

    print "<hr />";
    print "<p>Мы изменили данные о пользователе. Сейчас они такие.</p>";
    echo "<ul>
        <li>Логин: {$result[0]->login}</li>
        <li>Электропочта: {$result[0]->email}</li>
    </ul>";

Теперь упомнятый выше код должен вывести на экран это:


    Мы загрузили из базы данных информацию о пользователе с идентификатором 40.

    array(1) {
    [0]=>
    object(User_Model)#6 (16) {
        ["_queryType":protected]=>
        NULL
        ["_primaryKey":protected]=>
        string(2) "id"
        ["_modelName":protected]=>
        string(4) "user"
        ["_tableName":protected]=>
        string(5) "users"
        ["_init":protected]=>
        bool(true)
        ["_loaded":protected]=>
        bool(true)
        ["_database":protected]=>
        object(Core_Database_Pdo)#2 (7) {
        ["_config":protected]=>
        array(8) {
            ["host"]=>
            string(9) "localhost"
            ["user"]=>
            string(4) "demo"
            ["password"]=>
            string(16) "fy)s@6!9cJ*g!xvZ"
            ["dbname"]=>
            string(15) "demo_auth_reg_2"
            ["options"]=>
            array(1) {
            [12]=>
            bool(true)
            }
            ["charset"]=>
            string(4) "utf8"
            ["driverName"]=>
            string(5) "mysql"
            ["attr"]=>
            array(1) {
            [12]=>
            bool(false)
            }
        }
        ["_lastQuery":protected]=>
        string(112) "SELECT `id`, `login`, `email`, `registration_date`, `active` FROM `users` WHERE  `deleted` = '0' AND `id` = '40'"
        ["_connection":protected]=>
        object(PDO)#3 (0) {
        }
        ["_lastQueryRows":protected]=>
        int(1)
        ["_result":protected]=>
        object(PDOStatement)#8 (1) {
            ["queryString"]=>
            string(112) "SELECT `id`, `login`, `email`, `registration_date`, `active` FROM `users` WHERE  `deleted` = '0' AND `id` = '40'"
        }
        ["_fetchType":protected]=>
        int(8)
        ["_asObject":protected]=>
        string(10) "User_Model"
        }
        ["_queryBuilder":protected]=>
        object(Core_Querybuilder_Select)#9 (8) {
        ["_database":protected]=>
        object(Core_Database_Pdo)#2 (7) {
            ["_config":protected]=>
            array(8) {
            ["host"]=>
            string(9) "localhost"
            ["user"]=>
            string(4) "demo"
            ["password"]=>
            string(16) "fy)s@6!9cJ*g!xvZ"
            ["dbname"]=>
            string(15) "demo_auth_reg_2"
            ["options"]=>
            array(1) {
                [12]=>
                bool(true)
            }
            ["charset"]=>
            string(4) "utf8"
            ["driverName"]=>
            string(5) "mysql"
            ["attr"]=>
            array(1) {
                [12]=>
                bool(false)
            }
            }
            ["_lastQuery":protected]=>
            string(112) "SELECT `id`, `login`, `email`, `registration_date`, `active` FROM `users` WHERE  `deleted` = '0' AND `id` = '40'"
            ["_connection":protected]=>
            object(PDO)#3 (0) {
            }
            ["_lastQueryRows":protected]=>
            int(1)
            ["_result":protected]=>
            object(PDOStatement)#8 (1) {
            ["queryString"]=>
            string(112) "SELECT `id`, `login`, `email`, `registration_date`, `active` FROM `users` WHERE  `deleted` = '0' AND `id` = '40'"
            }
            ["_fetchType":protected]=>
            int(8)
            ["_asObject":protected]=>
            string(10) "User_Model"
        }
        ["_select":protected]=>
        array(1) {
            [0]=>
            string(1) "*"
        }
        ["_from":protected]=>
        string(7) "`users`"
        ["_fields":protected]=>
        array(0) {
        }
        ["_values":protected]=>
        array(0) {
        }
        ["_tableName":protected]=>
        NULL
        ["_where":protected]=>
        array(1) {
            [0]=>
            string(15) "`deleted` = '0'"
        }
        ["_queryType":protected]=>
        NULL
        }
        ["_sql":protected]=>
        NULL
        ["_lastQuery":protected]=>
        NULL
        ["_statement":protected]=>
        NULL
        ["_initialData":protected]=>
        array(5) {
        ["id"]=>
        int(40)
        ["login"]=>
        string(4) "demo"
        ["email"]=>
        string(17) "demo@lezhenkin.ru"
        ["registration_date"]=>
        string(19) "2024-01-16 15:45:17"
        ["active"]=>
        int(0)
        }
        ["_newData":protected]=>
        array(0) {
        }
        ["_columns":protected]=>
        array(7) {
        ["id"]=>
        array(6) {
            ["name"]=>
            string(2) "id"
            ["columntype"]=>
            string(16) "int(10) unsigned"
            ["null"]=>
            bool(false)
            ["key"]=>
            string(3) "PRI"
            ["default"]=>
            NULL
            ["extra"]=>
            string(14) "auto_increment"
        }
        ["login"]=>
        array(6) {
            ["name"]=>
            string(5) "login"
            ["columntype"]=>
            string(8) "char(16)"
            ["null"]=>
            bool(false)
            ["key"]=>
            string(0) ""
            ["default"]=>
            NULL
            ["extra"]=>
            string(0) ""
        }
        ["email"]=>
        array(6) {
            ["name"]=>
            string(5) "email"
            ["columntype"]=>
            string(8) "char(32)"
            ["null"]=>
            bool(false)
            ["key"]=>
            string(0) ""
            ["default"]=>
            NULL
            ["extra"]=>
            string(0) ""
        }
        ["password"]=>
        array(6) {
            ["name"]=>
            string(8) "password"
            ["columntype"]=>
            string(9) "char(255)"
            ["null"]=>
            bool(false)
            ["key"]=>
            string(0) ""
            ["default"]=>
            NULL
            ["extra"]=>
            string(0) ""
        }
        ["registration_date"]=>
        array(6) {
            ["name"]=>
            string(17) "registration_date"
            ["columntype"]=>
            string(9) "timestamp"
            ["null"]=>
            bool(false)
            ["key"]=>
            string(0) ""
            ["default"]=>
            string(19) "current_timestamp()"
            ["extra"]=>
            string(0) ""
        }
        ["active"]=>
        array(6) {
            ["name"]=>
            string(6) "active"
            ["columntype"]=>
            string(19) "tinyint(1) unsigned"
            ["null"]=>
            bool(false)
            ["key"]=>
            string(3) "MUL"
            ["default"]=>
            string(1) "1"
            ["extra"]=>
            string(0) ""
        }
        ["deleted"]=>
        array(6) {
            ["name"]=>
            string(7) "deleted"
            ["columntype"]=>
            string(19) "tinyint(1) unsigned"
            ["null"]=>
            bool(false)
            ["key"]=>
            string(3) "MUL"
            ["default"]=>
            string(1) "0"
            ["extra"]=>
            string(0) ""
        }
        }
        ["_allowedProperties":protected]=>
        array(5) {
        [0]=>
        string(2) "id"
        [1]=>
        string(5) "login"
        [2]=>
        string(5) "email"
        [3]=>
        string(17) "registration_date"
        [4]=>
        string(6) "active"
        }
        ["_forbiddenProperties":protected]=>
        array(2) {
        [0]=>
        string(8) "password"
        [1]=>
        string(7) "deleted"
        }
    }
    }

    Изменяем данные о пользователе. Сейчас они такие.

        Логин: demo
        Электропочта: demo@lezhenkin.ru

    Мы изменили данные о пользователе. Сейчас они такие.

        Логин: demo_new
        Электропочта: demo_new@lezhenkin.ru


Теперь нам осталось это всё проверить в работе с формами авторизации и регистрации. Начнем с регистрации. Наш файл /users.php почти не изменился.


    <?php
    require_once('bootstrap.php');

    // Здесь будет храниться результат обработки форм
    $aFormHandlerResult = [];

    // Если была заполнена форма
    // Здесь вместо прежних $_POST['sign-in'] и $_POST['sign-up'] мы используем константы класса USER
    // Это позволяет избежать ошибок в коде разных сценариев и страниц, когда дело касается одних и тех же сущностей
    if (!empty($_POST[User_Model::ACTION_SIGNIN]) || !empty($_POST[User_Model::ACTION_SIGNUP]))
    {
        // Создаем объект класса User_Model
        $oUser = Core_Entity::factory('User');
        
        // Обрабатываем пользовательские данные
        // Теперь здесь мы не определяем авторизуется или регистрируется пользователь.
        // Просто вызываем метод User_Model::processUserData(), который это и определит
        $aFormHandlerResult = $oUser->processUserData($_POST);
    }

    // Создаем объект класса User_Model авторизованного пользователя
    // Если пользователь не авторизован, новый объект будет иметь значение NULL
    $oCurrentUser = Core_Entity::factory('User')->getCurrent();

    // Если пользователь желает разлогиниться
    if (!empty($_GET['action']) && $_GET['action'] == User_Model::ACTION_LOGOUT && !is_null($oCurrentUser))
    {
        // Завершаем сеанс пользователя
        $oCurrentUser->unsetCurrent();
    }

    // Если пользователь вводил данные, покажем их ему
    $sLogin = (!empty($aFormHandlerResult['data']['login'])) ? htmlspecialchars_decode($aFormHandlerResult['data']['login']) : "";
    $sEmail = (!empty($aFormHandlerResult['data']['email'])) ? htmlspecialchars_decode($aFormHandlerResult['data']['email']) : "";

    // Остальной код страницы остался практически тем же
    ?>
    <!DOCTYPE html>
    <html lang="ru">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Личный кабинет пользователя. Авторизация/регистрация. PHP+MySQL+JavaScript,jQuery</title>
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
        <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r" crossorigin="anonymous"></script>
        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js" integrity="sha384-BBtl+eGJRgqQAUMxJ7pMwbEyER4l1g+O15P+16Ep7Q9Q+zqX6gSbd85u4mG4QzX+" crossorigin="anonymous"></script>
        <script>
            (() => {
                'use strict'
                document.addEventListener('DOMContentLoaded', (event) => {
                    // Fetch all the forms we want to apply custom Bootstrap validation styles to
                    const forms = document.querySelectorAll('.needs-validation');

                    if (forms)
                    {
                        // Loop over them and prevent submission
                        Array.from(forms).forEach(form => {
                            form.addEventListener('submit', event => {
                            if (!form.checkValidity()) {
                                event.preventDefault();
                                event.stopPropagation();
                            }

                            form.classList.add('was-validated')
                            }, false);
                        });
                    }
                });
            })();
        </script>
    </head>
    <body>
        <div class="container-md container-fluid">
            <h1 class="my-3">Личный кабинет пользователя</h1>
            <p>
                Сейчас вы <?php echo (!is_null($oCurrentUser)) ? "авторизованы" : "не авторизованы";?> на сайте. <br />
                <?php
                if (!is_null($oCurrentUser))
                {
                    echo "<p>Ваш логин: <strong>" . $oCurrentUser->login . "</strong>.</p>";
                    echo "<p>Вы можете <a href='/users.php?action=exit'>выйти</a> из системы.</p>";
                }
                // Если пользователь вышел из системы
                elseif (!empty($_GET['action']))
                {
                    if ($_GET['action'] == User_Model::ACTION_LOGOUT)
                    {
                        echo "Вы успешно вышли из системы";
                    }
                }

                // Перенаправим пользователя на главную страницу при успешной авторизации/регистрации
                if (!empty($aFormHandlerResult['success']) && $aFormHandlerResult['success'] === TRUE)
                {
                ?>
                    <script>
                        setTimeout(() => {
                            window.location.href="/";
                        }, 3000);
                    </script>
                <?php
                }
                ?>
            </p>
            <?php
            // Блок с формами авторизации/регистрации показываем только неавторизованным пользователям
            if (!!is_null($oCurrentUser))
            {
            ?>
                <ul class="nav nav-tabs my-3" id="user-action-tabs" role="tablist">
                    <li class="nav-item" role="presentation">
                        <button class="nav-link <?php print (empty($aFormHandlerResult) || $aFormHandlerResult['type'] == User_Model::ACTION_SIGNIN) ? "active" : "";?>" id="user-auth-tab" data-bs-toggle="tab" data-bs-target="#user-auth-tab-pane" type="button" role="tab" aria-controls="user-auth-tab-pane" aria-selected="true">Авторизация</button>
                    </li>
                    <li class="nav-item" role="presentation">
                        <button class="nav-link <?php print (!empty($aFormHandlerResult) && $aFormHandlerResult['type'] == User_Model::ACTION_SIGNUP) ? "active" : "";?>" id="user-reg-tab" data-bs-toggle="tab" data-bs-target="#user-reg-tab-pane" type="button" role="tab" aria-controls="user-reg-tab-pane" aria-selected="false">Регистрация</button>
                    </li>
                </ul>
                <div class="tab-content bg-light" id="user-action-tabs-content">
                    <div class="tab-pane fade px-3 <?php print (empty($aFormHandlerResult) || $aFormHandlerResult['type'] == User_Model::ACTION_SIGNIN) ? "show active" : "";?>" id="user-auth-tab-pane" role="tabpanel" aria-labelledby="user-auth-tab-pane" tabindex="0">
                        <div class="row">
                            <div class="col-xxl-8 col-md-10 rounded text-dark p-3">
                                <!-- Блок для сообщений о результате обработки формы -->
                                <?php
                                // Если была обработана форма
                                if (!empty($aFormHandlerResult) && $aFormHandlerResult['type'] == User_Model::ACTION_SIGNIN)
                                {
                                    $sClass = match($aFormHandlerResult['success']) {
                                                TRUE => "my-3 alert alert-success",
                                                FALSE => "my-3 alert alert-danger"
                                    };

                                    ?>
                                    <div class="<?=$sClass?>">
                                        <?=$aFormHandlerResult['message'];?>
                                    </div>
                                    <?php
                                }
                                ?>
                                <h3 class="my-3">Авторизация пользователя</h3>
                                <form id="form-auth" class="needs-validation" name="form-auth" action="/users.php" method="post" autocomplete="off" novalidate>
                                    <div class="my-3">
                                        <label for="auth-login">Логин или электропочта:</label>
                                        <input type="text" id="auth-login" name="login" class="form-control" placeholder="Ваши логин или электропочта" required value="<?php print (empty($aFormHandlerResult) || $aFormHandlerResult['type'] == User_Model::ACTION_SIGNIN) ? $sLogin : "";?>" />
                                        <div class="error invalid-feedback" id="auth-login_error"></div>
                                        <div class="help form-text" id="auth-login_help">Напишите логин или адрес электропочты, указанные вами при регистрации на сайте</div>
                                    </div>
                                    <div class="my-3">
                                        <label for="auth-password">Пароль:</label>
                                        <input type="password" id="auth-password" name="password" class="form-control" placeholder="Напишите ваш пароль" required />
                                        <div class="error invalid-feedback" id="auth-password_error"></div>
                                        <div class="help form-text" id="auth-password_help">Напишите пароль, указанный вами при регистрации на сайте</div>
                                    </div>
                                    <div class="my-3">
                                        <input type="submit" class="btn btn-primary" id="auth-submit" name="<?=User_Model::ACTION_SIGNIN;?>" value="Войти" />
                                    </div>
                                </form>
                            </div>
                        </div>
                    </div>
                    <div class="tab-pane fade px-3 <?php print (!empty($aFormHandlerResult) && $aFormHandlerResult['type'] == User_Model::ACTION_SIGNUP) ? "show active" : "";?>" id="user-reg-tab-pane" role="tabpanel" aria-labelledby="user-reg-tab-pane" tabindex="0">
                        <div class="row">
                            <div class="col-xxl-8 col-md-10 rounded text-dark p-3">
                                <!-- Блок для сообщений о результате обработки формы -->
                                <?php
                                // Если была обработана форма
                                if (!empty($aFormHandlerResult) && $aFormHandlerResult['type'] == User_Model::ACTION_SIGNUP)
                                {
                                    $sClass = match($aFormHandlerResult['success']) {
                                                TRUE => "my-3 alert alert-success",
                                                FALSE => "my-3 alert alert-danger"
                                    };
                                
                                    ?>
                                    <div class="<?=$sClass?>">
                                        <?=$aFormHandlerResult['message'];?>
                                    </div>
                                    <?php
                                }
                                ?>
                                <h3 class="my-3">Регистрация пользователя</h3>
                                <form id="form-reg" class="needs-validation" name="form-reg" action="/users.php" method="post" autocomplete="off" novalidate>
                                    <div class="row gy-2 mb-3">
                                        <div class="col-md">
                                            <label for="reg-login">Логин:</label>
                                            <input type="text" id="reg-login" name="login" class="form-control" placeholder="Ваш логин для регистрации" required value="<?php print (!empty($aFormHandlerResult) && $aFormHandlerResult['type'] == User_Model::ACTION_SIGNUP) ? $sLogin : "";?>" />
                                            <div class="error invalid-feedback" id="reg-login_error">Логин введен неверно</div>
                                            <div class="help form-text" id="reg-login_help">Напишите логин для регистрации на сайте</div>
                                        </div>
                                        <div class="col-md">
                                            <label for="reg-email">Электропочта:</label>
                                            <input type="email" id="reg-email" name="email" class="form-control" placeholder="Ваш адрес электропочты" required value="<?php print (!empty($aFormHandlerResult) && $aFormHandlerResult['type'] == User_Model::ACTION_SIGNUP) ? $sEmail : "";?>" />
                                            <div class="error invalid-feedback" id="reg-email_error"></div>
                                            <div class="help form-text" id="reg-email_help">Напишите ваш действующий адрес электропочты для регистрации на сайте</div>
                                        </div>
                                    </div>
                                    <div class="row gy-2 mb-3">
                                        <div class="col-md">
                                            <label for="reg-password">Пароль:</label>
                                            <input type="password" id="reg-password" name="password" class="form-control" placeholder="Напишите ваш пароль" required />
                                            <div class="error invalid-feedback" id="reg-password_error"></div>
                                            <div class="help form-text" id="reg-password_help">Напишите пароль, для регистрации на сайте</div>
                                        </div>
                                        <div class="col-md">
                                            <label for="reg-password2">Подтверждение пароля:</label>
                                            <input type="password" id="reg-password2" name="password2" class="form-control" placeholder="Повторите ваш пароль" required />
                                            <div class="error invalid-feedback" id="reg-password2_error"></div>
                                            <div class="help form-text" id="reg-password2_help">Повторите пароль для его подтверждения и исключения ошибки</div>
                                        </div>
                                    </div>
                                    <div class="my-3 d-flex">
                                        <input type="submit" class="btn btn-success me-3" id="reg-submit" name="<?=User_Model::ACTION_SIGNUP;?>" value="Зарегистрироваться" />
                                        <input type="reset" class="btn btn-danger" id="reg-reset" name="reset" value="Очистить" />
                                    </div>
                                </form>
                            </div>
                        </div>
                    </div>
                </div>
            <?php
            }
            ?>  
        </div>
    </body>
    </html>

Обработчик данных формы находится в классе User_Model::processUserData().


    /**
     * Обрабатывает данные, которыми пользователь заполнил форму
     * @param array $post
     */
    public function processUserData(array $post)
    {
        $aReturn = [
            'success' => FALSE,
            'message' => "При обработке формы произошла ошибка",
            'data' => [],
            'type' => static::ACTION_SIGNIN
        ];
        
        // Если не передан массив на обработку, останавливаем работу сценария
        if (empty($post))
        {
            die("<p>Для обработки пользовательских данных формы должен быть передан массив</p>");
        }
        
        // Если в массиве отсутствуют данные о типе заполненной формы, останавливаем работу сценария
        if (empty($post[static::ACTION_SIGNIN]) && empty($post[static::ACTION_SIGNUP]))
        {
            die("<p>Метод <code>User_Model::processUserData()</code> должен вызываться только для обработки данных из форм авторизации или регистрации</p>");
        }

        // Флаг регистрации нового пользователя
        $bRegistrationUser = !empty($post[static::ACTION_SIGNUP]);

        // Логин и пароль у нас должны иметься в обоих случаях
        $sLogin = strval(htmlspecialchars(trim($post['login'])));
        $sPassword = strval(htmlspecialchars(trim($post['password'])));

        // А вот электропочта и повтор пароля будут только в случае регистрации
        if ($bRegistrationUser)
        {
            $aReturn['type'] = static::ACTION_SIGNUP;

            $sEmail = strval(htmlspecialchars(trim($_POST['email'])));
            $sPassword2 = strval(htmlspecialchars(trim($_POST['password2'])));

            // Проверяем данные на ошибки
            if ($this->validateEmail($sEmail))
            {
                // Логин и пароли не могут быть пустыми
                if (empty($sLogin))
                {
                    $aReturn['message'] = "Поле логина не было заполнено";
                    $aReturn['data'] = $post;
                }
                elseif (empty($sPassword))
                {
                    $aReturn['message'] = "Поле пароля не было заполнено";
                    $aReturn['data'] = $post;
                }
                // Пароли должны быть идентичны
                elseif ($sPassword !== $sPassword2)
                {
                    $aReturn['message'] = "Введенные пароли не совпадают";
                    $aReturn['data'] = $post;
                }
                // Если логин не уникален
                elseif ($this->isValueExist($sLogin, 'login'))
                {
                    $aReturn['message'] = "Указанный вами логин ранее уже был зарегистрирован";
                    $aReturn['data'] = $post;
                }
                // Если email не уникален
                elseif ($this->isValueExist($sEmail, 'email'))
                {
                    $aReturn['message'] = "Указанный вами email ранее уже был зарегистрирован";
                    $aReturn['data'] = $post;
                }
                // Если все проверки прошли успешно, можно регистрировать пользователя
                else
                {
                    $this->login = $sLogin;
                    // Пароль теперь нет необходимости отдельно хэшировать перед сохранением
                    // Это происходит автоматически с помощью метода __set()
                    $this->password = $sPassword;
                    $this->email = $sEmail;
                    $this->save();

                    if (Core_Database::instance()->lastInsertId())
                    {
                        $aReturn['success'] = TRUE;
                        $aReturn['message'] = "Пользователь с логином <strong>{$sLogin}</strong> и email <strong>{$sEmail}</strong> успешно зарегистрирован.";
                        $aReturn['data']['user_id'] = Core_Database::instance()->lastInsertId();
                    }
                }
            }
            else
            {
                $aReturn['message'] = "Указанное значение адреса электропочты не соответствует формату";
                $aReturn['data'] = $post;
            }
        }
        // Если пользователь авторизуется
        else
        {
            // Если не передан пароль
            if (empty($sPassword))
            {
                $aReturn['message'] = "Поле пароля не было заполнено";
                $aReturn['data'] = $post;
            }
            else 
            {
                // Ищем соответствие переданной информации в БД
                $oUserTarget = $this->getByLoginOrEmail($sLogin);

                // Если была найдена запись
                if (!is_null($oUserTarget))
                {
                    // Проверяем пароль пользователя
                    // Если хэш пароля совпадает
                    if ($oUserTarget->checkPassword($sPassword))
                    {
                        // Авторизуем пользователя
                        // Устанавливаем значение перв
                        $oUserTarget->setCurrent();
                        
                        $aReturn['success'] = TRUE;
                        $aReturn['message'] = "Вы успешно авторизовались на сайте";
                        $aReturn['data'] = $post;
                        $aReturn['data']['user_id'] = $oUserTarget->id;
                    }
                    else
                    {
                        $aReturn['message'] = "Для учетной записи <strong>{$sLogin}</strong> указан неверный пароль";
                        $aReturn['data'] = $post;
                    }
                }
            }
        }

        return $aReturn;
    }

Если пользователь регистрируется.

  1. Данные проходят проверку.
  2. Для объекта модели устанавливаются: логин, пароль, адрес электропочты.
  3. Пароль теперь не нужно хэшировать, это происходит автоматически при установке его значения. В этом момент вызывается магический метод User_Model::__set().
  4. Вызов метода User_Model::save() записывает в БД информацию о новом пользователе. Страница перезагружается, пользователь попадает на стартовую страницу.

Если пользователь авторизуется.

  1. Данные проходят проверку.
  2. Через вызов метода User_Model::getByLoginOrEmail() происходит поиск данных по заданному пользователю логину или адресу электропочты.
  3. Если запись в БД имеется, проверяется пароль на соответствие хранимому в БД хэшу.
  4. Если пароль соответствует, через вызов метода User_Model::setCurrent() пользователь авторизуется в системе.
  5. Обработка данных формы завершается, пользователь перенаправляется на главную страницу.

    <?php
    require_once('bootstrap.php');

    // Создаем объект авторизованного пользователя
    $oCurrentUser = Core_Entity::factory('User')->getCurrent();
    ?>
    <!DOCTYPE html>
    <html lang="ru">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Авторизация/регистрация. PHP+MySQL+JavaScript,jQuery</title>
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
        <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r" crossorigin="anonymous"></script>
        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js" integrity="sha384-BBtl+eGJRgqQAUMxJ7pMwbEyER4l1g+O15P+16Ep7Q9Q+zqX6gSbd85u4mG4QzX+" crossorigin="anonymous"></script>
    </head>
    <body>
        <div class="container-md container-fluid">
            <h1 class="my-3">Добро пожаловать на сайт</h1>
            <p class="h6">
                Вы зашли на сайт примера авторизации и регистрации пользователя. 
            </p>
            <p>
                Сейчас вы <?php echo (!is_null($oCurrentUser)) ? "авторизованы" : "не авторизованы";?> на сайте. <br />
                <?php
                if (!is_null($oCurrentUser))
                {
                    // Для вывода даты регистрации пользователя значение из формата TIMESTAMP, 
                    // в котором оно хранится в MySQL, нужно преобразовать
                    $oDateTime = new DateTime($oCurrentUser->registration_date);
                    
                    echo "<p>Ваш логин: <strong>" . $oCurrentUser->login . "</strong>.</p>";
                    echo "<p>Ваша электропочта: <strong>" . $oCurrentUser->email . "</strong>.</p>";
                    echo "<p>Вы зарегистрировались: <strong>" . $oDateTime->format("d.m.Y") . "</strong>.</p>";
                    echo "<p>Вы можете <a href='/users.php?action=exit'>выйти</a> из системы.</p>";
                }
                else
                {
                    ?>
                    <p>На этом сайте вам доступно:</p>
                    <ul class="list-unstyled">
                        <li>
                            <a href="/users.php">Авторизация и регистрация</a>
                        </li>
                    </ul>
                    <?php
                }
                ?>
            </p> 
        </div>
    </body>
    </html>

Для того, чтобы всё сработало так, как описано, мы изменили код некоторых частей класса User_Model.


    <?php
    class User_Model extends Core_Entity
    {
        // .....................
        
        /**
        * Возвращает объект пользователя, для которого в сессию записаны данные об авторизации
        * @return Core_ORM
        */
        protected function _getCurrentUser() : Core_ORM
        {
            try {
                // Если в сессии имеются данные об авторизованном пользователе
                if (isset($_SESSION['user_id']))
                {
                    return Core_Entity::factory('User', $_SESSION['user_id']);
                }
                else
                {
                    throw new Exception("<p>Ошибка " . __METHOD__ . ": данные об авторизованном пользователе утеряны или отсутствуют</p>");
                }
            }
            catch(Exception $e)
            {
                print $e->getMessage();

                die();
            }
        }

        // .....................
        
        /**
        * Получает информацию об авторизованном пользователе
        * @return mixed self | NULL если пользователь не авторизован
        */
        public function getCurrent()
        {
            $return = NULL;
            
            /**
            * Информация о пользователе, если он авторизован, хранится в сессии
            * Поэтому нужно просто проверить, имеется ли там нужная информация
            * Если в сессии её нет, значит пользователь не авторизован
            */
            (!empty($_SESSION['user_id']))
                    && $return = $this->_getCurrentUser();
            
            // Возвращаем результат вызову
            return $return;
        }

        /**
        * Устанавливает в сесии параметры пользователя, прошедшего авторизацию
        * @return object self
        */
        public function setCurrent()
        {
            $_SESSION['user_id'] = $this->getPrimaryKey();

            return $this;
        }

        /**
        * Завершает сеанс пользователя в системе
        * @return object self
        */
        public function unsetCurrent()
        {
            // Уничтожение данных о пользователе в сессии
            unset($_SESSION['user_id']);

            header("Refresh:0;"); die();

            return NULL;
        }

        /**
        * Ищет в БД запись по переданному значению полей login или email
        * @param string $value
        * @return mixed Core_ORM|NULL
        */
        public function getByLoginOrEmail(string $value) : User_Model|NULL
        {
            // Определяем тип авторизации: по логину или адресу электропочты
            $sType = NULL;
            $sType = match($this->validateEmail($value)) {
                        TRUE => 'email',
                        FALSE => 'login'
            };

            $oUser = Core_Entity::factory('User');
            $oUser->queryBuilder()
                ->clearSelect()
                ->select($this->_primaryKey)
                ->where($sType, '=', $value);

            // Ищем пользователя
            $aUsers = $oUser->findAll();
            
            // Возвращаем объект вызову
            return isset($aUsers[0]) ? $aUsers[0] : NULL;
        }

        /**
        * Проверяет пароль пользователя, совпадает ли он с хранимым в БД
        * @param string $password пароль пользователя
        * @return boolean TRUE|FALSE
        */
        public function checkPassword(string $password) : bool
        {
            $return = FALSE;

            try {
                // Если у объекта установлено значение первичного ключа
                if ($this->getPrimaryKey())
                {
                    // Создаем объект для запроса SELECT 
                    $oCore_Querybuilder_Select = Core_Querybuilder::select('password');
                    $oCore_Querybuilder_Select->from($this->getTableName())
                                            ->where($this->_primaryKey, '=', $this->getPrimaryKey())
                                            ->where('deleted', '=', 0);
                    
                    // Получаем данные
                    $aResult = $oCore_Querybuilder_Select->query()->asAssoc()->result()->fetch();

                    // Если данные получены
                    if (Core_Database::instance()->getRowCount())
                    {
                        $sHash = $aResult['password'];
                    }
                    else
                    {
                        $return = FALSE;
                    }
                }
                else
                {
                    throw new Exception("<p>Ошибка: " . __METHOD__ . ": невозможно проверить пароль для пустого объекта модели пользователя</p>");
                }
            }
            catch (Exception $e)
            {
                print $e->getMessage();

                die();
            }

            /**
            * Согласно документации к PHP, мы для подготовки пароля пользователя к сохранению в БД
            * мы использовали функцию password_hash() https://www.php.net/manual/ru/function.password-hash
            * Теперь для проверки пароля для авторизации нам нужно использовать функцию password_verify()
            * https://www.php.net/manual/ru/function.password-verify.php
            */
            if (password_verify($password, $sHash))
            {
                $return = TRUE;
            }

            return $return;
        }

        // .....................

        /**
        * Проверяет уникальность логина в системе
        * @param string $value
        * @param string $field
        * @return TRUE | FALSE
        */
        public function isValueExist($value, $field) : bool
        {
            // Подключаемся к СУБД
            $oCore_Querybuilder_Select = Core_Querybuilder::select();
            $oCore_Querybuilder_Select
                ->from('users')
                ->where($field, '=', $value)
                ->where('deleted', '=', 0);

            // Выполняем запрос
            try {
                $stmt = $oCore_Querybuilder_Select->query()->result();
            }
            catch (PDOException $e)
            {
                die("<p><strong>При выполнении запроса произошла ошибка:</strong> {$e->getMessage()}</p>");
            }
            
            // Если логин уникален, в результате запроса не должно быть строк
            return $stmt->rowCount() !== 0;
        }

        // .....................
    }
    ?>

Пожалуй, на этом всё. Впереди у нас ещё такое понятие как контроллеры. В следующей статье, конечно же.

Таблица пользователей осталась без изменений.


    --
    -- Структура таблицы `users`
    --

    CREATE TABLE `users` (
    `id` int(10) UNSIGNED NOT NULL,
    `login` char(16) NOT NULL,
    `email` char(32) NOT NULL,
    `password` char(255) NOT NULL,
    `registration_date` timestamp NOT NULL DEFAULT current_timestamp(),
    `active` tinyint(1) UNSIGNED NOT NULL DEFAULT 1,
    `deleted` tinyint(1) UNSIGNED NOT NULL DEFAULT 0
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

    --
    -- Индексы сохранённых таблиц
    --

    --
    -- Индексы таблицы `users`
    --
    ALTER TABLE `users`
    ADD PRIMARY KEY (`id`),
    ADD KEY `active` (`active`),
    ADD KEY `deleted` (`deleted`);

    --
    -- AUTO_INCREMENT для сохранённых таблиц
    --

    --
    -- AUTO_INCREMENT для таблицы `users`
    --
    ALTER TABLE `users`
    MODIFY `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;

Посмотреть на рабочий пример кода можно здесь.

Исходный код файлов можно заполучить по ссылке.

Также особым извращенцам доступен просмотр документации в виде phpDocumentor здесь.


PHP
Сайт принадлежит ООО Группа Ралтэк. 2014 — 2024 гг