UpdateQuestion (PUT /tests/{id}/questions/{questionId}):
- транзакционно: UPDATE test_questions SET ... + DELETE test_answers +
INSERT новых ответов. question_id стабилен — test_attempt_answers
(FK CASCADE) остаются. Снимок ответа в payload — для будущего
показа истории, в MVP не реализовано;
- семантика full-replace: проще на клиенте (один POST вместо
per-answer патчей), атомарно на сервере.
ReorderQuestions (POST /tests/{id}/questions/reorder):
- батч-апдейт position через UNNEST(uuid[], int[]) — один запрос
вместо N UPDATE'ов; идемпотентно;
- /reorder регистрируется ДО /{questionId} чтобы chi не заматчил
его как questionId="reorder".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
371 lines
12 KiB
Go
371 lines
12 KiB
Go
package repository
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"learning-service/internal/model"
|
||
)
|
||
|
||
type TestRepository struct {
|
||
pool *pgxpool.Pool
|
||
}
|
||
|
||
func NewTestRepository(pool *pgxpool.Pool) *TestRepository {
|
||
return &TestRepository{pool: pool}
|
||
}
|
||
|
||
// testCols включает correlated subquery для счётчика вопросов — UI получает
|
||
// бейджи без отдельного round-trip'а на каждый тест в списке.
|
||
const testCols = `
|
||
t.id, t.title, t.description, t.passing_score, t.max_attempts,
|
||
t.time_limit_sec, t.shuffle_questions, t.show_correct_after,
|
||
t.is_published, t.owner_user_id, t.created_at, t.updated_at,
|
||
(SELECT COUNT(*)::int FROM test_questions q WHERE q.test_id = t.id) AS questions_count
|
||
`
|
||
|
||
func scanTest(scan func(...any) error) (*model.Test, error) {
|
||
var t model.Test
|
||
if err := scan(
|
||
&t.ID, &t.Title, &t.Description, &t.PassingScore, &t.MaxAttempts,
|
||
&t.TimeLimitSec, &t.ShuffleQuestions, &t.ShowCorrectAfter,
|
||
&t.IsPublished, &t.OwnerUserID, &t.CreatedAt, &t.UpdatedAt,
|
||
&t.QuestionsCount,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
return &t, nil
|
||
}
|
||
|
||
// List — листинг тестов. Фильтр onlyPublished исключает черновики (для не-владельца).
|
||
// ownerFilter (не nil) — показать только тесты конкретного юзера.
|
||
//
|
||
// Для гранулярного access-check'а (через access_grants) предусмотрено
|
||
// поле visibleIDs: если не nil, добавляется фильтр id = ANY($X). Это
|
||
// позволяет handler'у дёрнуть access-репо отдельно и передать id'шники
|
||
// сюда — repo не зависит от access-репо (плоский граф).
|
||
func (r *TestRepository) List(ctx context.Context, ownerFilter *uuid.UUID, onlyPublished bool, visibleIDs []uuid.UUID) ([]model.Test, error) {
|
||
conds := []string{}
|
||
args := []any{}
|
||
push := func(cond string, val any) {
|
||
args = append(args, val)
|
||
conds = append(conds, strings.Replace(cond, "?", fmt.Sprintf("$%d", len(args)), 1))
|
||
}
|
||
if ownerFilter != nil {
|
||
push("t.owner_user_id = ?", *ownerFilter)
|
||
}
|
||
if onlyPublished {
|
||
conds = append(conds, "t.is_published = TRUE")
|
||
}
|
||
if visibleIDs != nil {
|
||
// nil = без фильтра, пустой массив = «ничего не видно».
|
||
if len(visibleIDs) == 0 {
|
||
return []model.Test{}, nil
|
||
}
|
||
push("t.id = ANY(?)", visibleIDs)
|
||
}
|
||
where := ""
|
||
if len(conds) > 0 {
|
||
where = "WHERE " + strings.Join(conds, " AND ")
|
||
}
|
||
q := fmt.Sprintf(`SELECT %s FROM tests t %s ORDER BY t.updated_at DESC`, testCols, where)
|
||
rows, err := r.pool.Query(ctx, q, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []model.Test{}
|
||
for rows.Next() {
|
||
t, err := scanTest(rows.Scan)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, *t)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *TestRepository) Get(ctx context.Context, id uuid.UUID) (*model.Test, error) {
|
||
q := fmt.Sprintf(`SELECT %s FROM tests t WHERE t.id = $1`, testCols)
|
||
t, err := scanTest(r.pool.QueryRow(ctx, q, id).Scan)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
return nil, err
|
||
}
|
||
return t, nil
|
||
}
|
||
|
||
func (r *TestRepository) Create(ctx context.Context, ownerID uuid.UUID, req model.CreateTestRequest) (*model.Test, error) {
|
||
var id uuid.UUID
|
||
err := r.pool.QueryRow(ctx, `
|
||
INSERT INTO tests (
|
||
title, description, passing_score, max_attempts, time_limit_sec,
|
||
shuffle_questions, show_correct_after, owner_user_id
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||
RETURNING id`,
|
||
req.Title, req.Description, req.PassingScore, req.MaxAttempts,
|
||
req.TimeLimitSec, req.ShuffleQuestions, req.ShowCorrectAfter, ownerID,
|
||
).Scan(&id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return r.Get(ctx, id)
|
||
}
|
||
|
||
func (r *TestRepository) Update(ctx context.Context, id uuid.UUID, req model.UpdateTestRequest) (*model.Test, error) {
|
||
sets := []string{}
|
||
args := []any{}
|
||
add := func(col string, val any) {
|
||
args = append(args, val)
|
||
sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args)))
|
||
}
|
||
if req.Title != nil {
|
||
add("title", *req.Title)
|
||
}
|
||
if req.Description != nil {
|
||
add("description", *req.Description)
|
||
}
|
||
if req.PassingScore != nil {
|
||
add("passing_score", *req.PassingScore)
|
||
}
|
||
if req.MaxAttempts != nil {
|
||
add("max_attempts", *req.MaxAttempts)
|
||
}
|
||
if req.TimeLimitSec != nil {
|
||
add("time_limit_sec", *req.TimeLimitSec)
|
||
}
|
||
if req.ShuffleQuestions != nil {
|
||
add("shuffle_questions", *req.ShuffleQuestions)
|
||
}
|
||
if req.ShowCorrectAfter != nil {
|
||
add("show_correct_after", *req.ShowCorrectAfter)
|
||
}
|
||
if req.IsPublished != nil {
|
||
add("is_published", *req.IsPublished)
|
||
}
|
||
if len(sets) == 0 {
|
||
return r.Get(ctx, id)
|
||
}
|
||
sets = append(sets, "updated_at = NOW()")
|
||
args = append(args, id)
|
||
q := fmt.Sprintf(`UPDATE tests SET %s WHERE id = $%d`, strings.Join(sets, ", "), len(args))
|
||
tag, err := r.pool.Exec(ctx, q, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return nil, ErrNotFound
|
||
}
|
||
return r.Get(ctx, id)
|
||
}
|
||
|
||
func (r *TestRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||
tag, err := r.pool.Exec(ctx, `DELETE FROM tests WHERE id = $1`, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ListQuestions — все вопросы теста, плюс ответы (через отдельный запрос
|
||
// и in-memory join по question_id). Дёшево — обычно у теста 5-30 вопросов
|
||
// по 2-4 ответа = единицы сотен строк.
|
||
func (r *TestRepository) ListQuestions(ctx context.Context, testID uuid.UUID) ([]model.Question, error) {
|
||
rows, err := r.pool.Query(ctx, `
|
||
SELECT id, test_id, position, kind, text, points, explanation, created_at
|
||
FROM test_questions
|
||
WHERE test_id = $1
|
||
ORDER BY position, created_at`, testID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
qs := []model.Question{}
|
||
qIDs := []uuid.UUID{}
|
||
for rows.Next() {
|
||
var q model.Question
|
||
if err := rows.Scan(&q.ID, &q.TestID, &q.Position, &q.Kind, &q.Text, &q.Points, &q.Explanation, &q.CreatedAt); err != nil {
|
||
return nil, err
|
||
}
|
||
qs = append(qs, q)
|
||
qIDs = append(qIDs, q.ID)
|
||
}
|
||
if len(qs) == 0 {
|
||
return qs, nil
|
||
}
|
||
// Один батч-запрос на все ответы.
|
||
aRows, err := r.pool.Query(ctx, `
|
||
SELECT id, question_id, position, text, is_correct, created_at
|
||
FROM test_answers
|
||
WHERE question_id = ANY($1)
|
||
ORDER BY position, created_at`, qIDs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer aRows.Close()
|
||
byQ := map[uuid.UUID][]model.Answer{}
|
||
for aRows.Next() {
|
||
var a model.Answer
|
||
if err := aRows.Scan(&a.ID, &a.QuestionID, &a.Position, &a.Text, &a.IsCorrect, &a.CreatedAt); err != nil {
|
||
return nil, err
|
||
}
|
||
byQ[a.QuestionID] = append(byQ[a.QuestionID], a)
|
||
}
|
||
for i := range qs {
|
||
qs[i].Answers = byQ[qs[i].ID]
|
||
}
|
||
return qs, nil
|
||
}
|
||
|
||
// CreateQuestion — транзакционно создаёт вопрос + ответы (отдельный INSERT'ом
|
||
// на каждый, без bulk — у теста типично 2-6 ответов на вопрос).
|
||
func (r *TestRepository) CreateQuestion(ctx context.Context, testID uuid.UUID, req model.CreateQuestionRequest) (*model.Question, error) {
|
||
tx, err := r.pool.Begin(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
||
var qID uuid.UUID
|
||
err = tx.QueryRow(ctx, `
|
||
INSERT INTO test_questions (test_id, position, kind, text, points, explanation)
|
||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||
testID, req.Position, req.Kind, req.Text, req.Points, req.Explanation,
|
||
).Scan(&qID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, a := range req.Answers {
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO test_answers (question_id, position, text, is_correct)
|
||
VALUES ($1, $2, $3, $4)`,
|
||
qID, a.Position, a.Text, a.IsCorrect); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
// Перечитываем целиком, чтобы вернуть и вопрос, и ответы.
|
||
all, err := r.ListQuestions(ctx, testID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, q := range all {
|
||
if q.ID == qID {
|
||
return &q, nil
|
||
}
|
||
}
|
||
return nil, ErrNotFound
|
||
}
|
||
|
||
func (r *TestRepository) DeleteQuestion(ctx context.Context, questionID uuid.UUID) error {
|
||
tag, err := r.pool.Exec(ctx, `DELETE FROM test_questions WHERE id = $1`, questionID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// UpdateQuestion — транзакционная замена вопроса: фиксированный question_id,
|
||
// обновлённые поля + полностью пересозданные ответы. Зачем не PATCH per-answer:
|
||
//
|
||
// 1. Конструктор в UI хранит локальный state, юзеру удобно жать «Сохранить»
|
||
// один раз; отдельные ADD/DELETE/UPDATE для каждого ответа = много сетевых
|
||
// round-trip'ов и сложнее undo'ить наполовину применённое;
|
||
// 2. question_id сохраняется → test_attempt_answers (FK CASCADE) остаются.
|
||
// Старые payload'ы хранят answer_id как строку в JSONB; при пересоздании
|
||
// ответов эти id могут больше не существовать, но correct/score уже
|
||
// зафиксированы в attempt_answers и для итоговой оценки это не критично.
|
||
// Если в будущем понадобится «показать что юзер выбрал по истории» —
|
||
// добавим snapshot текста ответа прямо в payload.
|
||
func (r *TestRepository) UpdateQuestion(ctx context.Context, questionID uuid.UUID, req model.CreateQuestionRequest) (*model.Question, error) {
|
||
tx, err := r.pool.Begin(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE test_questions
|
||
SET position = $2, kind = $3, text = $4, points = $5, explanation = $6
|
||
WHERE id = $1`,
|
||
questionID, req.Position, req.Kind, req.Text, req.Points, req.Explanation)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return nil, ErrNotFound
|
||
}
|
||
// Пересоздаём ответы. CASCADE FK очистит test_answers; на пустых тестах
|
||
// (text-вопрос) это no-op.
|
||
if _, err := tx.Exec(ctx, `DELETE FROM test_answers WHERE question_id = $1`, questionID); err != nil {
|
||
return nil, err
|
||
}
|
||
for _, a := range req.Answers {
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO test_answers (question_id, position, text, is_correct)
|
||
VALUES ($1, $2, $3, $4)`,
|
||
questionID, a.Position, a.Text, a.IsCorrect); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
// Перечитаем через ListQuestions (один из элементов).
|
||
var testID uuid.UUID
|
||
if err := r.pool.QueryRow(ctx, `SELECT test_id FROM test_questions WHERE id = $1`, questionID).Scan(&testID); err != nil {
|
||
return nil, err
|
||
}
|
||
all, err := r.ListQuestions(ctx, testID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, q := range all {
|
||
if q.ID == questionID {
|
||
return &q, nil
|
||
}
|
||
}
|
||
return nil, ErrNotFound
|
||
}
|
||
|
||
// ReorderQuestions — батч-апдейт position'ов одного теста. Идемпотентно;
|
||
// принимает массив (id, position) в одной транзакции через UNNEST.
|
||
// Используется фронтом при drag-and-drop вопросов или up/down кнопках.
|
||
func (r *TestRepository) ReorderQuestions(ctx context.Context, testID uuid.UUID, items []struct {
|
||
ID uuid.UUID
|
||
Position int
|
||
}) error {
|
||
if len(items) == 0 {
|
||
return nil
|
||
}
|
||
ids := make([]uuid.UUID, len(items))
|
||
positions := make([]int, len(items))
|
||
for i, it := range items {
|
||
ids[i] = it.ID
|
||
positions[i] = it.Position
|
||
}
|
||
_, err := r.pool.Exec(ctx, `
|
||
UPDATE test_questions q
|
||
SET position = u.pos
|
||
FROM UNNEST($1::uuid[], $2::int[]) AS u(id, pos)
|
||
WHERE q.id = u.id AND q.test_id = $3`,
|
||
ids, positions, testID)
|
||
return err
|
||
}
|