В 2016 году я опубликовал сборник рецептов и секретов ImpEx. Он писался под hybris 6.x и был в основном практическим: как удалить все записи типа, как написать условный блок, как подтянуть данные из чужой базы. Про то, почему ImpEx ведёт себя так, а не иначе, там почти ничего не было.

С тех пор ImpEx пережил переход на ServiceLayer Direct, обзавёлся распределённым режимом, многопоточностью по умолчанию и десятком новых свойств, которые описаны только комментариями в project.properties. При этом снаружи он выглядит ровно так же, как десять лет назад: заголовок, точки с запятой, квадратные скобки.

Поэтому я повторил с ImpEx то же, что с FlexibleSearch: проверил на 2211, что из известного про него верно и что движок делает с файлом.

Большинство рецептов 2016 года работает до сих пор, но не все, а некоторые работают не так, как я тогда думал.

ImpEx используют на каждом проекте, но в его код заглядывают редко. Документация описывает синтаксис заголовков и десяток модификаторов. На практике модификаторов больше трёх десятков, у CSV-слоя свои правила, ссылки разрешаются не так, как обычно думают, а от галки Enable code execution в HAC зависит, какие строки файла импортируются.

Общеизвестного здесь не будет. Синтаксис заголовков, режимы и основные модификаторы хорошо описаны в документации SAP, и пересказывать её я не стану. Почти всё, что ниже, — дополнения: то, чего в документации нет или что упомянуто одной строкой комментария в project.properties.

Про базы данных. SAP Commerce Cloud давно работает только в облаке SAP, и база там одна — Microsoft SQL Server. Платформа по-прежнему поддерживает HANA, Oracle, MySQL и HSQLDB, и код до сих пор различает их, поэтому они встречаются в тексте. Но на практике из этих различий важно одно: локальная HSQLDB ведёт себя не так, как SQL Server на проде.

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

О коде в этой статье

Все фрагменты Java ниже — иллюстративная реконструкция, а не исходный код платформы и не декомпиляция. Это мой пересказ поведения, восстановленного по наблюдениям: SQL в JDBC-логе, сообщениям в консольном логе, текстам исключений и экспериментам на работающей установке. Они показывают, что делает движок, а не то, как это реализовано. Имена, структура и детали будут отличаться от настоящей реализации, а в версиях новее той, что я тестировал (2211, JDK 21, HSQLDB), поведение могло измениться. Считайте каждый фрагмент схемой.

А вот ImpEx-примеры, SQL и сообщения об ошибках — настоящие: каждый из них я прогнал через HAC и привожу вывод как есть.

Песочница, которая есть в каждой установке

Чтобы проверять поведение ImpEx, нужен тип, на котором не жалко экспериментировать: без интерцепторов, без обязательных атрибутов, без уникального ключа, который помешает создать дубликат. Такой тип есть в любой установке SAP Commerce — он объявлен прямо в core-items.xml:

TestItemType2  (extends TestItem)
  foo, bar, fooBar        : String
  localizedFooBar         : localized String
  gender                  : Gender, не writable
  + всё, что унаследовано от TestItem: string, integer, date, …

Все примеры ниже используют foo как ключ и bar как поле данных. У TestItem есть Jalo-логика, которая подменяет значения string и integer при чтении, поэтому проверять имеет смысл только foo и bar. Поле fooBar вычисляемое, так что foo,bar в нём — это нормально.

Читать результат удобнее всего из Groovy-консоли HAC:

import de.hybris.platform.servicelayer.search.FlexibleSearchQuery
def q = new FlexibleSearchQuery(
    "SELECT {pk} FROM {TestItemType2} WHERE {foo} LIKE 'ih-%' ORDER BY {foo}")
flexibleSearchService.search(q).result.each {
    println "[" + it.foo + "] bar=[" + it.bar?.replace("\n", "\\n") + "]"
}

А убирать за собой — рецептом из статьи 2016 года, который по-прежнему работает:

$item=TestItemType2
REMOVE $item[batchmode=true];itemtype(code)[unique=true]
;$item

Тогда я не написал, как это выполняется. В JDBC-логе один SELECT по типу, а затем удаление каждого объекта по отдельности, со всеми сопутствующими таблицами. На 47 объектах:

  47  SELECT * FROM testitem WHERE PK=?
  47  DELETE FROM testitem WHERE PK = ?
  94  DELETE FROM testitemlp WHERE ITEMPK=?
  96  DELETE FROM props WHERE ITEMPK=?
  50  DELETE FROM aclentries WHERE ItemPK=?

Для тысячи строк это терпимо, для миллиона — несколько миллионов запросов; тут ImpEx уже не подходит.

Для экспериментов с кодом (всё, что начинается с #%) в форме импорта HAC нужно включить галку Enable code execution. По умолчанию она выключена. Что от этого меняется для блоков if:, описано в разделе про код.

Собственный CSV-парсер ImpEx

ImpEx читает файл собственным CSVReader. В 2016 году я ссылался на RFC 4180 как на спецификацию. Это была ошибка: у этого парсера свои правила.

Кавычки не защищают пробелы

Значение в кавычках обрезается по краям точно так же, как без кавычек:

// иллюстрация
String value = quoted
    ? unescape(line.substring(start + 1, end).trim())   // ← trim и внутри кавычек
    : line.substring(start, end).trim();

Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.

Пример.

INSERT_UPDATE TestItemType2;foo[unique=true];bar
;ih-a1;"   padded   "
[ih-a1] bar=[padded]

Импортировать через ImpEx значение с ведущими или хвостовыми пробелами нельзя никак — только cell decorator'ом или транслятором, который допишет их обратно. Пробелы внутри многострочного значения при этом сохраняются: обрезаются только края всего значения.

Кавычка открывает цитирование только в начале ячейки

Посреди значения кавычка — обычный символ:

;ih-a2;abc"def
[ih-a2] bar=[abc"def]

А вот непробельный символ после закрывающей кавычки — ошибка:

;ih-a3;"abc"def
unexpected char d after cell end at 12 in line 1 : ';ih-a3;"abc"def'

На этом часто спотыкаются с дюймами. Обратный слэш в ImpEx не экранирует кавычку, поэтому "5\" monitor" — это ячейка 5\, закрытая кавычкой, за которой идёт monitor:

unexpected char m after cell end at 12 in line 2 : ';ih-b2;"5\" monitor'

Кавычка внутри кавычек удваивается: "5"" monitor".

Непарная кавычка и ошибка не на той строке

Если строка заканчивается «внутри кавычек», парсер считает, что значение продолжается, и присоединяет следующую строку. Потом ещё одну — пока кавычки не сбалансируются или не кончится файл.

// иллюстрация
boolean isWrappedLine(String line, boolean alreadyQuoted) {
    boolean quoted = alreadyQuoted, cellStarted = alreadyQuoted, cellEnded = false;
    for (char c : line) {
        if (quoted) {
            if (c == '"') { if (next == '"') skip(); else { quoted = false; cellEnded = true; } }
        } else if (c == '"') {
            if (cellEnded)    throw new IllegalStateException("unexpected text separator ...");
            if (!cellStarted) { quoted = true; cellStarted = true; }   // только в начале ячейки
        } else if (c == ';') {
            cellStarted = cellEnded = false;
        } else if (!isWhitespace(c)) {
            if (cellEnded)    throw new IllegalStateException("unexpected char " + c + " after cell end ...");
            cellStarted = true;
        }
    }
    return quoted;   // true → приклеить следующую строку
}

Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.

Пример. Здесь виновата вторая строка данных — открывающая кавычка без закрывающей:

INSERT_UPDATE TestItemType2;foo[unique=true];bar
;ih-e1;ok
;ih-e2;"27 inch monitor
;ih-e3;fine
;ih-e4;"bold";x
;ih-e5;last

Результат:

[ih-e1] bar=[ok]
[ih-e5] bar=[last]

Три строки пропали, а в логе:

unexpected char b after cell end at 8 in line 2 : ';ih-e4;"bold";x'

Строка из сообщения, ;ih-e4;"bold";x, корректна. Ошибка возникла, когда парсер, склеивая строки после ih-e2, дошёл до кавычки в ih-e4 и принял её за закрывающую. Номер line 2 указывает на настоящую причину (ih-e2 — строка 2, если считать с нуля и вместе с заголовком), а текст — на ih-e4. И ih-e3, которой в сообщении нет вообще, тоже не импортирована.

Если до конца файла кавычек больше нет, сообщение точнее:

reached EOF and got odd number of '"'! File is corrupt. Error starts in line:
";ih-c2;"27 inch monitor" and occurs somewhere in the following lines.

Если склеивается больше csv.maxmultilines строк (по умолчанию 10 000), парсер останавливается с отдельным сообщением.

Ни одна из этих ошибок не видна в HAC. Блок результата пишет «Import has encountered problems.», а под ним ничего нет. Текст ошибки есть только в консольном логе сервера.

Перевод строки и обратный слэш — два разных механизма

Кавычки сохраняют перевод строки внутри значения:

;ih-a4;"line1
line2"
[ih-a4] bar=[line1\nline2]

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

;ih-a5;part1\
part2
[ih-a5] bar=[part1part2]

Здесь поправка к статье 2016 года. Тогда я писал, что \ нельзя использовать как разделитель многострочности в блоке данных. На 2211 это не так: в примере выше \ стоит именно в строке данных, и склейка работает.

Комментарий определяется по первой ячейке

Комментарием считается строка, у которой первая ячейка начинается с #. Поэтому # в середине строки — обычные данные:

;ih-a7;value # not a comment
[ih-a7] bar=[value # not a comment]

А вот это — комментарий, хотя выглядит как данные:

"# quoted comment?";ih-a7b;x

Проверка выполняется уже после того, как ячейка извлечена из кавычек. Поэтому первая ячейка в кавычках, начинающаяся с #, — тоже комментарий. Именно благодаря этому работают строки кода в кавычках вида "#% ...", о которых ниже.

Пустая ячейка, <ignore> и <null>

Как ведут себя три варианта на 2211:

В ячейке Строковый атрибут Ссылка
пусто записывается null записывается null
<ignore> атрибут не трогается атрибут не трогается
<null> записывается строка <null> ищется объект, у которого ключ равен null

Пример.

INSERT_UPDATE TestItemType2;foo[unique=true];bar
;ih-m1;start
;ih-m2;start
;ih-m3;start
INSERT_UPDATE TestItemType2;foo[unique=true];bar
;ih-m1;
;ih-m2;<ignore>
;ih-m3;<null>
[ih-m1] bar=[null]
[ih-m2] bar=[start]
[ih-m3] bar=[<null>]

С <ignore> всё ожидаемо. А <null> используется для поиска по ссылке. Попробуйте обнулить ссылку:

INSERT_UPDATE Employee;uid[unique=true];sessionLanguage(isocode)
;ih-emp2;<null>
column 2: cannot resolve value '<null>' for attribute 'sessionLanguage'

ImpEx искал язык с isocode IS NULL и не нашёл его. Чтобы обнулить ссылку, нужна пустая ячейка.

<null> нужен для составных ключей, у которых одна из частей пустая. Например, адрес, у которого нет города:

INSERT Address;owner(Employee.uid);streetname;town
;ih-emp1;ih-street;
;ih-emp1;ih-street;Berlin

INSERT_UPDATE Employee;uid[unique=true];defaultShipmentAddress(streetname,town)
;ih-emp1;ih-street:<null>
;ih-emp2;ih-street:Berlin
ih-emp1 -> ih-street/null
ih-emp2 -> ih-street/Berlin

Под капотом движок просто переписывает текст запроса: каждое =?параметр, значение которого оказалось null, заменяется на IS NULL.

// иллюстрация
for (Map.Entry<String, Object> e : values.entrySet()) {
    if (e.getValue() == null) {
        query = query.replaceAll("=\\?" + e.getKey(), " IS NULL ");
    }
}

Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.

Регистр не важен: <NULL> работает так же.

Разрешение ссылок одним UNION ALL на всю строку

Если в строке больше одной колонки-ссылки (catalogVersion(...), unit(code), supercategories(code)), ImpEx разрешает их все одним запросом. Вот что в JDBC-логе для такой строки:

INSERT_UPDATE Employee;uid[unique=true];sessionLanguage(isocode);sessionCurrency(isocode);defaultShipmentAddress(streetname,town)
;ih-emp6;fr;USD;ih-street-q3:Paris
SELECT x.a, x.b FROM (
    SELECT item_t0.PK AS a, 0 AS b FROM languages  item_t0
     WHERE ( item_t0.p_isocode =?) AND (item_t0.TypePkString=? )
  UNION ALL
    SELECT item_t1.PK AS a, 1 AS b FROM currencies item_t1
     WHERE ( item_t1.p_isocode =?) AND (item_t1.TypePkString=? )
  UNION ALL
    SELECT item_t2.PK AS a, 2 AS b FROM addresses  item_t2
     WHERE ( item_t2.p_streetname =? AND item_t2.p_town =?) AND (item_t2.TypePkString=? )
) x

Колонка b — номер транслятора. По ней результаты раскладываются обратно по колонкам. Параметры каждого подзапроса переименовываются (?v0 → ?t0v0, ?t1v0, …), чтобы не столкнуться между собой.

// иллюстрация
sb.append("SELECT x.a, x.b FROM (");
for (int i = 0; i < translators.size(); i++) {
    if (i > 0) sb.append(" UNION ALL ");
    sb.append("{{").append(t.select()).append(" AS a, ").append(i).append(" AS b ")
      .append(t.body().replaceAll("\\?v", "?t" + i + "v")).append("}}");
}
sb.append(") x");

Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.

Тот же механизм работает для элементов коллекции в одной ячейке. Три группы в groups(uid) — тоже один запрос:

SELECT x.a, x.b FROM (
    SELECT item_t0.PK AS a, 0 AS b FROM usergroups item_t0 WHERE ( item_t0.p_uid =?) AND (...)
  UNION ALL
    SELECT item_t1.PK AS a, 1 AS b FROM usergroups item_t1 WHERE ( item_t1.p_uid =?) AND (...)
  UNION ALL
    SELECT item_t2.PK AS a, 2 AS b FROM usergroups item_t2 WHERE ( item_t2.p_uid =?) AND (...)
) x

Запрос идёт через кэш FlexibleSearch

Это обычный FlexibleSearch-запрос с обычным ключом кэша. Когда я повторил импорт с той же комбинацией языка, валюты и адреса, но с другим uid, в JDBC-логе этого запроса не оказалось: результат взят из кэша. Если тысячи строк ссылаются на одну версию каталога и одну единицу измерения, это экономит запросы. Если комбинация значений в каждой строке уникальна, кэш забивается одноразовыми записями. И это ещё одна причина, по которой проблема с NOW() из статьи про FlexibleSearch касается и ImpEx.

Чанки

Объединённый запрос режут на чанки, но не на всех базах. Размер чанка по умолчанию зависит от базы:

// иллюстрация
int defaultChunkSize() {
    boolean bigQueriesAreAProblem = isHana() || isOracle() || isSqlServer() || isMySQL8();
    return config.getInt("impex.query.chunk.size", bigQueriesAreAProblem ? 400 : 0);
}

Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.

0 значит «не резать». На HANA, Oracle, SQL Server и MySQL 8 запрос режется по 400 подзапросов, на остальных — то есть на практике на локальной HSQLDB — собирается целиком. Значит, локально на HSQLDB вы проверяете не тот запрос, который пойдёт на прод в SAP Commerce Cloud на SQL Server. Модификатор колонки query-chunk-size переопределяет это значение, но только для элементов коллекции в этой колонке. Объединённый запрос по колонкам строки всегда использует глобальное значение. Если импорт падает с «too many tables in query» на строке с большой коллекцией — помогает модификатор. Если на строке с большим числом колонок-ссылок — только impex.query.chunk.size.

Как это выключить

impex.query.combined=false (по умолчанию true) возвращает по запросу на ячейку. Иногда это единственный способ заставить импорт работать на базе, которая давится длинными UNION.

Многопроходный импорт и условие остановки

Строки, ссылки которых не разрешились, не считаются ошибкой сразу. Они сбрасываются в dump, и dump импортируется ещё раз. Классический случай — ссылка вперёд:

INSERT_UPDATE Category;code[unique=true];catalogVersion(catalog(id),version)[unique=true]
;ih-cat-root;ih-catalog:Staged
INSERT_UPDATE CatalogVersion;catalog(id)[unique=true];version[unique=true]
;ih-catalog;Staged
INSERT_UPDATE Catalog;id[unique=true]
;ih-catalog

Категория ссылается на версию каталога, которая ссылается на каталог, который объявлен последним. В логе:

Finished 1 pass ... - processed: 3, dumped: 2 (last pass: 0)
Starting pass 2
Finished 2 pass ... - processed: 2, dumped: 1 (last pass: 2)
Starting pass 3
Finished 3 pass ... - processed: 1, no lines dumped (last pass 1)

Импорт прошёл за три прохода, по одному на звено цепочки.

А теперь ссылка, которая не разрешится никогда:

INSERT_UPDATE Employee;uid[unique=true];sessionLanguage(isocode)
;ih-emp9;no_such_lang
Finished 1 pass ... - processed: 1, dumped: 1 (last pass: 0)
Starting pass 2
Finished 2 pass ... - processed: 1, dumped: 1 (last pass: 1)
Can not resolve any more lines ... Aborting further passes (at pass 2).
Finally could not import 1 lines!

Условие продолжения выглядит так:

// иллюстрация
boolean anotherPass =
       (maxPass < 0 || currentPass <= maxPass)                 // maxPass по умолчанию -1: без лимита
    && (currentPass == 1 || dumpedNow < dumpedLastPass);      // строго меньше, чем в прошлый раз

Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.

Второй проход выполняется всегда, если после первого что-то осталось. Третий и дальше — только если число отложенных строк строго уменьшилось. Что из этого следует на практике:

Неимпортированная строка может оставить объект в базе

Посмотрим, что лежит в dump'е для неразрешимой строки (HAC показывает его в блоке результата):

INSERT_UPDATE Employee;uid[unique=true];sessionLanguage(isocode)
Employee,8809954312196,,,column 2: cannot resolve value 'no_such_lang' for attribute 'sessionLanguage';<ignore>ih-emp9;no_such_lang

В первой ячейке — тип и PK. Ключевая колонка помечена <ignore>. Это значит, что сотрудник ih-emp9 уже создан в первом проходе со всеми атрибутами, которые удалось разрешить. Отложена только неразрешённая часть, которая будет дописана по PK. И если импорт в итоге «не удался», объект остаётся в базе — без языка, но существующий:

userService.getUserForUID("ih-emp9")  →  EmployeeModel (8809954312196@1)

Импорт не откатывается: после «Finally could not import 1 lines!» в базе может остаться недоделанный объект.

Ошибки валидации тоже идут на второй проход

Если сохранение упало с ModelSavingException — валидатор, интерцептор, нарушение уникальности, — строка не считается окончательно провалившейся. Она уходит в dump с сообщением Failed to process item creation due to ModelSavingException. Retrying in the next turn. и выполняется ещё раз на следующем проходе. Поэтому сообщения об ошибках валидации в ImpEx часто выглядят вот так, с двумя номерами строки:

Exception : line 4: cannot create Title ... found 1 item(s) using the same keys,
Exception : line 3: cannot create Title ... found 1 item(s) using the same keys

Это одна и та же строка: номер из основного файла и номер из dump'а второго прохода. Интерцепторы с побочными эффектами на такой строке выполнятся дважды.

Есть недокументированный ключ impex.retry.on.interceptorexceptions.only (по умолчанию false, ни в одном .properties поставки его нет), который сужает повтор до случаев, когда причиной был именно InterceptorException.

Модификаторы

Документация описывает около полутора десятков модификаторов. В платформе их 29 в одном списке констант, ещё три заголовок читает отдельно, и есть testMode, который читается прямо в коде и нигде не объявлен.

Модификатор Где Что делает
unique колонка входит в ключ поиска существующего объекта
allownull колонка разрешить null для обязательного атрибута
ignorenull колонка пропускать null-элементы коллекции
default колонка значение для пустой ячейки
virtual колонка колонки нет в данных, только default
lang колонка язык локализованного атрибута
translator колонка свой транслятор значения
cellDecorator колонка свой декоратор ячейки
dateformat, numberformat колонка форматы разбора
collection-delimiter колонка по умолчанию ,
path-delimiter колонка по умолчанию :
key2value-delimiter колонка по умолчанию ->
map-delimiter колонка по умолчанию ;
mode колонка append / remove / merge для коллекций
query-chunk-size колонка чанк объединённого запроса — только для элементов коллекции
forceWrite колонка писать в не-writable атрибут
pos колонка явная позиция колонки
alias колонка имя колонки при экспорте
sourceSystemId, pkFromExternalImportKey колонка внешние ключи импорта
batchmode заголовок UPDATE/REMOVE по неуникальному ключу, много объектов
cacheUnique заголовок кэш ключ → PK на весь тип
processor заголовок свой ImportProcessor
parallel заголовок false — этот блок в один поток
ignoreKeyCase заголовок регистронезависимый поиск по ключу
unlessExisting заголовок пропускать существующие объекты в INSERT
impex.legacy.mode заголовок Jalo вместо ServiceLayer для этого блока
sld.enabled заголовок читается только распределённым ImpEx
disable.interceptor.types заголовок отключить интерцепторы по типу (validate, prepare, …)
disable.interceptor.beans заголовок отключить интерцепторы по имени бина
disable.UniqueAttributesValidator.for.types заголовок отключить проверку уникальности
testMode заголовок с cacheUnique: сверять кэш с базой на каждой строке

Последний, testMode, — отладочный режим cacheUnique. Он перепроверяет каждый ответ кэша обычным запросом и падает при расхождении. В списке констант его нет.

Дальше — те, что ведут себя неочевидно.

Без forceWrite запись молча пропускается

Атрибут sealed есть у любого объекта и не writable. Попробуем его записать с модификатором и без:

INSERT_UPDATE TestItemType2;foo[unique=true];bar
;ih-s1;x
;ih-s2;x
UPDATE TestItemType2;foo[unique=true];sealed
;ih-s1;true
UPDATE TestItemType2;foo[unique=true];sealed[forceWrite=true]
;ih-s2;true
[ih-s1, null, ...]
[ih-s2, true, ...]

HAC пишет «Import finished successfully». Строка ih-s1 не изменилась, и единственный след этого — WARN в консольном логе:

column sealed of type TestItemType2 is not writable, but you want to update it.
If you want to write this non writable attribute explicit, you have to use the
forceWrite=true modifier. This warning is printed only once for header.
Please check subsequent lines for similar problem.

«Printed only once for header» объясняет, почему в большом импорте вы видите одно предупреждение и думаете, что проблема единичная.

Атрибут, который можно задать только при создании (initial), forceWrite обновить не даст:

UPDATE TestItemType2;foo[unique=true];creationtime[dateformat=yyyy-MM-dd,forceWrite=true]
;ih-a4;2001-01-01
attribute de.hybris.platform.jalo.test.TestItem.creationtime cannot be changed except during item creation

unlessExisting работает только в INSERT и только со значением true

INSERT TestItemType2[unlessExisting=true];foo[unique=true];bar
;ih-a1;overwritten?
;ih-u1;new
INSERT TestItemType2[unlessExisting=yes];foo[unique=true];bar
;ih-a2;overwritten?
INSERT_UPDATE TestItemType2[unlessExisting=true];foo[unique=true];bar
;ih-a4;overwritten-by-insert-update

Результат:

ignoreKeyCase работает только на заголовке

Модификатор выглядит как колоночный — он про сравнение значения ключа. Но поиск существующего объекта читает его с заголовка:

INSERT_UPDATE TestItemType2;foo[unique=true,ignoreKeyCase=true];bar
;IH-A2;matched-case-insensitively?
INSERT_UPDATE TestItemType2[ignoreKeyCase=true];foo[unique=true];bar
;IH-A4;header-level
ih-a2 -> abc"def                          ← не найден
IH-A2 -> matched-case-insensitively?      ← создан новый объект
ih-a4 -> header-level                     ← найден и обновлён

На колонке модификатор игнорируется, предупреждения нет.

Раз уж речь о регистре: имена атрибутов в заголовке регистронезависимы. INSERT_UPDATE TestItemType2;foo[unique=true];BAR пишет в bar.

cacheUnique загружает ключи всего типа

При старте заголовка движок выгружает ключи всех объектов типа:

INSERT_UPDATE Employee[cacheUnique=true];uid[unique=true];name
;ih-emp6;Cached
SELECT item_t0.PK, item_t0.p_uid FROM users item_t0 WHERE (item_t0.TypePkString=? )

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

В INSERT без уникальной колонки модификатор запрещён:

You can not use cacheUnique modifier in INSERT mode when no column is unique,
use UPDATE / INSERT_UPDATE mode or declare at least one column as unique

А если в базе уже есть два объекта с одинаковым значением ключа, заголовок падает целиком: found more than one item for unique key values .... Поэтому cacheUnique заодно находит дубли ключа, которые уже есть в базе.

Префиксы коллекций (+), (-) и недокументированный (+?)

В статье 2016 года были (+) и (-). Есть и третий префикс:

Префикс Действие
(+) добавить элемент — всегда
(-) удалить элемент
(+?) добавить, если его ещё нет

Разница между (+) и (+?) видна на атрибуте-списке. fallbackLanguages у Language — список:

UPDATE Language;isocode[unique=true];fallbackLanguages(isocode)
;xx_test;en
;xx_test;(+) de
;xx_test;(+) de
;xx_test;(+?) en
;xx_test;(+?) fr
[en, de, de, fr]

(+) добавил de дважды. (+?) не стал добавлять существующий en. На множествах (Set, например groups у пользователя) дубликатов не будет, поэтому разница проявляется только на списках.

Префиксы нельзя смешивать с «голыми» значениями в одной ячейке:

;ih-emp7;ih-g2,(+) ih-g3
Invalid syntax! The assigned collectionvalues must either have one of the prefixes
'(+)' or '(-)' or modifier mode must be set

Модификатор mode=append|remove|merge на колонке задаёт действие сразу для всех значений колонки. Без него префикс нужен у каждого значения в ячейке, а не только у первого.

Заголовок, который выключает бизнес-логику

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

disable.interceptor.types=validate,prepare
disable.interceptor.beans='beanName1,beanName2'
disable.UniqueAttributesValidator.for.types='Title,Product'

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

Как ImpEx отключает пользователям вход

В расширении impex зарегистрирован интерцептор disableLoginForImportedUserInterceptor. Он срабатывает на любого User, которого создаёт или изменяет ImpEx, и выставляет loginDisabled=true, если у пользователя нет пароля.

INSERT_UPDATE Employee;uid[unique=true];name
;ih-emp10;plain
INSERT_UPDATE Employee[disable.interceptor.beans='disableLoginForImportedUserInterceptor'];uid[unique=true];name
;ih-emp11;interceptor off
UPDATE Employee;uid[unique=true];name
;ih-emp-sl;just renamed

Пользователь ih-emp-sl перед этим был создан через ServiceLayer, без пароля, с loginDisabled=false. Результат:

ih-emp10  loginDisabled=true   name=plain
ih-emp11  loginDisabled=false  name=interceptor off
ih-emp-sl loginDisabled=true   name=just renamed

Обычный UPDATE имени через ImpEx заблокировал пользователю вход. Если у вас есть пользователи без локального пароля (SSO, технические учётки), любой ImpEx, который их трогает, молча выключит им вход.

В логе при этом:

Disabling login for 'null' because of empty password.

null — потому что сообщение печатает PK, а при создании его ещё нет.

Как интерцептор узнаёт, что работает ImpEx? По атрибутам сессии, которые ImpEx выставляет на время сохранения: impex.creation и impex.modification вместе с impex.modification.pk. Эти атрибуты может проверять и ваш интерцептор, чтобы вести себя иначе при импорте.

Интерцептор помечен @Deprecated(since = "2211", forRemoval = true), но включён по умолчанию. Глобально его выключает свойство disable.login.for.imported.user.interceptor.enabled=false, которое в project.properties поставки есть только в закомментированном виде.

disable.UniqueAttributesValidator.for.types

Модификатор действительно выключает валидатор — это видно по тому, как меняется ошибка:

INSERT Title;code
;ih-title
INSERT Title[disable.UniqueAttributesValidator.for.types='Title'];code
;ih-title
... due to [UniqueAttributesInterceptor]:ambiguous unique keys {code=ih-title} ... found 1 item(s) using the same keys
... due to Duplicate code 'ih-title' for type Title

Первую строку остановил валидатор уникальности, вторую — проверка ниже по стеку. У Title она есть. Отключая валидатор, вы полагаетесь на то, что у вашего типа она тоже есть. Стоит проверить это на конкретном типе, прежде чем оставлять модификатор в файле.

Практическое правило: grep -rn "disable\.\(interceptor\|UniqueAttributesValidator\)" --include=*.impex . по всему проекту. Эти модификаторы обычно появляются «временно».

Переписывание заголовков через impex.header.replacement

В документации этого механизма нет, хотя сокращения E1p, E1t, E1d и C@ используются в любом проекте с ценами и классификацией. Это не синтаксис ImpEx, а правила из advanced.properties:

impex.header.replacement.1=C@(\\w+) ... @$1[system='\\$systemName',version='\\$systemVersion',translator='...ClassificationAttributeTranslator']
impex.header.replacement.2=E1p ... europe1Prices[translator='de.hybris.platform.europe1.jalo.impex.Europe1PricesTranslator']
impex.header.replacement.3=E1t ... europe1Taxes[translator='...Europe1TaxesTranslator']
impex.header.replacement.4=E1d ... europe1Discounts[translator='...Europe1DiscountsTranslator']

Формат: impex.header.replacement.<приоритет> = <регулярка> ... <замена>, разделитель — три точки. Правила применяются так:

// иллюстрация
for (int i = 1; i < columns.size(); i++) {          // колонка 0 (режим и тип) не трогается
    for (Rule r : rulesByPriorityDesc) {
        String replaced = columns.get(i).replaceAll(r.pattern, r.replacement);
        if (!replaced.equals(columns.get(i))) {
            columns.set(i, expandMacros(replaced));  // макросы раскрываются после замены
            break;                                   // первое сработавшее правило — последнее
        }
    }
}

Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.

Свойства читаются при создании каждого reader'а, так что правило можно добавить без перезапуска. Я добавил его из Groovy-консоли:

de.hybris.platform.util.Config.setParameter(
    "impex.header.replacement.10", "barD ... bar[default='from-rule']")
INSERT_UPDATE TestItemType2;foo[unique=true];barD
;ih-h3;
;ih-h4;explicit
[ih-h3] bar=[from-rule]
[ih-h4] bar=[explicit]

Вот почему в правиле для C@ стоит \\$systemName: замена вставляет в колонку ссылку на макрос, а макрос раскрывается следующим шагом.

Регулярка не заякорена: replaceAll ищет совпадение в любом месте имени колонки:

INSERT_UPDATE TestItemType2;foo[unique=true];oldbarD
INSERT_UPDATE TestItemType2;foo[unique=true];oldbar[default='from-rule']
# unknown attribute 'oldbar' in header 'INSERT_UPDATE TestItemType2'

Колонка oldbarD превратилась в oldbar[...]. Со стандартными правилами это значит, что атрибут, в имени которого встречается E1p, E1t или E1d, будет искалечен ещё до разбора заголовка. Свои правила стоит писать с якорями: ^MyAttr$ ... myRealAttribute[...].

Программно то же самое делает reader.addHeaderReplacementRule(src, tgt, prio).

Код внутри ImpEx

Строки, начинающиеся с #%, — это код. По умолчанию (impex.legacy.scripting=true) его исполняет BeanShell. В статье 2016 года этому было посвящено полстатьи. Почти всё из неё работает, но есть несколько отличий, о которых я тогда не писал.

Без Enable code execution блок if: всегда истинен

В форме импорта HAC галка Enable code execution по умолчанию снята. Код в этом случае пропускается с WARN в логе. С блоком if: всё интереснее:

INSERT_UPDATE TestItemType2;foo[unique=true];bar
"#% if: ""a"".equals(""b"")"
;ih-nocode-inside-false-if;imported anyway?
#% endif:

Без Enable code execution:

[ih-nocode-inside-false-if] bar=[imported anyway?]

HAC: «Import finished successfully». Строка импортирована, хотя условие ложное: когда выполнение кода выключено, условие не вычисляется, и движок считает его истинным:

// иллюстрация
if (!(ifLine.getResult() instanceof Boolean)) {
    result = codeExecutionEnabled
        ? Boolean.FALSE    // невалидное выражение — «ложь», с WARN
        : Boolean.TRUE;    // код выключен — всё внутри if: импортируется
}

Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.

Если вы используете if: для «этот блок только для dev» или «только если расширение установлено», то файл, импортированный без галки, импортирует всё.

Точка с запятой внутри кода исчезает

Строка кода — это тоже CSV-строка. ; в ней — разделитель ячеек. В режиме BeanShell по умолчанию движок собирает код, склеивая все ячейки без разделителя:

#% impex.info("IH1 a;b;c");
"#% impex.info(""IH2 a;b;c"");"
IH1 abc
IH2 a;b;c

Если операторов два, получится синтаксическая ошибка, по которой трудно понять причину:

#% counter = counter + 1; impex.info("counter=" + counter);
inline evaluation of: ``counter = counter + 1impex.info("IH3 counter=" + counter);''
Encountered "impex" at line 1, column 22.

Отсюда правило, которое в 2016 году я соблюдал, не объясняя: код в ImpEx всегда пишется в кавычках, а кавычки внутри удваиваются.

Один интерпретатор на файл

Интерпретатор BeanShell создаётся один на reader и живёт до конца файла. Переменные переживают заголовки:

#% counter = 1;
INSERT_UPDATE TestItemType2;foo[unique=true];bar
;ih-bsh1;x
INSERT_UPDATE TestItemType2;foo[unique=true];bar
"#% impex.info(""counter still visible="" + counter);"
counter still visible=1

В контексте всегда есть:

Переменная Что это
impex сам reader (ImpExImportJob$MyImpExImportReader при импорте из HAC)
jaloSession текущая Jalo-сессия
line Map<Integer,String> текущей строки данных
currentLineNumber, currentLocation позиция в файле

Плюс предымпортированные пакеты de.hybris.platform.core, ...core.model.user, ...util, ...impex.jalo, ...jalo, ...jalo.c2l, ...jalo.user, ...jalo.flexiblesearch. Поэтому в коде можно писать Config.getBoolean(...) без импорта.

beforeEach: может переписать или выбросить строку

Механизм из примера 2016 года с языками по-прежнему работает:

INSERT_UPDATE TestItemType2;foo[unique=true];bar
"#% beforeEach: if (line.get(Integer.valueOf(2)).startsWith(""skip"")) line.clear(); else line.put(Integer.valueOf(2), line.get(Integer.valueOf(2)).toUpperCase());"
;ih-be1;make me loud
;ih-be2;skip me
#% beforeEach:end
;ih-be3;untouched
[ih-be1] bar=[MAKE ME LOUD]
[ih-be3] bar=[untouched]

line.clear() — недокументированный способ отфильтровать строку по условию: пустая строка просто не импортируется. afterEach: работает так же, но после сохранения объекта, и вызывается на каждой строке (forEach: — его устаревшее имя, при использовании пишется WARN).

getLastImportedItem() возвращает Jalo-объект

Даже в режиме ServiceLayer Direct:

"#% impex.info(""IH-LAST "" + impex.getLastImportedItem().getClass().getName());"
IH-LAST de.hybris.platform.jalo.test.TestItem

Именно поэтому в примере 2016 года с заказом работал impex.getLastImportedItem().calculate(): это Jalo-метод Jalo-объекта. Чтобы работать с моделью, нужен modelService.get(impex.getLastImportedItem()).

Два рецепта 2016 года, которые больше не работают

addDefinition из кода. В 2016 году макрос можно было задать из BeanShell. Сейчас метод protected, и BeanShell его не видит:

#% impex.addDefinition("$dyn=from-code");
Error in method invocation: Method addDefinition ...

При этом неизвестный макрос $dyn ниже по файлу не вызывает ошибку, а остаётся в данных буквально: bar=[$dyn]. Рабочий обход — вставить строку определения в поток:

"#% m = new HashMap(); m.put(Integer.valueOf(0), ""$dyn=from-code-"" + (6 * 7)); impex.insertLine(m);"
INSERT_UPDATE TestItemType2;foo[unique=true];bar
;ih-def3;$dyn
[ih-def3] bar=[from-code-42]

includeSQLData с тремя аргументами. Сигнатуры includeSQLData(url, driver, sql) из статьи 2016 года нет. Есть (url, user, password, driverClass, sql) — о ней ниже.

А $config- по-прежнему работает, если установлен commerceservices:

UPDATE GenericItem[processor=de.hybris.platform.commerceservices.impex.impl.ConfigPropertyImportProcessor];pk[unique=true]
$workers=$config-impex.import.workers
INSERT_UPDATE TestItemType2;foo[unique=true];bar
;ih-cfg1;$workers
;ih-cfg2;$config-impex.legacy.scripting
[ih-cfg1] bar=[4]
[ih-cfg2] bar=[true]

#%groovy% при impex.legacy.scripting=true

Есть и «современный» синтаксис с указанием движка: #%groovy% ..., #%javascript% .... Но он работает только при impex.legacy.scripting=false. При значении по умолчанию строка целиком, вместе с groovy%, уходит в BeanShell:

#%groovy% impex.info("IH-GROOVY engine=" + this.getClass().getName())
IH-GROOVY engine=bsh.XThis
... inline evaluation of: ``groovy% impex.info(...)'' : illegal use of undefined variable, class, or 'void' literal

Сообщение в логе появилось раньше ошибки. BeanShell прочитал groovy% impex.info(...) как выражение «groovy по модулю impex.info(...)», вычислил правый операнд — то есть вызвал метод — и упал на левом. Если в такой строке стоит что-то с побочным эффектом, побочный эффект случится, а импорт сообщит об ошибке.

При impex.legacy.scripting=false та же строка выполняется Groovy (engine=Script73). Но и у этого режима свои отличия: код берётся только из первой ячейки, то есть обрезается на первой ; без кавычек, а не склеивается. Старые файлы с BeanShell-кодом, у которого точка с запятой внутри, после переключения сломаются по-новому.

Строка кода останавливает параллельную обработку

Импорт по умолчанию многопоточный: читатель раскладывает строки данных по очереди, воркеры их сохраняют. Если в файле стоит строка кода, она должна увидеть результат всех строк выше. Поэтому перед каждой отдельной #%-строкой и перед каждым новым заголовком читатель ждёт, пока очередь опустеет:

// иллюстрация
@Override
protected void execute(CodeLine line, Map csvLine, boolean perLineHook) {
    if (!perLineHook) {
        waitForEmptyValueLineQueue();   // барьер: все воркеры должны доделать свою работу
    }
    super.execute(line, csvLine, perLineHook);
}

Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.

Строка #% impex.info("progress") после каждой сотни строк данных превращает многопоточный импорт в последовательность коротких параллельных участков. Сам интерпретатор при этом защищён synchronized, так что beforeEach: / afterEach: тоже выполняются строго по одному.

Модификатор заголовка parallel=false переводит конкретный блок в один поток, не трогая остальные.

Файлы, classpath и чужие базы данных

Чтение файлов сервера через file:, zip: и jar:

Колонка @media с MediaDataTranslator принимает путь с префиксом:

INSERT_UPDATE Media;code[unique=true];catalogVersion(catalog(id),version)[unique=true];mime;@media[translator=de.hybris.platform.impex.jalo.media.MediaDataTranslator]
;ih-m-file;ih-catalog:Staged;text/plain;file:/path/on/server/srvfile.txt
;ih-m-zip;ih-catalog:Staged;text/plain;zip:/path/on/server/arch.zip&inner/note.txt
;ih-m-jar;ih-catalog:Staged;application/octet-stream;jar:de.hybris.platform.impex.jalo.ImpExManager&ImpExManager.class
ih-m-file size=37    data=hello from the app server filesystem
ih-m-zip  size=24    data=hello from inside a zip
ih-m-jar  size=29123 data=����...de/hybris/platform/impex/jalo/GeneratedImpExMan...

http: сломан, а https: не поддерживается

Префикс http: не скачивает файл, а записывает URL в медиа. Точнее, должен записывать:

;ih-m-http;ih-catalog:Staged;image/png;http://example.com/x.png
;ih-m-https;ih-catalog:Staged;image/png;https://example.com/x.png
Exception : Error while setting URL to media
Path 'https://example.com/x.png' couldn't be resolved!

Префикса https: нет. А http: ломается потому, что движок отрезает префикс, прежде чем записать URL:

// иллюстрация
if (path.toLowerCase().startsWith("http:")) {
    media.setURL(path.substring("http:".length()));   // → "//example.com/x.png"
}

Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.

Остаётся //example.com/x.png, и его отвергает MediaExternalUrlSourcesValidator, который в текстах ImpEx не появляется. Если повторить запись из Groovy, видна настоящая причина:

Access protocol is not allowed for media URL //example.com/y.png.
Only: [https, http, fromjar] are allowed.

В legacy-режиме (Jalo) — то же самое. Обход — продублировать префикс:

;ih-m-http3;ih-catalog:Staged;image/png;http:http://example.com/z.png
;ih-m-https3;ih-catalog:Staged;image/png;http:https://example.com/z.png
ih-m-http3  url=http://example.com/z.png
ih-m-https3 url=https://example.com/z.png

В неудачном случае объект Media всё равно создаётся, без URL и без данных. Это тот же эффект «создано, но не импортировано», что и в разделе о проходах.

Строки из чужой базы через includeSQLData

Переменная impex — это reader со всеми публичными методами. Один из них открывает JDBC-соединение и подставляет результат запроса как строки данных текущего заголовка:

INSERT_UPDATE TestItemType2;foo[unique=true];bar
"#% impex.includeSQLData(""jdbc:hsqldb:mem:ihdemo"", ""SA"", """", ""org.hsqldb.jdbcDriver"", ""SELECT 'ih-sql-' || TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'INFORMATION_SCHEMA' ORDER BY TABLE_NAME LIMIT 3"");"
[ih-sql-ADMINISTRABLE_ROLE_AUTHORIZATIONS] bar=[VIEW]
[ih-sql-APPLICABLE_ROLES] bar=[VIEW]
[ih-sql-ASSERTIONS] bar=[VIEW]

Первая колонка результата идёт в первую колонку заголовка после пустой ячейки, вторая — во вторую. Для ETL это вполне рабочий инструмент. Вместе с ним есть семейство includeExternalData(...) для файлов и потоков и initDatabase(...) для нескольких запросов к одной базе.

Безопасность

#% — произвольный код на сервере, file: и zip: — чтение файловой системы, jar: — загрузка любого класса по имени, includeSQLData — соединение с любой базой, до которой дотягивается сервер. Право запускать ImpEx-импорт с включённым выполнением кода эквивалентно праву выполнять код на сервере. Без выполнения кода остаётся чтение файлов через file:, zip: и jar:.

В HAC это и так администраторская операция. Но ImpEx часто дают контент-менеджерам через Backoffice, интеграциям через ImportService и пайплайнам загрузки данных. Там это стоит пересмотреть.

Число потоков задаётся выражением SpEL

Число воркеров берётся из свойства, и его значение — это выражение SpEL:

impex.import.workers=#cores * 2          # project.properties
impex.import.workers.hsqldb=1            # advanced.properties: override по имени базы
// иллюстрация
int defaultMaxThreads(Tenant t) {
    String expr = config("impex.import.workers." + t.databaseName().toLowerCase());
    if (isBlank(expr)) expr = config("impex.import.workers");
    return numberOfThreadsFromExpression(expr, 2);   // переменная #cores, запасное значение 2
}

Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.

Я прогнал несколько выражений через ту же функцию на машине с 14 ядрами:

Выражение Результат
#cores * 2 28
#cores + 2 16
4 4
#core * 2 (опечатка) 2, с WARN
cores * 2 (без #) 2, с WARN
#cores / 0 2, с WARN
T(java.lang.Runtime).getRuntime().availableProcessors() 2, с WARN
#cores * 2,5 исключение SpelParseException

Контекст вычисления ограничен: доступна арифметика над #cores, но не обращение к классам — T(...) не работает. Опечатка тихо откатывает вас на два потока, и всё, что остаётся, — WARN Exception has been thrown during evaluation of '...'. Null will be returned. А вот синтаксическая ошибка не перехватывается и вылетает наружу.

На HSQLDB эта настройка не действует: impex.import.workers.hsqldb=1 переопределяет общее значение, и getDefaultMaxThreads возвращает 1. Если вы меряете скорость импорта локально на HSQLDB, вы меряете однопоточный импорт.

Распределённый ImpEx работает на другом движке

В HAC у формы импорта есть галка Distributed mode. Это отдельная реализация поверх task engine: файл режется на батчи (impex.distributed.batch.size=100), батчи сохраняются в базе как ImportBatch / ImportBatchContent и обрабатываются задачами, которые могут выполняться на любой ноде кластера.

Я прогнал через него файл с двумя обычными строками, строкой кода и неразрешимой ссылкой:

INSERT_UPDATE TestItemType2;foo[unique=true];bar
;ih-dist1;d1
;ih-dist2;d2
#% impex.info("IH-DIST code line");
INSERT_UPDATE Employee;uid[unique=true];sessionLanguage(isocode)
;ih-dist-emp;no_such_lang

В логе видно, что строка кода выполнилась один раз, при нарезке файла: IH-DIST code line появилась в HTTP-потоке раньше, чем стартовала первая задача обработки батча. Поэтому всё, что зависит от порядка (getLastImportedItem(), счётчики, afterEach:), в распределённом режиме работает иначе, чем в обычном.

Модели сохраняются пакетами: Number of models saved in bulk: 1.

Повторы идут «ходами» по таймеру. Неразрешимая строка обрабатывалась четыре раза: в 20:33:33, 20:34:03, 20:34:34 и 20:35:04 — с шагом около 30 секунд на моей установке. Условие остановки — impex.distributed.noProgressionLimit=3 хода без прогресса. Импорт, который в обычном режиме упал бы за доли секунды, в распределённом занял больше трёх минут.

После неудачи батчи остаются в базе. До запуска ImportBatch и ImportBatchContent было по нулю, после — 12 и 3. Удаляются они только при успехе (impex.distributed.importbatchcontent.removeonsuccess=true). Если у вас растёт таблица ImportBatchContent, это следы провалившихся распределённых импортов.

Объединённый запрос для разрешения ссылок у распределённого режима свой, со своим выключателем: impex.distributed.query.for.each.line=false.

Справочник свойств, описанных только комментариями

Большинство ключей, описанных только комментариями, лежит в ext/impex/project.properties. Вместе с ключами из advanced.properties и теми, что читаются только кодом:

Свойство По умолчанию Что делает
impex.legacy.mode false Jalo вместо ServiceLayer для всех операций
impex.legacy.scripting true BeanShell со склейкой ячеек; false — движки по #%groovy% и т. д.
impex.query.combined true один UNION ALL на строку
impex.query.chunk.size 400 / 0 400 для HANA, Oracle, SQL Server, MySQL 8; 0 для остальных (на практике — HSQLDB)
impex.import.workers #cores * 2 SpEL; .hsqldb=1 и т. п. — override по базе
impex.header.replacement.N E1p, E1t, E1d, C@ правила переписывания заголовков
impex.log.unresolved.lines true логировать отложенные строки
impex.retry.on.interceptorexceptions.only false повтор на следующем проходе только для InterceptorException
impex.clearing.model.context.after.processing.each.item.enabled true чистить model context после каждого объекта
disable.login.for.imported.user.interceptor.enabled true (в коде) loginDisabled для импортированных пользователей без пароля
documentidregistry.disallow.ignored.values true запрет <ignore>-значений в &-ссылках
csv.maxmultilines 10000 сколько строк можно склеить при непарной кавычке
impex.distributed.batch.size 100 размер батча
impex.distributed.noProgressionLimit 3 ходов без прогресса до остановки
impex.distributed.importbatchcontent.removeonsuccess true удалять батчи только при успехе
impex.distributed.query.for.each.line false свой выключатель объединённого запроса
impex.import.service.distributed.enabled false распределённый режим для всего ImportService

Про impex.clearing.model.context.after.processing.each.item.enabled комментарий в поставке сам признаёт, что включённое значение может давать «significant performance degradation». Если импорт заметно просел после апгрейда — это первый кандидат на проверку. Но выключение — осознанный размен: валидаторы перестанут гарантированно видеть свежие данные.

Чек-лист

  1. Без галки Enable code execution блоки if: импортируются целиком. Проверьте, как ваши файлы импортируются в каждом из путей: HAC, Backoffice, ImportService, system setup.
  2. Ошибки CSV видны только в консольном логе, не в HAC. При непарной кавычке номер строки в сообщении правильный, а текст взят из другой строки, и строки между ними пропадают.
  3. <null> не обнуляет ссылку. Обнулить её можно только пустой ячейкой.
  4. «Could not import N lines» не значит, что объектов нет. Строка создаётся в первом проходе, а отложенная часть может так и не дописаться.
  5. Aborting further passes (at pass 2): число отложенных строк перестало уменьшаться, ищите циклы и опечатки в ключах. Лимита проходов нет.
  6. forceWrite, unlessExisting=yes, ignoreKeyCase на колонке, (+) на списке, неизвестный $макрос не дают ошибки, но результат отличается от написанного.
  7. cacheUnique на маленьком файле против большой таблицы выгружает ключи всего типа.
  8. ImpEx-UPDATE пользователя без пароля выключает ему вход (disableLoginForImportedUserInterceptor). SSO и технические учётки — в первую очередь.
  9. disable.interceptor.* и disable.UniqueAttributesValidator.for.types найдите grep'ом по всем .impex проекта.
  10. Код в ImpEx пишется в кавычках, иначе ; исчезает. #%groovy% при настройках по умолчанию выполняет BeanShell.
  11. #%-строки посреди данных ставят барьеры в многопоточном импорте.
  12. http: для медиа работает только с двойным префиксом, https: не поддерживается.
  13. Доступ к импорту с кодом равен праву выполнять код на сервере; без кода остаётся чтение файлов сервера через file:, zip:, jar:.
  14. Растущая ImportBatchContent — следы провалившихся распределённых импортов.
  15. Скорость импорта на HSQLDB однопоточная, сравнивать её с продом бессмысленно.

Что изменилось с 2016 года

Из рецептов 2016 года не работают два: addDefinition из кода (метод стал protected) и трёхаргументный includeSQLData (такой сигнатуры нет). Одно утверждение было неверным с самого начала: обратный слэш работает и в строках данных. Всё остальное — удаление через batchmode, if:, beforeEach: с line, $config-, (+) и (-) — работает как раньше. Под каждым рецептом, правда, нашлась деталь, которую тогда стоило бы упомянуть.

Сильнее всего меня удивило, сколько в ImpEx отказов, о которых HAC не сообщает: «Import finished successfully», а в базе не то, что в файле. А ошибки, которые ImpEx всё-таки выдаёт, часто указывают не туда: на другую строку, на второй проход вместо первого, на null вместо идентификатора пользователя.

Как и в случае с FlexibleSearch, для компонента, спроектированного в начале 2000-х и совместимого с каждым когда-либо импортированным файлом, это объяснимо. Практическое правило отсюда одно: результат ImpEx нужно проверять по базе, например FlexibleSearch-запросом по импортированным ключам и сравнением количества объектов.