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
|
|||
|
|
}
|