Перед тем, как начать реализовывать эту часть, я решил все-таки проверить, насколько битрикс трепетно относится к структуре тех таблиц, которые сам создает. И, кажется, не зря.

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

Таблица процессов

Нам понадобится таблица для хранения процессов (поначалу только для процессов генерации, но позже и для прочих процессов). Назовем таблицу maximaster_coupanda_process. Для работы с этой таблицей нам понадобится ORM сущность. Можно пойти двумя путями - сначала сделать таблицу в БД, а потом сгенерировать php код сущности, а можно наоборот, сначала написать код сущности, а потом сгенерировать таблицу. Я пойду вторым путем, т.к. битрикс будет генерировать неактуальный php-код, который сам же считает устаревшим (битрикс такой битрикс … ).

У битриксового автозагрузчика есть огромный недостаток (он не использует PSR-4). Имя файла для таблиц ORM отличается от имени класса сущности. Поэтому все классы сущностей ORM я буду складировать в отдельный неймспейс и, соответственно, в отдельную директорию. Опишем класс сущности ProcessTable и положим его в файл /lib/orm/process.php:

namespace Maximaster\Coupanda\Orm;

use Bitrix\Main\Entity\DataManager;
use Bitrix\Main\Entity\DatetimeField;
use Bitrix\Main\Entity\IntegerField;
use Bitrix\Main\Entity\TextField;
use Bitrix\Main\Localization\Loc;

Loc::loadMessages(__FILE__);

class ProcessTable extends DataManager
{
    public static function getTableName()
    {
        return 'maximaster_coupanda_process';
    }

    public static function getMap()
    {
        return [
            'ID' => new IntegerField('ID', [
                'primary' => true,
                'autocomplete' => true,
                'title' => Loc::getMessage('PROCESS_ENTITY_ID_FIELD')
            ]),
            'STARTED_AT' => new DatetimeField('STARTED_AT', [
                'required' => true,
                'title' => Loc::getMessage('PROCESS_ENTITY_STARTED_AT_FIELD')
            ]),
            'FINISHED_AT' => new DatetimeField('FINISHED_AT', [
                'default_value' => null,
                'title' => Loc::getMessage('PROCESS_ENTITY_FINISHED_AT_FIELD')
            ]),
            'SETTINGS' => new TextField('SETTINGS', [
                'required' => true,
                'serialized' => true,
                'title' => Loc::getMessage('PROCESS_ENTITY_SETTINGS_FIELD')
            ]),
            'REPORT' => new TextField('REPORT', [
                'required' => true,
                'serialized' => true,
                'title' => Loc::getMessage('PROCESS_ENTITY_REPORT_FIELD')
            ]),
        ];
    }
}

Все просто, понятно, и по документации. Единственное, я отошел тут от архитектуры в плане сериализации значений колонок REPORT и SETTINGS. Я планировал делать json, но битрикс довольно часто устанавливается с windows-1251 кодировкой, в которой кириллица сериализоваться в json нативным для php способом не будет. Да и к тому же в битриксе для полей сущности есть специальный флаг, который будет сам следить за необходимостью сериализации/десериализации значений. Поэтому не вижу смысла не воспользоваться нативным функционалом на данном этапе.

Но описания самой сущности недостаточно, нужно ещё эту таблицу инсталлировать/деинсталлировать. Выделим создание этой таблицы в отдельный метод и добавим вызов этого метода в InstallDB(). Для того, чтобы битрикс смог найти этот класс, нужно предварительно зарегистрировать этот модуль и подключить его:

public function InstallDB()
{
    \Bitrix\Main\ModuleManager::registerModule($this->MODULE_ID);
    \Bitrix\Main\Loader::includeModule($this->MODULE_ID);  
    $connection = \Bitrix\Main\Application::getConnection();      
    $this->createProcessTable($connection);        
    return true;
}

protected function createProcessTable(\Bitrix\Main\DB\Connection $connection)
{
    $tableName = \Maximaster\Coupanda\Orm\ProcessTable::getTableName();
    if (!$connection->isTableExists($tableName)) {
        $connection->createTable(\Maximaster\Coupanda\Orm\ProcessTable::getTableName(),
            \Maximaster\Coupanda\Orm\ProcessTable::getMap(), ['ID'], ['ID']);
    }
}

Примерно то же самое делаем и для деинсталляции таблицы (писать код сюда не буду, оно итак понятно).

Хранение связи с ProcessTable

Теперь нам нужно добавить колонку в таблицу с купонами, которая будет ссылаться на данную таблицу. Добавить колонку в существующую таблицу довольно просто, делается это с помощью запроса ALTER_TABLE, его нужно также добавить в инсталлятор. Создаем новый метод, который добавит колонку:

protected function addPIDColumn(\Bitrix\Main\DB\Connection $connection)
{
    $connection->queryExecute('ALTER TABLE `b_sale_discount_coupon` ADD `MAXIMASTER_COUPANDA_PID` INT NULL DEFAULT NULL');
}

И добавляем его вызов в инсталлятор.

Но тут сразу кроется засада - что если запрос на создание таблицы пройдет успешно, а запрос на добавление колонки - нет? Или наоборот, запрос на создание таблицы не пройдет, а на создание колонки - прокатит? Добавлять кучу проверок на этот счет? Нафиг, сразу лучше использовать транзакции.

Кроме этого нужно какую-то ошибку показывать пользователю, если вдруг инсталляция не удалась. Сделаем пока простую заглушку, которая тупо будет транслировать сообщение из словленного исключения в процессе инсталляции БД.

В результате наш метод инсталляции БД превращается в нечто такое:

public function InstallDB()
    {
        $connection = \Bitrix\Main\Application::getConnection();
        try {
            $connection->startTransaction();
            \Bitrix\Main\ModuleManager::registerModule($this->MODULE_ID);
            \Bitrix\Main\Loader::includeModule($this->MODULE_ID);
            $this->createProcessTable($connection);
            $this->addPIDColumn($connection);
            $connection->commitTransaction();
        } catch (\Exception $e) {
            $connection->rollbackTransaction();
            global $APPLICATION;
            $APPLICATION->ResetException();
            $APPLICATION->ThrowException($e->getMessage());
            return false;
        }

        return true;
    }

Примерно то же самое опишем в методе деинсталляции БД, но в немного другом порядке (снятие регистрации модуля оставляем в конце, после того как все данные удалены успешно):

public function UninstallDB()
    {
        $connection = \Bitrix\Main\Application::getConnection();

        try {
            \Bitrix\Main\Loader::includeModule($this->MODULE_ID);
            $connection->startTransaction();
            $this->dropProcessTable($connection);
            $this->dropPIDColumn($connection);
            \Bitrix\Main\ModuleManager::unRegisterModule($this->MODULE_ID);
            $connection->commitTransaction();
        } catch (\Exception $e) {
            $connection->rollbackTransaction();
            global $APPLICATION;
            $APPLICATION->ResetException();
            $APPLICATION->ThrowException($e->getMessage());
            return false;
        }

        return true;
    }

По хорошему, нужно обработать каждый тип исключения, который выбрасывается битриксом при выполнении работы с БД, и обработать каждую ошибку по разному. Но мы же делаем MVP, а значит отложим это до момента запуска проекта, т.к. частота появления ошибки будет слишком низкой, а полученной информации чаще всего будет достаточно для того, чтобы разработчики смогли разобраться с проблемой инсталляции.

Проверяем

После инсталляции модуля с нуля тестируем наличие новой таблицы:

show create table maximaster_coupanda_process;
CREATE TABLE `maximaster_coupanda_process` (
  `ID` int(11) NOT NULL AUTO_INCREMENT,
  `STARTED_AT` datetime NOT NULL,
  `FINISHED_AT` datetime NOT NULL,
  `SETTINGS` text COLLATE utf8_unicode_ci NOT NULL,
  `REPORT` text COLLATE utf8_unicode_ci NOT NULL,
  PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci

Добавим запись:

\Bitrix\Main\Loader::includeModule('maximaster.coupanda');

$addedResult = \Maximaster\Coupanda\Orm\ProcessTable::add([
    'STARTED_AT' => new \Bitrix\Main\Type\DateTime(),
    'REPORT' => ['add' => 1, 'all' => 100],
    'SETTINGS' => ['setting1' => '1', 'setting2' => '2']
]);

var_dump($addedRsult->isSuccess()); // true

Проверим, корректно ли была выполнена связь между таблицами:

$updateResult = \Maximaster\Coupanda\Orm\DiscountCouponTable::update(2, ['MAXIMASTER_COUPANDA_PID' => 1]);
value($updateResult->isSuccess()); //true

$selectedCoupon = \Maximaster\Coupanda\Orm\DiscountCouponTable::query()
    ->addFilter('ID', 2)
    ->addSelect('MAXIMASTER_COUPANDA_PID')
    ->addSelect('MAXIMASTER_COUPANDA_PROCESS.SETTINGS')
    ->exec()->fetch();

print_r($selectedCoupon);
Array
(
    [MAXIMASTER_COUPANDA_PID] => 1
    [MAXIMASTER_COUPANDA_ORM_DISCOUNT_COUPON_MAXIMASTER_COUPANDA_PROCESS_SETTINGS] => Array
        (
            [setting1] => 123
            [setting2] => 123123
        )

)

Данные из связанной таблицы выводятся корректно.

В процессе написания этой статьи стало понятно, что для работы с колонками SETTINGS и REPORT будет гораздо удобнее использовать объекты. Для этого потребуется добавить в класс сущности валидацию этих полей с помощью механизов ORM. В методе getMap для каждого из этих полей добавляем настройку validation, которая должна содержать коллбек, возвращающий массив валидаторов:

'SETTINGS' => new TextField('SETTINGS', [
    'required' => true,
    'serialized' => true,
    'validation' => function() {
        return [[__CLASS__, 'validateSettingsInstance']];
    },
    'title' => Loc::getMessage('PROCESS_ENTITY_SETTINGS_FIELD')
]),
'REPORT' => new TextField('REPORT', [
    'required' => true,
    'serialized' => true,
    'validation' => function() {
        return [[__CLASS__, 'validateReportInstance']];
    },
    'title' => Loc::getMessage('PROCESS_ENTITY_REPORT_FIELD')
]),

а также добавим методы валидации:

public static function validateSettingsInstance($value, $primary, array $row, Field $field)
{
    if (!$value instanceof ProcessSettings) {
        return new FieldError($field, Loc::getMessage('PROCESS_ENTITY_SETTINGS_FIELD_NOT_VALID'), 'VALIDATION_ERROR');
    }

    return true;
}

public static function validateReportInstance($value, $primary, array $row, Field $field)
{
    if (!$value instanceof ProcessReport) {
        return new FieldError($field, Loc::getMessage('PROCESS_ENTITY_REPORT_FIELD_NOT_VALID'), 'VALIDATION_ERROR');
    }

    return true;
}

Теперь, при создании и обновлении записей в БД битрикс будет использовать эти валидаторы, у благодаря сериализации/десериализации мы сможем хранить эти данные в БД и получать из БД готовый заполненный инстанс нужного класса.

Подводим итоги.

Я не претендую на экспертность по части БД, возможно я тут накосячил. Напишите мне в комментах, если вдруг где-то тут есть серьезные огрехи, буду исправлять.

В этой статье я создал небольшой реальный пример связки модуля Битрикс с БД и все минимально необходимое для этого. Осталось узнать, как на эти изменения отреагирует сам битрикс при проверке модуля 🙂

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

К следующей статье цикла.