Перейти к основному содержимому

Сущности (EntityApi)

Внутренняя ORM для управления сущностями бизнес-логики сайта.

EntityAPI - это подсистема управления сущностями в сайтах и веб-приложениях, работающих на базе фреймворка XSyst.

Сущность (Entity) - это комплексный компонент, имеющий уникальное имя в рамках приложения и моделирующий определённую важную часть системы.

Каждая сущность имеет своё состояние. Состояние хранится в БД.

В настоящий момент каждому типу сущностей соответствует своя таблица в БД. Каждой сущности соответствует строка в соответствующей таблице БД. Каждая сущность имеет уникальный ID, который отображается в PK в соответствующей таблице БД. PK может быть как простой, так и составной.

Примеры сущностей:

  • User - моделирует сущность "Пользователь"
  • Role - моделирует сущность "Роль пользователя"
  • UserRole - моделирует сущность связи "Пользователь-Роль"

Технически EntityAPI реализована в виде PluginApi-плагинов типа Entity и работает в соответствии с особенностями PluginApi.

Сущности могут располагаться только в модулях.
На уровне файловой системы каждый виджет представляет собой папку вида:

  {module}/src/Plugins/Entity/{EntityName}

Описание файлов Entity

В папке Entity могут быть следующие файлы:

  • {EntityName}.info.inc
    Файл с определением и настройками сущности.
  • {EntityName}.schema.inc (Опционально)
    Файл с настройками схемы сущности.
  • {EntityName}.relations.inc (Опционально)
    Файл с настройками связей сущности с другими сущностями.
  • {EntityName}Entity.inc
    Файл с классом сущности.
  • {EntityName}Repository.inc (Опциально)
    Файл с классом репозитария сущностей данного типа.
  • {EntityName}QueryBuilder.inc (Опциально)
    Файл с классом построителя запросов для получения сущностей данного типа из хранилища.
  • {EntityName}Storage.inc (Опциально)
    Файл с классом, реализующим методы сохранения сущности в хранилище.
  • {EntityName}Install.inc (Опциально)
    Файл с классом, реализующим расширенные методы установки/удаления данного типа сущностей в систему.
  • {EntityName}FormHelper.inc (Опциально)
    Файл с классом, упрощающим создание форм редактирования полей сущности.
  • {EntityName}OutputHelper.inc (Опциально)
    Файл с классом, содержащим методы форматирования и отображения полей сущности.
  • Папки вложенных Entity (Опциально)

Названия папок и файлов сущностей следует общим правилам PluginApi.

Если в начале названия сущности (а значит и его папки) есть символы '~', '_' или '-', то они игнорируются. Добавление данных символов имеет следующий смысл:

  1. Если в начале названия сущности есть '~', то такая сущность считается переопределённой, т.е. это не новая сущность, а изменение уже существующей (например, изменение в файле схемы или в файле самой сущности).
  2. Если в начале названия сущности есть '-', то такая сущность полностью замещает все иные реализации данной сущности.
  3. Если в начале названия сущности есть '_', то такая сущность считается внутренней (приватной) в рамках родительской сущности.

Все классы имплиментации сущности должны иметь следующий namespace:

namespace module\<modulename>\Entity\<EntityName>;

Например, если сущность User определена в module app_user, то namespace будет таким:

namespace module\app_user\Entity\User;

Примеры использования сущностей


//
//Задача 1:
//Изменить свойства name и picture у пользователя с uid==1
//
$user1 = EM::getRepository(UserEntity::class)->find(1); //Получим сущность User с id = 1
$user1->name = 'SuperAdmin'; //Изменим name
$fid = 123;
$user1->picture = EM::getRepository(FileEntity::class)->find($fid)->filepath; //Изменим picture (для этого сначала получим сущность File по $fid
EM::save($user1); //Сохраним сущность user

//
//Задача 2:
//Найти всех заблокированных пользователей с mail вида '%@mail.ru'
//
$blockedMailUsers = EM::getRepository(UserEntity::class)->findBy([
['status', 0],
['mail', XQL_LIKE, '%@mail.ru'],
]);

//
//Задача 3:
//Получить Query-объект для поиска не более 10-ти заблокированных пользователей с mail вида '%@mail.ru'.
//Пользователи должны быть отсортированы по дате последнего доступа к сайту.
//После этого удалить всех найденных пользователей.
//
$blockedMailUsersQuery = EM::getRepository('User')
->findByQuery([
['status', 0],
['mail', XQL_LIKE, '%@mail.ru'],
])
->setOrderBy(['access' => DB_ORDER_ASC])
->limit(10)
;
foreach($blockedMailUsersQuery as $userEntity){
EM::delete($userEntity);
}

//
//Задача 4:
//Получить Query-объект для поиска всех комментариев, созданных пользователем $user1.
//
$userCommentsQuery = $user1->getRelatedQuery('comments');

Переопределение элементов сущности

Элемены сущности могут быть переопределены. Это достигается путём создания папки сущности и добавления туда файлов, которые требуется переопределить.

Остальные файлы добавляются в режиме дополнения ресурсов виджета. Если требуется переопределить какой-либо файл, то в начале имени файла нужно добавить '-'. Папка сущности, содержащая перепределённые файлы, должна начинаться с '~'.

Последовательность поиска и добавления Entity в репозитарий следующая:

  1. core-модули
  2. x- и contrib-модули
  3. app-модули

На каждом следующем уровне могут быть переопределены/дополнены элементы предыдущего.

Вложенные сущности

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

Идентификация сущностей и названия связанных классов

Название сущности должно следовать следующему правилу:

  • Название сущности начинается с большой буквы
  • Если в названии несколько слов, то они соединяются по правилу CamelCase.

Примеры названия сущностей:

  • User
  • TaxonomyTerm
  • CustomerData

Файл {EntityName}.info.inc

Этот файл является обязательным. Он может содержать параметры и настройки сущности по умолчанию.
Все параметры и настройки определяются в виде элементов массива $plugin_info.

Как и все .info.inc файлы в рамках PluginApi {EntityName}.info.inc подключается и обрабатывается только в момент формирования реестра сущностей. После этого все параметры и настройки, добавленные в $plugin_info, сохраняются в реестр и в дальнейшем загружаются из реестра.

Параметры $plugin_info

Будут рассмотрены следующие важные параметры:

  • $plugin_info['table']
  • $plugin_info['tableAlias']
  • $plugin_info['listenEvents']
  • $plugin_info['description']

$plugin_info['table'] - название таблицы БД для хранения данных

Определяет название таблицы БД, в которой будут сохраняться состояния сущностей данного типа.

Если значение пустое, то система выберет название таблицы автоматически согласно алгоритму:

  1. Если в .schema.inc файле определён параметр 'name', то он будет использоваться в качестве имени таблицы.
  2. Если имя таблицы не определено, то в качестве имени таблицы будет использоваться имя сущности.

Рекомендуется оставлять это поле пустым.

$plugin_info['table_alias'] - alias таблицы БД для хранения данных

Определяет alias таблицы БД, в которой будут сохраняться данные сущностей данного типа. Если alias определён, то он будет использоваться при построении запросов к БД.

Если значение пустое, то в качестве alias будет использоваться имя сущности.

Рекомендуется оставлять это поле пустым.

$plugin_info['listenEvents'] - настройка слушателей событий, происходящих на других сущностях

С помощью данного параметра можно настроить, какие события на каких сущностях будут 'слушаться' в репозитории данной сущности. Это может потребоваться в случаях, если сущность должна реагировать на какие-либо события в сущностях других типов.

Для реакции на события собственных сущностей достаточно в классе репозитария добавить методв onEntityEvent.

Примеры использования:

//
//Пример 1:
//Сущность ModrTask должна реагировать на события в сущностях Order, OrderOffer, OrderReview
//
$plugin_info['table'] = 'app_modr_task'; // Рекомендуется не добавлять это свойство
$plugin_info['table_alias'] = 'modr_task'; // Рекомендуется не добавлять это свойство
$plugin_info['description'] = 'Сущность ModrTask представляет собой одну задачу для модератора'; // Рекомендуется добавлять это свойство

$plugin_info['listenEvents'] = [
[
'entityType' => 'Order', //Тип сущностей, которые будем слушать
'events' => ['after_insert','after_update'], //События, которые нас интересуют
'handler' => 'onOrderEntityEvent', //Название метода, определенного в ModrTaskRepository, который будет вызываться при наступлении определённых выше событий
],
[
'entityType' => 'OrderOffer',
'events' => ['after_insert','after_update',],
'handler' => 'onOrderOfferEntityEvent',
],
[
'entityType' => 'OrderReview',
'events' => ['after_insert',],
'handler' => 'onOrderReviewEntityEvent',
],
[
'entityType' => 'Contractor',
'events' => ['after_insert','after_update'],
'handler' => 'onContractorEntityEvent',
],
[
'entityType' => 'Xfeedback',
'events' => ['after_insert',],
'handler' => 'onXfeedbackEntityEvent',
],
[
'entityType' => 'Xcallback',
'events' => ['after_insert',],
'handler' => 'onXcallbackEntityEvent',
],
];

$plugin_info['description'] - описание сущности

Если добавлен этот параметр, то он будет использоваться в панели управления сущностями, а также других административных интерфейсах.

Это не обязательный параметр, но рекомендуется всегда его добавлять.

Файл {EntityName}Entity.inc

Это обязательный файл. В нём определяется класс сущности EntitName. Данный класс должен наследоваться от класса Entity или его наследника.

В рамках данного класса слудует определять:

  • Определения свойств сущностей
  • Ограничения для свойств сущностей (валидация/санитизация)
  • Расширенные алгоритмы валидации/санитизации сущностей
  • Бизнес-методы для данной сущности
  • format-методы для полей данной сущности

Пример файла сущности:

namespace modules\app\Plugins\Entity\Order; 

use XSyst\PluginType\Entity\Entity;

class OrderEntity extends Entity{


public function hasModerator(): bool
{
return !empty($this->moderator_uid);
}

public function hasContractor(): bool
{
return !empty($this->contractor_uid);
}

public function hasReviews(): bool
{
$reviews = $this->getRelated('reviews');
return !empty($reviews);
}

public function hasOffers(): bool
{
$offers = $this->getRelated('offers');
return !empty($offers);
}


public function canModerate($account = null): bool
{
return $this->isAdministrator($account) || $this->isModerator($account);
}

public function isAdministrator($account = null): bool
{
$account = $account ?? $GLOBALS['user'];
$res = user_access('administer orders', $account);
return $res;
}

public function isModerator($account = null): bool
{
$account = $account ?? $GLOBALS['user'];
$res = ($account->uid && $account->uid == $this->moderator_uid);
return $res;
}

public function isOwner($account = null): bool
{
$account = $account ?? $GLOBALS['user'];
$res = ($account->uid && $account->uid == $this->customer_uid);
return $res;
}

public function isContractor($account = null): bool
{
$account = $account ?? $GLOBALS['user'];
$res = ($account->uid && $account->uid == $this->contractor_uid);
return $res;
}

public function canBecomeContractor($account = null): bool
{
$account = $account ?? $GLOBALS['user'];
if($account->account_type == 'contractor'){
$contractor = $this->getRepository('Contractor')->find($account->uid);
return ($contractor->modr_status == 'checked');
}
return false;
}


public function validateProp(string $propName): bool
{
$currentTimeText = Format::formatDate(time());

$error = parent::validateProp($propName);
if(!empty($error)){
return $error;
}
switch($propName){
case 'deadline_date':
case 'tender_days':
$orderDeadline = strtotime($this->deadline_date) ? strtotime($this->deadline_date) : 0;
if(!empty($orderDeadline)){
$tenderDays = $this->tender_days;
$tenderDeadline = time() + $tenderDays*3600*24;
if($tenderDeadline >= $orderDeadline){
$error = 'Дата завершения тендера больше даты окончания работ. Скорректируйте Желаемую дату окончания работ либо длительность тендера.';
}
}
break;
case 'contract_budget':
if(empty($this->contract_budget) && $this->status == 'working'){
$error = 'Конечная стоимость работ над заказом не установлена.';
}
break;
case 'contract_finish_date':
if( $this->status == 'working'){
$orderContractFinishDate = strtotime($this->contract_finish_date) ? strtotime($this->contract_finish_date) : 0;
if(empty($orderContractFinishDate)){
$error = 'Дата завершения работ над заказом не установлена.';
}
else if($orderContractFinishDate < time()){
$error = "Дата завершения работ над заказом не может быть меньше $currentTimeText.";
}
}
break;
case 'result_budget':
if(empty($this->result_budget) && $this->status == 'completed'){
$error = 'Реальная стоимость работ над заказом не установлена.';
}
break;
case 'result_finish_date':
if( $this->status == 'completed'){
$date = strtotime($this->result_finish_date) ? strtotime($this->result_finish_date) : 0;
if(empty($date)){
$error = 'Реальная дата завершения работ над заказом не установлена.';
}
}
break;
}

return $error;
}

public function formatTenderDays(?array $options = null): string
{
$text = Format::formatPlural($this->tender_days, '@count день', '@count дня', '@count дней');

return $text;
}

public function formatOrderId(?array $options = null): string
{
$output = "Заказ №{$this->order_id}";

return $output;
}

public function formatStatus(?array $options = null): string
{
$output = $this->getEntityRepository()->getStatuses()[$this->status];

return $output;
}

}

Файл {Entity}.schema.inc

Файл {Entity}.schema.inc определяет схему таблицы БД для хранения состояния сущностей. Как правило, любая сущность имеет такой файл, поскольку должна иметь возможность сохранять и восстанавливать своё состояние.

Схема таблицы БД определяется в виде ассоциативного массива, имеющего структуру, подробно описанную в документации по SchemaApi.

Кратко основные ключи этого массива описаны ниже:

  • name (не рекомендуется использовать, будет удалено)
    Название таблицы
  • alias
    Алиас таблицы по умолчанию
  • description
    Описание назначения таблицы
  • fields
    Индексированный массив определений полей таблицы. Каждое поле определяется в виде ассоциативного массива.
  • indexes
    Индексированный массив определений ключей таблицы. Каждый ключ представлен массивом названий полей, входящих в ключ.
  • primary key
    Индексированный массив названий полей, входящих в primary key.

Пример файла схемы сущности User:

return [
'description' => 'Stores user data.',
'fields' => [
'uid' => [
'type' => 'serial',
'unsigned' => TRUE,
'not null' => TRUE,
'description' => 'Primary Key: Unique user ID.',
],
'added_by' => [
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
'description' => 'UID пользователя, который добавил аккаунт (не пустой, если аккаунт добавлен не самостоятельно).',
],
'name' => [
'type' => 'varchar',
'length' => 60,
'not null' => TRUE,
'default' => '',
'description' => 'Unique user name.',
],
'pass' => [
'type' => 'varchar',
'length' => 32,
'not null' => TRUE,
'default' => '',
'description' => "User's password (md5 hash).",
],
'mail' => [
'type' => 'varchar',
'length' => 64,
'not null' => FALSE,
'default' => '',
'description' => "User's email address.",
],
'phone' => [
'type' => 'varchar',
'length' => 12,
'not null' => FALSE,
'default' => '',
'description' => "User's phone number.",
],

'signature' => [
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
'default' => '',
'description' => "User's signature.",
],

'created' => [
'type' => 'int',
'not null' => TRUE,
'default' => 0,
'description' => 'Timestamp for when user was created.',
],
'access' => [
'type' => 'int',
'not null' => TRUE,
'default' => 0,
'description' => 'Timestamp for previous time user accessed the site.',
],
'login' => [
'type' => 'int',
'not null' => TRUE,
'default' => 0,
'description' => "Timestamp for user's last login.",
],
'status' => [
'type' => 'int',
'not null' => TRUE,
'default' => 0,
'size' => 'tiny',
'description' => 'Whether the user is active(1) or blocked(0).',
],

'picture' => [
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
'default' => '',
'description' => "Path to the user's uploaded picture.",
],
'picture_fid' => [
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
'description' => 'FID файла в таблице files, который будет использоваться в качестве аватара пользователя.',
],
],
'indexes' => [
'access' => ['access'],
'created' => ['created'],
'mail' => ['mail'],
],
'unique keys' => [
'name' => ['name'],
],
'primary key' => ['uid'],
];

Файл {EntityName}.relations.inc

Файл {EntityName}.relations.inc позволяет определить связи данной сущности с другими сущностями путём создания виртуальных свойств.

Связи определяются в виде массива. Можно задавать любое кол-во связей таким образом.

Схематично определение связи показано ниже:

return [
//Определение связи relation1Name
'{EntityName}.{relation1Name}' => [
'type' => '{relationType}', //hasOne, hasMany или belongsTo
'relatedEntity' => '{RelatedEntityName}',
'relatedByKeys' => [
'{fieldOfRelatedEntity}' => '@.{fieldOfEntity}'
],
'cascadeDelete' => true|false, //Необязательно. По умолчанию false. Только для связей hasOne и hasMany.
'cascadeUpdate' => [ //Необязательно. Только для связей hasOne и hasMany.
'{field1OfEntity}' => {newValue1},
'{field2OfEntity}' => {newValue2},
],
],

];

Реальный пример определения связей:

return [

'User.roles' => [
'type' => 'hasMany',
'relatedEntity' => 'UserRole',
'relatedByKeys' => ['uid' => '@.uid'],
'cascadeDelete' => true,
],
'User.profileData' => [
'type' => 'hasOne',
'relatedEntity' => 'UserProfile',
'relatedByKeys' => ['user_id' => '@.uid'],
'cascadeDelete' => true,
],
'User.company' => [
'type' => 'belongsTo',
'relatedEntity' => 'Company',
'relatedByKeys' => ['id' => '@.company_id'],
],

];

Рассмотрим подробнее каждый параметр при определении связи.

Выбор названия связи ({EntityName}.{relation1Name})

По сути связи с другими сущностями могут рассматриваться как виртуальные свойства сущности. Более того, в будущих версиях EntityApi такие свойства вероятно будут работать как обычные свойства класса.

С учётом этого название связи должно соответствовать требованиями к называниям обычных публичных свойств класса:

  • Используем camelCase
  • Для свойств, указывающих на единичный экземпляр сущности (тип связи hasOne, belongsTo), используем названия в единственном числе.
  • Для свойств, указывающих на множество экземпляров сущности (тип связи hasMany), используем названия во множественном числе.

Выбор типа связи сущностей (type)

При определении связи необходимо выбрать правильный тип отношений со связываемой сущностью. Существуют 3 типа отношений:

  • hasOne
    В этой связи текущая сущность является главной по отношению к связываемой. Такой тип связи позволяет ссылаться только на один (или 0) экземпляр связываемого типа сущности. При таком типе связи доступны параметры 'cascadeDelete' и 'cascadeUpdate'.
  • hasMany
    В этой связи текущая сущность является главной по отношению к связываемой. Такой тип связи позволяет ссылаться на несколько (или 0) экземпляров связываемого типа сущности. При таком типе связи доступны параметры 'cascadeDelete' и 'cascadeUpdate'.
  • belongsTo
    В этой связи текущая сущность является дочерней по отношению к связываемой. Такой тип связи позволяет ссылаться только на один экземпляр связываемого типа сущности. При таком типе связи НЕ доступны параметры 'cascadeDelete' и 'cascadeUpdate'.

Указание связываемой сущности (relatedEntity)

В параметре relatedEntity необходимо указать название типа свызываемых сущностей.

Указание правила связывания сущностей (relatedByKeys)

В этом параметре необходимо указать, по каким полям будет осуществляться связь текущей сущности со связываемой.

Данная связь определяется в виде асоциативного массива, в котором ключом является поле в связываемой сущности, а значением - поле в текущей сущности, начинающееся с '@.'. Технически '@.' означает объект текущей сущности.

Как правило, связь осуществляется по принципу FK => PK, но ничто не запрещает использовать больше полей в критерии связи. Также разрешено (и даже рекомендуется для большей читаемости) использовать полное название FK-поля (т.е. с указанием названия связываемой сущности). Пример: вместо ['uid' => '@.uid'] лучше записать ['UserRole.uid' => '@.uid'].

Примеры:

    'User.roles' => [
'type' => 'hasMany',
'relatedEntity' => 'UserRole',
'relatedByKeys' => ['UserRole.uid' => '@.uid'],
'cascadeDelete' => true,
],
'User.profileData' => [
'type' => 'hasOne',
'relatedEntity' => 'UserProfile',
'relatedByKeys' => ['UserProfile.user_id' => '@.uid'],
'cascadeDelete' => true,
],
'User.company' => [
'type' => 'belongsTo',
'relatedEntity' => 'Company',
'relatedByKeys' => ['Company.id' => '@.company_id'],
],

Каскадное удаление связанных сущностей (cascadeDelete)

Для связей hasOne и hasMany можно настроить каскадное удаление связанных сущностей. Для этого достаточно установить свойство cascadeDelete в true. По умолчанию это свойство всегда имеет значение false.

Если для связи активировано каскадное удаление, то при удалении текущей сущности автоматически будут удалены и все связанные по этой связи дочерние сущности.

Будьте внимательны при установке данного свойства в true, чтобы избежать потери данных!

Как правило, данное свойство подходит для простых случаев. Часто при удалении сущности удаление зависимых сущностей требует дополнительной логики. В этом случае установите это свойство в false и в классе репозитория текущей сущности добавьте обработчик событий onEntityEvent, в котором и реализуйте необходимую логику удаления зависимых сущностей.

Каскадное обновление полей связанных сущностей (cascadeUpdate)

Для связей hasOne и hasMany можно настроить каскадное обновление полей связанных сущностей. Для этого достаточно установить значения полей в свойстве cascadeUpdate. По умолчанию это свойство всегда имеет значение [].

Пример:

    'Company.users' => [
'type' => 'hasMany',
'relatedEntity' => 'User',
'relatedByKeys' => ['User.company_id' => '@.id'],
//При удалении компании у всех участников компании установим company_id в 0,
//а job_status в 'unemployed'
'cascadeUpdate' => [
'User.company_id' => 0,
'User.job_status' => 'unemployed',
],
],

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

Будьте внимательны при установке данного свойства, чтобы избежать потери данных!

Как правило, данное свойство подходит для простых случаев. Часто при удалении сущности изменение полей зависимых сущностей требует дополнительной логики. В этом случае установите это свойство в false (или лучше просто удалите это свойство) и в классе репозитория текущей сущности добавьте обработчик событий onEntityEvent, в котором и реализуйте необходимую логику обновления зависимых сущностей.