Заготовка. Миграция Highload-блок (HL ) Битрикс API

<?php

/**
 * Демо-миграция Highload-блока со всеми стандартными типами пользовательских полей.
 *
 * Использование (после prolog_before.php):
 *   $migration = new DemoHlMigration();
 *   $migration->up();
 *   // $migration->down();
 *
 * CLI: php /opt/www/local/migrations/run-demo-hl-migration.php up
 *
 */

use Bitrix\Highloadblock\HighloadBlockLangTable;
use Bitrix\Highloadblock\HighloadBlockTable;
use Bitrix\Main\Loader;

class DemoHlMigration
{
    /** Символьный код HL-блока (ORM entity name) */
    private const HL_NAME = 'DemoHlMigration';

    /** Имя физической таблицы в БД */
    private const HL_TABLE = 'demo_hl_migration';

    /** Справочный HL-блок для поля типа hlblock */
    private const REF_HL_NAME = 'DemoHlMigrationRef';

    private const REF_HL_TABLE = 'demo_hl_migration_ref';

    /**
     * Применяет миграцию: создаёт HL-блоки и все стандартные UF-поля.
     *
     * @return void
     * @throws \RuntimeException
     */
    public function up(): void
    {
        $this->ensureModules();

        $refHlblockId = $this->createHlblock(self::REF_HL_NAME, self::REF_HL_TABLE, 'Справочник для привязки');
        $hlblockId = $this->createHlblock(self::HL_NAME, self::HL_TABLE, 'Демо HL-блок (все типы полей)');

        $refEntityId = HighloadBlockTable::compileEntityId($refHlblockId);
        $entityId = HighloadBlockTable::compileEntityId($hlblockId);

        $this->addRefFields($refEntityId);
        $this->addAllFields($entityId, $refHlblockId);
    }

    /**
     * Откатывает миграцию: удаляет HL-блоки каскадно.
     *
     * @return void
     * @throws \RuntimeException
     */
    public function down(): void
    {
        $this->ensureModules();

        $this->deleteHlblockByName(self::HL_NAME);
        $this->deleteHlblockByName(self::REF_HL_NAME);
    }

    /**
     * Подключает необходимые модули.
     *
     * @return void
     * @throws \RuntimeException|\Bitrix\Main\LoaderException
     */
    private function ensureModules(): void
    {
        if (!Loader::includeModule('highloadblock')) {
            throw new \RuntimeException('Модуль highloadblock не установлен');
        }
    }

    /**
     * Создаёт HL-блок или возвращает ID существующего (идемпотентность).
     *
     * @param string $name Символьный код (DemoCatalog)
     * @param string $tableName Имя таблицы (demo_catalog)
     * @param string $langName Русское название для админки
     *
     * @return int
     * @throws \RuntimeException
     */
    private function createHlblock(string $name, string $tableName, string $langName): int
    {
        $existing = HighloadBlockTable::query()
            ->addSelect('ID')
            ->where('NAME', $name)
            ->exec()
            ->fetch()
        ;

        if ($existing) {
            $hlblockId = (int) $existing['ID'];
        } else {
            $result = HighloadBlockTable::add([
                'NAME' => $name,
                'TABLE_NAME' => $tableName,
            ]);

            if (!$result->isSuccess()) {
                throw new \RuntimeException(
                    'Ошибка создания HL-блока ' . $name . ': ' . implode('; ', $result->getErrorMessages())
                );
            }

            $hlblockId = (int) $result->getId();
        }

        $this->saveLangName($hlblockId, $langName);

        return $hlblockId;
    }

    /**
     * Сохраняет или обновляет русское название HL-блока.
     *
     * @param int $hlblockId
     * @param string $name
     *
     * @return void
     */
    private function saveLangName(int $hlblockId, string $name): void
    {
        $langRow = HighloadBlockLangTable::getByPrimary([
            'ID' => $hlblockId,
            'LID' => 'ru',
        ])->fetch();

        if ($langRow) {
            HighloadBlockLangTable::update(
                ['ID' => $hlblockId, 'LID' => 'ru'],
                ['NAME' => $name]
            );
        } else {
            HighloadBlockLangTable::add([
                'ID' => $hlblockId,
                'LID' => 'ru',
                'NAME' => $name,
            ]);
        }
    }

    /**
     * Добавляет минимальные поля в справочный HL-блок.
     *
     * @param string $entityId
     *
     * @return void
     */
    private function addRefFields(string $entityId): void
    {
        $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_TITLE',
            'USER_TYPE_ID' => 'string',
            'SORT' => 100,
            'SETTINGS' => [
                'SIZE' => 50,
                'ROWS' => 1,
                'DEFAULT_VALUE' => '',
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Название'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Название'],
        ]);
    }

    /**
     * Добавляет все стандартные типы пользовательских полей.
     *
     * @param string $entityId
     * @param int $refHlblockId ID справочного HL-блока для типа hlblock
     *
     * @return void
     */
    private function addAllFields(string $entityId, int $refHlblockId): void
    {
        $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_STRING',
            'USER_TYPE_ID' => 'string',
            'SORT' => 100,
            'MANDATORY' => 'Y',
            'SHOW_FILTER' => 'S',
            'IS_SEARCHABLE' => 'Y',
            'SETTINGS' => [
                'SIZE' => 50,
                'ROWS' => 1,
                'REGEXP' => '',
                'MIN_LENGTH' => 1,
                'MAX_LENGTH' => 255,
                'DEFAULT_VALUE' => '',
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Строка'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Строка'],
            'LIST_FILTER_LABEL' => ['ru' => 'Строка'],
            'HELP_MESSAGE' => ['ru' => 'Тип string, однострочный ввод'],
        ]);

        $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_TEXT',
            'USER_TYPE_ID' => 'string',
            'SORT' => 110,
            'SETTINGS' => [
                'SIZE' => 80,
                'ROWS' => 5,
                'MIN_LENGTH' => 0,
                'MAX_LENGTH' => 0,
                'DEFAULT_VALUE' => '',
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Текст'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Текст'],
        ]);

        $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_INTEGER',
            'USER_TYPE_ID' => 'integer',
            'SORT' => 120,
            'SHOW_FILTER' => 'I',
            'SETTINGS' => [
                'SIZE' => 20,
                'MIN_VALUE' => 0,
                'MAX_VALUE' => 999999,
                'DEFAULT_VALUE' => null,
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Целое число'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Число'],
        ]);

        $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_DOUBLE',
            'USER_TYPE_ID' => 'double',
            'SORT' => 130,
            'SHOW_FILTER' => 'I',
            'SETTINGS' => [
                'PRECISION' => 2,
                'SIZE' => 20,
                'MIN_VALUE' => 0,
                'MAX_VALUE' => 0,
                'DEFAULT_VALUE' => null,
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Дробное число'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Дробное'],
        ]);

        $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_BOOLEAN',
            'USER_TYPE_ID' => 'boolean',
            'SORT' => 140,
            'SHOW_FILTER' => 'I',
            'SETTINGS' => [
                'DEFAULT_VALUE' => 0,
                'DISPLAY' => 'CHECKBOX',
                'LABEL' => ['Нет', 'Да'],
                'LABEL_CHECKBOX' => 'Активен',
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Флаг'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Активен'],
        ]);

        $enumFieldId = $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_ENUM',
            'USER_TYPE_ID' => 'enumeration',
            'SORT' => 150,
            'SHOW_FILTER' => 'I',
            'SETTINGS' => [
                'DISPLAY' => 'LIST',
                'LIST_HEIGHT' => 5,
                'CAPTION_NO_VALUE' => '— не выбрано —',
                'SHOW_NO_VALUE' => 'Y',
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Список'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Статус'],
        ]);

        if ($enumFieldId) {
            $this->addEnumValues($enumFieldId);
        }

        $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_DATE',
            'USER_TYPE_ID' => 'date',
            'SORT' => 160,
            'SHOW_FILTER' => 'I',
            'SETTINGS' => [
                'DEFAULT_VALUE' => [
                    'TYPE' => 'NONE',
                    'VALUE' => '',
                ],
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Дата'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Дата'],
        ]);

        $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_DATETIME',
            'USER_TYPE_ID' => 'datetime',
            'SORT' => 170,
            'SHOW_FILTER' => 'I',
            'SETTINGS' => [
                'DEFAULT_VALUE' => [
                    'TYPE' => 'NONE',
                    'VALUE' => '',
                ],
                'USE_SECOND' => 'Y',
                'USE_TIMEZONE' => 'N',
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Дата и время'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Дата/время'],
        ]);

        $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_FILE',
            'USER_TYPE_ID' => 'file',
            'SORT' => 180,
            'SETTINGS' => [
                'SIZE' => 20,
                'LIST_WIDTH' => 200,
                'LIST_HEIGHT' => 200,
                'MAX_SHOW_SIZE' => 0,
                'MAX_ALLOWED_SIZE' => 0,
                'EXTENSIONS' => [
                    'jpg' => true,
                    'jpeg' => true,
                    'png' => true,
                    'gif' => true,
                    'pdf' => true,
                ],
                'TARGET_BLANK' => 'Y',
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Файл'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Файл'],
        ]);

        $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_URL',
            'USER_TYPE_ID' => 'url',
            'SORT' => 190,
            'SETTINGS' => [
                'POPUP' => 'Y',
                'SIZE' => 50,
                'MIN_LENGTH' => 0,
                'MAX_LENGTH' => 0,
                'ROWS' => 1,
                'DEFAULT_VALUE' => '',
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Ссылка'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Ссылка'],
        ]);

        $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_STRING_FORMATTED',
            'USER_TYPE_ID' => 'string_formatted',
            'SORT' => 200,
            'SETTINGS' => [
                'SIZE' => 50,
                'ROWS' => 1,
                'REGEXP' => '',
                'MIN_LENGTH' => 0,
                'MAX_LENGTH' => 0,
                'DEFAULT_VALUE' => '',
                'PATTERN' => '#VALUE#',
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Шаблонное выражение'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Шаблон'],
        ]);

        $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_HLBLOCK',
            'USER_TYPE_ID' => 'hlblock',
            'SORT' => 210,
            'SHOW_FILTER' => 'I',
            'SETTINGS' => [
                'DISPLAY' => 'LIST',
                'LIST_HEIGHT' => 5,
                'HLBLOCK_ID' => $refHlblockId,
                'HLFIELD_ID' => 0,
                'DEFAULT_VALUE' => '',
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Привязка к HL'],
            'LIST_COLUMN_LABEL' => ['ru' => 'HL-элемент'],
        ]);

        $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_STRING_MULTI',
            'USER_TYPE_ID' => 'string',
            'SORT' => 300,
            'MULTIPLE' => 'Y',
            'SETTINGS' => [
                'SIZE' => 40,
                'ROWS' => 1,
                'DEFAULT_VALUE' => '',
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Теги (множественное)'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Теги'],
        ]);

        $enumMultiFieldId = $this->addUserField($entityId, [
            'FIELD_NAME' => 'UF_ENUM_MULTI',
            'USER_TYPE_ID' => 'enumeration',
            'SORT' => 310,
            'MULTIPLE' => 'Y',
            'SETTINGS' => [
                'DISPLAY' => 'CHECKBOX',
                'LIST_HEIGHT' => 5,
                'SHOW_NO_VALUE' => 'N',
            ],
            'EDIT_FORM_LABEL' => ['ru' => 'Категории (множественный список)'],
            'LIST_COLUMN_LABEL' => ['ru' => 'Категории'],
        ]);

        if ($enumMultiFieldId) {
            $this->addEnumMultiValues($enumMultiFieldId);
        }
    }

    /**
     * Добавляет пользовательское поле, если его ещё нет.
     *
     * @param string $entityId
     * @param array $fieldData
     *
     * @return int|null ID созданного поля или null если уже существует
     * @throws \RuntimeException
     */
    private function addUserField(string $entityId, array $fieldData): ?int
    {
        $fieldName = $fieldData['FIELD_NAME'];

        $rs = \CUserTypeEntity::GetList([], [
            'ENTITY_ID' => $entityId,
            'FIELD_NAME' => $fieldName,
        ]);

        if ($rs->Fetch()) {
            return null;
        }

        $defaults = [
            'ENTITY_ID' => $entityId,
            'XML_ID' => $fieldName,
            'SORT' => 500,
            'MULTIPLE' => 'N',
            'MANDATORY' => 'N',
            'SHOW_FILTER' => 'N',
            'SHOW_IN_LIST' => 'Y',
            'EDIT_IN_LIST' => 'Y',
            'IS_SEARCHABLE' => 'N',
            'SETTINGS' => [],
            'EDIT_FORM_LABEL' => ['ru' => $fieldName],
            'LIST_COLUMN_LABEL' => ['ru' => $fieldName],
            'LIST_FILTER_LABEL' => ['ru' => $fieldName],
            'ERROR_MESSAGE' => ['ru' => ''],
            'HELP_MESSAGE' => ['ru' => ''],
        ];

        $payload = array_merge($defaults, $fieldData);
        $payload['ENTITY_ID'] = $entityId;

        $userType = new \CUserTypeEntity();
        $fieldId = $userType->Add($payload);

        if (!$fieldId) {
            global $APPLICATION;
            $error = $APPLICATION->GetException()
                ? $APPLICATION->GetException()->GetString()
                : 'Неизвестная ошибка';

            throw new \RuntimeException(
                'Не удалось создать поле ' . $fieldName . ': ' . $error
            );
        }

        return (int) $fieldId;
    }

    /**
     * Добавляет значения для одиночного списка UF_ENUM.
     *
     * @param int $fieldId
     *
     * @return void
     */
    private function addEnumValues(int $fieldId): void
    {
        $enum = new \CUserFieldEnum();

        $enum->SetEnumValues($fieldId, [
            'n0' => [
                'VALUE' => 'Новый',
                'DEF' => 'Y',
                'SORT' => 100,
                'XML_ID' => 'NEW',
            ],
            'n1' => [
                'VALUE' => 'В работе',
                'DEF' => 'N',
                'SORT' => 200,
                'XML_ID' => 'IN_PROGRESS',
            ],
            'n2' => [
                'VALUE' => 'Закрыт',
                'DEF' => 'N',
                'SORT' => 300,
                'XML_ID' => 'CLOSED',
            ],
        ]);
    }

    /**
     * Добавляет значения для множественного списка UF_ENUM_MULTI.
     *
     * @param int $fieldId
     *
     * @return void
     */
    private function addEnumMultiValues(int $fieldId): void
    {
        $enum = new \CUserFieldEnum();

        $enum->SetEnumValues($fieldId, [
            'n0' => [
                'VALUE' => 'Категория A',
                'DEF' => 'N',
                'SORT' => 100,
                'XML_ID' => 'CAT_A',
            ],
            'n1' => [
                'VALUE' => 'Категория B',
                'DEF' => 'N',
                'SORT' => 200,
                'XML_ID' => 'CAT_B',
            ],
            'n2' => [
                'VALUE' => 'Категория C',
                'DEF' => 'N',
                'SORT' => 300,
                'XML_ID' => 'CAT_C',
            ],
        ]);
    }

    /**
     * Удаляет HL-блок по символьному коду NAME.
     *
     * @param string $name
     *
     * @return void
     * @throws \RuntimeException
     */
    private function deleteHlblockByName(string $name): void
    {
        $hlblock = HighloadBlockTable::query()
            ->addSelect('ID')
            ->where('NAME', $name)
            ->exec()
            ->fetch()
        ;

        if (!$hlblock) {
            return;
        }

        $result = HighloadBlockTable::delete((int) $hlblock['ID']);

        if (!$result->isSuccess()) {
            throw new \RuntimeException(
                'Ошибка удаления HL-блока ' . $name . ': ' . implode('; ', $result->getErrorMessages())
            );
        }
    }
}

Пример запуска

<?php

/**
 * CLI-runner для DemoHlMigration.
 *
 * docker compose exec php php /opt/www/local/migrations/run-demo-hl-migration.php up
 * docker compose exec php php /opt/www/local/migrations/run-demo-hl-migration.php down
 */

$_SERVER['DOCUMENT_ROOT'] = realpath(__DIR__ . '/../..');
define('NO_KEEP_STATISTIC', true);
define('NOT_CHECK_PERMISSIONS', true);
define('BX_NO_ACCELERATOR_RESET', true);

require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';
require __DIR__ . '/demo-hl-migration.php';

$action = $argv[1] ?? 'up';

try {
    $migration = new DemoHlMigration();

    if ($action === 'down') {
        $migration->down();
        echo "OK: down()\n";
        exit(0);
    }

    $migration->up();
    echo "OK: up()\n";

    $blocks = \Bitrix\Highloadblock\HighloadBlockTable::getList(array(
        'filter' => array(
            '@NAME' => array('DemoHlMigration', 'DemoHlMigrationRef'),
        ),
        'select' => array('ID', 'NAME', 'TABLE_NAME'),
        'order' => array('ID' => 'ASC'),
    ))->fetchAll();

    foreach ($blocks as $block) {
        $entityId = \Bitrix\Highloadblock\HighloadBlockTable::compileEntityId((int)$block['ID']);
        $fields = \CUserTypeEntity::GetList(
            array('SORT' => 'ASC'),
            array('ENTITY_ID' => $entityId)
        );

        $fieldCount = 0;
        while ($fields->Fetch()) {
            $fieldCount++;
        }

        echo sprintf(
            "HL ID=%d NAME=%s TABLE=%s fields=%d\n",
            (int)$block['ID'],
            $block['NAME'],
            $block['TABLE_NAME'],
            $fieldCount
        );
    }

    exit(0);
} catch (\Throwable $e) {
    fwrite(STDERR, 'ERROR: ' . $e->getMessage() . "\n");
    exit(1);
}