В 2016 году я опубликовал две короткие заметки о FlexibleSearch. Обе писались под hybris 6.x. С тех пор продукт дважды переименовали, у слоя персистентности появился совершенно новый путь записи, а у кэша запросов — три отдельных механизма TTL, которых тогда не было.
Отсюда очевидный вопрос: что из этого всё ещё правда?
Я заново проверил поведение актуальной SAP Commerce Cloud 2211. Короткий ответ: почти всё из 2016 года по-прежнему в силе — включая проблему с маркерами ORDER BY, которая стала даже чуть интереснее, — и вдобавок всплыл ряд новых вещей. Длинный ответ — эта статья, потому что стоит начать разбираться, что транслятор на самом деле делает со строкой вашего запроса, как обнаруживается куда больше, чем три модификатора.
FlexibleSearch — один из старейших компонентов платформы. Он старше ServiceLayer, старше слоя моделей, и это заметно. А ещё это единственный компонент, с которым каждый проект сталкивается в первый же день и который почти никто не читает внимательно. Дальше — та часть его поведения, которую вы не найдёте на SAP Help Portal.
Стоит оговориться: некоторые недокументированные возможности не задокументированы не просто так. Это наследие — не всегда удачные решения, которые SAP почему-то не убирает из кода, видимо, ради совместимости с какими-то древними внедрениями. Не документируют их во многом как раз потому, что они неадекватны или бесполезны. Но знать о них всё равно стоит: на них можно случайно наткнуться и получить баг или нежелательное поведение.
О коде в этой статье
Весь код ниже — иллюстративная реконструкция. Доступа к исходному коду платформы у меня нет. Эти фрагменты — мой собственный пересказ поведения, восстановленного по наблюдениям: выводу запросов, сгенерированному SQL, сообщениям в логах, текстам исключений и экспериментам на работающей установке. Они написаны, чтобы показать, что делает движок, а не воспроизвести то, как это реализовано. Имена, структура и детали будут отличаться от настоящей реализации, а в версиях новее той, что я тестировал, поведение могло измениться. Считайте каждый фрагмент схемой, а не декомпиляцией.Как SAP Commerce разбирает FS
Первое, что стоит усвоить: у FlexibleSearch нет SQL-парсера. Границы предложений он находит, ища слова select, from, where, group by и order by в строке запроса, приведённой к нижнему регистру, — причём каждый поиск начинается там, где закончился предыдущий.
// иллюстрация
int selectPos = findWholeWord(lower, "select");
int fromPos = findWholeWord(lower, "from", selectPos + "select".length());
int wherePos = findWholeWord(lower, "where", fromPos + "from".length());
int groupByPos = findWholeWord(lower, "group by", wherePos >= 0 ? wherePos + 5 : fromPos + 4);
int orderByPos = findWholeWord(lower, "order by", groupByPos >= 0 ? ... : ...);Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
«Целое слово» здесь означает лишь то, что символы по обе стороны — не буквы, не цифры и не подчёркивания. Ни строковые литералы, ни комментарии, ни вложенность не учитываются.
Пример. Этот запрос работает не так, как кажется:
SELECT {pk} FROM {Product} WHERE {code} = 'order by me'Поиск order by начинается после where, и первое совпадение оказывается внутри литерала. Предложение WHERE обрезается в этом месте — от него остаётся {code} = ', — а me' становится предложением ORDER BY. Затем транслятор дописывает свой предикат типа к обрезанному WHERE, и предикат оказывается внутри строкового литерала:
SELECT item_t0.PK FROM products item_t0
WHERE ( item_t0.p_code = ') AND (item_t0.TypePkString IN (?,?,...) ) order by me'На HSQLDB всё заканчивается ошибкой unexpected token : LIMIT required: ). С 'group by me' происходит ровно то же самое. Вы полагаетесь на то, что база данных отвергнет результат; останется ли искалеченный запрос синтаксически корректным, зависит от того, что идёт после ключевого слова в вашем литерале.
То же относится к group by. select, from и where надёжнее, потому что каждое ищется от конца предыдущего и первое совпадение почти всегда оказывается настоящим ключевым словом, — но литерал с from внутри списка выборки всё равно всё сломает.
Практическое правило: всё, что может содержать order by, group by или from, передаётся как bind-параметр и никогда не склеивается с текстом запроса. Это и так хорошая практика, но здесь мотив не в инъекциях, а в том, что запрос попросту не будет правильно разбит на части.
Одно следствие стоит отметить отдельно: HAVING в списке ключевых слов нет. Отдельным предложением он не считается и поглощается текстом GROUP BY. Оказывается, это безвредно: выражения полей внутри него транслируются вместе с GROUP BY, включая переписывание в столбцы uhu.*, когда запрос превращается в UNION (см. ниже). HAVING MAX({name}) IS NULL по {Principal} корректно превращается в HAVING MAX(uhu.groupByArg0) IS NULL.
{{ … }} сопоставляется подсчётом скобок
Подзапросы извлекаются раньше всего остального: движок проходит по строке и считает фигурные скобки. Двойные скобки открывают и закрывают подзапрос; одинарные считаются отдельно, чтобы выражение поля случайно его не завершило.
// иллюстрация
if (c == '{') {
if (next == '{') { if (++subqueryDepth == 1) start = pos; pos++; }
else { singleBraceDepth++; }
} else if (c == '}') {
if (next == '}' && singleBraceDepth == 0) { /* закрыть подзапрос, вставить плейсхолдер */ }
else if (--singleBraceDepth < 0) {
throw new FlexibleSearchException("missing '{' for '}' at " + pos);
}
}Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
И снова: литералы не учитываются.
Пример. Этот запрос падает, даже не дойдя до базы данных:
SELECT {pk} FROM {Product} WHERE {code} = '}'missing '{' for '}' at 43 in '''Зеркальный случай тоже падает, с противоположным сообщением:
SELECT {pk} FROM {Product} WHERE {code} = '{'missing '}' for '{' at 11 in '{code} = '{''Обратите внимание: здесь в сообщении цитируется предложение WHERE, а не весь запрос, и позиция отсчитывается внутри этого фрагмента — к этому моменту предложения уже разделены. Ни одно из сообщений не говорит, что скобка стоит внутри строкового литерала, поэтому их легко принять за опечатку в выражении поля.
У выражения поля есть настоящая грамматика — и почти вся она не задокументирована
Статья 2016 года остановилась на трёх буквах-модификаторах. На самом деле возможностей заметно больше. Полная форма выражения поля:
{ [type] ( '.' | ':' ) attribute [ '[' language ']' ] [ ':' options ] }Во-первых, . и : взаимозаменяемы в роли разделителя типа
{Product.code} и {Product:code} — одно и то же. Разница в том, что точка работает только как первый разделитель, а двоеточие — и как разделитель типа, и как разделитель перед опциями. Поэтому корректны все эти варианты:
{Product.code}
{Product:code}
{Product.code:o}
{Product:code:o}Во-вторых, числовой квалификатор типа — это позиционный индекс, а не имя типа
Если квалификатор типа состоит только из цифр, он интерпретируется как индекс в предложении FROM, начиная с 1, а не как код типа.
// иллюстрация
Object parseTypeQualifier(String s) {
if (s == null || s.isEmpty()) return null; // → тип по умолчанию (первый)
if (isAllDigits(s)) return parseInt(s) - 1; // → позиционный индекс
return s; // → код типа или алиас
}Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
Пример.
SELECT {1:code}, {2:version}
FROM {Product AS p JOIN CatalogVersion AS cv ON {p:catalogVersion} = {cv:pk}}{1:code} — это код продукта, {2:version} — версия каталога. Индекс учитывает каждый тип в предложении FROM, включая типы отношений: в
{Product AS p JOIN CategoryProductRelation AS r ON ... JOIN Category AS c ON ...}
категория — это {3:code}, а не {2:code}.
Сам движок считает это наследием — в платформе есть сообщение
deprecated syntax of type index (like {1:name}) is not allowed inside restrictions
- use alias names insteadНа практике вызвать его обычным ограничением мне не удалось: ограничение с запросом {1:isocode} = 'en' на Language применилось без возражений, и 1 разрешилась относительно ограничиваемого типа — даже когда Language был вторым типом в предложении FROM внешнего запроса. Объяснение, судя по всему, в том, что внутри ограничения тип поля жёстко привязан к ограничиваемому типу ещё до того, как квалификатор вообще рассматривается, поэтому до позиционного поиска — а вместе с ним и до проверки — дело просто не доходит. Чтобы туда попасть, понадобился бы вложенный запрос со своим FROM внутри ограничения. Так или иначе: используйте алиасы.
Больно бьёт другое: к типу, код которого состоит из одних цифр, в выражении поля никогда не обратиться по имени — он всегда будет прочитан как индекс.
В-третьих, пустой квалификатор типа означает «тип по умолчанию»
Чтобы указать опции, нужен разделитель типа. Но сам тип может быть пустым:
SELECT {pk} FROM {Product} WHERE {:name:o} IS NULLПустой квалификатор разрешается в тип по умолчанию, то есть в первый тип в предложении FROM.
И вот здесь ловушка. Это не одно и то же:
WHERE {code:o} IS NULLПроследим работу автомата вручную: на первом двоеточии парсер всё ещё в начальном состоянии, поэтому он решает, что code — это тип (или алиас), а o — атрибут. Вы получите
cannot find (visible) type for alias code within [Product:Product]или — если в запросе случайно есть алиас code — молча получите не тот столбец. Опции без явного — или хотя бы пустого — квалификатора типа не работают, и никакой диагностики на этот счёт нет.
В-четвёртых, [any] — поиск по локализованному атрибуту на всех языках
Указание языка принимает ISO-код, но есть и одно особое значение:
// иллюстрация
PK resolveLanguage(String clause) {
if (clause == null) return null; // язык сессии
if ("any".equalsIgnoreCase(clause)) return PK.NULL_PK; // маркер: не фильтровать по языку
PK byIso = languagePkForIsoCode(clause);
return byIso != null ? byIso : PK.parse(clause); // запасной вариант: PK языка
}Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
Пример. Найти продукты, в названии которых есть «bike» на любом языке, не зная, на каком именно:
SELECT DISTINCT {p:pk}
FROM {Product AS p}
WHERE {p:name[any]} LIKE '%bike%'Без [any] фильтрация идёт только по языку сессии. Я ни разу не видел упоминания [any] в документации, а ведь это самый чистый способ сделать поиск по всем языкам, не прописывая вручную join с таблицей *lp. Транслятор вообще выбрасывает основную таблицу и обращается к таблице lp напрямую:
SELECT DISTINCT lp_t0.ITEMPK FROM productslp lp_t0
WHERE ( lp_t0.p_name LIKE '%bike%') AND (lp_t0.ITEMTYPEPK IN (?,?,...) )У этого есть побочный эффект, которым пользуется сама платформа. Раз таблица lp теперь стала настоящей таблицей в запросе с предсказуемым алиасом, можно написать условие join по этому алиасу, хотя в вашем предложении FROM этой таблицы нигде нет. Запрос вроде этого — его можно увидеть в SQL-логе, пока backoffice строит свой кэш системы типов, — вытаскивает всю матрицу «тип × язык × перевод» одним запросом:
SELECT {t.code}, {l.isocode}, {t.name[any]}
FROM { ComposedType AS t JOIN Language AS l ON lp_t0.langpk = {l.pk} }
WHERE {l.isocode} IN (?langs)lp_t0 нигде не объявлена. Она существует только потому, что {t.name[any]} заставил транслятор её сгенерировать, и называется _t0 только потому, что оказалась первой таблицей такого рода в этом запросе. Это по-настоящему хитрый трюк и крайне хрупкий — он зависит от счётчика алиасов, а тот зависит от формы всего остального запроса. В проектном коде я бы так писать не стал. Зато это очень наглядная иллюстрация главной темы статьи: вы пишете не на языке запросов, а строку, которую переписывают в SQL, и всё, что видно на выходе, — законная добыча.
Вместо ISO-кода указание языка принимает и PK языка: {p:name[8796093055008]} работает. Это запасной вариант на случай, когда значение — не известный ISO-код, и именно поэтому опечатка даёт довольно странное сообщение:
pk has wrong format: 'xx':For input string: "xx"В-пятых, [language] должен идти перед :options, а не после
Грамматика выше — не рекомендация по стилю: порядок соблюдается принудительно. Указание языка принимается, только пока читается атрибут, так что, как только второе двоеточие открыло опции, [ — это синтаксическая ошибка:
{Product:name[en]:o} -- работает
{Product:name:o[en]} -- wrong search field definiton ... found '[' outside attribute specОбратите внимание на написание в этом сообщении. definiton без i — это именно то, что выдаёт платформа: полезно знать, когда ищете его grep'ом в логах, и заодно напоминание о том, насколько стар этот код.
В-шестых, есть опции c / l / o
Это те самые три опции из 2016 года, и ведут они себя так же:
// иллюстрация
String flags = options.toUpperCase();
this.core = flags.indexOf('C') > -1;
this.localized = flags.indexOf('L') > -1 || customLanguagePk != null;
this.optional = flags.indexOf('O') > -1;Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
| Опция | Значение |
|---|---|
c |
core-поле — искать атрибут в основной таблице типа |
l |
localized — принудительно считать атрибут локализованным (подразумевается, если указан [lang]) |
o |
optional — присоединять таблицу атрибута через внешний join вместо внутреннего |
У этой реализации есть два свойства, которые стоит назвать явно, потому что в них легко ошибиться:
- Регистр не важен.
:o,:O,:oL— всё работает. - Любой другой символ молча игнорируется.
:xyz— не ошибка, просто ничего не включается. Опечатка в опции не даёт вообще никакой диагностики.
Самая полезная из трёх по-прежнему o. Локализованные атрибуты живут в отдельной таблице *lp, которая по умолчанию присоединяется внутренним join'ом, — а значит, строки, у которых нет записи в lp для запрошенного языка, полностью исчезают из результата.
Пример. Продукты без названия на текущем языке:
SELECT {p:code}
FROM {Product AS p}
WHERE {p:name:o} IS NULLБез :o вы получите только продукты, у которых есть строка lp для этого языка, с NULL в ней. Продукты вообще без строки lp — обычно их большинство — отсеиваются ещё на внутреннем join'е. На моей тестовой системе разница составила 331 строку против 102 366:
-- без :o — таблицы products в запросе даже нет
SELECT COUNT(*) FROM productslp lp_t0
WHERE lp_t0.LANGPK = ? AND lp_t0.p_name IS NULL AND lp_t0.ITEMTYPEPK IN (...)
-- с :o
SELECT COUNT(*) FROM products item_t0
LEFT JOIN productslp lp_t0 ON item_t0.PK = lp_t0.ITEMPK AND lp_t0.LANGPK = ?
WHERE lp_t0.p_name IS NULL AND item_t0.TypePkString IN (...)Это самая частая путаница вида «FlexibleSearch возвращает меньше строк, чем есть в базе», и исправляется она одним символом.
В-седьмых, {pk} и {itemtype} разрешаются поздно
Оба обрабатываются особым образом и разрешаются во втором проходе, уже после распределения по таблицам. В какой столбец они превратятся, зависит от того, в какую таблицу попало выражение поля:
// иллюстрация
if ("pk".equals(name))
return alias + (table.isMainTable() ? ".PK" : ".ITEMPK");
if ("itemtype".equals(name))
return alias + (table.isMainTable() ? ".TypePkString" : ".ITEMTYPEPK");Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
Вот почему в сложном запросе с локализованными атрибутами или динамическими свойствами {pk} иногда разрешается относительно не той таблицы, которую вы ожидали.
У вопроса «какая таблица» больше ответов, чем привычные два. Тип может внести в запрос четыре таблицы, и транслятор подставляет их по плейсхолдерам, собирая SQL: основную таблицу (products), локализованную (productslp), таблицу свойств для атрибутов, хранящихся как динамические свойства, и локализованную таблицу свойств для тех, что одновременно и то и другое. В большинстве обсуждений FlexibleSearch упоминаются только первые две, и это нормально — пока запрос не затронет динамический атрибут и {pk} не попадёт в таблицу, где столбец PK называется ITEMPK.
Модификаторы типа: !, * и тот, о котором никто не говорит, — ^
Статья 2016 года описывала ! и *. Есть и третий, а семантику ! стоит зафиксировать точно, потому что её очень часто запоминают наоборот.
| Выражение | Поведение |
|---|---|
{Product} |
тип и все его подтипы — по умолчанию |
{Product!} |
только сам тип, без подтипов |
{Product*} |
никакой проверки типа — и никаких поисковых ограничений |
{Product^} |
тип плюс только те подтипы, что хранятся в той же таблице |
Для ! решающее доказательство — ошибка, которую вы получите, применив его к абстрактному типу:
cannot search on abstract type 'AbstractOrder' with no-subtypes option ('!')Это сообщение имеет смысл только в одном направлении: ! исключает подтипы, поэтому у абстрактного типа с ! искать просто нечего. Если бы ! добавлял подтипы, поиск по абстрактному типу с ним был бы как раз тем единственным случаем, который работает.
Модификатор — не суффикс, и у трёх модификаторов есть иерархия
Естественно читать {Product!} как «код типа, а за ним модификатор». Но находится он не так. Каждый из трёх символов ищется в выражении типа, а кодом типа считается всё, что стоит перед первым найденным. Отсюда два следствия, оба воспроизводимые.
У них фиксированный приоритет, в каком бы порядке вы их ни написали. Сначала ищется !, потом *, потом ^. Поэтому и {Product*!}, и {Product!*} означают !, а {Product^*} означает * — до ^ дело не доходит. Два модификатора подряд — очевидная ошибка, но молчаливая.
Модификатор распознаётся и посреди кода. {Pro*duct} ищет не Product с модификатором, а тип Pro, которого почти наверняка нет. В ошибке будет обрезанный код — это хотя бы понятная подсказка.
И ловушка, которую стоит запомнить:
{Product AS p!} -- НЕ «без подтипов»Поиск модификатора охватывает только часть выражения до AS. Если написать ! после алиаса, он просто станет частью алиаса — тип сопоставляется с подтипами как обычно, а ваш алиас теперь p!, так что ни одно {p:...} в запросе не разрешится. Запрос ломается, но ломается в другом месте — с сообщением о неизвестном алиасе, а не о модификаторе, который вы, как вам казалось, написали.
* отключает поисковые ограничения
Эта часть заслуживает отдельного пункта в security review, а не места в разделе курьёзов.
// иллюстрация
if (!query.restrictionsDisabled() && !this.typeChecksDisabled()) {
restrictions = loadQueryFilters(query, this.getCode(), includeSubtypes);
} else {
restrictions = Collections.emptyList(); // ← сюда попадает '*'
}Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
Модификатор * выставляет тот же внутренний флаг, что отключает проверку типа, а этот флаг заодно пропускает загрузку ограничений. Поэтому {Product*} делает не одну, а две вещи:
- убирает предикат
TypePkStringи возвращает все строки таблицы — включая строки, принадлежащие другим типам с тем же деплойментом; - отбрасывает все поисковые ограничения, определённые для этого типа.
Пример. На B2B-витрине с учётом каталогов эти два запроса возвращают ограниченному пользователю разные наборы продуктов:
SELECT {pk} FROM {Product} -- ограничения применяются
SELECT {pk} FROM {Product*} -- ограничения обходятсяЕсли поисковые ограничения — ваш механизм контроля доступа (а в сценариях с каталогами и B2B так обычно и есть), то * функционально равносилен вызову setDisableSearchRestrictions(true), с той разницей, что это один символ, спрятанный в строковом литерале и невидимый для любого, кто пробегает код глазами.
Стоит добавить в чек-лист ревью:
grep -rnE '\{[A-Za-z]+\*' --include=*.java --include=*.xml --include=*.impex .Этот grep найдёт то, что написала ваша команда. Более интересный случай он не найдёт: платформа делает это сама с собой. Слой Integration API / OData находит элементы, генерируя запросы вида
SELECT DISTINCT {mytype:pk} FROM {MyType* AS mytype} WHERE {mytype:a} = ?a— и * здесь не опция, которую кто-то выбрал для особого случая, а режим иерархии элементов по умолчанию для этой подсистемы. Сопоставление типа элемента настраивается (есть ещё режим «только этот тип», который соответствует !, и режим «тип и подтипы», которому не соответствует никакой модификатор), но если его никто не задал, поиск в интеграциях выполняется с отключёнными проверкой типа и поисковыми ограничениями.
Проблема ли это, целиком зависит от того, как защищены ваши интеграционные эндпоинты. Если вы считали, что поисковые ограничения применяются единообразно, потому что их обеспечивает слой персистентности, — вот контрпример: их обеспечивает транслятор, и вызывающая сторона, которая генерирует текст запроса, сама решает, задействовать ли их.
Есть ещё два глобальных рубильника для ограничений, столь же малозаметных:
flexible.search.disable.individual.search.restrictions=false
flexible.search.disable.individual.search.restrictions.for.members.of=Оба читаются один раз и мемоизируются, так что без перезапуска их изменение может не вступить в силу.
^ как способ избежать UNION
^ оставляет подтипы, живущие в той же таблице, и отбрасывает те, у которых свой деплоймент. Это ровно тот рычаг, который нужен, когда у типа есть ненужные вам подтипы, а UNION, который они вызывают, губит план выполнения, — и это подводит нас к следующему разделу.
Хороший пример — {Media} на системе с установленным rule engine: он объединяет medias с droolskiemodulemedia, тогда как {Media^} читает только medias. Но для типа без собственной таблицы ^ ничего не даёт. {AbstractOrder^} всё так же объединяет carts, orders и quotes, а {Principal^} — usergroups и users: «той же таблицы», в которой можно было бы остаться, просто нет.
Откуда берётся uhu
Если подтипы типа хранятся в разных таблицах, одного SELECT недостаточно. Движок генерирует по SELECT на каждую таблицу и объединяет их. Если в запросе есть ещё и GROUP BY, ORDER BY или агрегат, всё объединение оборачивается во внешний select — с жёстко зашитым алиасом:
SELECT ... FROM (
SELECT ... FROM products WHERE ...
UNION ALL
SELECT ... FROM mediaitems WHERE ...
) uhu
ORDER BY ...Если вы когда-нибудь находили uhu в логе медленных запросов и гадали, какой разработчик его там оставил, — это платформа. (По-немецки «филин». Платформу создавали в Мюнхене.)
Кстати, по умолчанию используется UNION ALL, и поменять это можно только из кода
// иллюстрация
boolean unionAll() {
Object v = currentSession().getAttribute("unionAllTypeHierarchy");
return (v instanceof Boolean) ? (Boolean) v : true; // по умолчанию: UNION ALL
}Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
Обратите внимание: это атрибут сессии, а не конфигурационное свойство. По умолчанию — UNION ALL, и это правильный выбор: PK из разных таблиц не могут совпасть, так что дедупликация была бы чистыми издержками.
Но он становится неправильным, как только вы выбираете что-то кроме PK:
SELECT {code} FROM {Product}на иерархии с несколькими деплойментами может вернуть дублирующиеся коды. Если нужна дедупликация:
sessionService.setAttribute("unionAllTypeHierarchy", Boolean.FALSE);Флаг входит в ключ кэша трансляции, так что его переключение не отравляет кэш для других сессий.
Маркеры [-- / --] всё ещё на месте, и теперь они могут съесть ваши данные
Этим разделом заканчивалась статья 2016 года, и именно эту находку я больше всего ожидал увидеть исправленной. Её не исправили.
Движок оборачивает ORDER BY верхнего уровня в маркеры, чтобы потом получить count-запрос, вырезав сортировку:
SELECT ... FROM ... WHERE ... [--order by p_name ASC--]Эти маркеры обрабатываются двумя разными операциями:
// иллюстрация
private static final Pattern MARKERS = Pattern.compile("(\\[--)|(--\\])");
private static final Pattern CLAUSES = Pattern.compile("\\[--(.*?)--\\]", Pattern.DOTALL);
String getSql() { return MARKERS.matcher(sql).replaceAll(""); } // убрать только маркеры
String getCountSql() { return CLAUSES.matcher(sql).replaceAll(""); } // убрать ORDER BY целикомИллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
Обе — replaceAll. Не «заменить пару, которую вставил движок», а каждое вхождение в любом месте запроса. Как и в 2016 году, все эти запросы ведут себя одинаково:
Select {name} from {Language} where {isocode} = '--]ru[--'
Select {name} from {Language} where {isocode} = 'ru[--'
Select {name} from {Language} where {isocode} = '[--ru--]'
Select {name} --]from {Language}[-- where {isocode} = '--]ru[----][--'Все они ищут язык с isocode ru.
К исходной находке стоит добавить три вещи:
У count-запроса радиус поражения больше, чем у основного. getCountSql() удаляет всё между [-- и --], нежадно и с включённым DOTALL, — так что переводы строк его не останавливают. Запрос, в котором оба маркера идут по порядку, теряет весь фрагмент между ними, включая легитимный ORDER BY, если тот оказался внутри. Если напрямую подать на вход процедуре удаления маркеров строку
SELECT x FROM t WHERE a = '--]ru[--' AND b = ? [--order by x--]основной запрос получится вполне нормальным (... a = 'ru' AND b = ? order by x), а count-запрос — вот таким:
SELECT x FROM t WHERE a = '--]ruВсё от [-- внутри литерала до настоящего закрывающего маркера пропало, включая bind-параметр.
Удаление выполняется после подстановки значений. Маркеры вырезаются из итогового текста запроса, а значит, если значения были подставлены прямо в SQL, а не переданы через bind (см. раздел о лимите bind-параметров ниже — на SQL Server это происходит по умолчанию при превышении порога), то под удар попадают пользовательские данные. Значение "--]" молча вырезается из строки ещё до того, как строка дойдёт до базы. "-- ]" — в порядке; "--]" — нет.
Параметры count-запроса отрезаются с хвоста. Bind-значения для ORDER BY убираются отсечением конца списка значений:
// иллюстрация
int cut = values.size() - orderByClause.getParameterCount();
countValues = values.subList(0, cut);Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
Для сгенерированного SQL это допущение верно. Оно перестаёт быть верным, как только вы сами постобрабатываете запрос и меняете порядок параметров.
Пример практического риска. Поиск продуктов, который позволяет фильтровать по SKU и передаёт введённое значение как есть, для пользователя, набравшего --], тихо выполнит не тот запрос, который вы написали. В логе приложения этого не будет видно, потому что к моменту логирования SQL уже вычищен.
Пагинация реализована семь раз, и одна из реализаций переписывает вашу сортировку
Для каждой базы данных есть своя стратегия пагинации, выбираемая по имени БД: HSQLDB, Oracle, MySQL, HANA, SQL Server, PostgreSQL — и запасной вариант.
Запасной вариант — это не пагинация. Он возвращает исходный запрос без изменений и сообщает, что поддержки лимитов на уровне БД у движка нет, — то есть выбирается весь результат, а страница вырезается уже в Java. Сюда же вы попадаете, установив:
flexible.search.disable.specific.db.limit.support=trueЭто значение читается и как одноимённый атрибут сессии.
Oracle получает классическую форму с двойной вложенностью и rownum:
SELECT * FROM (
SELECT row_.*, rownum rownum_ FROM (
...original query...
) row_ WHERE rownum <= ?
) WHERE rownum_ > ?Самое интересное — SQL Server. OFFSET … FETCH допустим только после ORDER BY. Если в вашем запросе его нет, движок добавляет его сам:
// иллюстрация
if (!orderByInfo.exists()) {
return sql + " ORDER BY 1 OFFSET ? ROWS FETCH NEXT ? ROWS ONLY";
}Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
ORDER BY 1 — сортировка по первому столбцу списка выборки.
Пример. Этот запрос с пагинацией:
SELECT {code}, {name} FROM {Product}на SQL Server возвращает вторую страницу, отсортированную по code, а на Oracle — вторую страницу в том порядке, в каком движок выдал строки. Тот же код, те же данные, разные строки. Если первый столбец выборки — {pk}, вы получите сортировку по PK, что для PK на счётчиках примерно соответствует порядку создания, — так что баг «работает» и остаётся скрытым до того дня, когда кто-нибудь мигрирует базу данных.
Смежный пограничный случай: SQL Server с count == 0 откатывается к построителю без пагинации, потому что FETCH NEXT 0 ROWS недопустим.
А у HANA есть конфигурационный флаг, название которого говорит о его истории всё:
hanadb.hack.limit.parameters.as.literal=trueДва разных способа обойти лимит bind-параметров
Базы данных ограничивают число bind-параметров в одном запросе. Платформа обходит это двумя не связанными между собой способами, и полезно знать, какой из них срабатывает.
Oracle: коллекция превращается в CTE. Когда параметр-коллекция превышает db.supported.params.limit (по умолчанию 1000), фрагмент IN (?param) переписывается в таблицу WITH, собранную из UNION запросов SELECT … FROM dual:
with with_tbl_productPks1 (id) as (
select '8796093022208' from dual UNION
select '8796093022272' from dual UNION
...
)
SELECT ... WHERE pk IN ( SELECT (id) FROM with_tbl_productPks1 )Обратите внимание: значения здесь — строковые литералы, а не bind-переменные, так что список из 5000 элементов даёт несколько сотен килобайт уникального SQL-текста и уникальную запись в shared pool. Такой запрос вдобавок полностью обходит кэш трансляции и заново разбирается при каждом вызове.
Во всех остальных случаях параметры подставляются в текст. Выше настроенного порога bind-значения подставляются прямо в текст запроса:
db.supported.inline.in.params.sqlserver=2000Эта строка есть в конфигурации по умолчанию. На SQL Server запрос, содержащий больше 2000 bind-значений, переписывается с литералами — причём на SQL Server подставляются и строки, с удвоением кавычек:
// иллюстрация
String inline(Object v) {
if (v instanceof String) return "'" + ((String) v).replace("'", "''") + "'";
if (v instanceof PK) return ((PK) v).getLongValueAsString();
if (v instanceof Number) return v.toString();
throw new IllegalArgumentException("cannot inline " + v);
}Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
Экранирование корректное. Практическое следствие касается не безопасности, а кэша планов: каждый такой запрос текстуально уникален. Массовые операции над большими списками PK — синхронизация каталога, пересчёт цен, перестроение индексов — забьют кэш планов SQL Server одноразовыми планами.
Симптом, который стоит искать grep'ом в логе приложения:
got query ... with > 2000 parameters on sqlserver, trying to inline themи, если подстановка не помогла:
got query ... with > 2000 parameters on sqlserver - this will most likely crash!Здесь же снова всплывают маркеры [-- / --]: как только значения подставлены, они становятся частью текста запроса, и вырезание [-- / --] применяется и к ним.
Именованные параметры глубже, чем ?session.user
В статье 2016 года отмечалось, что ?session.user, ?session.language и ?session.currency работают и что именованные значения могут содержать объекты, до атрибутов которых затем можно дотянуться. И то и другое по-прежнему верно, и то и другое оказалось шире, чем казалось.
Во-первых, белого списка значений сессии нет
?session.X — это просто поиск атрибута X в контексте сессии. Работает всё, что там лежит, — не только три задокументированных, но и, например:
?session.catalogversions -- разворачивается в список bind-значений
?session.user.nameи любой атрибут, который положил в сессию ваш собственный код.
Верно и обратное, и это стоит знать, прежде чем угадывать имя: значение, которого в сессии нет, падает на этапе трансляции, даже если по названию кажется, что оно там должно быть. ?session.locale даёт could not translate value expression 'session.locale'. А имя может разрешиться и всё равно оказаться непригодным: ?session.timezone находит объект TimeZone, а потом падает в JDBC с incompatible data type in conversion. Поиск универсальный, и никто не курирует этот список за вас.
Во-вторых, пути по атрибутам работают для любого именованного параметра
Обход атрибутов — не особенность session; session лишь переключает источник поиска. Для любого другого имени источник — собственная карта параметров запроса:
// иллюстрация
Object resolve(String key, Map<String,Object> values, SessionContext ctx) {
String[] parts = key.split("\\.");
boolean fromSession = "session".equalsIgnoreCase(parts[0]);
StringBuilder name = new StringBuilder();
for (int i = fromSession ? 1 : 0; i < parts.length; i++) {
if (name.length() > 0) name.append('.');
name.append(parts[i]);
Object o = fromSession ? ctx.getAttribute(name.toString())
: values.get(name.toString());
if (o != null) {
return (i + 1 < parts.length) ? walkAttributes(o, parts, i, ctx) : o;
}
}
throw new FlexibleSearchException("could not translate value expression '" + key + "'");
}Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
Пример — тот самый из 2016 года, по-прежнему рабочий:
String queryStr = "SELECT {code}, ?language.isocode FROM {Product} ORDER BY {name}";
FlexibleSearchQuery query = new FlexibleSearchQuery(queryStr);
query.addQueryParameter("language", currentLanguageModel);
query.setResultClassList(Arrays.asList(String.class, String.class));
SearchResult<List> result = fss.search(query);Пример — произвольная глубина, сессия вперемешку с атрибутами:
SELECT {o:code}
FROM {Order AS o}
WHERE {o:deliveryAddress} = ?session.user.defaultShipmentAddress
AND {o:currency} = ?session.currencyКаждый сегмент — это чтение атрибута с проверкой прав Jalo и полной ленивой загрузкой, так что длинный путь может стоить нескольких обращений к базе данных на каждый запрос. Вот настоящая причина использовать это умеренно — не корректность, а скрытый ввод-вывод.
Обратите внимание и на то, что имя собирается постепенно: a, потом a.b, потом a.b.c. Поэтому параметр, в самом имени которого есть точки, всё равно разрешится.
Есть и позиционные параметры: если символ после ? — цифра, он читается как число, так что ?1, ?2 работают. Имена параметров могут содержать буквы, цифры, _, - и ..
В-третьих, любопытное поведение с null-атрибутами
Если последний атрибут в пути равен null или пустой коллекции, результатом будет не null, а типизированная обёртка, хранящая Java-класс атрибута. Строгие базы данных отвергают нетипизированный setObject(null), и так движок этого избегает. Класс публичный, так что вы можете передать такую обёртку и сами — это единственный корректный способ привязать типизированный null в запросе FlexibleSearch.
Проблема с кэшированием десять лет спустя
Заметка 2016 года гласила: кэш запросов платформы не смотрит на ваш SQL, поэтому запрос с NOW() будет отдаваться из кэша и может вернуть устаревшие строки.
Это по-прежнему абсолютно верно. Ключ кэша строится из транслированного SQL-шаблона, bind-значений, языка, диапазона строк, классов результата и счётчиков поколений по типам. Ничто в нём не проверяет текст запроса на недетерминированные функции.
SELECT {pk} FROM {Promotion}
WHERE {startDate} <= NOW() AND {endDate} >= NOW()Это кэшируется, как и всё остальное, и остаётся в кэше, пока кто-нибудь не изменит Promotion.
Я проверил это напрямую: SELECT CURRENT_TIMESTAMP FROM {Language} WHERE {isocode} = 'en' возвращает одну и ту же метку времени при каждом вызове, пока что-нибудь не инвалидирует тип Language.
Изменилось другое: теперь есть три механизма TTL. Их хочется прочитать как «максимальный возраст закэшированного результата» — я так и сделал в первом черновике этой статьи, — и для двух из трёх это неверно.
TTL на уровне сессии и категории запросов: окно устаревания, а не срок жизни
Атрибут сессии, в секундах:
sessionService.setAttribute("TTLForUnitInCache", Integer.valueOf(60));
try {
return flexibleSearchService.search(query);
} finally {
sessionService.removeAttribute("TTLForUnitInCache");
}Или пометьте запрос категорией в коде, а политику настройте снаружи:
QueryOptions options = QueryOptions.newBuilder()
.withQuery("SELECT {pk} FROM {Promotion} WHERE {startDate} <= NOW() AND {endDate} >= NOW()")
.withHints(List.of(FlexibleSearchHints.categorizedQuery("promotions")))
.build();flexiblesearch.categorizedQuery.promotions.ttl=60
flexiblesearch.categorizedQuery.promotions.useReadOnlyDataSource=trueЕсли к запросу прикреплено несколько хинтов категорий, побеждает наименьший TTL.
Ни то ни другое не делает запрос с NOW() свежим. И с TTLForUnitInCache = 2, и (в отдельном опыте) с TTL категории в 2 секунды приведённый выше запрос с CURRENT_TIMESTAMP продолжал возвращать одно и то же значение через 3, 5, 7 и 9 секунд. TTL меняет ключ кэша (первый вызов с ним — промах), но не ограничивает срок жизни записи.
Почему так, объясняет собственный тестовый класс платформы, FlexibleSearchTTLTest. Семантика понятна уже по одним именам его методов:
shouldBeActualWhenCacheKeyHasTTLThatIsReachedAndGenerationsHasNotChanged
shouldBeActualWhenCacheKeyHasTTLNotReachedAndAnyGenerationHasChanged
shouldBeNotActualWhenCacheKeyHasTTLReachedAndGenerationsHasChangedЗапись с TTL инвалидируется, только когда TTL истёк и данные изменились. TTL — это то, как долго вы готовы отдавать устаревший результат после изменения: он ослабляет инвалидацию, а не добавляет срок жизни. Это полезно для горячих запросов по часто изменяемым типам и бесполезно для запросов, результат которых зависит от часов.
Механизм категорий всё равно стоит иметь, но по другой причине: он позволяет одним свойством перевести целый класс запросов на реплику для чтения.
Домены кэша на каждый источник данных
flexiblesearch.datasource.myReplica.cacheDomain=replicaDomain
flexiblesearch.cacheDomain.replicaDomain.ttl=30Домен становится частью ключа кэша, так что результаты с реплики никогда не смешиваются с результатами с мастера. Но обратите внимание, что происходит, когда у домена настроен TTL: движок переключается на ключ кэша, у которого проверка поколений жёстко зашита в «неактуально», а валидность определяется только временем.
// иллюстрация
class TypeGenerationIgnoringKey extends FlexibleSearchCacheKey {
@Override boolean isGenerationCurrent(FlexibleSearchCacheKey other) { return false; }
@Override public boolean isActual(Map<String,Long> generations) {
return hasTtl() ? validUntil > now() : super.isActual(generations);
}
}Иллюстративная реконструкция, а не исходный код платформы. См. примечание выше.
Инвалидация по изменению данных для такого домена полностью игнорируется. Для отстающей реплики это, пожалуй, честное поведение — инвалидация всё равно обгоняла бы репликацию, — но это должно быть осознанным выбором, а не сюрпризом.
Это единственный из трёх механизмов, который даёт запросу с NOW() настоящий максимальный возраст (в тестовом классе для этого есть shouldBeNotActualWhenIgnoreGenerationCacheKeyHasTTLReached), и это именно тот механизм, который не включишь походя, потому что он существует только для настроенного источника данных, — по этой же причине я не смог проверить его от начала до конца. На мастер-источнике по-прежнему действует совет 2016 года: держите часы вне текста запроса — передавайте параметром метку времени, округлённую до той точности, которую вы можете себе позволить.
Что на самом деле отключает кэш
Всего три вещи:
| Переключатель | Эффект |
|---|---|
disableExecution (атрибут сессии) |
транслировать, но не выполнять |
disableCache (атрибут сессии) |
обойти кэш результатов |
ctx.enable.fs.cache.on.read-replica=false (атрибут сессии) |
не кэшировать результаты, прочитанные с реплики |
Две поправки к заметке 2016 года
Подзапросы кэшируются. В 2016 году я писал, что подзапросы не кэшируются ни одним из слоёв. На актуальной установке это не так: нигде на пути кэширования нет структурной проверки, а типы, упомянутые внутри подзапроса {{ … }}, собираются в набор зависимостей вместе с внешними, так что результат и кэшируется, и корректно инвалидируется. (Что действительно пропускается — и только при переписывании больших IN под Oracle, — так это кэш трансляции, а не кэш результатов. Другой кэш, другая причина.)
Часть статьи, посвящённая MySQL, устарела. MySQL Query Cache объявили устаревшим в 5.7.20 и удалили в 8.0. У Oracle, SQL Server и HANA такого кэша результатов никогда не было — они кэшируют планы, а не строки. Так что единственный кэш результатов, о котором сегодня стоит рассуждать, — собственный кэш платформы, и три описанных выше механизма — и есть те рычаги, через которые им управляют.
Другой кэш — кэш трансляции — привязан к пользователю
Всё сказанное выше касается кэша результатов. Есть и второй, о котором легко забыть, потому что он почти никогда не ведёт себя странно: кэшируется и сам транслированный SQL, а размер этого кэша задаёт cache.flexiblesearchquery (по умолчанию 1000 записей).
Знать стоит, что должно входить в его ключ. Поисковые ограничения компилируются прямо в сгенерированный SQL, а ограничения зависят от того, кто спрашивает, — так что одна и та же строка запроса не может делить трансляцию между двумя пользователями с разными ограничениями. Поэтому ключ включает principal, а вместе с ним текст запроса, динамические ограничения, флаги failOnUnknownFields / языка / ограничений и настройку unionAllTypeHierarchy из раздела про uhu выше. (Именно из-за последней переключение unionAllTypeHierarchy не отравляет кэш для других сессий.)
Практическое следствие — вопрос ёмкости, который никто не закладывает в бюджет: на витрине, где у многих пользователей индивидуальные ограничения, эффективное число записей кэша равно запросы × уникальные principal'ы, а не запросы. Перевалите за 1000 — и вы заново транслируете запрос при каждом обращении (разбор, поиск типов, компиляция ограничений, всё вместе), тогда как кэш результатов выше по-прежнему выглядит совершенно здоровым. Если будете поднимать лимит, учтите, что платформа ограничивает его 50 000 и пишет в лог предупреждение с советом обратиться в поддержку.
Хинты запросов: MAXDOP
На SQL Server движок может дописывать OPTION (MAXDOP n), чтобы ограничить параллелизм. Источники проверяются в таком порядке:
- явный хинт, переданный вместе с запросом (если в нём уже есть
MAXDOP, ничего не добавляется); - атрибут сессии
flexiblesearch.hints.sqlserver.maxdop; - одноимённое свойство, по умолчанию
-1, что означает «не добавлять хинт».
Две детали, которых нет в документации:
- Значение
-1в сессии означает «полностью подавить хинт», так что сессия может отменить глобальную настройку, а не просто переопределить число. - Хинт не применяется, когда запрос выполняется на реплике только для чтения. Если вы ограничили MAXDOP, чтобы защитить пропускную способность OLTP, имейте в виду, что на реплике это ограничение молча снимается.
sessionService.setAttribute("flexiblesearch.hints.sqlserver.maxdop", Integer.valueOf(4));Свойство отслеживается change listener'ом, так что изменение вступает в силу без перезапуска.
Справочник — атрибуты сессии
Большая часть поведения FlexibleSearch управляется атрибутами сессии, а не свойствами. Вот они, собранные в одном месте:
| Атрибут сессии | Эффект |
|---|---|
disableRestrictions |
отключить поисковые ограничения |
disableRestrictionGroupInheritance |
не наследовать ограничения групп |
disableSessionAttributes |
не разрешать ?session.* |
disableExecution |
транслировать, но не выполнять |
disableCache |
обойти кэш результатов |
ctx.enable.fs.cache.on.read-replica |
false — не кэшировать чтения с реплики |
TTLForUnitInCache |
сколько секунд можно отдавать устаревший результат после изменения данных |
ctxSearchRestrictions |
динамические ограничения для этого запроса |
unionAllTypeHierarchy |
false — использовать UNION вместо UNION ALL |
loadLazyCollections |
ленивые коллекции в результате |
prefetchSize |
размер предвыборки |
flexible.search.disable.specific.db.limit.support |
пагинация в Java, а не в SQL |
flexiblesearch.hints.sqlserver.maxdop |
MAXDOP; -1 подавляет хинт |
Заключение
Десять лет спустя картина на удивление стабильна. Модификаторы полей и типов из 2016 года не тронуты. Поведение маркеров ORDER BY не тронуто — и тихо обзавелось новым способом навредить, теперь, когда значения могут подставляться в текст запроса. Наблюдение о кэшировании тоже не утратило силы. У платформы появились настройки TTL, но две из трёх ограничивают устаревание после изменения, а не возраст результата, так что вне домена кэша с настроенным TTL обходной путь 2016 года — держать время вне текста запроса или округлять его — по-прежнему единственный рабочий.
Больше всего меня в этот раз поразило, насколько FlexibleSearch построен на манипуляциях со строками, а не на разборе. Границы предложений находятся через indexOf. Подзапросы сопоставляются подсчётом скобок. Count-запросы получаются регулярным выражением поверх сгенерированного SQL. Порядок сортировки берётся из воздуха, когда его требует целевая база данных. Ничто из этого не выглядит неразумным для компонента, спроектированного в начале 2000-х и с тех пор сохраняющего обратную совместимость, — но это объясняет целую категорию багов, которые снаружи кажутся необъяснимыми, и именно поэтому совет «всегда передавайте значения через bind» здесь важнее, чем в большинстве ORM.
Discussion
Powered by GitHub Discussions — sign in with a GitHub account to join the conversation.