Главная/Статьи/Битрикс: проверка уникальности свойства при добавлении элемента

Битрикс: проверка уникальности свойства при добавлении элемента

Валидация артикула, email или другого свойства на уникальность. Обработчики OnBeforeIBlockElementAdd и OnBeforeIBlockElementUpdate.

ДМ
Дмитрий Мещеряков
📅 22 марта 2021 г.📖 8 мин чтения

Уникальность артикула — требование, которое кажется тривиальным до первого столкновения с реальностью: проверка есть, а дубли в базе всё равно появляются. Причин обычно две — проверка стоит не на всех путях записи, и между проверкой и сохранением есть промежуток, в который успевает вклиниться параллельный запрос.

Разберём рабочую реализацию и обе эти дыры.

Задача

При добавлении товара в каталог проверять, что артикул (или другое свойство) уникален. Если товар с таким артикулом уже существует — показать ошибку и отменить сохранение.

Это актуально для:

  • Артикулов товаров
  • Email пользователей в инфоблоке
  • Внешних ID при импорте
  • Серийных номеров

Базовое решение

Проверка вешается на OnBefore*-события: только они позволяют отменить сохранение. Добавьте в /local/php_interface/init.php:

php
<?php
use Bitrix\Main\EventManager;

// Проверка при добавлении
EventManager::getInstance()->addEventHandler(
    'iblock',
    'OnBeforeIBlockElementAdd',
    'checkUniqueVendor'
);

// Проверка при обновлении
EventManager::getInstance()->addEventHandler(
    'iblock',
    'OnBeforeIBlockElementUpdate',
    'checkUniqueVendor'
);

function checkUniqueVendor(&$arFields)
{
    // ID инфоблоков для проверки
    $catalogIblockId = 2;       // Каталог товаров
    $offersIblockId = 3;        // Торговые предложения
    $vendorPropertyCode = 'VENDOR'; // Код свойства артикула

    // Проверяем только нужные инфоблоки
    if (!in_array($arFields['IBLOCK_ID'], [$catalogIblockId, $offersIblockId])) {
        return true;
    }

    // Получаем значение артикула
    $vendor = null;
    
    if (isset($arFields['PROPERTY_VALUES'])) {
        foreach ($arFields['PROPERTY_VALUES'] as $propId => $propValue) {
            $value = is_array($propValue) ? current($propValue) : $propValue;
            if (is_array($value) && isset($value['VALUE'])) {
                $value = $value['VALUE'];
            }
            if (!empty($value)) {
                // Проверяем по коду свойства
                $propData = \CIBlockProperty::GetByID($propId, $arFields['IBLOCK_ID'])->Fetch();
                if ($propData && $propData['CODE'] === $vendorPropertyCode) {
                    $vendor = trim($value);
                    break;
                }
            }
        }
    }

    if (empty($vendor)) {
        return true; // Артикул не заполнен — пропускаем проверку
    }

    // Ищем элемент с таким артикулом
    $filter = [
        'IBLOCK_ID' => [$catalogIblockId, $offersIblockId],
        '=PROPERTY_' . $vendorPropertyCode => $vendor,
    ];

    // Исключаем текущий элемент при обновлении
    if (!empty($arFields['ID'])) {
        $filter['!ID'] = $arFields['ID'];
    }

    $existing = \CIBlockElement::GetList(
        [],
        $filter,
        false,
        ['nTopCount' => 1],
        ['ID', 'NAME', 'IBLOCK_ID']
    )->Fetch();

    if ($existing) {
        global $APPLICATION;
        $APPLICATION->ThrowException(
            "Артикул «{$vendor}» уже используется в товаре «{$existing['NAME']}» (ID: {$existing['ID']})"
        );
        return false;
    }

    return true;
}

Универсальный класс валидатора

php
<?php

namespace Local\Iblock;

use Bitrix\Main\Loader;
use Bitrix\Main\EventManager;

class UniquePropertyValidator
{
    /**
     * Конфигурация валидации
     * [
     *     'PROPERTY_CODE' => [
     *         'iblock_ids' => [1, 2, 3],
     *         'error_message' => 'Значение должно быть уникальным',
     *         'case_sensitive' => false,
     *     ]
     * ]
     */
    private static array $config = [];

    /**
     * Регистрация валидатора
     */
    public static function register(array $config): void
    {
        self::$config = $config;

        EventManager::getInstance()->addEventHandler(
            'iblock',
            'OnBeforeIBlockElementAdd',
            [self::class, 'validate']
        );

        EventManager::getInstance()->addEventHandler(
            'iblock',
            'OnBeforeIBlockElementUpdate',
            [self::class, 'validate']
        );
    }

    /**
     * Валидация
     */
    public static function validate(&$arFields): bool
    {
        if (!Loader::includeModule('iblock')) {
            return true;
        }

        foreach (self::$config as $propertyCode => $settings) {
            // Проверяем, относится ли элемент к нужному инфоблоку
            if (!in_array($arFields['IBLOCK_ID'], $settings['iblock_ids'])) {
                continue;
            }

            // Получаем значение свойства
            $value = self::getPropertyValue($arFields, $propertyCode);
            
            if (empty($value)) {
                continue;
            }

            // Проверяем уникальность
            $duplicate = self::findDuplicate(
                $value,
                $propertyCode,
                $settings['iblock_ids'],
                $arFields['ID'] ?? null,
                $settings['case_sensitive'] ?? false
            );

            if ($duplicate) {
                global $APPLICATION;
                
                $message = $settings['error_message'] ?? 
                    "Значение «{$value}» уже используется";
                    
                $APPLICATION->ThrowException(
                    "{$message} (элемент «{$duplicate['NAME']}», ID: {$duplicate['ID']})"
                );
                
                return false;
            }
        }

        return true;
    }

    /**
     * Получение значения свойства из массива полей
     */
    private static function getPropertyValue(array $arFields, string $propertyCode): ?string
    {
        if (empty($arFields['PROPERTY_VALUES'])) {
            return null;
        }

        // Получаем ID свойства по коду
        $property = \CIBlockProperty::GetList(
            [],
            [
                'IBLOCK_ID' => $arFields['IBLOCK_ID'],
                'CODE' => $propertyCode,
            ]
        )->Fetch();

        if (!$property) {
            return null;
        }

        $propId = $property['ID'];

        if (!isset($arFields['PROPERTY_VALUES'][$propId])) {
            return null;
        }

        $propValue = $arFields['PROPERTY_VALUES'][$propId];

        // Обрабатываем разные форматы значения
        if (is_array($propValue)) {
            $first = current($propValue);
            if (is_array($first) && isset($first['VALUE'])) {
                return trim($first['VALUE']);
            }
            return trim($first);
        }

        return trim($propValue);
    }

    /**
     * Поиск дубликата
     */
    private static function findDuplicate(
        string $value,
        string $propertyCode,
        array $iblockIds,
        ?int $excludeId,
        bool $caseSensitive
    ): ?array {
        $filter = [
            'IBLOCK_ID' => $iblockIds,
        ];

        if ($caseSensitive) {
            $filter['=PROPERTY_' . $propertyCode] = $value;
        } else {
            // Регистронезависимый поиск
            $filter['PROPERTY_' . $propertyCode] = $value;
        }

        if ($excludeId) {
            $filter['!ID'] = $excludeId;
        }

        $result = \CIBlockElement::GetList(
            [],
            $filter,
            false,
            ['nTopCount' => 1],
            ['ID', 'NAME', 'IBLOCK_ID']
        );

        return $result->Fetch() ?: null;
    }
}

Регистрация валидатора

php
<?php
// /local/php_interface/init.php

use Local\Iblock\UniquePropertyValidator;

UniquePropertyValidator::register([
    'VENDOR' => [
        'iblock_ids' => [2, 3], // Каталог и торговые предложения
        'error_message' => 'Артикул должен быть уникальным',
        'case_sensitive' => false,
    ],
    'EXTERNAL_ID' => [
        'iblock_ids' => [2],
        'error_message' => 'Внешний ID уже используется',
        'case_sensitive' => true,
    ],
    'EMAIL' => [
        'iblock_ids' => [5], // Инфоблок подписчиков
        'error_message' => 'Этот email уже подписан',
        'case_sensitive' => false,
    ],
]);

Проверка через API (без событий)

Для проверки перед добавлением через API:

php
<?php

namespace Local\Iblock;

class ElementValidator
{
    /**
     * Проверка уникальности перед добавлением
     */
    public static function checkUnique(
        int $iblockId,
        string $propertyCode,
        string $value,
        ?int $excludeId = null
    ): array {
        $filter = [
            'IBLOCK_ID' => $iblockId,
            '=PROPERTY_' . $propertyCode => $value,
        ];

        if ($excludeId) {
            $filter['!ID'] = $excludeId;
        }

        $result = \CIBlockElement::GetList(
            [],
            $filter,
            false,
            ['nTopCount' => 1],
            ['ID', 'NAME']
        );

        if ($existing = $result->Fetch()) {
            return [
                'success' => false,
                'error' => "Значение уже используется в элементе «{$existing['NAME']}»",
                'duplicate_id' => $existing['ID'],
            ];
        }

        return ['success' => true];
    }
}

// Использование
$check = ElementValidator::checkUnique(2, 'VENDOR', 'ABC-12345');

if (!$check['success']) {
    echo $check['error'];
} else {
    // Добавляем элемент
    $el = new \CIBlockElement();
    $el->Add($arFields);
}

Валидация в форме редактирования (JS)

Для проверки «на лету» при заполнении формы:

php
<?php
// /local/ajax/check_vendor.php

require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';

use Bitrix\Main\Web\Json;

header('Content-Type: application/json');

$vendor = trim($_REQUEST['vendor'] ?? '');
$elementId = (int) ($_REQUEST['element_id'] ?? 0);
$iblockId = (int) ($_REQUEST['iblock_id'] ?? 0);

if (empty($vendor)) {
    echo Json::encode(['valid' => true]);
    die();
}

$filter = [
    'IBLOCK_ID' => $iblockId,
    '=PROPERTY_VENDOR' => $vendor,
];

if ($elementId > 0) {
    $filter['!ID'] = $elementId;
}

$existing = \CIBlockElement::GetList([], $filter, false, ['nTopCount' => 1], ['ID', 'NAME'])->Fetch();

echo Json::encode([
    'valid' => !$existing,
    'message' => $existing 
        ? "Артикул используется в «{$existing['NAME']}»" 
        : null,
]);
javascript
// Проверка при потере фокуса
document.querySelector('[name="PROP[VENDOR]"]').addEventListener('blur', async function() {
    const vendor = this.value.trim();
    if (!vendor) return;

    const elementId = document.querySelector('[name="ID"]')?.value || 0;
    const iblockId = document.querySelector('[name="IBLOCK_ID"]').value;

    const response = await fetch('/local/ajax/check_vendor.php?' + new URLSearchParams({
        vendor,
        element_id: elementId,
        iblock_id: iblockId,
    }));

    const result = await response.json();
    
    const errorEl = this.nextElementSibling;
    if (!result.valid) {
        errorEl.textContent = result.message;
        errorEl.style.display = 'block';
        this.classList.add('error');
    } else {
        errorEl.style.display = 'none';
        this.classList.remove('error');
    }
});

Почему дубли всё равно появляются

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

Запись в обход событий

OnBeforeIBlockElementAdd и OnBeforeIBlockElementUpdate срабатывают на CIBlockElement::Add() и ::Update(). Но свойство можно записать и мимо них:

  • CIBlockElement::SetPropertyValues() и SetPropertyValuesEx() — свои события;
  • прямой INSERT/UPDATE в таблицу свойств, который иногда встречается в самописных импортах;
  • ORM-обёртки инфоблоков, работающие с таблицей напрямую.

Отсюда практическое правило: проверка в обработчике события — это защита от ошибок оператора, а не гарантия целостности данных. Гарантию даёт только уникальный индекс в базе.

Гонка между проверкой и записью

Классика: два запроса приходят одновременно, оба выполняют SELECT и не находят дубля, оба сохраняют. Окно между проверкой и записью маленькое, но при импорте в несколько потоков или при активном вводе несколькими операторами оно отрабатывает.

💡 Совет

Настоящее решение — уникальный индекс на уровне MySQL. Для инфоблока со вторым вариантом хранения свойств значение лежит в отдельной колонке, и индекс вешается на неё:

sql
-- Сначала убедитесь, что дублей уже нет
SELECT PROPERTY_42, COUNT(*) AS c
FROM b_iblock_element_prop_s2
WHERE PROPERTY_42 IS NOT NULL AND PROPERTY_42 <> ''
GROUP BY PROPERTY_42
HAVING c > 1;

-- Затем индекс (42 — ID свойства, 2 — ID инфоблока)
ALTER TABLE b_iblock_element_prop_s2
  ADD UNIQUE INDEX ux_vendor_code (PROPERTY_42);

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

Оговорка: NULL в MySQL не участвует в проверке уникальности, поэтому несколько элементов с пустым артикулом сосуществуют спокойно — обычно это как раз то поведение, которое нужно.

Итоги

Проверка в событии — про UX, индекс в базе — про целостность. Нужны оба: первое даёт понятное сообщение, второе — гарантию.

Подписывайтесь на все пути записи, которыми реально пользуются на проекте, включая SetPropertyValues.

Помните про стоимость на импорте. Проверка — это запрос к базе на каждое сохранение. На каталоге в 50 тысяч позиций она множится на объём импорта; для массовой загрузки предусмотрите пакетную валидацию до начала записи и отключение поштучной проверки на время прогона.

Индекс на колонку свойства обязателен и без требования уникальности: для каталога 100k+ поиск по неиндексированному артикулу — полное сканирование таблицы при каждом сохранении.

⚠️ Важно

Перед добавлением уникального индекса на живой базе проверьте, что дублей нет (запрос выше), и помните, что ALTER TABLE на большой таблице блокирует запись. Операцию делают в окно минимальной нагрузки, а на действительно больших каталогах — через pt-online-schema-change или аналог.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.