Files
learning/internal/repository/test.go
Ilya 47a76bef7c
All checks were successful
CI / test (push) Successful in 14s
Build and Deploy / build-and-deploy (push) Successful in 27s
feat(tests): UpdateQuestion (full-replace) + ReorderQuestions
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>
2026-05-25 23:18:05 +03:00

371 lines
12 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 (
"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
}