У 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-кодом, у якому є крапка з комою, після перемикання зламаються по-новому.

Рядок коду зупиняє паралельну обробку

Імпорт за замовчуванням багатопотоковий: reader розкладає рядки даних у чергу, воркери їх зберігають. Якщо у файлі є рядок коду, він має побачити результат усіх рядків вище. Тому перед кожним окремим #%-рядком і перед кожним новим заголовком reader чекає, доки черга спорожніє:

// ілюстрація
@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-запитом за імпортованими ключами та порівнянням кількості об'єктів.