Сущности (Entities) являются основной абстракцией над базой данных в Drupal. Пользователи, ноды, термины таксономии, элементы конфигурации и другие объекты Drupal являются сущностями и поддерживают единый API. Помимо CRUD-операций, сущности предоставляют API для управления доступом к ним, отображения, построения формы добавления новой сущности из админки, интеграции с Views и т.д.

Сущности появились в Drupal 7. В Drupal 8 сущности разделились на два типа — сущности конфигурации и контента. Первые используются системой управления конфигурациями Drupal и хранятся в общей таблице config как строки. Вторые хранятся в отдельный таблицах, названия которых совпадают с id типа сущности, а колонки соответствуют свойствам (properties) сущности. Сущности контента могут поддерживать ревизии. К сущностям контента можно прикреплять дополнительные поля, хранящиеся в отдельных таблицах. При этом для одного типа сущности можно прикреплять разные наборы полей, создавая бандлы (bundle), по одному на разный набор полей. Примерами типа сущности и его бандлов с различными наборами полей являются ноды и типы нодов, товары commerce и типы товаров.

Если нам необходимо создавать и хранить в базе данных какие-либо объекты с различными свойствами, схожие по структуре и поведению, но стандартные типы сущностей Drupal для нас не подходят (ноды, пользователи, таксономия), то надо создать новый тип контент-сущности. Это можно сделать в админке Drupal с помощью contrib-модуля Entity Construction Kit (ECK) либо определить их в кастомном модуле. Рассмотрим как сделать последнее в Drupal 8. Создадим простейший модуль с описанием контент-сущности Example, с набором свойств, поддержкой Views, веб-сервисов, контролем доступа, без бандлов и ревизий.

Объявим новый тип сущности и опишем его свойства. Структура файлов модуля будет такая:

web/modules/custom/example$
├── example.info.yml
└── src
    └── Entity
         └── Example.php

В example.info.yml ничего особенного:

name: Example
type: module
description: 'Example content entity'
package: custom
core: 8.x

В Example.php создем класс Example, наследуя ContentEntityBase и реализуя интерфейс ContentEntityInterface. В аннотации описываем тип сущности и подключаем хендлеры, например, для поддержки Views. В методе baseFieldDefinitions() описываем свойства сущности, для которых будут создаваться поля в нашей таблице.

<?php

namespace Drupal\example\Entity;

use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Url;

/**
* Defines the Example entity.
*
* @ingroup example
*
* @ContentEntityType(
*   id = "example",
*   label = @Translation("Example"),
*   handlers = {
*     "views_data" = "Drupal\views\EntityViewsData",
*   },
*   base_table = "example",
*   entity_keys = {
*     "id" = "id",
*     "uuid" = "uuid",
*   },
* )
*/

class Example extends ContentEntityBase implements ContentEntityInterface {

  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {

    // Standard field, used as unique if primary index.
    $fields['id'] = BaseFieldDefinition::create('integer')
      ->setLabel(t('ID'))
      ->setDescription(t('The ID of the Bonus entity.'))
      ->setReadOnly(TRUE);

    // Standard field, unique outside of the scope of the current project.
    $fields['uuid'] = BaseFieldDefinition::create('uuid')
      ->setLabel(t('UUID'))
      ->setDescription(t('The UUID of the Bonus entity.'))
      ->setReadOnly(TRUE);

    // Int field.
    $fields['fint'] = BaseFieldDefinition::create('integer')
      ->setLabel(t('Int field'))
      ->setDescription(t('Example int field.'));

    // Record creation date.
    $fields['created'] = BaseFieldDefinition::create('created')
      ->setLabel(t('Created'))
      ->setDescription(t('The time that example was created.'));

    // String field.
    $fields['fstring'] = BaseFieldDefinition::create('string')
      ->setLabel(t('String field'))
      ->setDescription(t('Example string field.'))
      ->setSettings(array(
        'default_value' => '',
        'max_length' => 100,
        'text_processing' => 0,
      ));

    // Float field.
    $fields['fdecimal'] = BaseFieldDefinition::create('decimal')
      ->setLabel(t('Float field'))
      ->setDescription(t('Example float field.'))
      ->setSettings(array(
        'precision' => 17,
        'scale' => 2,
      ));

    return $fields;
  }

  public function toUrl($rel = 'canonical', array $options = []) {
    // Return default URI as a base scheme as we do not have routes yet.
    return Url::fromUri('base:entity/example/' . $this->id(), $options);
  }
}

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

+----------+---------------+------+-----+---------+----------------+
| Field    | Type          | Null | Key | Default | Extra          |
+----------+---------------+------+-----+---------+----------------+
| id       | int(11)       | NO   | PRI | NULL    | auto_increment |
| uuid     | varchar(128)  | NO   | UNI | NULL    |                |
| fint     | int(11)       | YES  |     | NULL    |                |
| created  | int(11)       | YES  |     | NULL    |                |
| fstring  | varchar(100)  | YES  |     | NULL    |                |
| fdecimal | decimal(17,2) | YES  |     | NULL    |                |
+----------+---------------+------+-----+---------+----------------+

Если в дальнейшем потребуется поменять структуру таблицы, то необходимо внести изменения в метод baseFieldDefinitions() и выполнить команду:

drush updatedb --entity-updates

Экземпляр новой сущности можно создать программно с помощью следующего кода:

<?php

use Drupal\example\Entity\Example;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Language\LanguageInterface;

$created = time();
$uuid_service = \Drupal::service('uuid');
$uuid = $uuid_service->generate();
$lc = LanguageInterface::LANGCODE_DEFAULT;
$example = new Example([
  'uuid' => array($lc => $uuid),
  'created' => array($lc => $created),
  'fint' => array($lc => 10),
  'fstring' => array($lc => 'some text'),
  'fdecimal' => array($lc => 10.1),
], 'example');
$example->save();

Результат:

> select * from example;
+----+--------------------------------------+------+------------+-----------+----------+
| id | uuid                                 | fint | created    | fstring   | fdecimal |
+----+--------------------------------------+------+------------+-----------+----------+
|  1 | cdc9537c-a140-47d6-a12b-89cdd14d20ce |   10 | 1504699716 | some text |    10.10 |
+----+--------------------------------------+------+------------+-----------+----------+

Загрузить сущность программно, зная, например, uuid, можно так:

$entity_ids = \Drupal::entityQuery('example')
  ->condition('uuid', 'cdc9537c-a140-47d6-a12b-89cdd14d20ce', '=')
  ->execute();

Поля нового типа сущности Example сразу можно вывести во Views:

Если включить модули RESTful Web Services и Serialization, то все новые сущности можно будет вывести во Views в формате JSON, добавив дисплей типа REST export:

[{
  "id":[{"value":1}],
  "uuid":[{"value":"cdc9537c-a140-47d6-a12b-89cdd14d20ce"}],
  "fint":[{"value":10}],
  "created":[{"value":1504699716}],
  "fstring":[{"value":"some text"}],
  "fdecimal":[{"value":"10.10"}]}]

Аналогично, можно получить XML, добавив в URL параметр ?_format=xml:

<response>
  <item key="0">
    <id><value>1</value></id>
    <uuid><value>cdc9537c-a140-47d6-a12b-89cdd14d20ce</value></uuid>
    <fint><value>10</value></fint>
    <created><value>1504699716</value></created>
    <fstring><value>some text</value></fstring>
    <fdecimal><value>10.10</value></fdecimal>
  </item>
</response>

Чтобы сделать веб-сервис загрузки сущности по UUID достаточно просто добавить аргумент во Views.

Чтобы читать напрямую без Views, создавать, менять, удалять новые сущности с помощью вызова веб-сервиса, необходимо установить contrib-модуль REST UI, в его настройках включить ресурс Example и настроить доступные методы и форматы, разрешить доступ всем, добавив в Example.php реализацию метода access:

use Drupal\Core\Access\AccessResult;

...

  public function access($operation, AccountInterface $account = NULL, $return_as_object = FALSE) {
    return AccessResult::allowed();
  }

После этого можно сразу загрузить сущность через веб-сервис в формате JSON по адресу:

/entity/example/1?_format=json

Создать новую сущность Example запросом к веб-сервису с помощью curl:

curl --include --request POST
  --user 'superuser:123'
  --header 'Content-Type: application/xml'
  http://localhost/entity_example/drupal-8.3.7/entity/example?_format=xml
  --data-binary
  '<?xml version="1.0" ?><request><uuid><value>0000000000005</value></uuid></request>'

HTTP/1.1 201 Created
Date: Wed, 06 Sep 2017 13:11:19 GMT
Server: Apache/2.4.25 (Debian)
Cache-Control: must-revalidate, no-cache, private
X-UA-Compatible: IE=edge
Content-language: en
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Expires: Sun, 19 Nov 1978 05:00:00 GMT
X-Generator: Drupal 8 (https://www.drupal.org)
Content-Length: 182
Content-Type: text/xml; charset=UTF-8

<?xml version="1.0"?>
<response>
  <id><value>5</value></id>
  <uuid><value>0000000000005</value></uuid>
  <fint/>
  <created><value>1504703479</value></created>
  <fstring/><fdecimal/>
</response>

Аналогично можно удалять и редактировать ресурсы используя другие методы HTTP. Примечательно, что для редактирования используется метод PATCH и шлются только измененные поля.

Контроль доступа

Для контроля доступа к операциям с сущностью, в том числе при вызовах веб-сервиса, необходимо реализовать EntityAccessControlHandler. Для этого добавим файл src/ExampleAccessControlHandler.php:

<?php

/**
* @file
* Contains \Drupal\example\exampleAccessControlHandler.
*/

namespace Drupal\example;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;

/**
* Access controller for the Example entity.
*
* @see \Drupal\example\Entity\Example.
*/
class ExampleAccessControlHandler extends EntityAccessControlHandler {

  /**
   * {@inheritdoc}
   *
   * Link the activities to the permissions. checkAccess is called with the
   * $operation as defined in the routing.yml file.
   */
  protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
    switch ($operation) {
      case 'view':
        return AccessResult::allowedIfHasPermission($account, 'view example entity');

      case 'edit':
        return AccessResult::allowedIfHasPermission($account, 'edit example entity');

      case 'delete':
        return AccessResult::allowedIfHasPermission($account, 'delete example entity');
    }
    return AccessResult::allowed();
  }

  /**
   * {@inheritdoc}
   *
   * Separate from the checkAccess because the entity does not yet exist, it
   * will be created during the 'add' process.
   */
  protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
    return AccessResult::allowedIfHasPermission($account, 'add example entity');
  }

}

Наш хендлер будет проверять права 'add example entity', 'view example entity', 'edit example entity', 'delete example entity' при обращении к веб-сервису. Чтобы добавить эти права в админку нужно создать файл example.permissions.yml:

add example entity:
  title: 'Add example entity'
view example entity:
  title: 'View example entity'
edit example entity:
  title: 'Edit example entity'
delete example entity:
  title: 'Delete example entity'

Теперь чтобы подключить наш хендлер доступа к сущности добавим его в список хендлеров в аннотации:

"access" = "Drupal\example\ExampleAccessControlHandler",

и сделаем соответствующие вызовы в методе access():

  public function access($operation, AccountInterface $account = NULL, $return_as_object = FALSE) {
    if ($operation == 'create') {
      return $this->entityManager()
        ->getAccessControlHandler($this->entityTypeId)
        ->createAccess($this->bundle(), $account, [], $return_as_object);
    }
    return $this->entityManager()
      ->getAccessControlHandler($this->entityTypeId)
      ->access($this, $operation, $account, $return_as_object);
  }

Таким образом, Example.php примет вид:

<?php

namespace Drupal\example\Entity;

use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Url;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Access\AccessResult;

/**
* Defines the Example entity.
*
* @ingroup example
*
* @ContentEntityType(
*   id = "example",
*   label = @Translation("Example"),
*   handlers = {
*     "views_data" = "Drupal\views\EntityViewsData",
*     "access" = "Drupal\example\ExampleAccessControlHandler",
*   },
*   base_table = "example",
*   entity_keys = {
*     "id" = "id",
*     "uuid" = "uuid",
*   },
* )
*/

class Example extends ContentEntityBase implements ContentEntityInterface {

  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {

    // Standard field, used as unique if primary index.
    $fields['id'] = BaseFieldDefinition::create('integer')
      ->setLabel(t('ID'))
      ->setDescription(t('The ID of the Bonus entity.'))
      ->setReadOnly(TRUE);

    // Standard field, unique outside of the scope of the current project.
    $fields['uuid'] = BaseFieldDefinition::create('uuid')
      ->setLabel(t('UUID'))
      ->setDescription(t('The UUID of the Bonus entity.'))
      ->setReadOnly(TRUE);

    // Int field.
    $fields['fint'] = BaseFieldDefinition::create('integer')
      ->setLabel(t('Int field'))
      ->setDescription(t('Example int field.'));

    // Record creation date.
    $fields['created'] = BaseFieldDefinition::create('created')
      ->setLabel(t('Created'))
      ->setDescription(t('The time that example was created.'));

    // String field.
    $fields['fstring'] = BaseFieldDefinition::create('string')
      ->setLabel(t('String field'))
      ->setDescription(t('Example string field.'))
      ->setSettings(array(
        'default_value' => '',
        'max_length' => 100,
        'text_processing' => 0,
      ));

    // Float field.
    $fields['fdecimal'] = BaseFieldDefinition::create('decimal')
      ->setLabel(t('Float field'))
      ->setDescription(t('Example float field.'))
      ->setSettings(array(
        'precision' => 17,
        'scale' => 2,
      ));

    return $fields;
  }

  public function access($operation, AccountInterface $account = NULL, $return_as_object = FALSE) {
    if ($operation == 'create') {
      return $this->entityManager()
        ->getAccessControlHandler($this->entityTypeId)
        ->createAccess($this->bundle(), $account, [], $return_as_object);
    }
    return $this->entityManager()
      ->getAccessControlHandler($this->entityTypeId)
      ->access($this, $operation, $account, $return_as_object);
  }
}

Наш модуль Example получит следующую структуру:

web/modules/custom/example$
├── example.info.yml
├── example.permissions.yml
└── src
    ├── ExampleAccessControlHandler.php
    └── Entity
         └── Example.php        

Это минимальный код, которого достаточно для создания простого типа сущности, управляемой по веб-сервисам. Такие сущности мы используем при создании веб-сервисов для Headless Drupal, при использовании Drupal 8 в качестве бекэнда для мобильных приложений на React. Для экономии времени при создании новых сущностей можно использовать генераторы кода, о которых подробнее будет рассказано в следующих статьях.

Дополнительная информация о сущностях Drupal 8

Официальная документация
Модуль примеров и статья c примерами роутов, ссылок и форм для работы с сущностями из админки
Примеры программного создания различных сущностей
Веселая статья про сущности Drupal

Добавить комментарий