В практике разработки на платформе 1С предприятия довольно часто возникает классическая задача: необходимо объединить несколько таблиц значений (ТЗ) горизонтально по ключевому полю. Типичный пример — сборка сводного отчета по остаткам товаров на разных складах. На входе мы имеем несколько таблиц одинаковой структуры (например, с колонками Номенклатура и Остаток), полученных в цикле по разным складам. На выходе нам нужно получить одну сводную таблицу вида: Номенклатура, ОстатокСклад1, ОстатокСклад2, ..., ОстатокСкладN.
В рамках этой статьи мы подробно разберем три наиболее эффективных способа решения этой задачи. Мы проанализируем ситуацию с точки зрения производительности, рассмотрим подводные камни каждого подхода и научимся писать оптимальный код для решения данной задачи как на сервере, так и на клиенте.
Использование механизма запросов платформы 1С — это наиболее надежный, декларативный и масштабируемый способ решения задачи, если обработка данных происходит на стороне сервера. Однако при реализации этого метода разработчики часто совершают две критические ошибки. Разберем их по шагам, чтобы понять, как спроектировать решение правильно.
Критическая ошибка №1: Потеря данных при последовательном левом соединении. Если мы начнем последовательно соединять таблицы (например, ТЗ1 ЛЕВОЕ СОЕДИНЕНИЕ ТЗ2 ЛЕВОЕ СОЕДИНЕНИЕ ТЗ3...), то в итоговую таблицу попадут только те товары, которые были в самой первой таблице (ТЗ1). Если товар присутствует на втором или третьем складе, но его не было на первом, он просто исчезнет из результирующего набора данных. Использование ПОЛНОГО СОЕДИНЕНИЯ для 7 таблиц сделает запрос крайне тяжелым для СУБД и трудночитаемым.
Правильный алгоритм построения запроса выглядит следующим образом:
ОБЪЕДИНИТЬ ВСЕ, выбирая только колонку-ключ, а затем выполняем группировку СГРУППИРОВАТЬ ПО. Получаем временную таблицу ВТ_ВсеТовары.ВТ_ВсеТовары и последовательно присоединяем к ней левым соединением все наши исходные таблицы по полю Номенклатура.Критическая ошибка №2: Отсутствие типизации колонок ТЗ. Передать таблицу значений в качестве параметра запроса можно только в том случае, если все её колонки жестко типизированы. Если колонки создавались динамически без явного указания типов (через ОписаниеТипов), платформа 1С выдаст ошибку при попытке передать такую ТЗ в запрос. Перед помещением таблиц в запрос убедитесь, что колонки имеют конкретные типы.
Рассмотрим подробнее программную реализацию этого метода на примере кода:
// Предположим, у нас есть массив таблиц значений МассивТЗ, полученных в цикле (всего 7 штук)
// Каждая ТЗ содержит колонки: Номенклатура (СправочникСсылка.Номенклатура) и Остаток (Число)
МенеджерВТ = Новый МенеджерВременныхТаблиц;
Запрос = Новый Запрос;
Запрос.МенеджерВременныхТаблиц = МенеджерВТ;
// Шаг 1. Помещаем все таблицы во временные таблицы и готовим части запроса для объединения ключей
ТекстОбъединенияКлючей = "";
Для Индекс = 0 По МассивТЗ.ВГРАНИЦА() Цикл
ИмяПараметра = "ТЗ_" + Индекс;
ИмяВТ = "ВТ_Склад_" + Индекс;
Запрос.УстановитьПараметр(ИмяПараметра, МассивТЗ[Индекс]);
// Запрос для помещения во ВТ
Запрос.Текст =
"ВЫБРАТЬ
| ТЗ.Номенклатура КАК Номенклатура,
| ТЗ.Остаток КАК Остаток
|ПОМЕСТИТЬ " + ИмяВТ + "
|ИЗ
| &" + ИмяПараметра + " КАК ТЗ";
Запрос.Выполнить();
// Формируем текст для получения уникального списка товаров
Если Индекс > 0 Тогда
ТекстОбъединенияКлючей = ТекстОбъединенияКлючей + "
|ОБЪЕДИНИТЬ ВСЕ
|";
КонецЕсли;
ТекстОбъединенияКлючей = ТекстОбъединенияКлючей + "
|ВЫБРАТЬ Номенклатура ИЗ " + ИмяВТ;
КонецЦикла;
// Шаг 2. Создаем временную таблицу уникальных ключей
Запрос.Текст =
"ВЫБРАТЬ
| ТК.Номенклатура КАК Номенклатура
|ПОМЕСТИТЬ ВТ_ВсеТовары
|ИЗ
| (" + ТекстОбъединенияКлючей + ") КАК ТК
|ГРУППИРОВАТЬ ПО
| ТК.Номенклатура";
Запрос.Выполнить();
// Шаг 3. Собираем итоговую таблицу через левое соединение
ТекстВыбораПолей = "ВЫБРАТЬ ВТ_ВсеТовары.Номенклатура КАК Номенклатура";
ТекстСоединений = "ИЗ ВТ_ВсеТовары КАК ВТ_ВсеТовары";
Для Индекс = 0 По МассивТЗ.ВГРАНИЦА() Цикл
ИмяВТ = "ВТ_Склад_" + Индекс;
ИмяКолонкиОстатка = "Остаток_Склад" + (Индекс + 1);
ТекстВыбораПолей = ТекстВыбораПолей + ",
| ЕСТЬNULL(" + ИмяВТ + ".Остаток, 0) КАК " + ИмяКолонкиОстатка;
ТекстСоединений = ТекстСоединений + "
| ЛЕВОЕ СОЕДИНЕНИЕ " + ИмяВТ + " КАК " + ИмяВТ + "
| ПО ВТ_ВсеТовары.Номенклатура = " + ИмяВТ + ".Номенклатура";
КонецЦикла;
Запрос.Текст = ТекстВыбораПолей + "
" + ТекстСоединений;
РезультатТаблица = Запрос.Выполнить().Выгрузить();
Этот способ является наиболее производительным при работе с большими объемами данных на сервере, так как СУБД оптимизирует операцию хэш-соединения на низком уровне.
Если код выполняется на клиенте (где использование запросов невозможно) или если исходные таблицы значений не типизированы должным образом, нам на помощь приходит объект Соответствие.
Проанализируем типичную ошибку начинающих разработчиков: при программном обходе они начинают использовать метод поиска строк ТЗ.Найти() или ТЗ.НайтиСтроки() в цикле. При объемах данных более нескольких тысяч строк такой алгоритм начинает работать катастрофически медленно. Сложность алгоритма возрастает до квадратичной $O(N^2)$, так как на каждую строку текущей таблицы система вынуждена заново перебирать строки итоговой таблицы.
Чтобы ускорить этот процесс и свести сложность к линейной $O(N)$, мы используем Соответствие, поиск в котором работает мгновенно за счет хэширования ключей. Алгоритм решения по шагам:
ИтоговаяТЗ и сразу добавляем в нее ключевую колонку Номенклатура.АдресаСтрок = Новый Соответствие;. Ключом этого соответствия будет выступать ссылка на номенклатуру, а значением — сама строка нашей ИтоговаяТЗ, куда мы записываем данные.ИтоговаяТЗ новую колонку для остатков.Получить(). Если строка для товара еще не создана — создаем ее в итоговой таблице, заполняем ключевое поле и фиксируем ее адрес в соответствии. Записываем показатель остатка в нужную динамическую колонку.Посмотрим на пример реализации данного алгоритма:
// Создаем результирующую таблицу значений
ИтоговаяТЗ = Новый ТаблицаЗначений;
ИтоговаяТЗ.Колонки.Добавить("Номенклатура"); // Желательно указать тип ОписанияТипов
// Соответствие для быстрого поиска строк по ключу
АдресаСтрок = Новый Соответствие;
ИндексСклада = 1;
Для Каждого ТекущаяТЗ Из МассивТЗ Цикл
// Динамически добавляем колонку для текущего склада
ИмяКолонкиОстатка = "Остаток_Склад_" + ИндексСклада;
// Опишем тип колонки как Число
МассивТипов = Новый Массив;
МассивТипов.Добавить(Тип("Число"));
ОписаниеТиповЧисло = Новый ОписаниеТипов(МассивТипов, , , Новый КвалификаторыЧисла(15, 3));
ИтоговаяТЗ.Колонки.Добавить(ИмяКолонкиОстатка, ОписаниеТиповЧисло);
// Заполняем данными
Для Каждого СтрокаИсточника Из ТекущаяТЗ Цикл
КлючПоиска = СтрокаИсточника.Номенклатура;
// Быстрый поиск строки в итоговой таблице через хэш-таблицу Соответствия
СтрокаИтого = АдресаСтрок.Получить(КлючПоиска);
Если СтрокаИтого = Неопределено Тогда
// Если товара еще нет в итоговой таблице, создаем для него новую строку
СтрокаИтого = ИтоговаяТЗ.Добавить();
СтрокаИтого.Номенклатура = КлючПоиска;
// Запоминаем строку для последующих шагов
АдресаСтрок.Вставить(КлючПоиска, СтрокаИтого);
КонецЕсли;
// Заполняем остаток по текущему складу
СтрокаИтого[ИмяКолонкиОстатка] = СтрокаИсточника.Остаток;
КонецЦикла;
ИндексСклада = ИндексСклада + 1;
КонецЦикла;
Этот способ демонстрирует великолепное быстродействие на любых объемах данных. Даже при слиянии десятков тысяч строк время выполнения составит доли секунды, поскольку поиск в объекте Соответствие не зависит от размера таблицы.
Еще один альтернативный способ, который часто упускается из виду — это использование механизма индексирования самой таблицы значений. Начиная с версии 1С 8.0, у объекта ТаблицаЗначений есть коллекция Индексы.
Если мы добавим индекс по ключевому полю, то стандартный метод Найти() начнет работать значительно быстрее, так как платформа перейдет от последовательного сканирования к поиску по индексному дереву. Выясним причину, почему этот способ применяется реже, чем способ с Соответствием. При динамическом добавлении новых строк в таблицу значений индекс постоянно перестраивается, что может вызывать дополнительные накладные расходы по памяти и процессорному времени.
Тем не менее, разберем по шагам, как использовать этот метод на практике:
ИтоговаяТЗ = Новый ТаблицаЗначений;
ИтоговаяТЗ.Колонки.Добавить("Номенклатура");
// Добавляем индекс по ключевой колонке
ИтоговаяТЗ.Индексы.Добавить("Номенклатура");
ИндексСклада = 1;
Для Каждого ТекущаяТЗ Из МассивТЗ Цикл
ИмяКолонкиОстатка = "Остаток_Склад_" + ИндексСклада;
ИтоговаяТЗ.Колонки.Добавить(ИмяКолонкиОстатка, Новый ОписаниеТипов("Число"));
Для Каждого СтрокаИсточника Из ТекущаяТЗ Цикл
// Метод Найти() работает быстро благодаря ранее созданному индексу
СтрокаИтого = ИтоговаяТЗ.Найти(СтрокаИсточника.Номенклатура, "Номенклатура");
Если СтрокаИтого = Неопределено Тогда
СтрокаИтого = ИтоговаяТЗ.Добавить();
СтрокаИтого.Номенклатура = СтрокаИсточника.Номенклатура;
КонецЕсли;
СтрокаИтого[ИмяКолонкиОстатка] = СтрокаИсточника.Остаток;
КонецЦикла;
ИндексСклада = IndexСклада + 1;
КонецЦикла;
Мы проанализировали три принципиально разных подхода к горизонтальному слиянию таблиц в 1С. Подводя итоги, дадим рекомендации по выбору инструмента в зависимости от условий:
Соответствие, однако помните, что на сверхбольших массивах данных он может уступать второму способу по потреблению памяти.