Кофе && Код

Утренние советы, best practices и продвинутые техники за чашкой кофе.

Text\Emoji: 4-байтовые символы в колонках utf8

Text\Emoji: 4-байтовые символы в колонках utf8

Проблема/контекст

Колонка в кодировке utf8 (utf8mb3) в MySQL хранит максимум три байта на символ. Эмодзи и часть CJK-расширений занимают четыре байта, поэтому запись обрывается на первом таком символе и остаток текста теряется. Переводить рабочую базу в utf8mb4 ради одного поля дорого, и ядро идёт другим путём — классом Bitrix\Main\Text\Emoji.

Решение с кодом

Emoji::encode() прогоняет строку через preg_replace_callback и заменяет каждую четырёхбайтовую последовательность на ":" . bin2hex($match) . ":". Кодируются байты UTF-8, а не кодовая точка: 😊 (U+1F60A) даёт :f09f988a: — 10 символов вместо 4 байт. Паттерн покрывает плоскости 1–16 (\xF0[\x90-\xBF], [\xF1-\xF3], \xF4[\x80-\x8F]), трёхбайтовые символы не затрагиваются.

Emoji::decode() работает по собственной регулярке /:([A-F0-9]{8}):/isu и возвращает подстроку без изменений, если hex2bin() не дал валидную четырёхбайтовую последовательность. Обратная сторона: строка :f09f988a:, набранная пользователем руками, при чтении станет эмодзи.

Ключевая деталь модификаторов: getSaveModificator() и getFetchModificator() ничего не преобразуют. Каждый возвращает массив колбэков — [[Emoji::class, 'encode']] и [[Emoji::class, 'decode']]. В параметр поля кладут именно фабрику: Field::getSaveDataModifiers() вызывает её через call_user_func() и бросает SystemException, если вернулся не массив. Так объявлены поля в Bitrix\Blog\PostTable, Bitrix\Vote\QuestionTable, моделях im и landing:

        'TITLE' => [
    'data_type' => 'string',
    'save_data_modification' => [\Bitrix\Main\Text\Emoji::class, 'getSaveModificator'],
    'fetch_data_modification' => [\Bitrix\Main\Text\Emoji::class, 'getFetchModificator'],
],

    

В объектной карте полей удобнее добавлять колбэки напрямую — они проходят проверку is_callable без фабрики:

        <?php
declare(strict_types=1);

namespace Vendor\Module\Model;

use Bitrix\Main\ORM\Data\DataManager;
use Bitrix\Main\ORM\Fields\IntegerField;
use Bitrix\Main\ORM\Fields\StringField;
use Bitrix\Main\ORM\Fields\TextField;
use Bitrix\Main\Text\Emoji;

final class CommentTable extends DataManager
{
    public static function getTableName(): string
    {
        return 'vendor_module_comment';
    }

    public static function getMap(): array
    {
        return [
            (new IntegerField('ID'))->configurePrimary()->configureAutocomplete(),
            (new StringField('TITLE'))
                ->addSaveDataModifier([Emoji::class, 'encode'])
                ->addFetchDataModifier([Emoji::class, 'decode']),
            (new TextField('MESSAGE'))
                ->addSaveDataModifier([Emoji::class, 'encode'])
                ->addFetchDataModifier([Emoji::class, 'decode']),
        ];
    }
}

    

Save-модификаторы применяются в DataManager в add(), update() и их multi-вариантах через Field::modifyValueBeforeSave(). Fetch-модификаторы собирает Query::isFetchModificationRequired() и применяет к алиасам выборки, то есть только к полям, попавшим в select.

Границы механизма:

  • фильтр не модифицируется, поиск по значению с эмодзи требует ручного вызова:
        CommentTable::getList([
    'filter' => ['=TITLE' => Emoji::encode($userInput)],
]);

    
  • закодированное значение длиннее исходного, для varchar закладывайте запас;
  • сырые запросы через Connection модификаторы не проходят — вызывайте Emoji::encode()/decode() сами;
  • флаг колонки читается через Application::getConnection()->isUtf8mb4($table, $column) из секции utf8mb4 в connections. Компоненты соцсети кодируют текст только при false, но ORM-модификаторы срабатывают всегда, независимо от этой настройки.

Итог

Text\Emoji держит четырёхбайтовые символы в трёхбайтовой колонке ценой десяти ASCII-символов на эмодзи. Подключайте модификаторы в карте полей, а фильтры и прямые SQL-запросы кодируйте вручную.

Парсер телефонов и страна по умолчанию в #[Phone]

Парсер телефонов и страна по умолчанию в #[Phone]

Проблема/контекст

Атрибут #[Phone] из Bitrix\Main\Validation\Rule выглядит как проверка формата строки. На деле он собирает единственный валидатор PhoneValidator, а тот вызывает Parser::getInstance()->parse($value)->isValid() — без второго аргумента. Внутри parse() пустая страна заменяется на Parser::getDefaultCountry(), поэтому один и тот же номер без + на двух порталах даст разный результат валидации.

Решение с кодом

Порядок источников в Parser::getDefaultCountry():

  1. Option::get('main', 'phone_number_default_country') — в опции хранится ID страны, а не двухбуквенный код. Значение задаётся в настройках главного модуля полем «Страна по умолчанию».
  2. Если опция пуста, вызывается detectCountry(): сначала REG_COUNTRY модуля bitrix24, затем портальная зона из списка br, cn, de, in, ru, ua, by, kz, fr, pl, uz, затем Context::getCurrent()->getLanguage() из списка br, cn, de, in, ru, ua, by, kz, fr, pl, и только в конце GeoIp\Manager::getCountryCode().
  3. Найденный код конвертируется в ID и записывается в опцию — первый же вызов фиксирует страну на всё время жизни портала.
  4. Наружу возвращается GetCountryCodeById($id), при неудаче — пустая строка.

Пустая строка означает, что MetadataProvider::getCountryMetadata('') вернёт false, и parse() отдаст невалидный PhoneNumber для любого номера без +. Номер с + разбирается по коду страны из самой строки, и $defaultCountry игнорируется — это единственный формат, устойчивый к настройкам портала.

В коде модуля страну задавайте явно вторым аргументом:

        <?php declare(strict_types=1);

use Bitrix\Main\PhoneNumber\Format;
use Bitrix\Main\PhoneNumber\Parser;

$number = Parser::getInstance()->parse('8 (916) 123-45-67', 'RU');

$number->isValid();            // true
$number->getCountry();         // RU
$number->getCountryCode();     // 7
$number->format(Format::E164); // +79161234567

    

Сигнатура — PhoneNumber::format($formatType = '', $forceNationalPrefix = false). Константы класса Bitrix\Main\PhoneNumber\Format: E164 (значение 'E.164'), INTERNATIONAL, NATIONAL. При пустом $formatType вызывается Formatter::formatOriginal() — он сохраняет исходное написание и откатывается к сырой строке, если форматирование изменило состав цифр. Для невалидного номера format() всегда возвращает getRawNumber(), поэтому isValid() проверяем до форматирования, а не после.

В базе храните Format::E164 — это '+' . countryCode . nationalNumber без разделителей, единственный вид, пригодный для поиска дублей и сравнения:

        <?php declare(strict_types=1);

namespace Vendor\Module\Application\Service;

use Bitrix\Main\PhoneNumber\Format;
use Bitrix\Main\PhoneNumber\Parser;

final class PhoneNormalizer
{
    public function __construct(private readonly string $defaultCountry = 'RU') {}

    public function normalize(string $raw): ?string
    {
        $number = Parser::getInstance()->parse($raw, $this->defaultCountry);

        return $number->isValid() ? $number->format(Format::E164) : null;
    }
}

    

Если валидация должна быть привязана к стране, напишите свой валидатор — интерфейс состоит из одного метода:

        <?php declare(strict_types=1);

namespace Vendor\Module\Validation\Validator;

use Bitrix\Main\Localization\LocalizableMessage;
use Bitrix\Main\PhoneNumber\Parser;
use Bitrix\Main\Validation\ValidationError;
use Bitrix\Main\Validation\ValidationResult;
use Bitrix\Main\Validation\Validator\ValidatorInterface;

final class CountryPhoneValidator implements ValidatorInterface
{
    public function __construct(private readonly string $country) {}

    public function validate(mixed $value): ValidationResult
    {
        $result = new ValidationResult();

        if (!is_string($value) || !Parser::getInstance()->parse($value, $this->country)->isValid())
        {
            $result->addError(new ValidationError(
                new LocalizableMessage('MAIN_VALIDATION_PHONE_INVALID'),
                failedValidator: $this,
            ));
        }

        return $result;
    }
}

    

Подключается он через собственный атрибут, унаследованный от AbstractPropertyValidationAttribute, с возвратом валидатора из getValidators().

Итог

#[Phone] проверяет номер относительно страны портала, а не относительно вашей предметной области, и эта страна может прийти из зоны Битрикс24, языка или GeoIP. Задавайте страну явно в Parser::parse() и храните результат в Format::E164.

Принудительный logout или обновление сессии через UserAuthActionTable

Принудительный logout или обновление сессии через UserAuthActionTable

Проблема/контекст

После смены пароля, блокировки пользователя или отзыва app-password нельзя «убить» чужую сессию прямым DELETE из b_user_session или изменить её: ядро хранит несколько типов авторизации (браузер, remember-me, REST с APPLICATION_ID), и рассинхрон легко оставит активный доступ.

Решение с кодом

Разлогинить пользователя везде (браузер и все приложения) — без APPLICATION_ID:

        <?php
declare(strict_types=1);

use Bitrix\Main\UserAuthActionTable;

// После компрометации аккаунта или смены пароля админом
UserAuthActionTable::addLogoutAction($userId);

    

Отозвать только один REST-клиент (как при удалении app-password в ApplicationPasswordTable::onDelete):

        <?php
declare(strict_types=1);

use Bitrix\Main\UserAuthActionTable;

// Только сессии с тем же APPLICATION_ID в Context
UserAuthActionTable::addLogoutAction($userId, 'my.module.api');

    

Обновить данные в сессии без выхода — например, после смены групп:

        <?php
declare(strict_types=1);

use Bitrix\Main\Type\DateTime;
use Bitrix\Main\UserAuthActionTable;

// Сейчас: пересчитать права в $_SESSION
UserAuthActionTable::addUpdateAction($userId);

// Отложенно: когда наступит дата активации группы
UserAuthActionTable::addUpdateAction($userId, new DateTime('2026-06-01 00:00:00'));

    

Важно:

  • срабатывание не мгновенное: запись обрабатывается на следующем запросе пользователя (после include.php ваш код уже не вызовет logout в том же хите);
  • если задан APPLICATION_ID, браузерная сессия запись пропустит (continue при несовпадении id);
  • getList() в проверке кешируется на 3600 секунд — в редких случаях logout может задержаться до истечения кеша ORM-запроса;
  • при смене пароля самим пользователем ядро ставит AUTH_ACTION_SKIP_LOGOUT, чтобы не выбрасывать его сразу после сохранения.

Просроченные записи чистит агент CUser::AuthActionsCleanUpAgent() (старше суток).

Итог

UserAuthActionTable — отложенная команда ядру: «на следующем хите разлогинь или обнови сессию». Для полного отзыва доступа вызывайте addLogoutAction($userId), для одного API-клиента добавляйте APPLICATION_ID и учитывайте кеш выборки при срочном отзыве.

Приватные поля ORM: select и filter ведут себя по-разному

Приватные поля ORM: select и filter ведут себя по-разному

Проблема/контекст

В D7 ORM поле можно пометить как приватное через 'private' => true или configurePrivate(). Такие поля скрыты от обычных запросов: ядро считает их служебными или чувствительными. Классический пример — PASSWORD в Bitrix\Main\UserTable, где хеш пароля не должен попадать в типовые выборки списков и отчётов.

Разработчик часто ожидает, что Query::enablePrivateFields() снимает все ограничения. Это не так: перед сборкой SQL ядро вызывает checkForPrivateFields(), и private-поля всегда запрещены в filter. В select и runtime они доступны только после явного разрешения. Тот же запрет распространяется на ExpressionField, если в его buildFrom участвует private-колонка — метод Query::isFieldPrivate() обходит цепочки выражения.

Решение с кодом

Попытка выбрать PASSWORD без флага завершится SystemException с текстом про enablePrivateFields(). Фильтрация по private-полю упадёт в любом случае — даже после enablePrivateFields().

        <?php
declare(strict_types=1);

use Bitrix\Main\UserTable;

// Ошибка: Private field ... PASSWORD is restricted in query
UserTable::query()
    ->setSelect(['ID', 'PASSWORD'])
    ->where('ACTIVE', 'Y')
    ->fetchAll();

    

Для чтения private-полей передайте 'private_fields' => true в getList() или вызовите enablePrivateFields() у query. Это удобно в админских скриптах и сервисах миграции, где доступ к служебным колонкам действительно нужен.

        <?php
declare(strict_types=1);

use Bitrix\Main\UserTable;

$rows = UserTable::getList([
    'select' => ['ID', 'LOGIN', 'PASSWORD'],
    'filter' => ['=ACTIVE' => 'Y'],
    'private_fields' => true,
    'limit' => 10,
])->fetchAll();

    

Фильтр по private-полю ядро не пропустит — в checkForPrivateFields() проверка filter_chains выполняется до проверки флага private_fields_on:

        <?php
declare(strict_types=1);

use Bitrix\Main\UserTable;

// Ошибка: Private field ... PASSWORD is restricted in filter
UserTable::query()
    ->enablePrivateFields()
    ->setSelect(['ID'])
    ->where('PASSWORD', 'some_hash')
    ->fetchAll();

    

В своих DataManager помечайте служебные колонки явно и не используйте их в публичных API:

        (new StringField('INTERNAL_TOKEN'))
    ->configurePrivate(),

    

Если нужна выборка «по секретному значению», добавьте отдельный публичный метод сервиса с параметризованным SQL или whitelist-фильтром по не-private полю. Не расширяйте filter ORM и не снимайте configurePrivate() ради одного запроса — это откроет поле для всех последующих вызовов getList() без контроля.

Итог

configurePrivate() защищает поле от случайного попадания в запросы и API. enablePrivateFields() и 'private_fields' => true разрешают только select и runtime; filter для private-полей в ORM закрыт намеренно и без обходного пути в query builder. Для поиска по чувствительным данным проектируйте отдельный контур, а не пытайесь «обойти» ограничение через WHERE.

Application::addBackgroundJob — что происходит после ответа клиенту

Application::addBackgroundJob — что происходит после ответа клиенту

Проблема

Метод \Bitrix\Main\Application::addBackgroundJob() часто используют по памяти: передают callback с use и надеются, что «задача выполнится где-то в фоне». На практике у метода строгая сигнатура и несколько неочевидных побочных эффектов из Application::terminate(), о которых молчит PHPDoc. Разберём реальное поведение по коду ядра main/lib/application.php.

Решение

Сигнатура из ядра (main/lib/application.php):

        public function addBackgroundJob(
    callable $job,
    array $args = [],
    $priority = self::JOB_PRIORITY_NORMAL
);

    

Аргументы задачи — это второй параметр $args, а не замыкание с use. Оба варианта работают (внутри вызов идёт через call_user_func_array), но штатный путь — именно $args:

        \Bitrix\Main\Application::getInstance()->addBackgroundJob(
    [\Vendor\Module\Application\Service\Notifier::class, 'sendWelcome'],
    [$userId, $locale],
    \Bitrix\Main\Application::JOB_PRIORITY_NORMAL,
);

    

Доступные приоритеты: JOB_PRIORITY_NORMAL = 100, JOB_PRIORITY_LOW = 50. Очередь построена на SplPriorityQueue — большее число выполняется раньше.

Что именно происходит после отправки ответа, видно в terminate():

        // псевдокод из Application::terminate()
\CMain::RunFinalActionsInternal();
session_write_close();              // 1. Сессия закрыта
$pool = $this->getConnectionPool();
$pool->useMasterOnly(true);         // 2. Только мастер БД
$this->runBackgroundJobs();         // 3. Запуск джобов
$pool->useMasterOnly(false);

    

К моменту запуска джобов HttpResponse::send() уже вызвал fastcgi_finish_request() — клиент получил ответ и закрыл соединение. Отсюда прикладные следствия:

  1. Писать в $_SESSION бесполезно — сессия закрыта. Нужна сессия — используйте отдельное хранилище или перенесите логику в Messenger.
  2. Все SQL-запросы идут в мастер, реплики не используются — держите задачи короткими.
  3. Исключения внутри джобов ловятся и логируются через ExceptionHandler::writeToLog(), но последнее исключение после прогона всей очереди пробрасывается наверх. На ответ клиента это не повлияет, но зафиксируется в логах php-fpm и может оборвать воркер.
  4. Задачи могут добавлять новые задачи — внешний while ($this->backgroundJobs->valid()) дренирует очередь до пустоты. Следите за идемпотентностью, чтобы не получить бесконечный цикл.
  5. Нет гарантии доставки: падение процесса (OOM, таймаут) — задача теряется. Для надёжной обработки с ретраями используйте Messenger.

Итог

addBackgroundJob — это не «очередь», а хвост текущего запроса с уже закрытой сессией и мастер-соединением. Подходит для короткой работы в несколько сотен миллисекунд: метрики, уведомления, отложенные логи. Всё, что требует гарантий, — только через Bitrix\Main\Messenger.

Почему нельзя фильтровать по CryptoField и как это обойти

Почему нельзя фильтровать по CryptoField и как это обойти

Проблема/контекст

CryptoField шифрует данные при записи и расшифровывает при чтении. Однако каждый вызов encrypt() в Bitrix\Main\Security\Cipher генерирует случайный вектор инициализации (IV). Одно и то же значение при каждом шифровании превращается в разный шифротекст. Это значит, что фильтрация через getList() по зашифрованному полю не вернет результатов: SQL-запрос сравнит открытый текст из фильтра с шифротекстом в базе и ничего не найдет.

На практике проблема возникает, как только нужно найти запись по API-токену, ключу доступа или другому зашифрованному идентификатору — задача, которая появляется в любой интеграции.

Решение с кодом

Добавьте к таблице индексируемую колонку с хешем открытого значения. Фильтруйте по хешу, а зашифрованное поле используйте только для чтения.

        <?php
declare(strict_types=1);

namespace Vendor\Integration\Model;

use Bitrix\Main\ORM\Data\DataManager;
use Bitrix\Main\ORM\Fields;
use Bitrix\Main\Security\Random;

final class ApiTokenTable extends DataManager
{
    public static function getTableName(): string
    {
        return 'vendor_api_tokens';
    }

    public static function getMap(): array
    {
        return [
            new Fields\IntegerField('ID', [
                'primary' => true,
                'autocomplete' => true,
            ]),
            new Fields\IntegerField('USER_ID', [
                'required' => true,
            ]),
            // Зашифрованный токен — только для хранения и чтения
            new Fields\CryptoField('TOKEN', [
                'crypto_enabled' => static::cryptoEnabled('TOKEN'),
            ]),
            // SHA-256 хеш токена — для поиска по индексу
            new Fields\StringField('TOKEN_HASH', [
                'size' => 64,
            ]),
        ];
    }

    // Создает запись с новым токеном
    public static function issueToken(int $userId): array
    {
        $token = Random::getString(32);

        $result = static::add([
            'USER_ID' => $userId,
            'TOKEN' => $token,
            'TOKEN_HASH' => hash('sha256', $token),
        ]);

        return ['id' => $result->getId(), 'token' => $token];
    }

    // Находит запись по открытому токену через хеш
    public static function findByToken(string $token): ?array
    {
        return static::getRow([
            'filter' => ['=TOKEN_HASH' => hash('sha256', $token)],
        ]);
    }
}

    

Схема: при создании записи токен шифруется CryptoField и сохраняется в TOKEN, а его SHA-256 хеш записывается в TOKEN_HASH. При входящем запросе вычисляется хеш и выполняется поиск по индексированной колонке. Исходный токен восстанавливается из зашифрованного поля автоматически при чтении.

Размер колонки TOKEN должен учитывать увеличение данных при шифровании. Для 32-байтного токена в режиме CTR минимальный размер: (32 + 16 + 32) * 1.5 ≈ 120 байт. Используйте VARCHAR(255). Колонка TOKEN_HASH фиксированная — CHAR(64) с уникальным индексом.

Еще одна деталь, которую стоит учитывать: если шифрование завершится ошибкой — например, ключ не настроен в .settings.php — метод encrypt() возвращает null, а не исходное значение. Данные молча теряются. Поэтому всегда проверяйте доступность шифрования через CryptoField::cryptoAvailable() перед включением криптополей, как это делается в ядре — например, в UserPhoneAuthTable для OTP-секретов.

Паттерн похож на хранение паролей, но с важным отличием: здесь исходное значение нужно восстановить, поэтому вместо необратимого bcrypt используется обратимое шифрование AES-256-CTR через CryptoField, а хеш служит только для поиска.

Итог

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

Отложенный ресайз облачных файлов в CFile::ResizeImageGet

Отложенный ресайз облачных файлов в CFile::ResizeImageGet

Проблема/контекст

У CFile::ResizeImageGet() есть шестой параметр $bImmediate, но по коду ядра он не включает "отложенный ресайз" сам по себе. В main/classes/general/file.php метод только передает этот флаг в событие OnBeforeResizeImage, а реальная delayed-логика подключается обработчиком модуля clouds.

Это важно, потому что для локальных файлов ресайз остается синхронным, а для файлов с HANDLER_ID из облачного хранилища поведение меняется. В CCloudStorage::OnBeforeResizeImage() ядро проверяет опцию clouds.delayed_resize и при $bImmediate = false не строит миниатюру сразу, а ставит задачу в таблицу b_clouds_file_resize через ResizeImageFileDelay(). Затем CCloudStorage::OnBeforeProlog() перехватывает запрос к resize_cache, вызывает ResizeImageFileCheck() и уже после генерации перенаправляет на готовый файл в бакете.

Решение с кодом

Практическое правило простое: delayed-режим подходит для публичных списков, где допустим отдельный HTTP-запрос на первую генерацию картинки. Для AJAX-ответов, модальных превью, административных форм и мест, где URL должен вести на уже существующий файл, ставьте $bImmediate = true. Сам Bitrix следует этому же правилу: в Bitrix\Main\UI\FileInputUnclouder::exec() и шаблоне main.file.input превью строится через ResizeImageGet(..., true).

        <?php
declare(strict_types=1);

/**
 * Для cloud-файлов решаем, можно ли отложить генерацию.
 */
function getPreviewImage(array $file, int $width, int $height, bool $needImmediate): array
{
    $result = \CFile::ResizeImageGet(
        $file,
        ['width' => $width, 'height' => $height],
        bInitSizes: true,   // вернуть реальные размеры результата
        bImmediate: $needImmediate
    );

    return is_array($result) ? $result : [];
}

$file = \CFile::GetFileArray($pictureId);
$isCloudFile = (int)($file['HANDLER_ID'] ?? 0) > 0;

// В списке товаров допускаем delayed-генерацию только для cloud-файлов.
$listImage = getPreviewImage($file, 320, 320, false);

// В AJAX-превью файл нужен сразу, иначе можно получить URL на еще не созданный объект.
$modalImage = getPreviewImage($file, 1200, 1200, $isCloudFile);

    

Если вы вызываете ResizeImageGet() в JSON-ответе или сразу вставляете src в интерфейс, не рассчитывайте, что delayed-режим успеет создать файл к этому же ответу. Документация показывает только прямой CFile::ResizeImage() после SaveFile(), но не объясняет этот разрыв между локальным и cloud-сценарием. Поэтому проверяйте HANDLER_ID и осознанно выбирайте режим генерации, а не передавайте шестой параметр "на всякий случай".

Есть и еще один нюанс, который виден только в исходниках clouds. Метод ResizeImageFileDelay() не ставит задачу без необходимости: если Rectangle::resize() показывает, что новое изображение не требуется и нет фильтров или watermark, delayed-сценарий не включается. А если задача уже падала, ядро пытается перезапустить ее только спустя пять минут по TIMESTAMP_X. Это означает, что delayed-режим полезен именно как фоновая оптимизация выдачи, а не как гарантия мгновенного результата при повторном запросе.

Итог

$bImmediate имеет практический смысл только в связке с облачным хранилищем и clouds.delayed_resize. Для интерактивных превью включайте немедленную генерацию, для публичных страниц можно оставлять delayed-режим.

Как удаляются связи OneToMany и ManyToMany в D7 ORM

Как удаляются связи OneToMany и ManyToMany в D7 ORM

Проблема/контекст

В D7-связях удаление часто понимают слишком упрощенно: если сущности связаны, значит ядро само "разрулит" каскад. Но в исходниках ORM поведение у OneToMany и ManyToMany принципиально разное, и ошибка в ожиданиях быстро приводит либо к лишнему удалению, либо к зависшим данным.

Ключевая точка здесь Bitrix\Main\ORM\Objectify\EntityObject::delete(). Для OneToMany метод смотрит на getCascadeDeletePolicy() и либо удаляет дочерние объекты, либо снимает ссылку через sysRemoveAllFromCollection(). Для ManyToMany логика иная: при удалении объекта ядро всегда очищает таблицу-посредник, а сами связанные сущности не удаляет. Это напрямую следует из связки EntityObject::delete(), sysRemoveAllFromCollection() и sysSaveRelations().

Решение с кодом

Если дочерняя запись принадлежит только одному владельцу, используйте OneToMany и явно задавайте политику удаления:

        <?php
use Bitrix\Main\ORM\Fields\Relations\CascadePolicy;
use Bitrix\Main\ORM\Fields\Relations\OneToMany;

final class OrderTable extends \Bitrix\Main\ORM\Data\DataManager
{
    public static function getMap(): array
    {
        return [
            // При удалении заказа удалятся и его позиции
            (new OneToMany('ITEMS', OrderItemTable::class, 'ORDER'))
                ->configureCascadeDeletePolicy(CascadePolicy::FOLLOW),
        ];
    }
}

    

Если FOLLOW не указан, у OneToMany по умолчанию работает SET_NULL: ядро не удаляет дочерние строки, а отвязывает их от родителя. Это удобно для журналов, уведомлений и других записей, которые могут жить дольше основной сущности.

Для общих справочников и тегов используйте ManyToMany, но помните реальное поведение удаления:

        <?php
use Bitrix\Main\ORM\Fields\Relations\ManyToMany;

final class ArticleTable extends \Bitrix\Main\ORM\Data\DataManager
{
    public static function getMap(): array
    {
        return [
            // При удалении статьи ядро очистит только b_article_tag
            (new ManyToMany('TAGS', TagTable::class))
                ->configureTableName('b_article_tag'),
        ];
    }
}

    

После $article->delete() будут удалены строки из b_article_tag, потому что sysSaveRelations() удаляет записи посредника через data class медиаторной сущности. Сами теги останутся. Поэтому ManyToMany подходит там, где связь должна исчезнуть, а партнерская сущность продолжает использоваться в системе. Если же нужно удалять и "осиротевшие" записи, не полагайтесь на ManyToMany: описывайте таблицу связи явно или запускайте отдельный сервис очистки после удаления основной сущности.

Итог

Для OneToMany в D7 важно выбирать политику FOLLOW или SET_NULL осознанно. Для ManyToMany безопасно рассчитывать только на очистку таблицы связей, а не на удаление связанных объектов.

Как защитить агент импорта от двойного запуска с помощью Connection::lock()

Как защитить агент импорта от двойного запуска с помощью Connection::lock()

Проблема/контекст

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

В ядре для этого уже есть встроенный механизм. В Bitrix\Main\DB\MysqlCommonConnection::lock() платформа переводит пул в master only, вызывает SELECT GET_LOCK(...), а имя блокировки нормализует через CMain::GetServerUniqID(). Это важная деталь: блокировка берется не на уровне PHP-процесса, а на уровне текущего соединения с основной БД, поэтому она подходит именно для межпроцессной синхронизации.

Решение с кодом

Если задача должна выполняться строго в одном экземпляре, ставьте блокировку в самом начале и всегда снимайте ее в finally. Такой подход соответствует и самому ядру: Bitrix\Main\Messenger\Internals\Storage\Db\DbStorage не читает очередь без Application::getConnection()->lock('queueLock'), а в main/classes/general/usertype.php изменение UTS-таблиц обернуто в блокировку uf_add_*.

        <?php
declare(strict_types=1);

use Bitrix\Main\Application;
use Bitrix\Main\Diag\Logger;
use Bitrix\Main\Loader;

final class CatalogImportAgent
{
    public static function run(): string
    {
        $connection = Application::getConnection();
        $lockName = 'catalog_import';

        // Вторая копия не ждет: если импорт уже идет, просто выходим
        if (!$connection->lock($lockName, 0))
        {
            return CatalogImportAgent::class . '::run();';
        }

        try
        {
            Loader::includeModule('iblock');
            (new ImportService())->sync();
        }
        catch (\Throwable $e)
        {
            Logger::create('catalog_import')?->error($e->getMessage());
        }
        finally
        {
            // Освобождаем lock даже при исключении
            $connection->unlock($lockName);
        }

        return CatalogImportAgent::class . '::run();';
    }
}

    

Практический вывод здесь двойной. Во-первых, named lock не заменяет транзакцию: он защищает вход в критическую секцию, а не целостность отдельных SQL-операций. Во-вторых, имя должно быть стабильным и описывать конкретный ресурс, например catalog_import, prices_sync или agent_invoice_export. Если использовать случайные ключи, механизм потеряет смысл. Для долгих задач имеет смысл выбирать ненулевой таймаут, но для агентов и cron-скриптов чаще полезнее мгновенно завершить повторный запуск и дождаться следующего окна.

Итог

Connection::lock() в Битрикс уже решает задачу одиночного запуска без самодельных файлов-флажков и временных таблиц. Если процесс нельзя выполнять параллельно, ставьте named lock до начала работы и снимайте его гарантированно через finally.

Как маркировать предупреждения в Bitrix\Sale

Как маркировать предупреждения в Bitrix\Sale

Проблема/контекст

В sale не каждая проблемная ситуация должна ронять сохранение заказа. Часто интеграция с доставкой, кассой или внутренней проверкой находит состояние, которое нужно показать оператору, но не превращать в фатальную ошибку. В ядре для этого есть не только поля MARKED и REASON_MARKED, но и отдельный механизм Bitrix\Sale\EntityMarker: он берет warnings из Bitrix\Sale\Result, создает marker-записи, записывает последнюю причину в заказ и помечает оплату или отгрузку как проблемную.

Практически это значит: если вы уже возвращаете Result с предупреждениями, не дублируйте еще один собственный реестр проблем. Используйте штатный контур Sale.

Решение с кодом

Ключевой момент в ядре такой: EntityMarker::addMarker() работает именно с warnings, а не с обычными errors. Поэтому для нефатального кейса нужен Bitrix\Sale\ResultWarning.

        <?php
declare(strict_types=1);

use Bitrix\Main\Loader;
use Bitrix\Sale\EntityMarker;
use Bitrix\Sale\Order;
use Bitrix\Sale\Result;
use Bitrix\Sale\ResultWarning;
use Bitrix\Sale\Shipment;

Loader::includeModule('sale');

function markShipmentIntegrationWarning(Order $order, Shipment $shipment): void
{
    $result = new Result();

    // Нефатальная проблема: заказ сохранять можно, но отгрузку нужно подсветить оператору
    $result->addWarning(new ResultWarning(
        'Не удалось подтвердить трек-номер во внешней службе доставки',
        'SHIPMENT_TRACKING_SYNC_WARNING'
    ));

    EntityMarker::addMarker($order, $shipment, $result);
    EntityMarker::saveMarkers($order);

    $saveResult = $order->save();
    if (!$saveResult->isSuccess())
    {
        throw new \RuntimeException(implode('; ', $saveResult->getErrorMessages()));
    }
}

    

После этого в заказ попадет REASON_MARKED, у отгрузки выставится MARKED = 'Y', а сам marker сохранится в таблице маркеров. Это важнее простого $shipment->setField('MARKED', 'Y'): в ядре сохраняются код проблемы, тип маркера (AUTO или MANUAL) и статус успешности исправления.

Warning в заказе

Когда причина уже устранена, снимайте проблему не только с поля, но и с marker-записи. Для этого можно удалить маркеры сущности и пересчитать состояние заказа через refreshMarkers().

        <?php
declare(strict_types=1);

use Bitrix\Sale\EntityMarker;
use Bitrix\Sale\Order;
use Bitrix\Sale\Shipment;

function clearShipmentMarkers(Order $order, Shipment $shipment): void
{
    // deleteByEntity() работает только для уже сохраненной сущности с ID
    EntityMarker::deleteByEntity($shipment);
    EntityMarker::refreshMarkers($order);

    $saveResult = $order->save();
    if (!$saveResult->isSuccess())
    {
        throw new \RuntimeException(implode('; ', $saveResult->getErrorMessages()));
    }
}

    

Итог

EntityMarker в sale нужен не для декоративного флага, а для нормального жизненного цикла предупреждений. Если проблема не фатальна, но должна остаться в истории и интерфейсе заказа, сохраняйте ее как marker, а после исправления очищайте через API маркеров, а не только через поле MARKED.

walk() для ORM-коллекций: практичные массовые операции с инфоблоками

walk() для ORM-коллекций: практичные массовые операции с инфоблоками

Проблема/контекст

В D7 вы часто получаете из ORM не массив, а типизированную коллекцию объектов через fetchCollection(). Перебирать её через foreach нормально, но в реальном коде обычно хочется:

  • компактно “пройтись и изменить” объекты;
  • сохранить изменения одной цепочкой вызовов;
  • не плодить лишние временные переменные.

Начиная с main 26.0.0 у Bitrix\Main\ORM\Objectify\Collection появился метод walk(): это “аналог foreach, но как метод”, который удобно вставляется в цепочку.

Решение с кодом

walk() вызывает ваш callback для каждого объекта коллекции и возвращает эту же коллекцию (для чейнинга). В callback приходят:

  1. объект сущности,
  2. ключ итерации — это ключ внутреннего массива коллекции (обычно сериализованный первичный ключ; для составного — строка).

Пример 1. Массово деактивировать элементы инфоблока и сохранить одной цепочкой

Частый кейс: найти элементы по условию, поставить ACTIVE = 'N' и сохранить максимально “чисто”.

        <?php
declare(strict_types=1);

use Bitrix\Iblock\ElementTable;
use Bitrix\Main\Loader;

function deactivateSectionElements(int $iblockId, int $sectionId): void
{
	Loader::includeModule('iblock');

	$saveResult = ElementTable::query()
		->setSelect(['ID', 'ACTIVE', 'IBLOCK_ID', 'IBLOCK_SECTION_ID'])
		->where('IBLOCK_ID', $iblockId)
		->where('IBLOCK_SECTION_ID', $sectionId)
		->where('ACTIVE', 'Y')
		->fetchCollection()
		->walk(static function($element): void {
			$element->setActive('N');
		})
		->save()
	;

	if (!$saveResult->isSuccess())
	{
		throw new \RuntimeException(implode('; ', $saveResult->getErrorMessages()));
	}
}

    

Практические детали:

  • При одинаковом изменении для всех объектов save() умеет выполнять групповой UPDATE (однотипные правки). Это как раз тот случай, когда walk() хорошо “стыкуется” с коллекциями.
  • Если вы меняете разные значения для каждого объекта (например, SORT по формуле), ORM может уйти в серию UPDATE по одному объекту.

Пример 2. “Пройтись и собрать” данные без лишнего foreach

walk() удобен не только для мутаций, но и для побочных эффектов: собрать ID, составить карту, подсчитать суммы.

        <?php
declare(strict_types=1);

use Bitrix\Iblock\ElementTable;
use Bitrix\Main\Loader;

function collectElementIdsAndNames(int $iblockId, int $limit = 50): array
{
	Loader::includeModule('iblock');

	$ids = [];
	$namesById = [];

	ElementTable::query()
		->setSelect(['ID', 'NAME'])
		->where('IBLOCK_ID', $iblockId)
		->setLimit($limit)
		->fetchCollection()
		->walk(static function($element, $key) use (&$ids, &$namesById): void {
			$id = (int)$element->getId();
			$ids[] = $id;
			$namesById[$id] = (string)$element->getName();
		})
	;

	return [
		'ids' => $ids,
		'namesById' => $namesById,
	];
}

    

Пример 3. Когда walk() лучше, чем filter()

У коллекции есть “функциональный” filter(), но он не сработает, если в коллекции есть несохранённые изменения — тогда выбрасывается CollectionFilterException.

Поэтому практическое правило такое:

  • хотите “сначала отобрать, потом поменять” — делайте filter() первым шагом;
  • хотите просто “пройтись и поменять” — берите walk().
        <?php
declare(strict_types=1);

use Bitrix\Iblock\ElementTable;
use Bitrix\Main\Loader;

function deactivateActiveElementsInIblock(int $iblockId): void
{
	Loader::includeModule('iblock');

	$saveResult = ElementTable::query()
		->setSelect(['ID', 'ACTIVE'])
		->where('IBLOCK_ID', $iblockId)
		->fetchCollection()
		->filter(static fn($element) => $element->getActive() == 'Y')
		->walk(static function($element): void {
			$element->setActive('N');
		})
		->save();

	if (!$saveResult->isSuccess())
	{
		throw new \RuntimeException(implode('; ', $saveResult->getErrorMessages()));
	}
}

    

Ограничения, о которых полезно помнить

  • walk() не даёт “прервать” обход по условию. Если нужен ранний выход — используйте foreach или find() у коллекции.
  • В callback вторым аргументом приходит ключ итерации. Он удобен для отладки/логики по PK, но не стоит на него “завязывать” бизнес-логику (особенно при составных ключах).

Итог

walk() — маленький метод, но в D7 он хорошо раскрывается именно в связке fetchCollection() -> walk() -> save(): получается компактный, читабельный и часто более производительный массовый апдейт.

Получение DETAIL_PAGE_URL для элемента инфоблока в D7

Получение DETAIL_PAGE_URL для элемента инфоблока в D7

Проблема/контекст

В новом ядре D7 (таблица Bitrix\Iblock\ElementTable) нет готового поля DETAIL_PAGE_URL. Это связано с тем, что ссылка на детальную страницу — это динамический шаблон (например, /catalog/#SECTION_CODE#/#ELEMENT_CODE#/), который хранится в настройках инфоблока и требует подстановки реальных данных элемента для вычисления.

Решение с кодом

Чтобы получить корректную ссылку, необходимо выбрать данные элемента вместе с шаблоном URL из связанной таблицы инфоблока. Рекомендуется использовать Element API (если у инфоблока задан API_CODE), но способ также работает и с базовым ElementTable.

        <?php
declare(strict_types=1);

use Bitrix\Iblock\Elements\ElementCatalogTable; // Где 'Catalog' — это API_CODE вашего инфоблока
use Bitrix\Main\Loader;

Loader::includeModule('iblock');

$elementId = 123;

// Если у инфоблока нет API_CODE, используйте \Bitrix\Iblock\ElementTable
$element = ElementCatalogTable::getList([
    'select' => [
        'ID',
        'CODE',
        'IBLOCK_SECTION_ID',
        // Получаем шаблон URL из настроек инфоблока через связь IBLOCK
        'DETAIL_PAGE_URL_TEMPLATE' => 'IBLOCK.DETAIL_PAGE_URL'
    ],
    'filter' => ['=ID' => $elementId]
])->fetch();

if ($element)
{
    // Метод ReplaceDetailUrl заменит плейсхолдеры типа #ID#, #CODE# на реальные значения из массива $element
    $detailUrl = \CIBlock::ReplaceDetailUrl(
        url: $element['DETAIL_PAGE_URL_TEMPLATE'],
        arr: $element,
        server_name: true,
        arrType: 'E' // Тип "E" означает Element
    );

    echo $detailUrl;
}

    

Пример с использованием fetchObject()

Если вы предпочитаете работать с объектами (EO_Element), данные для замены масок нужно подготовить через метод collectValues():

        $elementObject = ElementCatalogTable::getList([
    'select' => ['ID', 'CODE', 'IBLOCK_SECTION_ID', 'IBLOCK.DETAIL_PAGE_URL'],
    'filter' => ['=ID' => $elementId]
])->fetchObject();

if ($elementObject)
{
    // ReplaceDetailUrl ожидает массив, поэтому используем collectValues()
    $detailUrl = \CIBlock::ReplaceDetailUrl(
        url: $elementObject->getIblock()->getDetailPageUrl(),
        arr: $elementObject->collectValues(),
        server_name: true,
        arrType: 'E'
    );
}

    

Параметры метода ReplaceDetailUrl

  1. $url (string) — Шаблон URL из настроек инфоблока (например, /catalog/#SECTION_CODE#/#ELEMENT_CODE#/).
  2. $arr (array) — Массив данных элемента. Ключи должны совпадать с масками (ID для #ID#, CODE для #CODE# и т.д.).
  3. $server_name (bool) — Если true, метод подставит SITE_DIR и домен сайта. Полезно для генерации полных ссылок (в письмах или RSS).
  4. $arrType (string) — Тип сущности: "E" для элементов, "S" для разделов. Это определяет, какие маски будут обрабатываться (например, #SECTION_CODE_PATH#).

Практические детали:

  • Обязательные поля: В select запроса getList нужно обязательно включать все поля, которые используются в шаблоне ссылки (обычно это ID, CODE, IBLOCK_SECTION_ID). Если в шаблоне есть #SECTION_CODE#, вам придется добавить в select связь с секцией, например 'SECTION_CODE' => 'IBLOCK_SECTION.CODE'.
  • Element API vs ElementTable: Использование ElementCatalogTable предпочтительнее для новых проектов, так как это дает типизацию и упрощенную работу со свойствами. Но если вы пишете универсальный код или API_CODE не задан, \Bitrix\Iblock\ElementTable работает по тому же принципу.
  • Тип сущности: При вызове ReplaceDetailUrl последний параметр 'E' указывает, что мы работаем с элементом. Для разделов используется 'S'.

Итог

Для получения ссылки в D7 используйте выборку шаблона через связь IBLOCK.DETAIL_PAGE_URL и стандартный метод \CIBlock::ReplaceDetailUrl для подстановки данных. Это наиболее производительный и правильный способ в рамках Bitrix Framework.

Мы используем файлы cookie для улучшения работы сайта. Продолжая использовать сайт, вы соглашаетесь с нашей политикой конфиденциальности.
AI Домовой

AI Домовой История

на связи

пишет…
Нет истории чатов
AI Домовой

Нужна авторизация

Войдите, чтобы задавать вопросы AI Домовому.

Войти