Диалекты и индексы
Из двадцати трёх операторов двадцать записываются в SQL одинаково на любой СУБД. Оставшиеся четыре — регистронезависимые строковые — не записываются одинаково нигде. Эта страница объясняет, почему выбор написания вынесен в отдельное решение, как оно принимается и что каждый вариант стоит по производительности.
Почему это вообще проблема
contains в MUI DataGrid означает «содержит, без учёта регистра». В SQL этого понятия нет —
есть три разных способа его выразить, и они не взаимозаменяемы:
| СУБД | Как пишется | Что будет с ILIKE |
|---|---|---|
| PostgreSQL | col ILIKE :v |
работает |
| MySQL / MariaDB | col LIKE :v при коллации *_ci |
ERROR 1064 — синтаксическая ошибка |
| SQLite | col LIKE :v (только ASCII) |
syntax error near "ILIKE" |
Обратите внимание на характер отказа: ILIKE на MySQL — не «менее эффективно», а
неработающий запрос целиком. Симметрично: если убрать регистронезависимость и всегда писать
LIKE, сломается PostgreSQL — но молча. Поиск «winter» перестанет находить «Winter
Internals», ошибки не будет, пользователь просто решит, что таких строк нет.
Отсюда вывод, определивший устройство: написание нельзя выбрать один раз за всех. Оно — свойство соединения, а грид про соединение ничего не знает.
Почему не «просто LIKE»
Соблазнительно писать LIKE всегда и объявить регистр заботой коллации. Не работает по
двум причинам.
Во-первых, в PostgreSQL нет регистронезависимых коллаций в привычном смысле — стандартная
ICU-коллация с deterministic = false существует, но задаётся на уровне колонки или
базы и в существующих схемах почти не встречается. Для подавляющего большинства
PostgreSQL-проектов LIKE останется регистрозависимым.
Во-вторых, тихая деградация хуже громкого отказа. Синтаксическая ошибка на MySQL обнаруживается на первом же запросе и в разработке, и в тестах. Молча пропавшие совпадения на PostgreSQL обнаруживаются через месяц по жалобе пользователя.
Как принимается решение
По умолчанию схема находится в режиме TextMatch::Auto, и MuiGrid::wrap() разрешает его
один раз за вызов, до построения условий:
MuiGrid::wrap($repo, $request, $schema)
│
├─ режим схемы = Auto?
│ └─ да → TextMatch::forRepository($repo)
│ │
│ ├─ $repo->getDbConfigClassName() имя класса конфигурации
│ ├─ PpaConnectionPool::getConfigDb() объект конфигурации (из кэша пула)
│ ├─ $config->getDriver() 'pgsql' | 'mysql' | 'sqlite' | …
│ └─ forDriver() ILike | Like | Lower
│
└─ построить WHERE с уже конкретным режимомТри свойства этой цепочки стоит отметить.
Она не открывает соединения. Драйвер известен из конфигурации, а не из живого
подключения. Конфигурация к моменту построения грида уже зарегистрирована пулом и лежит в
его статическом кэше, так что определение стоит одного обращения к массиву. Даже в худшем
случае — конфигурация ещё не тронута — пул создаст объект конфигурации и вызовет его
setUp(), но не подключится к базе.
Она не бросает исключений. Любая осечка — пула нет, класс не зарегистрирован, в тесте
подставлен двойник репозитория — приводит к откату на TextMatch::Lower, который валиден
везде. Страница таблицы не должна падать из-за того, что не удалось определить диалект.
Неизвестный драйвер — не ошибка. Oracle, MSSQL, свой DbConfig с экзотическим
драйвером получают Lower: lower(col) LIKE lower(:v) — стандартный SQL, он выполнится
где угодно.
Приоритет режимов
режим колонки (если не Auto)
↓ иначе
режим схемы (если не Auto)
↓ иначе
определение по драйверу репозитория
↓ не удалось
TextMatch::LowerЯвное всегда сильнее автоматического — определение никогда не «переубеждает» разработчика.
Что каждый режим стоит
Здесь важно разделить два случая: шаблон с ведущим % и шаблон-префикс.
contains и endsWith неиндексируемы в принципе. Шаблон '%winter%' начинается с
подстановочного знака, а btree-индекс упорядочен слева направо — начало строки неизвестно,
значит искать по дереву не с чего. Это верно для всех трёх СУБД и для всех трёх режимов.
Разницы между ILIKE, LIKE и lower() здесь нет: любой из них — полный проход.
startsWith — единственное место, где выбор влияет:
| Режим | startsWith |
Может использовать индекс |
|---|---|---|
Like |
col LIKE 'x%' |
да — btree по col (в PostgreSQL нужен text_pattern_ops или коллация C) |
ILike |
col ILIKE 'x%' |
нет — никогда |
Lower |
lower(col) LIKE 'x%' |
да — функциональный индекс по lower(col) |
-- под режим Lower
CREATE INDEX idx_articles_title_lower ON articles (lower(title) text_pattern_ops);
-- под режим Like в PostgreSQL (когда регистр не важен)
CREATE INDEX idx_articles_title_pattern ON articles (title text_pattern_ops);Парадоксально, но самый «медленный» на вид режим Lower — единственный, который вообще
позволяет проиндексировать регистронезависимый префиксный поиск в PostgreSQL. Если в вашей
таблице поиск по началу строки — горячий путь, ->textMatch(TextMatch::Lower) плюс
функциональный индекс дадут больше, чем любые другие правки.
Когда нужен полнотекстовый поиск
Если contains по большой таблице стал узким местом, правильный ответ — не другой режим, а
другой инструмент: pg_trgm с GIN-индексом в PostgreSQL, FULLTEXT в MySQL, FTS5 в
SQLite. Подключается это через filterUsing() для конкретной колонки — библиотека не
мешает подставить любое условие.
Свёртка регистра в двух местах
Режим Lower сворачивает обе стороны сравнения, но разными реализациями: колонку — функцией
SQL lower(), шаблон — функцией PHP mb_strtolower().
TextMatch::Lower->match('a.title', '%WiNTeR Ünïcode%');
// SQL: lower(a.title) LIKE :iqb0
// байнд: '%winter ünïcode%'В подавляющем большинстве случаев результаты совпадают. Расхождения возможны на краях: турецкая «i без точки», немецкая «ß», греческая «сигма в конце слова» — их правила свёртки зависят от локали, а PHP и СУБД берут их из разных источников. Если ваши данные это затрагивают, задайте режим явно и сворачивайте обе стороны одним способом — например, храните рядом заранее свёрнутую колонку и объявляйте её выражением колонки схемы.
Отдельно про SQLite: там LIKE регистронезависим только для ASCII, если не собран с
ICU. Поэтому для не-латинских данных на SQLite Lower — не оптимизация, а необходимость:
режим Like просто не найдёт Ünïcode по запросу ünïcode.
Запрос COUNT
Пагинация выполняет два запроса: страницу и общее число строк. Второй строится оборачиванием
вашего SELECT с отбрасыванием ORDER BY, LIMIT, OFFSET и FOR. Для диалектов отсюда
следует практическое: условие фильтра выполняется дважды.
Это не удваивает стоимость буквально — планировщик работает с одним и тем же предикатом, —
но означает, что дорогое выражение в колонке (коррелированный подзапрос, вызов функции по
каждой строке) оплачивается в обоих запросах. Если фильтровать по такому выражению не нужно,
объявляйте колонку только sortable(): сортировка из COUNT-запроса выбрасывается.
Что дальше
- Режимы совпадения — справочник по
TextMatch. - Белый список и безопасность — вторая «внутренняя» страница.
- Свои фильтры — как подставить своё условие, включая полнотекстовое.