axe/internal/repository/page.go

143 lines
6.1 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
package repository
import "fmt"
// Пагинация списков — общий контракт для всех GET-списков
// (/tickets, /projects, объекты, мастера, пользователи).
//
// Хендлер разбирает query и валидирует его против тех же словарей,
// что и здесь (400 с человеческим текстом), а репозиторий не доверяет
// даже ему: limit подрезается, а в SQL уходит только колонка
// из словаря — пользовательская строка никогда не конкатенируется.
const (
// DefaultLimit — строк на странице, если ?limit= не задан.
DefaultLimit = 20
// MaxLimit — потолок: столько максимум попадает в ответ.
MaxLimit = 100
)
// Page — параметры одной страницы.
type Page struct {
Limit int // строк на странице (нормализуется в normalize)
Offset int // сдвиг от начала выборки
Sort string // ключ из словаря сортировок; "" — сортировка по умолчанию
Order string // "asc" | "desc"; "" — направление по умолчанию ключа
}
// SortSpec — колонка сортировки и её направление по умолчанию.
// Col — готовое выражение ORDER BY, собирается только здесь.
type SortSpec struct {
Col string
Desc bool
}
// Словари сортировок. Единственный источник: по ним хендлер отвечает
// 400 на неизвестный ?sort=, а orderBy берёт колонку для SQL.
var (
// TicketSorts: priority — «сначала важные», поэтому колонка
// это CASE, а не текст (по алфавиту high оказался бы в середине).
TicketSorts = map[string]SortSpec{
"created_at": {Col: "t.created_at", Desc: true},
"priority": {Col: "CASE t.priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END", Desc: false},
"id": {Col: "t.id", Desc: true},
}
ProjectSorts = map[string]SortSpec{
"created_at": {Col: "p.created_at", Desc: true},
"name": {Col: "p.name", Desc: false},
"id": {Col: "p.id", Desc: true},
}
ObjectSorts = map[string]SortSpec{
"name": {Col: "o.name", Desc: false},
"created_at": {Col: "o.created_at", Desc: true},
"id": {Col: "o.id", Desc: true},
}
// ProfileSorts общий для /users и мастеров проекта: оба запроса
// используют алиас u (см. ListByRole/ListWorkersForClient/ListWorkers).
ProfileSorts = map[string]SortSpec{
"name": {Col: "u.name", Desc: false},
"created_at": {Col: "u.created_at", Desc: true},
"email": {Col: "u.email", Desc: false},
"id": {Col: "u.id", Desc: true},
}
// InvitationSorts — приглашения мастера (GET /invitations): по
// умолчанию сначала свежие; для алиасов см. ListInvitations.
InvitationSorts = map[string]SortSpec{
"invited_at": {Col: "pw.invited_at", Desc: true},
"expires_at": {Col: "pw.expires_at", Desc: false},
"project_name": {Col: "p.name", Desc: false},
}
// EventSorts — журнал заявки: хронология (id ASC) по умолчанию,
// алиас e — из ListEvents.
EventSorts = map[string]SortSpec{
"id": {Col: "e.id", Desc: false},
"created_at": {Col: "e.created_at", Desc: false},
}
// PhotoSorts — список фото заявки: тоже хронология (id ASC),
// алиас ph — из ListPhotos.
PhotoSorts = map[string]SortSpec{
"id": {Col: "ph.id", Desc: false},
"created_at": {Col: "ph.created_at", Desc: false},
}
// RecurringSorts — правила повторяющихся заявок (алиас r из
// ListRules): по умолчанию сначала свежие; «date» — по точке
// отсчёта расписания (ближайший первый запуск вперёд).
RecurringSorts = map[string]SortSpec{
"created_at": {Col: "r.created_at", Desc: true},
"id": {Col: "r.id", Desc: true},
"date": {Col: "r.anchor_at", Desc: false},
}
// RecurringRunSorts — история срабатываний правила (алиас rr из
// ListRuns): по умолчанию сначала свежие запуски.
RecurringRunSorts = map[string]SortSpec{
"run_at": {Col: "rr.run_at", Desc: true},
"id": {Col: "rr.id", Desc: true},
}
)
// normalize подрезает страницу в допустимые границы — второй рубеж
// после валидации в хендлере: лишних строк в ответ не уйдёт в любом случае.
func (p Page) normalize() Page {
if p.Limit <= 0 || p.Limit > MaxLimit {
p.Limit = DefaultLimit
}
if p.Offset < 0 {
p.Offset = 0
}
if p.Order != "asc" && p.Order != "desc" {
p.Order = ""
}
return p
}
// orderBy — фрагмент ORDER BY: ключ из словаря (или сортировка по
// умолчанию списка), направление из запроса или дефолт ключа.
// tiebreak — добавляемая последней колонка (обычно id): без неё
// одинаковые значения колонки «перетасовывались» бы между страницами.
func orderBy(p Page, sorts map[string]SortSpec, defaultKey, tiebreak string) (string, error) {
key := p.Sort
if key == "" {
key = defaultKey
}
spec, ok := sorts[key]
if !ok {
return "", fmt.Errorf("sort %q: %w", key, ErrInvalid)
}
desc := spec.Desc
switch p.Order {
case "":
case "asc":
desc = false
case "desc":
desc = true
default:
return "", fmt.Errorf("order %q: %w", p.Order, ErrInvalid)
}
dir := "ASC"
if desc {
dir = "DESC"
}
return " ORDER BY " + spec.Col + " " + dir + ", " + tiebreak, nil
}