Регистрация и авторизация пользователей в 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
. Конкретно для User
— classes/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()
.
- Создаем экземпляр класса модели.
- Получаем информацию о столбцах.
- Сохраняем эту информацию внутри объекта класса
Core_Entity
. - Если в БД уже имеется запись и её данные, загружаем и их из таблицы.
- Если данные для пользователя в ходе выполнения сценария меняются, мы должны иметь возможность это понять, и перезаписать такие данные.
- Информацию о модели нужно иметь возможность не только сохранять или перезаписывать, но и удалять из БД.
Но прежде чем это заработает как описано выше, требуется реализовать простую выборку информации из базы данных. Представим, что пользователь уже зарегистрирован, и будто бы он ввел корректные данные в форму авторизации. Мы его авторизовали, и перенаправили на главную страницу, где у нас есть выражения $oCurrentUser->login
и $oCurrentUser->email
. Как теперь эти данные могут быть получены?
Вдобавок к уже упоминавшимся выше Core_Entity, Core_ORM паттерн Active Record добавляет еще одно определение: Query Builder — построитель запросов. Имеются в виду SQL-запросы к базе данных.
В нашей логике появляются три новых составляющих:
- Непосредственно построитель запросов — абстрактный класс
Core_Querybuilder
. - Подготовленный запрос или результирующий набор — абстрактный класс
Core_Querybuilder_Statement
. - Дочерние классы
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;
}
// .....................
}
?>
- Сначала метод
Core_Entity::save()
проверяет, были ли вообще установлены данные для записи в БД. Если их нет, просто возвращается ссылка на текущий объект. - Далее, добавление новой записи в БД будет осуществляться через экземпляр нового класса
Core_Querybuilder_Insert
. - Формируем массивы с именами полей и значениями для них, устанавливаем их через методы
Core_Querybuilder_Insert::fields()
иCore_Querybuilder_Insert::values()
. - Результат выполнения запроса сохраняется в новом для нас свойстве
Core_ORM::$_statement
. - Если запрос затронул строки — в нашем случае, создал новую запись в таблице
`users`
, действуем дальше. - Если это новый объект пользователя, который мы сохранили в БД, у него изначально не было значения первичного ключа. Поэтому выражение
is_null($this->getPrimaryKey())
должно вернутьNULL
. - В этом случае мы берем из экземпляра соединения с СУБД значение последней вставленной записи, и устанавливаем его в качестве значения ключевого поля для объекта. Именно для объекта, а не записи в таблице БД.
- Через вызов
Core_Entity::clearEntity()
загружаем данные в наш новый-старый объект. - Метод
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;
}
Если пользователь регистрируется.
- Данные проходят проверку.
- Для объекта модели устанавливаются: логин, пароль, адрес электропочты.
- Пароль теперь не нужно хэшировать, это происходит автоматически при установке его значения. В этом момент вызывается магический метод
User_Model::__set()
. - Вызов метода
User_Model::save()
записывает в БД информацию о новом пользователе. Страница перезагружается, пользователь попадает на стартовую страницу.
Если пользователь авторизуется.
- Данные проходят проверку.
- Через вызов метода
User_Model::getByLoginOrEmail()
происходит поиск данных по заданному пользователю логину или адресу электропочты. - Если запись в БД имеется, проверяется пароль на соответствие хранимому в БД хэшу.
- Если пароль соответствует, через вызов метода
User_Model::setCurrent()
пользователь авторизуется в системе. - Обработка данных формы завершается, пользователь перенаправляется на главную страницу.
<?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 здесь.
- Я опубликовал эту статью:17.01.2024
- 1 070