143 lines
6.1 KiB
Go
143 lines
6.1 KiB
Go
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
|
||
}
|