You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

324 lines
7.6 KiB
Go

package ent
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
domain "knowfoolery/backend/services/question-bank-service/internal/domain/question"
sharederrors "knowfoolery/backend/shared/domain/errors"
)
// QuestionRepository implements question storage on PostgreSQL.
type QuestionRepository struct {
client *Client
}
// NewQuestionRepository creates a new question repository.
func NewQuestionRepository(client *Client) *QuestionRepository {
return &QuestionRepository{client: client}
}
// EnsureSchema creates the service table if missing.
func (r *QuestionRepository) EnsureSchema(ctx context.Context) error {
const ddl = `
CREATE TABLE IF NOT EXISTS questions (
id UUID PRIMARY KEY,
theme VARCHAR(100) NOT NULL,
text TEXT NOT NULL,
answer VARCHAR(500) NOT NULL,
hint TEXT,
difficulty VARCHAR(10) NOT NULL DEFAULT 'medium',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_questions_theme_active ON questions (theme, is_active);
CREATE INDEX IF NOT EXISTS idx_questions_difficulty_active ON questions (difficulty, is_active);
`
_, err := r.client.Pool.Exec(ctx, ddl)
return err
}
func (r *QuestionRepository) GetByID(ctx context.Context, id string) (*domain.Question, error) {
const q = `
SELECT id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at
FROM questions
WHERE id=$1`
row := r.client.Pool.QueryRow(ctx, q, id)
question, err := scanQuestion(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, sharederrors.Wrap(
sharederrors.CodeQuestionNotFound,
"question not found",
err,
)
}
return nil, err
}
return question, nil
}
func (r *QuestionRepository) Create(ctx context.Context, qn *domain.Question) (*domain.Question, error) {
id := uuid.NewString()
now := time.Now().UTC()
if qn.Difficulty == "" {
qn.Difficulty = domain.DifficultyMedium
}
const q = `
INSERT INTO questions (id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at`
row := r.client.Pool.QueryRow(ctx, q,
id,
qn.Theme,
qn.Text,
qn.Answer,
qn.Hint,
string(qn.Difficulty),
true,
now,
now,
)
created, err := scanQuestion(row)
if err != nil {
return nil, err
}
return created, nil
}
func (r *QuestionRepository) Update(
ctx context.Context,
id string,
qn *domain.Question,
) (*domain.Question, error) {
const q = `
UPDATE questions
SET theme=$2, text=$3, answer=$4, hint=$5, difficulty=$6, is_active=$7, updated_at=NOW()
WHERE id=$1
RETURNING id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at`
row := r.client.Pool.QueryRow(ctx, q,
id,
qn.Theme,
qn.Text,
qn.Answer,
qn.Hint,
string(qn.Difficulty),
qn.IsActive,
)
updated, err := scanQuestion(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, sharederrors.Wrap(
sharederrors.CodeQuestionNotFound,
"question not found",
err,
)
}
return nil, err
}
return updated, nil
}
func (r *QuestionRepository) SoftDelete(ctx context.Context, id string) error {
const q = `UPDATE questions SET is_active=false, updated_at=NOW() WHERE id=$1`
res, err := r.client.Pool.Exec(ctx, q, id)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return sharederrors.Wrap(
sharederrors.CodeQuestionNotFound,
"question not found",
nil,
)
}
return nil
}
func (r *QuestionRepository) ListThemes(ctx context.Context) ([]string, error) {
const q = `SELECT DISTINCT theme FROM questions WHERE is_active=true ORDER BY theme ASC`
rows, err := r.client.Pool.Query(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0)
for rows.Next() {
var theme string
if err := rows.Scan(&theme); err != nil {
return nil, err
}
out = append(out, theme)
}
return out, rows.Err()
}
func (r *QuestionRepository) CountRandomCandidates(
ctx context.Context,
filter domain.RandomFilter,
) (int, error) {
where, args := buildRandomWhere(filter)
query := `SELECT COUNT(*) FROM questions WHERE is_active=true` + where
row := r.client.Pool.QueryRow(ctx, query, args...)
var count int
if err := row.Scan(&count); err != nil {
return 0, err
}
return count, nil
}
func (r *QuestionRepository) RandomByOffset(
ctx context.Context,
filter domain.RandomFilter,
offset int,
) (*domain.Question, error) {
where, args := buildRandomWhere(filter)
args = append(args, offset)
query := `
SELECT id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at
FROM questions
WHERE is_active=true` + where + ` ORDER BY id LIMIT 1 OFFSET $` + fmt.Sprintf("%d", len(args))
row := r.client.Pool.QueryRow(ctx, query, args...)
question, err := scanQuestion(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, sharederrors.Wrap(
sharederrors.CodeNoQuestionsAvailable,
"no questions available",
err,
)
}
return nil, err
}
return question, nil
}
func (r *QuestionRepository) BulkCreate(
ctx context.Context,
questions []*domain.Question,
) (int, []domain.BulkError, error) {
tx, err := r.client.Pool.Begin(ctx)
if err != nil {
return 0, nil, err
}
defer func() {
_ = tx.Rollback(ctx)
}()
created := 0
errs := make([]domain.BulkError, 0)
const q = `
INSERT INTO questions (id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`
for i, item := range questions {
id := uuid.NewString()
now := time.Now().UTC()
_, execErr := tx.Exec(ctx, q,
id,
item.Theme,
item.Text,
item.Answer,
item.Hint,
string(item.Difficulty),
true,
now,
now,
)
if execErr != nil {
errs = append(
errs,
domain.BulkError{Index: i, Reason: execErr.Error()},
)
continue
}
created++
}
if err := tx.Commit(ctx); err != nil {
return 0, nil, err
}
return created, errs, nil
}
func buildRandomWhere(filter domain.RandomFilter) (string, []interface{}) {
clauses := make([]string, 0)
args := make([]interface{}, 0)
if filter.Theme != "" {
args = append(args, filter.Theme)
clauses = append(clauses, fmt.Sprintf("theme=$%d", len(args)))
}
if filter.Difficulty != "" {
args = append(args, string(filter.Difficulty))
clauses = append(clauses, fmt.Sprintf("difficulty=$%d", len(args)))
}
if len(filter.ExcludeQuestionIDs) > 0 {
excludeClause := make([]string, 0, len(filter.ExcludeQuestionIDs))
for _, id := range filter.ExcludeQuestionIDs {
args = append(args, id)
excludeClause = append(excludeClause, fmt.Sprintf("$%d", len(args)))
}
clauses = append(clauses, "id NOT IN ("+strings.Join(excludeClause, ",")+")")
}
if len(clauses) == 0 {
return "", args
}
return " AND " + strings.Join(clauses, " AND "), args
}
type scanner interface {
Scan(dest ...interface{}) error
}
func scanQuestion(row scanner) (*domain.Question, error) {
var (
id string
theme string
text string
answer string
hint *string
difficulty string
isActive bool
createdAt time.Time
updatedAt time.Time
)
if err := row.Scan(
&id,
&theme,
&text,
&answer,
&hint,
&difficulty,
&isActive,
&createdAt,
&updatedAt,
); err != nil {
return nil, err
}
q := &domain.Question{
ID: id,
Theme: theme,
Text: text,
Answer: answer,
Difficulty: domain.Difficulty(difficulty),
IsActive: isActive,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
if hint != nil {
q.Hint = *hint
}
return q, nil
}