Уникальность артикула — требование, которое кажется тривиальным до первого столкновения с реальностью: проверка есть, а дубли в базе всё равно появляются. Причин обычно две — проверка стоит не на всех путях записи, и между проверкой и сохранением есть промежуток, в который успевает вклиниться параллельный запрос.
Разберём рабочую реализацию и обе эти дыры.
Задача
При добавлении товара в каталог проверять, что артикул (или другое свойство) уникален. Если товар с таким артикулом уже существует — показать ошибку и отменить сохранение.
Это актуально для:
- Артикулов товаров
- Email пользователей в инфоблоке
- Внешних ID при импорте
- Серийных номеров
Базовое решение
Проверка вешается на OnBefore*-события: только они позволяют отменить сохранение. Добавьте в /local/php_interface/init.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
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
// /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
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
// /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,
]);// Проверка при потере фокуса
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. Для инфоблока со вторым вариантом хранения свойств значение лежит в отдельной колонке, и индекс вешается на неё:
-- Сначала убедитесь, что дублей уже нет
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.