<?php
/**
* Copyright (c) 2022 Roman Grinko <[email protected]>
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
* The above copyright notice and this permission notice shall be included
* in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
namespace Core\DataObjects;
use Core\CoreException;
use Core\DataBases\DB;
use Core\Helpers\SystemFunctions;
use DateTime;
use JsonSerializable;
use Throwable;
/**
* Абстрактный класс модели
*/
abstract class AbstractModel implements JsonSerializable
{
/** @var string Наименование таблицы */
public const TABLE = null;
/** @var string Поле первичного ключа таблицы */
public const ID_NAME = 'id';
/** @var string Коллекция, необходимая для данной модели */
protected const COLLECTION = ModelCollection::class;
/** @var string[] Поля, защищенные от записи */
protected const WRITE_PROTECTION = [
'date_created',
'date_updated',
'id',
];
/** @var string Тип столбца: булевой */
protected const COLUMN_TYPE_BOOL = 'tinyint';
/** @var string Тип столбца: строка */
protected const COLUMN_TYPE_STRING = 'varchar';
/** @var string Тип столбца: целочисленное число */
protected const COLUMN_TYPE_INT = 'int';
/** @var string Тип столбца: дата и время */
protected const COLUMN_TYPE_DATETIME = 'datetime';
/** @var string Тип столбца: json */
protected const COLUMN_TYPE_JSON = 'json';
/** @var string Тип столбца: enum */
protected const COLUMN_TYPE_ENUM = 'enum';
/** @var int|null Идентификатор элемента */
protected ?int $id;
/** @var array Переименование полей, если необходимо DB => MODEL */
protected array $renameProps = [];
/** @var array Поля, являющиеся флагами true|false */
protected array $boolFields = [];
/** @var array Список колонок для таблиц */
private static array $columnList = [];
/** @var array Исходные данные */
protected array $sourceData = [];
/** @var string Формат времени */
public const DATE_TIME_FORMAT = 'Y-m-d H:i:s';
/** @var string Формат времени */
public const JSON_DATE_TIME_FORMAT = 'd.m.Y';
/**
* Конструктор
*
* @param array|null $data Данные для заполнения свойств
*/
public function __construct(?array $data = null)
{
// Получаем свойства таблицы
self::getColumnList(static::TABLE);
// Наполняем данными
if (empty($data) === false) {
$this->setData($data);
}
}
/**
* Получение идентификатора записи
*
* @return int Идентификатор записи
*/
public function getId(): int
{
$id = static::ID_NAME;
return $this->$id;
}
/**
* Обнулить идентификатор и старые данные объекта
*
* @return $this
*/
public function clearClone(): self
{
$id = static::ID_NAME;
$this->$id = null;
$this->sourceData = [];
return $this;
}
/**
* Получить столбцы таблицы
*
* @param string $table Таблица
*
* @return array|null Имена колонок
*/
private static function getColumnList(string $table): array
{
$columnList = self::getColumnsInfo($table);
return array_keys($columnList);
}
/**
* Получить и сохранить свойства таблицы
*
* @param string $table Таблица
*
* @return array|null Имена колонок
* @throws Throwable
*/
private static function getColumnsInfo(string $table): array
{
// Получаем свойства таблицы
if (empty(self::$columnList[$table])) {
/** @var DB $db */
$db = DB::getInstance();
self::$columnList[$table] = $db->getColumnsList($table);
}
return self::$columnList[$table];
}
/**
* Установка данных объекта
*
* @param array $data Данные из БД
*/
private function setData(array $data): void
{
$this->sourceData = $data;
$columnsInfo = self::getColumnsInfo(static::TABLE);
foreach ($data as $prop => $value) {
$key = $prop;
if (!empty($this->renameProps[$prop])) {
$key = $this->renameProps[$prop];
}
if (property_exists($this, $key)) {
$this->$key = $value;
}
if (!empty($columnsInfo[$key])) {
switch ($columnsInfo[$key]['type']) {
case self::COLUMN_TYPE_BOOL:
$this->$key = (bool)$this->$key;
break;
}
}
}
}
/**
* Сохранить элемент
*
* @return self
* @throws DataObjectsException
*/
public function save(): self
{
/** @var DB $db */
$db = DB::getInstance();
$id = static::ID_NAME;
try {
if (!empty($this->$id)) {
$diff = $this->getDataDifference();
if (!empty($diff)) {
$db->update(static::TABLE, [$id => $this->$id], $this->sanitizeFields($diff));
}
} else {
$this->$id = $db->addItem(static::TABLE, $this->sanitizeFields($this->getDataDifference(true)));
}
$this->setData($db->getItem(static::TABLE, [$id => $this->$id]));
} catch (Throwable $e) {
throw new DataObjectsException('Произошла ошибка при сохранении данных: ' . $e->getMessage());
}
return $this;
}
/**
* Удалить элемент из базы
*
* @return bool
* @throws CoreException
*/
public function delete(): bool
{
/** @var DB $db */
$db = DB::getInstance();
$id = static::ID_NAME;
$result = $db->remove(static::TABLE, [$id => $this->$id]);
if ($result) {
$this->$id = null;
}
return $result;
}
/**
* Получить разность данных
*
* @param bool $withoutNull Убирать значения с null
*
* @return array
* @throws \Exception
*/
private function getDataDifference(bool $withoutNull = false): array
{
$diff = array_diff_assoc($this->getArray(true, false), $this->sourceData);
if ($withoutNull) {
foreach ($diff as $key => $value) {
if (is_null($value)) {
unset($diff[$key]);
}
}
}
return $diff;
}
/**
* Получить свойства объекта в виде массива
*
* @param bool $dbKeys Переименовать ключи, к исходному виду БД
* @param bool $includeWriteProtected Включать в массив защищенные от записи поля
* @param bool $camelCaseKeys Вернуть ключи в camelCase
* @param bool $jsonDateTimeFormat Формат даты для json
*
* @return array
* @throws \Exception
*/
private function getArray(
bool $dbKeys = false,
bool $includeWriteProtected = true,
$camelCaseKeys = false,
$jsonDateTimeFormat = false
): array {
$result = [];
$renameList = [];
if ($dbKeys) {
$renameList = array_flip($this->renameProps);
}
$columnList = self::getColumnsInfo(static::TABLE);
foreach ($columnList as $prop => $info) {
if (!$includeWriteProtected && in_array($prop, static::WRITE_PROTECTION, true)) {
continue;
}
// Переименовываем ключи в соответствии с правилами, если такие заданы
$key = $prop;
if ($dbKeys && empty($renameList[$prop]) === false) {
$key = $renameList[$prop];
}
if ($camelCaseKeys) {
// Преобразование имен свойств в camelCase
$key = SystemFunctions::stringToCamelCase($key);
}
if (empty($this->$prop) === false && $jsonDateTimeFormat && $info['type'] === self::COLUMN_TYPE_DATETIME) {
$this->$prop = (new DateTime($this->$prop))->format(self::JSON_DATE_TIME_FORMAT);
}
if (property_exists($this, $prop)) {
$result[$key] = $this->$prop;
}
}
return $result;
}
/**
* Получить массив для вывода в JSON
*
* @return array Массив для вывода в JSON
* @throws \Exception
*/
public function getJsonArray(): array
{
return $this->getArray(false, true, true, true);
}
/**
* Получить коллекцию элементов по фильтру
*
* @param array $filter Фильтр выборки
*
* @return ModelCollection
* @throws CoreException
*/
public static function select(array $filter): ModelCollection
{
/** @var DB $db */
$db = DB::getInstance();
$result = $db->getItems(static::TABLE, $filter);
$collectionClass = static::COLLECTION;
$collection = new $collectionClass();
foreach ($result as $element) {
$object = new static($element);
$collection->add($object);
}
return $collection;
}
/**
* Получить отдельный элемент по ID
*
* @param int $id ID элемента
*
* @return static|null Объект текущего класса с заполненными данными
* @throws CoreException
*/
public static function getItem(int $id): ?self
{
/** @var DB $db */
$db = DB::getInstance();
$data = $db->getItem(static::TABLE, [static::ID_NAME => $id]);
if (empty($data)) {
return null;
}
return new static($data);
}
/**
* Сериализация в JSON
*
* @return array Данные для json
* @throws \Exception
*/
public function jsonSerialize(): array
{
return $this->getArray(false, true, true, true);
}
/**
* Санитизация и проверка значений перед формированием запроса в БД
*
* @param array $array Входные данные
*
* @return array Результат
* @throws DataObjectsException
*/
private function sanitizeFields(array $array): array
{
$columnsInfo = self::getColumnsInfo(static::TABLE);
$result = [];
foreach ($array as $key => $value) {
if (empty($columnsInfo[$key])) {
// Если такого столбца нет, то и смысла добавлять его нет
continue;
}
switch ($columnsInfo[$key]['type']) {
case self::COLUMN_TYPE_ENUM:
if (in_array($value, $columnsInfo[$key]['enumValues'], true) === false) {
throw new DataObjectsException('Недопустимое значение для enum поля ' . $key);
}
$result[$key] = $value;
break;
case self::COLUMN_TYPE_BOOL:
if (is_bool($value) === false) {
throw new DataObjectsException('Значение ' . $key . ' должно быть булевым');
}
$result[$key] = $value;
break;
case self::COLUMN_TYPE_STRING:
if (is_string($value) === false) {
throw new DataObjectsException('Значение ' . $key . ' должно быть строкой');
}
$result[$key] = mb_substr($value, 0, $columnsInfo[$key]['length']);
break;
case self::COLUMN_TYPE_INT:
if (is_numeric($value) === false) {
throw new DataObjectsException('Значение ' . $key . ' должно быть целочисленным числом');
}
$result[$key] = (int)$value;
break;
case self::COLUMN_TYPE_DATETIME:
$date = strtotime($value);
if ($date === false) {
throw new DataObjectsException($key . ' имеет некорректное значение даты');
}
$result[$key] = date(self::DATE_TIME_FORMAT, $date);
break;
case self::COLUMN_TYPE_JSON:
try {
json_decode($value, false, 512, JSON_THROW_ON_ERROR);
} catch (Throwable $exception) {
throw new DataObjectsException($key . ' содержит некорректный json');
}
$result[$key] = $value;
break;
}
}
return $result;
}
}