Фильтрация и страницы списка
Когда карточек становится больше, таблицу неудобно читать целиком. Добавим поиск по названию или артикулу и разделение списка на страницы. Сначала используем обычную GET-форму. Поисковое условие и номер страницы будут находиться в URL, поэтому ссылку можно открыть заново и получить то же представление актуальных данных. Это важная основа для последующего htmx: частичная загрузка должна сохранять понятный адрес состояния.
Количество оборудования не меняется при фильтрации. Поиск выбирает представление, а не переписывает карточки. Наш запрос возвращает максимум десять видимых строк и одну дополнительную, по которой определяется наличие продолжения. Эта техника избавляет текущую страницу от обязательного подсчёта всех совпадений. При этом интерфейс не показывает точное общее количество страниц, потому что сервер его не вычисляет.
Параметры адреса
GET-форма отправляет q как поисковую строку. Номер страницы передаётся параметром page; если он отсутствует, используем первую страницу. Значение должно быть целым числом от единицы до десяти тысяч. Отрицательный номер, произвольные буквы или слишком длинное условие получают явный отказ, а не неожиданное отрицательное смещение SQL. Преобразование данных URL подчиняется тому же принципу, что и количество в форме: внешняя строка ещё не является допустимым предметным значением.
После удаления внешних пробелов поисковое условие передаётся как параметр SQL. Мы не вставляем его внутрь текста SELECT. Используем instr(lower(name),lower(?)) и такое же сравнение с SKU. В отличие от LIKE, символ процента в условии здесь не становится шаблонной командой. Читатель получает поиск буквального вхождения, а не необъявленный язык масок. Если нужны маски, их следует описать как отдельную возможность интерфейса.
func (a *App) list(w http.ResponseWriter,r *http.Request){
p:=Page{Page:1}
p.Q=strings.TrimSpace(r.URL.Query().Get("q"));if len(p.Q)>100{http.Error(w,"Слишком длинный поиск",400);return};if v:=r.URL.Query().Get("page");v!=""{value,err:=strconv.Atoi(v);if err!=nil||value<1||value>10000{http.Error(w,"Неверная страница",400);return};p.Page=value}
rows,err:=a.db.QueryContext(r.Context(),"SELECT id,sku,name,location,quantity,version FROM items WHERE instr(lower(name),lower(?))>0 OR instr(lower(sku),lower(?))>0 ORDER BY id LIMIT 11 OFFSET ?" ,p.Q,p.Q,(p.Page-1)*10)
if err!=nil {log.Print(err);http.Error(w,"Не удалось прочитать инвентарь",500);return}
defer rows.Close()
for rows.Next(){ var v Item; if err:=rows.Scan(&v.ID,&v.SKU,&v.Name,&v.Location,&v.Quantity,&v.Version);err!=nil{log.Print(err);http.Error(w,"Ошибка строки",500);return};p.Items=append(p.Items,v) }
if err:=rows.Err();err!=nil{log.Print(err);http.Error(w,"Чтение прервано",500);return}
if len(p.Items)>10{p.Items=p.Items[:10];p.Next=fmt.Sprintf("/?q=%s&page=%d",url.QueryEscape(p.Q),p.Page+1)};if p.Page>1{p.Prev=fmt.Sprintf("/?q=%s&page=%d",url.QueryEscape(p.Q),p.Page-1)}
a.render(w,r,p,200)
}
Фрагмент относится к обработчику списка в main.go состояния lesson-07. Полный файл содержит разбор параметров, чтение строк, обработку ошибок и подготовку ссылок. Названия и артикулы остаются теми же полями Item, поэтому фильтрация не вводит вторую модель оборудования. Один предметный набор может иметь множество представлений, но ссылки всё равно обращаются к исходным идентификаторам.
Ограничение регистра
Стандартное поведение SQLite lower не обеспечивает полноценное Unicode-приведение регистра для всех языков. Поэтому в этом учебном состоянии не обещаем, что русские заглавные и строчные буквы всегда считаются одинаковыми. Для SKU выбран латинский набор, а русское название можно искать точным регистром. Это реальное ограничение поисковой стратегии; его нельзя исправить только другим атрибутом HTML-поля или сменой подписи кнопки.
Для полноценного русского поиска понадобилось бы заранее определить нормализацию, хранить подготовленный поисковый ключ или подключить подходящее расширение. Затем нужно решить судьбу уже существующих строк и пересчитать ключи. Такой переход является отдельным изменением данных. Здесь мы выбираем небольшой понятный механизм, который позволяет изучить URL, ограничение выдачи и страницы, не выдавая простое SQL-вхождение за полноценный поисковый движок.
Пустое условие должно возвращать общий список. Когда вхождение не найдено, шаблон показывает сообщение о пустом текущем представлении. Оно отличается от ответа 500 при ошибке чтения базы. Человек может убрать условие и увидеть оборудование снова, потому что данные не удалены. Удобно формулировать сообщение именно через контекст поиска: «нет строк для текущего представления» точнее, чем безусловное «инвентарь пуст».
Почему порядок должен быть устойчивым
Пагинация использует ORDER BY id вместе с LIMIT и OFFSET. Без явной сортировки база не обязана возвращать строки в одном и том же порядке. Тогда оборудование может повториться на соседних страницах или исчезнуть из ожидаемого места после изменения плана выполнения. Мы выбираем неизменяемый ID, а не название: переименование не должно немедленно менять позицию строки в сортировке текущего учебного списка.
Смещение вычисляется как (page-1)*10. Дополнительная одиннадцатая строка не отображается, а служит признаком кнопки «Далее». Если строк меньше либо ровно десять, продолжение не предлагается. Такой ответ не сообщает, сколько записей будет на последующей странице к моменту её открытия. Между двумя GET-запросами данные могут измениться. Пагинация показывает актуальный список, а не создаёт снимок базы на всё время просмотра.
OFFSET имеет понятное ограничение: вставки и удаления перед текущей границей меняют содержимое следующих страниц. Для небольшого учёта этого достаточно, но при большой активно меняющейся ленте лучше курсор по устойчивому ключу. В этой серии цель — серверные формы и htmx, поэтому не маскируем ограничение сложным решением. Курсорная модель отдельно разобрана в новом курсе GraphQL, где адрес границы входит в договор ответа.
Ссылки «Назад» и «Далее» сохраняют условие q. Для этого значение кодируется через url.QueryEscape. Пробел, плюс или амперсанд в названии не должен становиться разделителем параметров и менять смысл адреса. Экранирование URL отличается от HTML-экранирования шаблона: сначала создаётся корректное значение адреса, затем шаблон помещает адрес в атрибут. Каждая граница выполняет собственное преобразование, и порядок этих действий имеет значение.
Рассмотрим поиск MON. Ожидается единственная карточка монитора без кнопки продолжения. Если параметр страницы вручную изменить на два, можно получить пустую выдачу: сервер не должен выдумывать строку, которой нет. На странице остаётся способ вернуться назад или изменить условие. При подготовке материалов этот сценарий не выполнялся; он описывает ожидаемый ответ полного состояния исходников.
Следующий урок добавит htmx к этой же GET-форме. Сервер продолжит получать q и page, выполнять тот же SELECT и строить ту же модель Page. Новое различие появится после чтения данных: вместо полного документа иногда будет возвращаться только фрагмент. Сохранив один запрос и одни правила сортировки, мы избежим расхождения между обычной страницей и интерактивным поиском.
Новый поиск естественно начинается с первой страницы: форма не отправляет прежний номер. Если сохранить page от старого длинного списка, короткое условие может сразу показать пустоту, хотя совпадения существуют в начале. Это проблема согласованности параметров представления, а не отсутствия данных. Ссылка продолжения сохраняет номер и условие вместе, а новое условие сбрасывает границу просмотра. Такой небольшой договор делает поведение предсказуемым без дополнительного клиентского состояния и сложного управления маршрутом.
Исходники урока
Оглавление серии · Скачать исходники. Полное самостоятельное состояние находится в lesson-07. Учебные приложения, зависимости, база и проверки при подготовке не запускались; описанный результат ожидается по исходникам. Материалы пока находятся в локальной редакционной версии.
Первичные источники: документация 1.