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 }