axe/internal/repository/page.go
2026-10-18 22:07:25 +03:00

143 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}