При импорте данных или интеграции со сторонними системами разработчики часто сталкиваются с задачей построения иерархического справочника на основе плоской таблицы. Типичный пример — создание структуры Улица (группа) -> Дом (группа) -> Прибор учета (элемент). Основная сложность заключается в том, чтобы не создавать пустые группы (например, улицу без домов или дом без приборов) и при этом избежать лавинообразного роста запросов к базе данных при проверке дублей.
В этой статье мы подробно разберем, как решить эту задачу изящно и профессионально. Мы откажемся от простых, но неэффективных методов «ручного» перебора плоской таблицы и обратимся к мощным штатным механизмам платформы 1С: конструкциям языка запросов, обходу результатов по группировкам и механизму кэширования в оперативной памяти.
Рассмотрим ситуацию, описанную автором темы. Имеются три сущности: улицы, дома и приборы учета. Нам нужно построить иерархию только для тех объектов, которые реально существуют в связке. То есть, если в доме нет ни одного прибора, то создавать группу для этого дома не имеет смысла. Аналогично, если на улице нет домов с приборами, саму улицу создавать в справочнике тоже не нужно.
Для решения этой задачи в первую очередь необходимо правильно составить запрос к базе данных. На форуме предлагалось использовать ЛЕВОЕ СОЕДИНЕНИЕ. Однако при левом соединении в результат попадут улицы, для которых нет домов, и дома, для которых нет приборов (в соответствующих полях будет значение Null).
Чтобы автоматически отсечь «пустые» ветки иерархии еще на уровне СУБД, нам необходимо использовать ВНУТРЕННЕЕ СОЕДИНЕНИЕ (Inner Join). В этом случае в выборку попадут только те строки, для которых заполнены все три звена цепочки: и улица, и дом, и прибор.
Вместо того чтобы выбирать плоскую таблицу и затем программно отслеживать смену улицы или дома в цикле, мы переложим задачу группировки на СУБД. Для этого в языке запросов 1С предусмотрена мощная секция ИТОГИ ПО. Она позволяет выстроить древовидную структуру данных прямо в результате запроса.
Проанализируем пример запроса, который решает эту задачу:
ВЫБРАТЬ
dbo_Street.Name КАК StreetName,
dbo_Dom.Name КАК DomName,
dbo_Pribor.Name КАК PriborName
ИЗ
dbo_Street КАК dbo_Street
ВНУТРЕННЕЕ СОЕДИНЕНИЕ dbo_Dom КАК dbo_Dom
ПО dbo_Street.СвязующееПоле = dbo_Dom.СвязующееПоле
ВНУТРЕННЕЕ СОЕДИНЕНИЕ dbo_Pribor КАК dbo_Pribor
ПО dbo_Dom.СвязующееПоле = dbo_Pribor.СвязующееПоле
ИТОГИ ПО
StreetName,
DomName
Благодаря предложению ИТОГИ ПО, платформа сгруппирует результат. На верхнем уровне иерархии будут находиться уникальные названия улиц, на втором уровне — уникальные дома для каждой конкретной улицы, а на самом нижнем (детальном) уровне — конечные приборы учета.
После выполнения запроса с итогами мы не можем обходить его результат линейным методом Выбрать(), так как это приведет к чтению дублирующихся данных. Мы применим метод обхода по группировкам, передавая в метод Выбрать() системное перечисление ОбходРезультатаЗапроса.ПоГруппировкам.
Разберем по шагам, как выглядит этот алгоритм в коде:
РезультатЗапроса = Запрос.Выполнить();
// 1. Обходим первый уровень иерархии — Улицы
ВыборкаУлицы = РезультатЗапроса.Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам);
Пока ВыборкаУлицы.Следующий() Цикл
// Здесь мы создаем или находим родительскую группу "Улица"
ГруппаУлица = ПолучитьИлиСоздатьГруппу(ВыборкаУлицы.StreetName, Справочники.Адреса.ПустаяСсылка());
// 2. Обходим второй уровень иерархии — Дома внутри текущей Улицы
ВыборкаДома = ВыборкаУлицы.Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам);
Пока ВыборкаДома.Следующий() Цикл
// Создаем или находим подчиненную группу "Дом" внутри "Улицы"
ГруппаДом = ПолучитьИлиСоздатьГруппу(ВыборкаДома.DomName, ГруппаУлица);
// 3. Обходим детальные записи — Приборы внутри текущего Дома
// Здесь мы используем обычный обход, так как это конечные элементы
ВыборкаПриборы = ВыборкаДома.Выбрать();
Пока ВыборкаПриборы.Следующий() Цикл
// Создаем элемент "Прибор" в группе "Дом"
СоздатьЭлементПрибор(ВыборкаПриборы.PriborName, ГруппаДом);
КонецЦикла;
КонецЦикла;
КонецЦикла;
Этот подход гарантирует, что группа для улицы будет создана ровно один раз, группа для дома — тоже один раз внутри своей улицы, а приборы будут записаны строго в соответствующие им папки. При этом полностью исключается вероятность появления пустых папок, так как внутреннее соединение в запросе гарантирует наличие данных на каждом уровне.
При записи большого объема данных в цикле критически важным фактором становится скорость работы алгоритма. Если для каждой итерации мы будем выполнять поиск группы в базе данных через метод НайтиПоНаименованию, это приведет к лавинообразному росту обращений к СУБД. Наш код начнет работать крайне медленно.
Чтобы избежать этой проблемы, профессиональные разработчики применяют кэширование созданных элементов в оперативной памяти с помощью объекта Соответствие. Ключом кэша будет выступать составная строка, уникально идентифицирующая группу (например, «Родитель_ИмяГруппы»), а значением — полученная или созданная СправочникСсылка.
Проанализируем функцию создания групп с использованием кэша:
// Кэш создается один раз перед началом обхода выборки
Перем КэшГрупп;
КэшГрупп = Новый Соответствие;
Функция ПолучитьИлиСоздатьГруппу(Знач ИмяГруппы, Знач РодительСсылка)
ИмяГруппы = СокрЛП(ИмяГруппы);
Если ИмяГруппы = "" Тогда
Возврат РодительСсылка;
КонецЕсли;
// Формируем уникальный ключ для поиска в кэше
КлючКэша = Строка(РодительСсылка.УникальныйИдентификатор()) + "_" + ВРег(ИмяГруппы);
// Пробуем получить ссылку из кэша в оперативной памяти
СсылкаНаГруппу = КэшГрупп.Получить(КлючКэша);
Если СсылкаНаГруппу = Неопределено Тогда
// Если в кэше нет, ищем один раз в базе данных
СсылкаНаГруппу = Справочники.Адреса.НайтиПоНаименованию(ИмяГруппы, Истина, РодительСсылка);
Если СсылкаНаГруппу.Пустая() Тогда
// Если в базе данных группы нет, создаем её
НоваяГруппа = Справочники.Адреса.СоздатьГруппу();
НоваяГруппа.Наименование = ИмяГруппы;
НоваяГруппа.Родитель = РодительСсылка;
Попытка
НоваяГруппа.Записать();
СсылкаНаГруппу = НоваяГруппа.Ссылка;
Исключение
ВызватьИсключение "Не удалось записать группу справочника: " + ОписаниеОшибки();
КонецПопытки;
КонецЕсли;
// Помещаем найденную или созданную ссылку в кэш
КэшГрупп.Вставить(КлючКэша, СсылкаНаГруппу);
КонецЕсли;
Возврат СсылкаНаГруппу;
КонецФункции
Благодаря этому кэшу, обращение к базе данных для поиска группы произойдет только один раз. При всех последующих обращениях (например, когда мы обрабатываем второй, третий или сотый прибор на той же улице и в том же доме) ссылка будет мгновенно извлечена из оперативной памяти.
Если ваша процедура заполнения справочника спроектирована так, что на вход ей требуется готовая коллекция объектов, мы можем выгрузить результат нашего запроса с итогами в объект ДеревоЗначений. Платформа 1С умеет делать это автоматически всего одной строкой кода:
ДеревоДанных = Запрос.Выполнить().Выгрузить(ОбходРезультатаЗапроса.ПоГруппировкам);
После этого вы получите готовую иерархическую структуру в памяти, которую можно обойти с помощью рекурсивной функции или передать в форму для визуализации пользователю перед непосредственной записью в базу данных.
Для достижения максимальной скорости работы при записи сотен или тысяч элементов справочника настоятельно рекомендуется оборачивать весь цикл создания в единую транзакцию. Это сократит накладные расходы СУБД на фиксацию каждой отдельной записи на диске.
НачатьТранзакцию();
Попытка
// Здесь выполняется наш цикл обхода и вызов ПолучитьИлиСоздатьГруппу
ЗафиксироватьТранзакцию();
Исключение
ОтменитьТранзакцию();
ЗаписьЖурналаРегистрации("ИмпортДанных", УровеньЖурналаРегистрации.Ошибка,,, ОписаниеОшибки());
ВызватьИсключение "Произошла ошибка при импорте данных. Процесс отменен.";
КонецПопытки;
Использование транзакции в сочетании с оперативным кэшированием групп позволяет ускорить процесс создания элементов в десятки раз, обеспечивая стабильную и надежную работу системы даже на больших объемах данных.