From e8c5f772210d496c719f076fb966bec3bcc7e436 Mon Sep 17 00:00:00 2001 From: Darren Shen Date: Tue, 28 Apr 2026 14:36:51 -0700 Subject: [PATCH 1/2] Add missing interfaces to repositories --- internal/core/repositories/repositories.go | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/internal/core/repositories/repositories.go b/internal/core/repositories/repositories.go index fe0b38c..fb1403b 100644 --- a/internal/core/repositories/repositories.go +++ b/internal/core/repositories/repositories.go @@ -3,20 +3,55 @@ package repositories import ( "CodeSCE/internal/core/models" "context" + "time" ) +type AnswerRepository interface { + Upsert(ctx context.Context, answer *models.Answer) error + GetByAttemptID(ctx context.Context, attemptID int64) ([]models.Answer, error) + SetScore(ctx context.Context, answerID int64, score int) error +} + type AssessmentRepository interface { Create(ctx context.Context, assessment *models.AssessmentTemplate) error List(ctx context.Context) ([]models.AssessmentTemplate, error) GetByID(ctx context.Context, id int64) (*models.AssessmentTemplate, error) } +type AttemptRepository interface { + Create(ctx context.Context, attempt *models.AssessmentAttempt) error + GetByID(ctx context.Context, id int64) (*models.AssessmentAttempt, error) + ListByAssessmentID(ctx context.Context, assessmentID int64) ([]models.AssessmentAttempt, error) + UpdateStatus(ctx context.Context, id int64, status string) error + SetScore(ctx context.Context, id int64, score int) error + SetCompletedAt(ctx context.Context, id int64, completedAt time.Time) error +} + +type InviteRepository interface { + Create(ctx context.Context, invite *models.AssessmentInvite) error + GetByToken(ctx context.Context, token string) (*models.AssessmentInvite, error) + ListByAssessmentID(ctx context.Context, assessmentID int64) ([]models.AssessmentInvite, error) + UpdateStatus(ctx context.Context, id int64, status string) error +} + type QuestionRepository interface { Create(ctx context.Context, question *models.Question) error GetByID(ctx context.Context, id int64) (*models.Question, error) ListByAssessmentID(ctx context.Context, assessmentID int64) ([]models.Question, error) } +type SubmissionRepository interface { + Create(ctx context.Context, submission *models.Submission) error + GetByID(ctx context.Context, id int64) (*models.Submission, error) + UpdateStatus(ctx context.Context, id int64, status string) error + UpdateResult(ctx context.Context, id int64, result *models.SubmissionResult) error +} + +type SubmissionResultRepository interface { + CreateBatch(ctx context.Context, results []models.SubmissionResult) error + GetBySubmissionID(ctx context.Context, submissionID int64) ([]models.SubmissionResult, error) +} + type TestCaseRepository interface { CreateMany(ctx context.Context, questionID int64, testCases []models.TestCase) error ListByQuestionID(ctx context.Context, questionID int64) ([]models.TestCase, error) From cd4f087c8020b80a0059061309d70a867b16b81f Mon Sep 17 00:00:00 2001 From: Darren Shen Date: Tue, 28 Apr 2026 22:13:47 -0700 Subject: [PATCH 2/2] Implement repository layer --- .../core/repositories/postgres/answer_repo.go | 109 +++++++++++++ .../repositories/postgres/assessment_repo.go | 89 +++++++++++ .../repositories/postgres/attempt_repo.go | 148 ++++++++++++++++++ .../core/repositories/postgres/invite_repo.go | 124 +++++++++++++++ .../repositories/postgres/question_repo.go | 140 +++++++++++++++++ .../repositories/postgres/submission_repo.go | 136 ++++++++++++++++ .../postgres/submission_result_repo.go | 108 +++++++++++++ .../repositories/postgres/test_case_repo.go | 96 ++++++++++++ internal/core/repositories/repositories.go | 3 + 9 files changed, 953 insertions(+) create mode 100644 internal/core/repositories/postgres/answer_repo.go create mode 100644 internal/core/repositories/postgres/assessment_repo.go create mode 100644 internal/core/repositories/postgres/attempt_repo.go create mode 100644 internal/core/repositories/postgres/invite_repo.go create mode 100644 internal/core/repositories/postgres/question_repo.go create mode 100644 internal/core/repositories/postgres/submission_repo.go create mode 100644 internal/core/repositories/postgres/submission_result_repo.go create mode 100644 internal/core/repositories/postgres/test_case_repo.go diff --git a/internal/core/repositories/postgres/answer_repo.go b/internal/core/repositories/postgres/answer_repo.go new file mode 100644 index 0000000..f6a5945 --- /dev/null +++ b/internal/core/repositories/postgres/answer_repo.go @@ -0,0 +1,109 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "CodeSCE/internal/core/models" + "CodeSCE/internal/core/repositories" +) + +type answerRepository struct { + db *sql.DB +} + +func NewAnswerRepository(db *sql.DB) repositories.AnswerRepository { + return &answerRepository{db: db} +} + +func (r *answerRepository) Upsert(ctx context.Context, answer *models.Answer) error { + const query = ` + INSERT INTO answers (attempt_id, question_id, response, score_awarded) + VALUES ($1, $2, $3, $4) + ON CONFLICT (attempt_id, question_id) DO UPDATE + SET response = EXCLUDED.response, + score_awarded = EXCLUDED.score_awarded, + updated_at = NOW() + RETURNING id, created_at, updated_at + ` + if err := r.db.QueryRowContext( + ctx, query, + answer.AttemptID, + answer.QuestionID, + jsonRawArg(answer.Response), + answer.ScoreAwarded, + ).Scan(&answer.ID, &answer.CreatedAt, &answer.UpdatedAt); err != nil { + return fmt.Errorf("upsert answer: %w", err) + } + return nil +} + +func (r *answerRepository) GetByAttemptID(ctx context.Context, attemptID int64) ([]models.Answer, error) { + const query = ` + SELECT id, attempt_id, question_id, response, score_awarded, created_at, updated_at + FROM answers + WHERE attempt_id = $1 + ORDER BY id + ` + rows, err := r.db.QueryContext(ctx, query, attemptID) + if err != nil { + return nil, fmt.Errorf("list answers: %w", err) + } + defer rows.Close() + + var answers []models.Answer + for rows.Next() { + var ( + a models.Answer + response []byte + ) + if err := rows.Scan( + &a.ID, + &a.AttemptID, + &a.QuestionID, + &response, + &a.ScoreAwarded, + &a.CreatedAt, + &a.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scan answer: %w", err) + } + if response != nil { + a.Response = json.RawMessage(response) + } + answers = append(answers, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate answers: %w", err) + } + return answers, nil +} + +func (r *answerRepository) SetScore(ctx context.Context, answerID int64, score int) error { + const query = ` + UPDATE answers + SET score_awarded = $2, updated_at = NOW() + WHERE id = $1 + ` + res, err := r.db.ExecContext(ctx, query, answerID, score) + if err != nil { + return fmt.Errorf("set answer score: %w", err) + } + rowsAffected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("set answer score rows affected: %w", err) + } + if rowsAffected == 0 { + return repositories.ErrNotFound + } + return nil +} + +func jsonRawArg(raw json.RawMessage) any { + if len(raw) == 0 { + return nil + } + return []byte(raw) +} diff --git a/internal/core/repositories/postgres/assessment_repo.go b/internal/core/repositories/postgres/assessment_repo.go new file mode 100644 index 0000000..4c07fce --- /dev/null +++ b/internal/core/repositories/postgres/assessment_repo.go @@ -0,0 +1,89 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "CodeSCE/internal/core/models" + "CodeSCE/internal/core/repositories" +) + +type assessmentRepository struct { + db *sql.DB +} + +func NewAssessmentRepository(db *sql.DB) repositories.AssessmentRepository { + return &assessmentRepository{db: db} +} + +func (r *assessmentRepository) Create(ctx context.Context, a *models.AssessmentTemplate) error { + const query = ` + INSERT INTO assessment_templates (title, description, duration_minutes) + VALUES ($1, $2, $3) + RETURNING id, created_at, updated_at + ` + if err := r.db.QueryRowContext(ctx, query, a.Title, a.Description, a.DurationMinutes). + Scan(&a.ID, &a.CreatedAt, &a.UpdatedAt); err != nil { + return fmt.Errorf("create assessment template: %w", err) + } + return nil +} + +func (r *assessmentRepository) List(ctx context.Context) ([]models.AssessmentTemplate, error) { + const query = ` + SELECT id, title, description, duration_minutes, created_at, updated_at + FROM assessment_templates + ORDER BY id + ` + rows, err := r.db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("list assessment templates: %w", err) + } + defer rows.Close() + + var templates []models.AssessmentTemplate + for rows.Next() { + var t models.AssessmentTemplate + if err := rows.Scan( + &t.ID, + &t.Title, + &t.Description, + &t.DurationMinutes, + &t.CreatedAt, + &t.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scan assessment template: %w", err) + } + templates = append(templates, t) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate assessment templates: %w", err) + } + return templates, nil +} + +func (r *assessmentRepository) GetByID(ctx context.Context, id int64) (*models.AssessmentTemplate, error) { + const query = ` + SELECT id, title, description, duration_minutes, created_at, updated_at + FROM assessment_templates + WHERE id = $1 + ` + var t models.AssessmentTemplate + err := r.db.QueryRowContext(ctx, query, id).Scan( + &t.ID, + &t.Title, + &t.Description, + &t.DurationMinutes, + &t.CreatedAt, + &t.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, repositories.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get assessment template by id: %w", err) + } + return &t, nil +} diff --git a/internal/core/repositories/postgres/attempt_repo.go b/internal/core/repositories/postgres/attempt_repo.go new file mode 100644 index 0000000..938d95f --- /dev/null +++ b/internal/core/repositories/postgres/attempt_repo.go @@ -0,0 +1,148 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "CodeSCE/internal/core/models" + "CodeSCE/internal/core/repositories" +) + +type attemptRepository struct { + db *sql.DB +} + +func NewAttemptRepository(db *sql.DB) repositories.AttemptRepository { + return &attemptRepository{db: db} +} + +func (r *attemptRepository) Create(ctx context.Context, attempt *models.AssessmentAttempt) error { + const query = ` + INSERT INTO assessment_attempts ( + invite_id, started_at, completed_at, status, total_score + ) + VALUES ($1, $2, $3, COALESCE(NULLIF($4, ''), 'in_progress'), $5) + RETURNING id, status, created_at, updated_at + ` + if err := r.db.QueryRowContext( + ctx, query, + attempt.InviteID, + attempt.StartedAt, + attempt.CompletedAt, + attempt.Status, + attempt.TotalScore, + ).Scan(&attempt.ID, &attempt.Status, &attempt.CreatedAt, &attempt.UpdatedAt); err != nil { + return fmt.Errorf("create assessment attempt: %w", err) + } + return nil +} + +func (r *attemptRepository) GetByID(ctx context.Context, id int64) (*models.AssessmentAttempt, error) { + const query = ` + SELECT id, invite_id, started_at, completed_at, status, total_score, created_at, updated_at + FROM assessment_attempts + WHERE id = $1 + ` + var attempt models.AssessmentAttempt + err := r.db.QueryRowContext(ctx, query, id).Scan( + &attempt.ID, + &attempt.InviteID, + &attempt.StartedAt, + &attempt.CompletedAt, + &attempt.Status, + &attempt.TotalScore, + &attempt.CreatedAt, + &attempt.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, repositories.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get assessment attempt by id: %w", err) + } + return &attempt, nil +} + +func (r *attemptRepository) ListByAssessmentID(ctx context.Context, assessmentID int64) ([]models.AssessmentAttempt, error) { + // Attempts link to assessments via assessment_invites.assessment_template_id. + const query = ` + SELECT a.id, a.invite_id, a.started_at, a.completed_at, a.status, + a.total_score, a.created_at, a.updated_at + FROM assessment_attempts a + JOIN assessment_invites i ON i.id = a.invite_id + WHERE i.assessment_template_id = $1 + ORDER BY a.id + ` + rows, err := r.db.QueryContext(ctx, query, assessmentID) + if err != nil { + return nil, fmt.Errorf("list assessment attempts: %w", err) + } + defer rows.Close() + + var attempts []models.AssessmentAttempt + for rows.Next() { + var attempt models.AssessmentAttempt + if err := rows.Scan( + &attempt.ID, + &attempt.InviteID, + &attempt.StartedAt, + &attempt.CompletedAt, + &attempt.Status, + &attempt.TotalScore, + &attempt.CreatedAt, + &attempt.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scan assessment attempt: %w", err) + } + attempts = append(attempts, attempt) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate assessment attempts: %w", err) + } + return attempts, nil +} + +func (r *attemptRepository) UpdateStatus(ctx context.Context, id int64, status string) error { + const query = ` + UPDATE assessment_attempts + SET status = $2, updated_at = NOW() + WHERE id = $1 + ` + return r.execAffectingOne(ctx, query, "update assessment attempt status", id, status) +} + +func (r *attemptRepository) SetScore(ctx context.Context, id int64, score int) error { + const query = ` + UPDATE assessment_attempts + SET total_score = $2, updated_at = NOW() + WHERE id = $1 + ` + return r.execAffectingOne(ctx, query, "set assessment attempt score", id, score) +} + +func (r *attemptRepository) SetCompletedAt(ctx context.Context, id int64, completedAt time.Time) error { + const query = ` + UPDATE assessment_attempts + SET completed_at = $2, updated_at = NOW() + WHERE id = $1 + ` + return r.execAffectingOne(ctx, query, "set assessment attempt completed_at", id, completedAt) +} + +func (r *attemptRepository) execAffectingOne(ctx context.Context, query, op string, args ...any) error { + res, err := r.db.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + rowsAffected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("%s rows affected: %w", op, err) + } + if rowsAffected == 0 { + return repositories.ErrNotFound + } + return nil +} diff --git a/internal/core/repositories/postgres/invite_repo.go b/internal/core/repositories/postgres/invite_repo.go new file mode 100644 index 0000000..9e63b46 --- /dev/null +++ b/internal/core/repositories/postgres/invite_repo.go @@ -0,0 +1,124 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "CodeSCE/internal/core/models" + "CodeSCE/internal/core/repositories" +) + +type inviteRepository struct { + db *sql.DB +} + +func NewInviteRepository(db *sql.DB) repositories.InviteRepository { + return &inviteRepository{db: db} +} + +func (r *inviteRepository) Create(ctx context.Context, invite *models.AssessmentInvite) error { + const query = ` + INSERT INTO assessment_invites ( + assessment_template_id, candidate_email, invite_token, expires_at, status + ) + VALUES ($1, $2, $3, $4, COALESCE(NULLIF($5, ''), 'pending')) + RETURNING id, status, created_at, updated_at + ` + if err := r.db.QueryRowContext( + ctx, query, + invite.AssessmentTemplateID, + invite.CandidateEmail, + invite.InviteToken, + invite.ExpiresAt, + invite.Status, + ).Scan(&invite.ID, &invite.Status, &invite.CreatedAt, &invite.UpdatedAt); err != nil { + return fmt.Errorf("create assessment invite: %w", err) + } + return nil +} + +func (r *inviteRepository) GetByToken(ctx context.Context, token string) (*models.AssessmentInvite, error) { + const query = ` + SELECT id, assessment_template_id, candidate_email, invite_token, + expires_at, status, created_at, updated_at + FROM assessment_invites + WHERE invite_token = $1 + ` + var invite models.AssessmentInvite + err := r.db.QueryRowContext(ctx, query, token).Scan( + &invite.ID, + &invite.AssessmentTemplateID, + &invite.CandidateEmail, + &invite.InviteToken, + &invite.ExpiresAt, + &invite.Status, + &invite.CreatedAt, + &invite.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, repositories.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get assessment invite by token: %w", err) + } + return &invite, nil +} + +func (r *inviteRepository) ListByAssessmentID(ctx context.Context, assessmentID int64) ([]models.AssessmentInvite, error) { + const query = ` + SELECT id, assessment_template_id, candidate_email, invite_token, + expires_at, status, created_at, updated_at + FROM assessment_invites + WHERE assessment_template_id = $1 + ORDER BY id + ` + rows, err := r.db.QueryContext(ctx, query, assessmentID) + if err != nil { + return nil, fmt.Errorf("list assessment invites: %w", err) + } + defer rows.Close() + + var invites []models.AssessmentInvite + for rows.Next() { + var invite models.AssessmentInvite + if err := rows.Scan( + &invite.ID, + &invite.AssessmentTemplateID, + &invite.CandidateEmail, + &invite.InviteToken, + &invite.ExpiresAt, + &invite.Status, + &invite.CreatedAt, + &invite.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scan assessment invite: %w", err) + } + invites = append(invites, invite) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate assessment invites: %w", err) + } + return invites, nil +} + +func (r *inviteRepository) UpdateStatus(ctx context.Context, id int64, status string) error { + const query = ` + UPDATE assessment_invites + SET status = $2, updated_at = NOW() + WHERE id = $1 + ` + res, err := r.db.ExecContext(ctx, query, id, status) + if err != nil { + return fmt.Errorf("update assessment invite status: %w", err) + } + rowsAffected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("update assessment invite status rows affected: %w", err) + } + if rowsAffected == 0 { + return repositories.ErrNotFound + } + return nil +} diff --git a/internal/core/repositories/postgres/question_repo.go b/internal/core/repositories/postgres/question_repo.go new file mode 100644 index 0000000..5f44262 --- /dev/null +++ b/internal/core/repositories/postgres/question_repo.go @@ -0,0 +1,140 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + + "CodeSCE/internal/core/models" + "CodeSCE/internal/core/repositories" +) + +type questionRepository struct { + db *sql.DB +} + +func NewQuestionRepository(db *sql.DB) repositories.QuestionRepository { + return &questionRepository{db: db} +} + +func (r *questionRepository) Create(ctx context.Context, q *models.Question) error { + // position is NOT NULL with a UNIQUE(assessment_template_id, position) constraint + // but is not part of the model, so derive the next slot for this template inline. + const query = ` + INSERT INTO questions ( + assessment_template_id, type, prompt, language, + options, correct_answer, score, position + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, + COALESCE((SELECT MAX(position) + 1 FROM questions WHERE assessment_template_id = $1), 1) + ) + RETURNING id + ` + if err := r.db.QueryRowContext( + ctx, query, + q.AssessmentTemplateID, + q.Type, + q.Prompt, + q.Language, + rawJSONArg(q.Options), + rawJSONArg(q.CorrectAnswer), + q.Score, + ).Scan(&q.ID); err != nil { + return fmt.Errorf("create question: %w", err) + } + return nil +} + +func (r *questionRepository) GetByID(ctx context.Context, id int64) (*models.Question, error) { + const query = ` + SELECT id, assessment_template_id, type, prompt, language, + options, correct_answer, score + FROM questions + WHERE id = $1 + ` + var ( + q models.Question + options []byte + correct []byte + ) + err := r.db.QueryRowContext(ctx, query, id).Scan( + &q.ID, + &q.AssessmentTemplateID, + &q.Type, + &q.Prompt, + &q.Language, + &options, + &correct, + &q.Score, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, repositories.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get question by id: %w", err) + } + q.Options = bytesToRawJSON(options) + q.CorrectAnswer = bytesToRawJSON(correct) + return &q, nil +} + +func (r *questionRepository) ListByAssessmentID(ctx context.Context, assessmentID int64) ([]models.Question, error) { + const query = ` + SELECT id, assessment_template_id, type, prompt, language, + options, correct_answer, score + FROM questions + WHERE assessment_template_id = $1 + ORDER BY position + ` + rows, err := r.db.QueryContext(ctx, query, assessmentID) + if err != nil { + return nil, fmt.Errorf("list questions: %w", err) + } + defer rows.Close() + + var questions []models.Question + for rows.Next() { + var ( + q models.Question + options []byte + correct []byte + ) + if err := rows.Scan( + &q.ID, + &q.AssessmentTemplateID, + &q.Type, + &q.Prompt, + &q.Language, + &options, + &correct, + &q.Score, + ); err != nil { + return nil, fmt.Errorf("scan question: %w", err) + } + q.Options = bytesToRawJSON(options) + q.CorrectAnswer = bytesToRawJSON(correct) + questions = append(questions, q) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate questions: %w", err) + } + return questions, nil +} + +func rawJSONArg(raw *json.RawMessage) any { + if raw == nil { + return nil + } + return []byte(*raw) +} + +func bytesToRawJSON(b []byte) *json.RawMessage { + if b == nil { + return nil + } + raw := json.RawMessage(b) + return &raw +} diff --git a/internal/core/repositories/postgres/submission_repo.go b/internal/core/repositories/postgres/submission_repo.go new file mode 100644 index 0000000..6a8b26e --- /dev/null +++ b/internal/core/repositories/postgres/submission_repo.go @@ -0,0 +1,136 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "CodeSCE/internal/core/models" + "CodeSCE/internal/core/repositories" +) + +type submissionRepository struct { + db *sql.DB +} + +func NewSubmissionRepository(db *sql.DB) repositories.SubmissionRepository { + return &submissionRepository{db: db} +} + +func (r *submissionRepository) Create(ctx context.Context, submission *models.Submission) error { + const query = ` + INSERT INTO submissions ( + attempt_id, question_id, language, source_code, status, + stdout, stderr, execution_ms, memory_bytes, score_awarded + ) + VALUES ($1, $2, $3, $4, COALESCE(NULLIF($5, ''), 'queued'), $6, $7, $8, $9, $10) + RETURNING id, status, created_at, updated_at + ` + if err := r.db.QueryRowContext( + ctx, query, + submission.AttemptID, + submission.QuestionID, + submission.Language, + submission.SourceCode, + submission.Status, + submission.Stdout, + submission.Stderr, + submission.ExecutionMS, + submission.MemoryBytes, + submission.ScoreAwarded, + ).Scan( + &submission.ID, + &submission.Status, + &submission.CreatedAt, + &submission.UpdatedAt, + ); err != nil { + return fmt.Errorf("create submission: %w", err) + } + return nil +} + +func (r *submissionRepository) GetByID(ctx context.Context, id int64) (*models.Submission, error) { + const query = ` + SELECT id, attempt_id, question_id, language, source_code, status, + stdout, stderr, execution_ms, memory_bytes, score_awarded, + created_at, updated_at + FROM submissions + WHERE id = $1 + ` + var s models.Submission + err := r.db.QueryRowContext(ctx, query, id).Scan( + &s.ID, + &s.AttemptID, + &s.QuestionID, + &s.Language, + &s.SourceCode, + &s.Status, + &s.Stdout, + &s.Stderr, + &s.ExecutionMS, + &s.MemoryBytes, + &s.ScoreAwarded, + &s.CreatedAt, + &s.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, repositories.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get submission by id: %w", err) + } + return &s, nil +} + +func (r *submissionRepository) UpdateStatus(ctx context.Context, id int64, status string) error { + const query = ` + UPDATE submissions + SET status = $2, updated_at = NOW() + WHERE id = $1 + ` + res, err := r.db.ExecContext(ctx, query, id, status) + if err != nil { + return fmt.Errorf("update submission status: %w", err) + } + rowsAffected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("update submission status rows affected: %w", err) + } + if rowsAffected == 0 { + return repositories.ErrNotFound + } + return nil +} + +func (r *submissionRepository) UpdateResult(ctx context.Context, id int64, result *models.SubmissionResult) error { + // Map a per-test-case execution result back onto the submission row's + // rollup fields. The submission's status flips to passed/failed, and the + // stdout / execution_ms columns receive the run's output and timing. + const query = ` + UPDATE submissions + SET status = CASE WHEN $2 THEN 'passed' ELSE 'failed' END, + stdout = $3, + execution_ms = $4, + updated_at = NOW() + WHERE id = $1 + ` + res, err := r.db.ExecContext( + ctx, query, + id, + result.Passed, + result.ActualOutput, + result.ExecutionMS, + ) + if err != nil { + return fmt.Errorf("update submission result: %w", err) + } + rowsAffected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("update submission result rows affected: %w", err) + } + if rowsAffected == 0 { + return repositories.ErrNotFound + } + return nil +} diff --git a/internal/core/repositories/postgres/submission_result_repo.go b/internal/core/repositories/postgres/submission_result_repo.go new file mode 100644 index 0000000..029a456 --- /dev/null +++ b/internal/core/repositories/postgres/submission_result_repo.go @@ -0,0 +1,108 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "CodeSCE/internal/core/models" + "CodeSCE/internal/core/repositories" +) + +type submissionResultRepository struct { + db *sql.DB +} + +func NewSubmissionResultRepository(db *sql.DB) repositories.SubmissionResultRepository { + return &submissionResultRepository{db: db} +} + +func (r *submissionResultRepository) CreateBatch(ctx context.Context, results []models.SubmissionResult) error { + if len(results) == 0 { + return nil + } + + const colsPerRow = 5 + placeholders := make([]string, 0, len(results)) + args := make([]any, 0, len(results)*colsPerRow) + for i, sr := range results { + base := i * colsPerRow + placeholders = append(placeholders, fmt.Sprintf("($%d, $%d, $%d, $%d, $%d)", + base+1, base+2, base+3, base+4, base+5)) + args = append(args, + sr.SubmissionID, + sr.TestCaseID, + sr.Passed, + sr.ActualOutput, + sr.ExecutionMS, + ) + } + + query := ` + INSERT INTO submission_results (submission_id, test_case_id, passed, actual_output, execution_ms) + VALUES ` + strings.Join(placeholders, ", ") + ` + RETURNING id, created_at + ` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("create submission results: %w", err) + } + defer rows.Close() + + idx := 0 + for rows.Next() { + if idx >= len(results) { + return fmt.Errorf("create submission results: more rows returned than inserted") + } + if err := rows.Scan(&results[idx].ID, &results[idx].CreatedAt); err != nil { + return fmt.Errorf("scan submission result id: %w", err) + } + idx++ + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate submission result ids: %w", err) + } + return nil +} + +func (r *submissionResultRepository) GetBySubmissionID(ctx context.Context, submissionID int64) ([]models.SubmissionResult, error) { + const query = ` + SELECT id, submission_id, test_case_id, passed, actual_output, execution_ms, created_at + FROM submission_results + WHERE submission_id = $1 + ORDER BY id + ` + rows, err := r.db.QueryContext(ctx, query, submissionID) + if err != nil { + return nil, fmt.Errorf("list submission results: %w", err) + } + defer rows.Close() + + var results []models.SubmissionResult + for rows.Next() { + var ( + sr models.SubmissionResult + actualOutput sql.NullString + ) + if err := rows.Scan( + &sr.ID, + &sr.SubmissionID, + &sr.TestCaseID, + &sr.Passed, + &actualOutput, + &sr.ExecutionMS, + &sr.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("scan submission result: %w", err) + } + if actualOutput.Valid { + sr.ActualOutput = actualOutput.String + } + results = append(results, sr) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate submission results: %w", err) + } + return results, nil +} diff --git a/internal/core/repositories/postgres/test_case_repo.go b/internal/core/repositories/postgres/test_case_repo.go new file mode 100644 index 0000000..9913b17 --- /dev/null +++ b/internal/core/repositories/postgres/test_case_repo.go @@ -0,0 +1,96 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "CodeSCE/internal/core/models" + "CodeSCE/internal/core/repositories" +) + +type testCaseRepository struct { + db *sql.DB +} + +func NewTestCaseRepository(db *sql.DB) repositories.TestCaseRepository { + return &testCaseRepository{db: db} +} + +func (r *testCaseRepository) CreateMany(ctx context.Context, questionID int64, testCases []models.TestCase) error { + if len(testCases) == 0 { + return nil + } + + const colsPerRow = 5 + placeholders := make([]string, 0, len(testCases)) + args := make([]any, 0, len(testCases)*colsPerRow) + for i, tc := range testCases { + base := i * colsPerRow + placeholders = append(placeholders, fmt.Sprintf("($%d, $%d, $%d, $%d, $%d)", + base+1, base+2, base+3, base+4, base+5)) + args = append(args, questionID, tc.Input, tc.ExpectedOutput, tc.IsHidden, tc.Score) + } + + query := ` + INSERT INTO test_cases (question_id, input, expected_output, is_hidden, score) + VALUES ` + strings.Join(placeholders, ", ") + ` + RETURNING id + ` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("create test cases: %w", err) + } + defer rows.Close() + + idx := 0 + for rows.Next() { + if idx >= len(testCases) { + return fmt.Errorf("create test cases: more ids returned than rows inserted") + } + if err := rows.Scan(&testCases[idx].ID); err != nil { + return fmt.Errorf("scan test case id: %w", err) + } + testCases[idx].QuestionID = questionID + idx++ + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate test case ids: %w", err) + } + return nil +} + +func (r *testCaseRepository) ListByQuestionID(ctx context.Context, questionID int64) ([]models.TestCase, error) { + const query = ` + SELECT id, question_id, input, expected_output, is_hidden, score + FROM test_cases + WHERE question_id = $1 + ORDER BY id + ` + rows, err := r.db.QueryContext(ctx, query, questionID) + if err != nil { + return nil, fmt.Errorf("list test cases: %w", err) + } + defer rows.Close() + + var testCases []models.TestCase + for rows.Next() { + var tc models.TestCase + if err := rows.Scan( + &tc.ID, + &tc.QuestionID, + &tc.Input, + &tc.ExpectedOutput, + &tc.IsHidden, + &tc.Score, + ); err != nil { + return nil, fmt.Errorf("scan test case: %w", err) + } + testCases = append(testCases, tc) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate test cases: %w", err) + } + return testCases, nil +} diff --git a/internal/core/repositories/repositories.go b/internal/core/repositories/repositories.go index fb1403b..f1a441a 100644 --- a/internal/core/repositories/repositories.go +++ b/internal/core/repositories/repositories.go @@ -3,9 +3,12 @@ package repositories import ( "CodeSCE/internal/core/models" "context" + "errors" "time" ) +var ErrNotFound = errors.New("not found") + type AnswerRepository interface { Upsert(ctx context.Context, answer *models.Answer) error GetByAttemptID(ctx context.Context, attemptID int64) ([]models.Answer, error)