Сломанный поиск и фильтрация в ИИ CRUD‑приложениях: как исправить это
Сломанный поиск и фильтры делают AI CRUD‑приложения неудобными. Научитесь исправлять сборщики запросов, ограничивать фильтры, предотвращать инъекции и ускорять запросы с помощью индексов.

Как выглядит «сломанный поиск» в CRUD-приложении
Сломанный поиск создаёт ощущение, что приложение лжёт. Вы вводите имя, которое точно есть в базе, а оно не появляется. Или фильтруете по статусу, а получаете записи, которые явно не соответствуют условию.
Наиболее частые симптомы просты:
- Отсутствующие результаты
- Дубли строк
- Порядок, который кажется случайным
Дубликаты часто появляются из-за JOIN. Одна запись превращается во множество строк, потому что запрос объединяет другую таблицу и не выполняет группировку или дедупликацию. «Случайная» сортировка обычно случается, когда нет стабильного порядка, и база возвращает строки в том порядке, который ей удобен.
Эти проблемы усугубляются с ростом данных. При 50 записях вы можете не заметить, что фильтр иногда игнорируется. При 50 000 та же ошибка превращается в таймауты, частично загружающиеся страницы и пользователей, которые сдаются, потому что ничего не могут найти.
Как только поиск перестаёт вызывать доверие, люди перестают верить всему приложению. Они предполагают, что данные неверны, а не запрос. Растёт количество тикетов в поддержку, и команды начинают вести собственные таблицы, потому что система учёта больше не выглядит надёжной.
Быстрый способ воспроизвести проблему — создать несколько записей, которые легко отличить. Например: «Анн Ли» (active), «Энн Ли» (inactive) и «Боб» (active). Затем проверьте:
- Поиск «Ann» и подтвердите, какие записи появляются
- Фильтр status = active и подтвердите, что «Anne Li» исчезает
- Сортировку по дате создания дважды и подтвердите, что порядок остаётся тем же
Если какой‑то результат вас удивляет, поиск сломан, даже если ошибка проявляется «иногда».
Почему в AI CRUD-приложениях часто неправильно работают поиск и фильтры
Большинство AI CRUD-приложений начинается как быстрые демо. Поиск и фильтры добавляются в конце и затем становятся тем, к чему пользователи обращаются на каждой странице. Поэтому сломанный поиск и фильтры так распространены: их создавали в спешке, с копипастой паттернов, которые кажутся рабочими до момента появления реальных данных и реальных пользователей.
Частая корневая причина — генерация сборщика запросов ИИ, который смешивает безопасную параметризацию со конкатенацией строк. Он может безопасно связывать значения в одном месте, а где‑то напрямую вставлять колонку сортировки, оператор или необработанный фрагмент WHERE. Это создаёт запутанные баги (неверные результаты, пропуски строк) и реальную опасность (SQL‑инъекция через «умные» фильтры).
Ещё одна проблема — позволять UI отправлять всё подряд: любое имя поля, любой оператор, любое значение. Это кажется гибким, но бекенд в итоге угадывает намерение. Один пользователь ищет status, другой использует createdAt, а третий пытается contains на числовом столбце. Даже если ничего не падает, поведение становится непоследовательным и трудно тестируемым.
JOINы усугубляют ситуацию. Поиск по объединённым таблицам без плана приводит к дубликатам, пропущенным совпадениям и медленным запросам. Страница «Customers» может присоединять orders и notes, а затем применять поисковый термин ко всем ним. Без явных правил группировки и дедупликации один клиент с множеством заказов может появляться много раз, а пагинация станет ненадёжной.
Производительность часто терпит неудачу по той же причине: база не индексирована под реальные сценарии. Команды индексируют id и считают дело сделанным, тогда как в продакшне фильтруют по tenant_id + status, сортируют по created_at и ищут по email.
Безопасность прежде всего: остановите небезопасные динамические фильтры
Сломанный поиск и фильтрация часто начинается с «гибкого» поля фильтра: пользователи могут передавать любое поле, любой оператор и любое значение. Если приложение склеивает эти части в SQL‑строку, атакующий может пронести лишний SQL в запрос. Проще говоря, он уже не фильтрует строки, а меняет то, что база исполняет.
Обычный пример: фильтр status=active превращается в WHERE status = 'active'. Если кто‑то отправит active' OR 1=1 --, запрос может превратиться в «вернуть всё». В худших случаях вставленный текст может читать чувствительные таблицы или изменять данные, в зависимости от прав.
Экранирование не то же самое, что параметризация. Экранирование пытается сделать опасные символы безопасными внутри строки. Параметризация (prepared statements) фиксирует структуру SQL и отправляет значения отдельно, поэтому база воспринимает их как данные, а не команды.
Сложность в том, что многие проблемы динамических фильтров не касаются значений. Особо рискованные входы — те, которые большинство SQL‑библиотек не могут параметризовать:
- Имена полей (пример:
sortBy=price) - Направление сортировки (
asc/desc) - Операторы (
=,LIKE,>,IN) - Необработанные фрагменты SQL (
where=...,order=...) - Имена таблиц или связей
Для таких случаев не экранируйте и не надейтесь. Используйте allowlist: заранее определите, какие поля можно фильтровать или сортировать, какие операторы разрешены для каждого поля и как каждый вариант маппится в безопасный SQL.
Также ограничьте ущерб с помощью ролей базы в духе наименьших привилегий. Даже если плохой запрос проскользнёт, аккаунт приложения не должен иметь права удалять таблицы или читать админские данные.
Создайте явный контракт фильтров (что разрешено, а что нет)
Большая часть сломанных фильтров возникает, потому что приложение принимает «всё» от UI и пытается превратить это в запрос к базе. Контракт фильтров исправляет это: он даёт простые правила о том, какие фильтры существуют, что они означают и как обрабатывается неверный ввод.
Держите контракт маленьким. Начните с короткого allowlist‑а фильтруемых полей и операторов для каждого из них. Например: status может быть equals, но не contains. createdAt — это даты с операторами before и after, а не произвольный текст.
Валидируйте типы до построения запроса. Обрабатывайте каждый фильтр как типизированный ввод, а не как строку для вставки в SQL: строки с ограничением длины, числа с пределами, строгие даты, enum‑значения, которые должны совпадать с разрешёнными, булевы только true/false.
Добавьте ограничения, чтобы один запрос не перегружал базу: максимальный размер страницы, максимальное число фильтров в запросе и максимальная длина поискового текста.
Наконец, выберите последовательные правила для пустых, null и неизвестных значений. Если значение фильтра пустое — игнорируете ли вы его или отклоняете? Если имя поля неизвестно — возвращаете валидационную ошибку или игнорируете? Эти решения предотвращают «почему исчезли результаты?» баги.
Пошагово: рефакторим сборщик запросов, который возвращает неверные результаты
Сломанный поиск и фильтрация часто начинается со сборщика запросов, который пытается быть «гибким», склеивая SQL‑строки. Это работает для демо, но потом даёт странные результаты, ломается на кавычках или становится угрозой безопасности.
Практический путь рефактора
Сначала запишите, что UI действительно должен уметь, простыми словами. Например: «Искать клиентов по имени или email», «Фильтровать по статусу», «Сортировать по новизне», «Показывать 25 на страницу». Если вы не можете описать это ясно, код не останется чистым.
Затем рефакторьте небольшими шагами:
- Закройте входы: какие фильтры существуют, какие опции сортировки поддерживаются и каким колонкам они соответствуют.
- Стройте WHERE только с параметрами (никакой конкатенации значений в строку).
- Обращайтесь к ключам фильтров как к недоверенным данным: мапьте UI‑ключи вроде
statusна известные колонкиcustomers.status, а неизвестные ключи отклоняйте или игнорируйте. - Сделайте сортировку стабильной: добавьте tie‑breaker (например,
created_at, затемid), чтобы пагинация не пропускала и не повторяла строки. - Явно опишите правила пагинации: сначала
limitиoffset, или позднее используйте курсорную пагинацию, но не смешивайте стили.
Если UI отправляет sort=createdAt, не передавайте это в SQL напрямую. Переводите в фиксированный безопасный фрагмент вроде ORDER BY customers.created_at DESC, customers.id DESC.
Тесты, которые ловят «вроде бы работало» баги
Несколько фокусных тестов предотвращают регрессии:
- Имена с апострофами (O'Connor)
- Эмодзи и нелатинские имена
- Разный регистр (alex vs Alex)
- Пустой поиск при включённых фильтрах
- Неизвестные ключи фильтров (последовательно игнорировать или отклонять)
Выберите правильное поведение поиска (и держите его предсказуемым)
Много сломанного поиска — это просто неясные правила. Если пользователи не знают, что значит поле поиска, любой результат будет казаться неправильным, даже если SQL делает то, что вы попросили.
Выберите один режим поиска по умолчанию и используйте его повсюду. Смешение точного совпадения на одном экране и «contains» на другом — частая причина путаницы в AI CRUD‑приложениях.
Точное совпадение подходит для ID и email. Префикс‑поиск хорош для имён и кодов и может быть быстрым при правильном индексе. Contains полезен, но легко становится медленным на больших таблицах. Фаззи‑поиск удобен при опечатках, но об этом нужно явно сообщать.
Если вы используете contains‑поиск, будьте осторожны с паттернами вроде LIKE '%term%' на больших таблицах — ведущий процент часто заставляет полный скан таблицы.
Что бы вы ни выбрали, нормализуйте ввод одинаково: обрезайте пробелы, приводите к единому регистру там, где регистр не важен, и определите, как обращаться с пунктуацией. Поиск « Acme, Inc » должен вести себя как «acme inc», но поиск «C++» не должен молча превращаться в «c».
Сделайте это быстро: добавьте правильные индексы под реальные запросы
Если пользователи жалуются, что поиск медленный, начните с определения нескольких тяжёлых запросов. Не гадать. Возьмите топ‑потребителей из логов базы, трассировок API или простого логирования точек доступа к поиску.
Индексы работают лучше всего, когда они соответствуют реальным шаблонам: колонкам в WHERE и тому, как вы сортируете. Если UI фильтрует по статусу и дате и сортирует по новизне, индекс только по status мало что даст.
Избегайте индексирования всего «на всякий случай». Каждый индекс — это стоимость: медленнее записи, сложнее миграции и больше поддержки. Добавьте небольшое число целевых индексов и снова проверьте производительность на реалистичном объёме данных.
Пагинация и сортировка, которые не ломаются под нагрузкой
Баги пагинации проявляются как «страница 2 повторяет элементы из страницы 1» или «некоторые строки исчезают». Корень обычно в нестабильной сортировке. Если вы сортируете только по created_at, у многих строк может быть одинаковая метка времени, и база может возвращать связки в любом порядке. Когда между запросами добавляются новые строки, порядок смещается и элементы пропадают или повторяются.
Используйте стабильную сортировку с tie‑breaker, например ORDER BY created_at DESC, id DESC. id делает позицию каждой строки уникальной, и «следующая страница» остаётся предсказуемой.
Offset‑пагинация (LIMIT 50 OFFSET 5000) проста, но с ростом смещения становится медленнее. Для больших таблиц курсорная (keyset) пагинация часто лучше: вместо «страница 101» вы запрашиваете «следующие 50 строк после последней увиденной (created_at, id)».
Подсчёт общего количества может стать самым медленным запросом. Фильтрованный COUNT(*) по большой таблице требует много работы, и выполнение его на каждом запросе вредно. Частые альтернативы — показывать счётчики только при необходимости, кешировать общие подсчёты или возвращать hasNextPage с LIMIT pageSize + 1 вместо полного подсчёта.
Как быстро отладить медленный поиск и фильтры
Когда сломанный поиск проявляется как «работает, но медленно», сначала займитесь измерением. Угадывания ведут к случайным изменениям индексов и новым багам.
Начните с захвата фактического SQL для медленных запросов. Ограничьте это по request ID или короткому окну времени и избегайте логирования сырых пользовательских данных, если в них могут быть email, токены или прочие чувствительные значения. Лог формы фильтров (какие поля использовались) часто достаточно.
Затем выполните EXPLAIN на точном SQL, который выполнялся. Ищите, использует ли база индекс или сканирует всю таблицу и сортирует большие наборы.
Распространённые причины замедлений:
- N+1 запросы (один запрос для строк, затем по одному на каждую связанную строку)
- Неограниченные JOIN (объединение больших таблиц без селективного фильтра)
- Отсутствие LIMIT (или пагинация, которая всё равно сортирует всю таблицу)
- Фильтры, мешающие использованию индекса (функции над колонками, ведущие шаблоны
%term%) - Сортировка по неиндексированной колонке
Если не получается воспроизвести замедление локально, постройте минимальный набор данных, который всё ещё его демонстрирует. Если замедление исчезло, проблема часто в распределении данных, а не только в коде.
Частые ошибки, которых следует избегать при исправлении поиска
Экранирование пользовательского ввода полезно, но это не делает весь запрос безопасным. Обычная ошибка в AI CRUD‑приложениях — экранировать значения, при этом позволяя клиенту отправлять сырые имена колонок вроде ?sort=users.email или ?filter[field]=status. Если кто‑то может контролировать имена столбцов, операторов или фрагменты SQL, он всё ещё может сломать запрос.
Разрешать клиенту выбирать любую колонку для сортировки — отдельная ловушка. Это вызывает ошибки (сортировка по колонке, которую вы не выбрали), утечки данных (сортировка по внутреннему полю) и проблемы с производительностью (сортировка по неиндексированной колонке на большой таблице). Ограничьте сортировку небольшим allowlist‑ом, который вы действительно поддерживаете.
Фильтрация по вычисляемым полям тоже бьёт по команде. Фильтрация по full_name, собранному из first_name + last_name, или «дни с последнего логина», вычисляемые в коде, обычно становятся медленными или непоследовательными. Если нужно фильтровать по такому полю, подумайте о сохранении этого значения, индексировании или кэшировании.
Будьте осторожны, чтобы не «починить» скорость за счёт изменения результатов (или починить результаты, сделав их медленными). Замена LEFT JOIN на INNER JOIN может ускорить, но молча убрать записи. Добавление DISTINCT для скрытия дубликатов может замаскировать баг в JOIN и запутать пагинацию и подсчёты.
Быстрый чек‑лист перед выпуском исправления
Протестируйте «скучные» случаи. Большинство багов скрываются в краевых входах, неожиданных комбинациях и производительности на реальных данных.
Проверки корректности: кавычки, проценты, подчёркивания, эмодзи, очень длинный текст, множественные пробелы и пустой поиск в сочетании с фильтрами. Хороший результат предсказуем даже при «грязном» вводе.
Проверки безопасности: бекенд принимает только узкий набор полей и операторов, и каждое значение передаётся как параметр (плейсхолдер), а не вставляется в SQL.
Проверки производительности: измерьте медленные запросы до и после изменений на одинаковом наборе данных и с одинаковыми входами. Документируйте индексы, от которых вы зависите, чтобы будущие правки их не сломали.
Проверки стабильности: сортировка детерминирована (с tie‑breaker’ом вроде id), и пагинация не пропускает и не повторяет элементы при появлении новых строк.
Пример: исправление сломанного поиска «Customers» в AI CRUD-приложении
Типичный экран «Customers»: фильтр по статусу (active, paused), плану (Free, Pro), диапазону дат регистрации и быстрый поиск по имени.
Симптомы выглядят случайными. «Active + Pro» возвращает клиентов, которые не Pro, поиск по имени пропускает очевидные совпадения, а порядок списка меняется после каждого обновления. Под нагрузкой страница настолько медлит, что таймаутит.
Что обычно пошло не так:
- JOIN к plans или subscriptions множит строки, поэтому один клиент появляется много раз и счётчик неверен.
- Сортировка строится из сырого ввода (небезопасно и нестабильно).
- База сканирует слишком много, потому что нет индекса, соответствующего реальному шаблону фильтра + сортировки.
Чистое решение начинается с того, чтобы сделать фильтры скучными и строгими: разрешать только известные поля, валидировать типы и строить запросы по небольшому контракту (status — одно из X, plan — одно из Y, даты — реальные даты, name — простая строка).
Далее — сделать результаты предсказуемыми: применяйте фильтры в первую очередь, переводите ключи сортировки через allowlist и добавляйте стабильный tie‑breaker (например, created_at, затем id), чтобы пагинация не перемешивала элементы.
Наконец — добавить целевые индексы, соответствующие реальному использованию. Для этого экрана это обычно один составной индекс, покрывающий самый частый набор фильтр+сорт, плюс отдельный подход для поиска по имени.
Если вы унаследовали AI‑сгенерированный код (Lovable, Bolt, v0, Cursor, Replit) с запутанными сборщиками запросов и небезопасными динамическими фильтрами, FixMyMess (fixmymess.ai) может начать с бесплатного аудита кода, чтобы указать неверные JOIN, рискованные SQL‑фрагменты и конкретные запросы для рефакторинга. Многие проекты можно восстановить и подготовить к релизу в течение 48–72 часов после утверждения контракта фильтров.
Часто задаваемые вопросы
Как понять, сломан ли поиск в моём приложении или это просто «странность»?
Начните с трёх вещей: отсутствующие совпадения, дубли строк и непоследовательный порядок. Создайте небольшую тестовую выборку (например, похожие имена с разными статусами), затем многократно выполняйте те же поиск, фильтр и сортировку. Если результаты меняются или удивляют — поиск сломан, даже если это происходит «иногда».
Почему после добавления поиска по связанным таблицам я вижу дубликаты строк?
JOIN часто умножают строки. Один родительский объект (например, customer) может совпадать по многим связанным строкам (например, orders), и запрос вернёт по строке на каждое совпадение, если вы явно не сгруппируете или не дедуплируете результаты. Это также ломает пагинацию, потому что база постранично возвращает строки, а не уникальных клиентов.
Почему порядок выглядит случайным, хотя я сортирую по дате?
Обычно это значит, что у вас нет стабильной сортировки. Если вы сортируете только по неуникальному полю (например, created_at), много строк будут иметь одинаковую метку времени, и база вернёт их в любом порядке. Добавьте tie-breaker, например created_at и id, чтобы порядок был детерминированным при обновлениях и при переходе по страницам.
Почему плохая идея давать UI возможность отправлять любое поле или оператор фильтра?
Гибкость с клиентской стороны опасна. Если бекенд позволяет отправлять произвольные имена полей, операторы или фрагменты SQL, это ведёт к непоследовательному поведению и риску инъекций. Безопасный путь — allowlist: определите, какие поля можно фильтровать/сортировать, и переводите UI-ключи в предопределённые SQL-колонки.
Как безопасно строить динамические фильтры без риска SQL-инъекции?
Везде, где можно — параметризуйте значения, и никогда не вставляйте пользовательский ввод прямо в строку SQL. Там, где параметризация невозможна (например, имя сортируемой колонки или направление), используйте allowlist и маппинг каждого разрешённого варианта на фиксированный безопасный SQL-фрагмент. Простое экранирование не заменяет параметризацию.
Что такое «контракт фильтров» и что в нём должно быть?
Это набор явных правил. Сделайте его небольшим: короткий список поддерживаемых фильтров, какие операторы разрешены для каждого фильтра, валидация типов (enum, дата, число, булево) и согласованное поведение для пустых или неизвестных значений. Такой контракт предотвращает баги «иногда работает» и упрощает тестирование.
Как отлаживать медленный поиск и фильтры без угадываний?
Логируйте или захватывайте точный SQL медленных запросов (без излишних чувствительных данных), затем выполняйте EXPLAIN для этого запроса. Ищите полные сканирования таблиц, большие сортировки, неограниченные JOIN и конструкции, которые мешают использованию индексов (например, ведущие шаблоны %term%). Исправляйте самый тяжёлый запрос первым вместо добавления рандомных индексов.
Какие индексы обычно помогают для экранов CRUD-поиска?
Индексируйте те столбцы, которые вы реально фильтруете и по которым сортируете, и делайте составные индексы под реальные шаблоны запросов. Например, если ваши запросы фильтруют по tenant_id и status и сортируют по created_at, то составной индекс, покрывающий эти поля, часто помогает. Не индексируйте всё подряд — это замедлит записи и усложнит поддержку.
Почему пагинация повторяет или пропускает записи под нагрузкой?
Проблема в нестабильной сортировке и в том, что offset-пагинация медленеет при больших смещениях. Используйте стабильную сортировку с tie-breaker (например, id) и подумайте о курсорной (keyset) пагинации для больших таблиц. Также учтите, что COUNT(*) по большому отфильтрованному набору может стать самым медленным запросом — возвращайте hasNextPage с LIMIT pageSize + 1 или кешируйте счётчики там, где это уместно.
Понадобится ли моей AI-сгенерированной CRUD-приложению профессиональная помощь для исправления поиска?
Если проект был сгенерирован инструментами вроде Lovable, Bolt, v0, Cursor или Replit, ищите конкатенацию строк SQL, клиент-контролируемые ключи сортировки, непоследовательные правила поиска и JOIN, создающие дубликаты. Если хотите быстро начать, FixMyMess (fixmymess.ai) предлагает бесплатный аудит кода, чтобы указать небезопасные фильтры и конкретные запросы для рефакторинга; многие проекты можно подготовить к деплою за 48–72 часа после фикса контракта фильтров.