aboutsummaryrefslogtreecommitdiffhomepage
path: root/services/app/src/Repositories
diff options
context:
space:
mode:
Diffstat (limited to 'services/app/src/Repositories')
-rw-r--r--services/app/src/Repositories/AnswerRepository.php277
-rw-r--r--services/app/src/Repositories/QuizRepository.php234
-rw-r--r--services/app/src/Repositories/TestcaseExecutionRepository.php296
-rw-r--r--services/app/src/Repositories/TestcaseRepository.php143
-rw-r--r--services/app/src/Repositories/UserRepository.php127
5 files changed, 1077 insertions, 0 deletions
diff --git a/services/app/src/Repositories/AnswerRepository.php b/services/app/src/Repositories/AnswerRepository.php
new file mode 100644
index 0000000..1798084
--- /dev/null
+++ b/services/app/src/Repositories/AnswerRepository.php
@@ -0,0 +1,277 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Nsfisis\Albatross\Repositories;
+
+use DateTimeImmutable;
+use Nsfisis\Albatross\Database\Connection;
+use Nsfisis\Albatross\Exceptions\EntityValidationException;
+use Nsfisis\Albatross\Models\AggregatedExecutionStatus;
+use Nsfisis\Albatross\Models\Answer;
+use Nsfisis\Albatross\Sql\DateTimeParser;
+use PDOException;
+
+final class AnswerRepository
+{
+ private const ANSWER_FIELDS = [
+ 'answer_id',
+ 'quiz_id',
+ 'answer_number',
+ 'submitted_at',
+ 'author_id',
+ 'code',
+ 'code_size',
+ 'execution_status',
+ ];
+
+ private const ANSWER_JOIN_USER_FIELDS = [
+ 'users.username AS author_name',
+ 'users.is_admin AS author_is_admin',
+ ];
+
+ public function __construct(
+ private readonly Connection $conn,
+ ) {
+ }
+
+ /**
+ * @return Answer[]
+ */
+ public function listByQuizId(int $quiz_id): array
+ {
+ $result = $this->conn
+ ->query()
+ ->select('answers')
+ ->leftJoin('users', 'answers.author_id = users.user_id')
+ ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS])
+ ->where('quiz_id = :quiz_id')
+ ->orderBy([['execution_status', 'DESC'], ['code_size', 'ASC'], ['submitted_at', 'ASC']])
+ ->execute(['quiz_id' => $quiz_id]);
+ return array_map($this->mapRawRowToAnswer(...), $result);
+ }
+
+ /**
+ * @return Answer[]
+ */
+ public function listByQuizIdAndAuthorId(int $quiz_id, int $author_id): array
+ {
+ $result = $this->conn
+ ->query()
+ ->select('answers')
+ ->leftJoin('users', 'answers.author_id = users.user_id')
+ ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS])
+ ->where('quiz_id = :quiz_id AND author_id = :author_id')
+ ->orderBy([['execution_status', 'DESC'], ['code_size', 'ASC'], ['submitted_at', 'ASC']])
+ ->execute(['quiz_id' => $quiz_id, 'author_id' => $author_id]);
+ return array_map($this->mapRawRowToAnswer(...), $result);
+ }
+
+ public function findByQuizIdAndAnswerNumber(int $quiz_id, int $answer_number): ?Answer
+ {
+ $result = $this->conn
+ ->query()
+ ->select('answers')
+ ->leftJoin('users', 'answers.author_id = users.user_id')
+ ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS])
+ ->where('quiz_id = :quiz_id AND answer_number = :answer_number')
+ ->first()
+ ->execute(['quiz_id' => $quiz_id, 'answer_number' => $answer_number]);
+ return isset($result) ? $this->mapRawRowToAnswer($result) : null;
+ }
+
+ public function findById(int $answer_id): ?Answer
+ {
+ $result = $this->conn
+ ->query()
+ ->select('answers')
+ ->leftJoin('users', 'answers.author_id = users.user_id')
+ ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS])
+ ->where('answer_id = :answer_id')
+ ->first()
+ ->execute(['answer_id' => $answer_id]);
+ return isset($result) ? $this->mapRawRowToAnswer($result) : null;
+ }
+
+ /**
+ * @param positive-int $upto
+ * @return Answer[]
+ */
+ public function getRanking(int $quiz_id, int $upto): array
+ {
+ $result = $this->conn
+ ->query()
+ ->select('answers')
+ ->leftJoin('users', 'answers.author_id = users.user_id')
+ ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS])
+ ->where('quiz_id = :quiz_id AND execution_status = :execution_status')
+ ->orderBy([['code_size', 'ASC'], ['submitted_at', 'ASC']])
+ ->limit($upto)
+ ->execute(['quiz_id' => $quiz_id, 'execution_status' => AggregatedExecutionStatus::OK->toInt()]);
+ return array_map($this->mapRawRowToAnswer(...), $result);
+ }
+
+ /**
+ * @return Answer[]
+ */
+ public function listAllCorrectAnswers(int $quiz_id): array
+ {
+ $result = $this->conn
+ ->query()
+ ->select('answers')
+ ->leftJoin('users', 'answers.author_id = users.user_id')
+ ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS])
+ ->where('quiz_id = :quiz_id AND execution_status = :execution_status')
+ ->orderBy([['submitted_at', 'ASC']])
+ ->execute(['quiz_id' => $quiz_id, 'execution_status' => AggregatedExecutionStatus::OK->toInt()]);
+ return array_map($this->mapRawRowToAnswer(...), $result);
+ }
+
+ public function create(
+ int $quiz_id,
+ int $author_id,
+ string $code,
+ ): int {
+ $answer = Answer::create(
+ quiz_id: $quiz_id,
+ author_id: $author_id,
+ code: $code,
+ );
+
+ $next_answer_number_query = $this->conn
+ ->query()
+ ->select('answers')
+ ->fields(['COALESCE(MAX(answer_number), 0) + 1'])
+ ->where('quiz_id = :quiz_id')
+ ->limit(1);
+
+ try {
+ return $this->conn
+ ->query()
+ ->insert('answers')
+ ->values([
+ 'quiz_id' => $answer->quiz_id,
+ 'answer_number' => $next_answer_number_query,
+ 'author_id' => $answer->author_id,
+ 'code' => $answer->code,
+ 'code_size' => $answer->code_size,
+ 'execution_status' => $answer->execution_status->toInt(),
+ ])
+ ->execute();
+ } catch (PDOException $e) {
+ throw new EntityValidationException(
+ message: '回答の投稿に失敗しました',
+ previous: $e,
+ );
+ }
+ }
+
+ public function markAllAsPending(int $quiz_id): void
+ {
+ $this->conn
+ ->query()
+ ->update('answers')
+ ->set(['execution_status' => AggregatedExecutionStatus::Pending->toInt()])
+ ->where('quiz_id = :quiz_id')
+ ->execute(['quiz_id' => $quiz_id]);
+ }
+
+ public function markAllAsUpdateNeeded(int $quiz_id): void
+ {
+ $this->conn
+ ->query()
+ ->update('answers')
+ ->set(['execution_status' => AggregatedExecutionStatus::UpdateNeeded->toInt()])
+ ->where('quiz_id = :quiz_id')
+ ->execute(['quiz_id' => $quiz_id]);
+ }
+
+ public function markAsPending(int $answer_id): void
+ {
+ $this->conn
+ ->query()
+ ->update('answers')
+ ->set(['execution_status' => AggregatedExecutionStatus::Pending->toInt()])
+ ->where('answer_id = :answer_id')
+ ->execute(['answer_id' => $answer_id]);
+ }
+
+ public function tryGetNextUpdateNeededAnswer(): ?Answer
+ {
+ $result = $this->conn
+ ->query()
+ ->select('answers')
+ ->leftJoin('users', 'answers.author_id = users.user_id')
+ ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS])
+ ->where('execution_status = :execution_status')
+ ->orderBy([['submitted_at', 'ASC']])
+ ->first()
+ ->execute(['execution_status' => AggregatedExecutionStatus::UpdateNeeded->toInt()]);
+ return isset($result) ? $this->mapRawRowToAnswer($result) : null;
+ }
+
+ public function updateExecutionStatus(
+ int $answer_id,
+ AggregatedExecutionStatus $execution_status,
+ ): void {
+ $this->conn
+ ->query()
+ ->update('answers')
+ ->set(['execution_status' => $execution_status->toInt()])
+ ->where('answer_id = :answer_id')
+ ->execute(['answer_id' => $answer_id]);
+ }
+
+ public function deleteAllByQuizId(int $quiz_id): void
+ {
+ $this->conn
+ ->query()
+ ->delete('answers')
+ ->where('quiz_id = :quiz_id')
+ ->execute(['quiz_id' => $quiz_id]);
+ }
+
+ public function deleteAllByUserId(int $user_id): void
+ {
+ $this->conn
+ ->query()
+ ->delete('answers')
+ ->where('author_id = :author_id')
+ ->execute(['author_id' => $user_id]);
+ }
+
+ /**
+ * @param array<string, ?string> $row
+ */
+ private function mapRawRowToAnswer(array $row): Answer
+ {
+ assert(isset($row['answer_id']));
+ assert(isset($row['quiz_id']));
+ assert(isset($row['answer_number']));
+ assert(isset($row['submitted_at']));
+ assert(isset($row['author_id']));
+ assert(isset($row['code']));
+ assert(isset($row['code_size']));
+ assert(isset($row['execution_status']));
+
+ $answer_id = (int) $row['answer_id'];
+ $quiz_id = (int) $row['quiz_id'];
+ $answer_number = (int) $row['answer_number'];
+ $submitted_at = DateTimeParser::parse($row['submitted_at']);
+ assert($submitted_at instanceof DateTimeImmutable, "Failed to parse " . $row['submitted_at']);
+ $author_id = (int) $row['author_id'];
+
+ return new Answer(
+ answer_id: $answer_id,
+ quiz_id: $quiz_id,
+ answer_number: $answer_number,
+ submitted_at: $submitted_at,
+ author_id: $author_id,
+ code: $row['code'],
+ code_size: (int) $row['code_size'],
+ execution_status: AggregatedExecutionStatus::fromInt((int)$row['execution_status']),
+ author_name: $row['author_name'] ?? null,
+ author_is_admin: (bool) ($row['author_is_admin'] ?? null),
+ );
+ }
+}
diff --git a/services/app/src/Repositories/QuizRepository.php b/services/app/src/Repositories/QuizRepository.php
new file mode 100644
index 0000000..b360f9e
--- /dev/null
+++ b/services/app/src/Repositories/QuizRepository.php
@@ -0,0 +1,234 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Nsfisis\Albatross\Repositories;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use Nsfisis\Albatross\Database\Connection;
+use Nsfisis\Albatross\Exceptions\EntityValidationException;
+use Nsfisis\Albatross\Models\Quiz;
+use Nsfisis\Albatross\Sql\DateTimeParser;
+use PDOException;
+
+final class QuizRepository
+{
+ private const QUIZ_FIELDS = [
+ 'quiz_id',
+ 'created_at',
+ 'started_at',
+ 'ranking_hidden_at',
+ 'finished_at',
+ 'title',
+ 'slug',
+ 'description',
+ 'example_code',
+ 'birdie_code_size',
+ ];
+
+ public function __construct(
+ private readonly Connection $conn,
+ ) {
+ }
+
+ /**
+ * @return Quiz[]
+ */
+ public function listAll(): array
+ {
+ $result = $this->conn
+ ->query()
+ ->select('quizzes')
+ ->fields(self::QUIZ_FIELDS)
+ ->orderBy([['created_at', 'ASC']])
+ ->execute();
+ return array_map($this->mapRawRowToQuiz(...), $result);
+ }
+
+ /**
+ * @return Quiz[]
+ */
+ public function listStarted(?DateTimeImmutable $now = null): array
+ {
+ if ($now === null) {
+ $now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
+ }
+ $result = $this->conn
+ ->query()
+ ->select('quizzes')
+ ->fields(self::QUIZ_FIELDS)
+ ->where('started_at <= :now')
+ ->orderBy([['created_at', 'ASC']])
+ ->execute(['now' => $now->format('Y-m-d H:i:s.u')]);
+ return array_map($this->mapRawRowToQuiz(...), $result);
+ }
+
+ public function findById(int $quiz_id): ?Quiz
+ {
+ $result = $this->conn
+ ->query()
+ ->select('quizzes')
+ ->fields(self::QUIZ_FIELDS)
+ ->where('quiz_id = :quiz_id')
+ ->first()
+ ->execute(['quiz_id' => $quiz_id]);
+ return isset($result) ? $this->mapRawRowToQuiz($result) : null;
+ }
+
+ public function findBySlug(string $slug): ?Quiz
+ {
+ $result = $this->conn
+ ->query()
+ ->select('quizzes')
+ ->fields(self::QUIZ_FIELDS)
+ ->where('slug = :slug')
+ ->first()
+ ->execute(['slug' => $slug]);
+ return isset($result) ? $this->mapRawRowToQuiz($result) : null;
+ }
+
+ public function create(
+ string $title,
+ string $slug,
+ string $description,
+ string $example_code,
+ ?int $birdie_code_size,
+ DateTimeImmutable $started_at,
+ DateTimeImmutable $ranking_hidden_at,
+ DateTimeImmutable $finished_at,
+ ): int {
+ $quiz = Quiz::create(
+ title: $title,
+ slug: $slug,
+ description: $description,
+ example_code: $example_code,
+ birdie_code_size: $birdie_code_size,
+ started_at: $started_at,
+ ranking_hidden_at: $ranking_hidden_at,
+ finished_at: $finished_at,
+ );
+
+ $values = [
+ 'title' => $quiz->title,
+ 'slug' => $quiz->slug,
+ 'description' => $quiz->description,
+ 'example_code' => $quiz->example_code,
+ 'started_at' => $quiz->started_at->format('Y-m-d H:i:s.u'),
+ 'ranking_hidden_at' => $quiz->ranking_hidden_at->format('Y-m-d H:i:s.u'),
+ 'finished_at' => $quiz->finished_at->format('Y-m-d H:i:s.u'),
+ ];
+ if ($quiz->birdie_code_size !== null) {
+ $values['birdie_code_size'] = $quiz->birdie_code_size;
+ }
+
+ try {
+ return $this->conn
+ ->query()
+ ->insert('quizzes')
+ ->values($values)
+ ->execute();
+ } catch (PDOException $e) {
+ throw new EntityValidationException(
+ message: '問題の作成に失敗しました',
+ previous: $e,
+ );
+ }
+ }
+
+ public function update(
+ int $quiz_id,
+ string $title,
+ string $description,
+ string $example_code,
+ ?int $birdie_code_size,
+ DateTimeImmutable $started_at,
+ DateTimeImmutable $ranking_hidden_at,
+ DateTimeImmutable $finished_at,
+ ): void {
+ Quiz::validate(
+ $title,
+ 'dummy',
+ $description,
+ $example_code,
+ $birdie_code_size,
+ $started_at,
+ $ranking_hidden_at,
+ $finished_at,
+ );
+
+ $values = [
+ 'title' => $title,
+ 'description' => $description,
+ 'example_code' => $example_code,
+ 'started_at' => $started_at->format('Y-m-d H:i:s.u'),
+ 'ranking_hidden_at' => $ranking_hidden_at->format('Y-m-d H:i:s.u'),
+ 'finished_at' => $finished_at->format('Y-m-d H:i:s.u'),
+ ];
+ if ($birdie_code_size !== null) {
+ $values['birdie_code_size'] = $birdie_code_size;
+ }
+
+ try {
+ $this->conn
+ ->query()
+ ->update('quizzes')
+ ->set($values)
+ ->where('quiz_id = :quiz_id')
+ ->execute(['quiz_id' => $quiz_id]);
+ } catch (PDOException $e) {
+ throw new EntityValidationException(
+ message: '問題の更新に失敗しました',
+ previous: $e,
+ );
+ }
+ }
+
+ public function delete(int $quiz_id): void
+ {
+ $this->conn
+ ->query()
+ ->delete('quizzes')
+ ->where('quiz_id = :quiz_id')
+ ->execute(['quiz_id' => $quiz_id]);
+ }
+
+ /**
+ * @param array<string, string> $row
+ */
+ private function mapRawRowToQuiz(array $row): Quiz
+ {
+ assert(isset($row['quiz_id']));
+ assert(isset($row['created_at']));
+ assert(isset($row['started_at']));
+ assert(isset($row['ranking_hidden_at']));
+ assert(isset($row['finished_at']));
+ assert(isset($row['title']));
+ assert(isset($row['slug']));
+ assert(isset($row['description']));
+ assert(isset($row['example_code']));
+
+ $quiz_id = (int) $row['quiz_id'];
+ $created_at = DateTimeParser::parse($row['created_at']);
+ assert($created_at instanceof DateTimeImmutable, "Failed to parse " . $row['created_at']);
+ $started_at = DateTimeParser::parse($row['started_at']);
+ assert($started_at instanceof DateTimeImmutable, "Failed to parse " . $row['started_at']);
+ $ranking_hidden_at = DateTimeParser::parse($row['ranking_hidden_at']);
+ assert($ranking_hidden_at instanceof DateTimeImmutable, "Failed to parse " . $row['ranking_hidden_at']);
+ $finished_at = DateTimeParser::parse($row['finished_at']);
+ assert($finished_at instanceof DateTimeImmutable, "Failed to parse " . $row['finished_at']);
+
+ return new Quiz(
+ quiz_id: $quiz_id,
+ created_at: $created_at,
+ started_at: $started_at,
+ ranking_hidden_at: $ranking_hidden_at,
+ finished_at: $finished_at,
+ title: $row['title'],
+ slug: $row['slug'],
+ description: $row['description'],
+ example_code: $row['example_code'],
+ birdie_code_size: ($row['birdie_code_size'] ?? '') === '' ? null : (int) $row['birdie_code_size'],
+ );
+ }
+}
diff --git a/services/app/src/Repositories/TestcaseExecutionRepository.php b/services/app/src/Repositories/TestcaseExecutionRepository.php
new file mode 100644
index 0000000..dea0931
--- /dev/null
+++ b/services/app/src/Repositories/TestcaseExecutionRepository.php
@@ -0,0 +1,296 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Nsfisis\Albatross\Repositories;
+
+use Nsfisis\Albatross\Database\Connection;
+use Nsfisis\Albatross\Models\ExecutionStatus;
+use Nsfisis\Albatross\Models\TestcaseExecution;
+
+final class TestcaseExecutionRepository
+{
+ private const TESTCASE_EXECUTION_FIELDS = [
+ 'testcase_execution_id',
+ 'testcase_id',
+ 'answer_id',
+ 'status',
+ 'stdout',
+ 'stderr',
+ ];
+
+ public function __construct(
+ private readonly Connection $conn,
+ ) {
+ }
+
+ public function findByAnswerIdAndTestcaseExecutionId(
+ int $answer_id,
+ int $testcase_execution_id,
+ ): ?TestcaseExecution {
+ $result = $this->conn
+ ->query()
+ ->select('testcase_executions')
+ ->fields(self::TESTCASE_EXECUTION_FIELDS)
+ ->where('answer_id = :answer_id AND testcase_execution_id = :testcase_execution_id')
+ ->first()
+ ->execute([
+ 'answer_id' => $answer_id,
+ 'testcase_execution_id' => $testcase_execution_id,
+ ]);
+ return isset($result) ? $this->mapRawRowToTestcaseExecution($result) : null;
+ }
+
+ /**
+ * @return TestcaseExecution[]
+ */
+ public function listByQuizId(int $quiz_id): array
+ {
+ $result = $this->conn
+ ->query()
+ ->select('testcase_executions')
+ ->fields(self::TESTCASE_EXECUTION_FIELDS)
+ ->where('quiz_id = :quiz_id')
+ ->orderBy([['testcase_execution_id', 'ASC']])
+ ->execute(['quiz_id' => $quiz_id]);
+ return array_map($this->mapRawRowToTestcaseExecution(...), $result);
+ }
+
+ /**
+ * @return TestcaseExecution[]
+ */
+ public function listByAnswerId(int $answer_id): array
+ {
+ $result = $this->conn
+ ->query()
+ ->select('testcase_executions')
+ ->fields(self::TESTCASE_EXECUTION_FIELDS)
+ ->where('answer_id = :answer_id')
+ ->orderBy([['testcase_execution_id', 'ASC']])
+ ->execute(['answer_id' => $answer_id]);
+ return array_map($this->mapRawRowToTestcaseExecution(...), $result);
+ }
+
+ /**
+ * @return array<int, ExecutionStatus>
+ */
+ public function getStatuses(int $answer_id): array
+ {
+ $result = $this->conn
+ ->query()
+ ->select('testcase_executions')
+ ->fields(['testcase_execution_id', 'status'])
+ ->where('answer_id = :answer_id')
+ ->orderBy([['testcase_execution_id', 'ASC']])
+ ->execute(['answer_id' => $answer_id]);
+ return array_combine(
+ array_map(fn ($row) => (int)$row['testcase_execution_id'], $result),
+ array_map(fn ($row) => ExecutionStatus::fromInt((int)$row['status']), $result),
+ );
+ }
+
+ public function tryGetNextPendingTestcaseExecution(): ?TestcaseExecution
+ {
+ return $this->conn->transaction(function () {
+ $pending_ex_result = $this->conn
+ ->query()
+ ->select('testcase_executions')
+ ->fields(self::TESTCASE_EXECUTION_FIELDS)
+ ->where('status = :status')
+ ->orderBy([['testcase_execution_id', 'ASC']])
+ ->first()
+ ->execute(['status' => ExecutionStatus::Pending->toInt()]);
+ $pending_ex = isset($pending_ex_result) ? $this->mapRawRowToTestcaseExecution($pending_ex_result) : null;
+ if ($pending_ex === null) {
+ return null;
+ }
+ $this->conn
+ ->query()
+ ->update('testcase_executions')
+ ->set(['status' => ExecutionStatus::Running->toInt()])
+ ->where('testcase_execution_id = :testcase_execution_id')
+ ->execute(['testcase_execution_id' => $pending_ex->testcase_execution_id]);
+ return new TestcaseExecution(
+ testcase_execution_id: $pending_ex->testcase_execution_id,
+ testcase_id: $pending_ex->testcase_id,
+ answer_id: $pending_ex->answer_id,
+ status: ExecutionStatus::Running,
+ stdout: null,
+ stderr: null,
+ );
+ });
+ }
+
+ public function create(
+ int $testcase_id,
+ int $answer_id,
+ ): int {
+ $ex = TestcaseExecution::create(
+ testcase_id: $testcase_id,
+ answer_id: $answer_id,
+ );
+
+ $values = [
+ 'testcase_id' => $ex->testcase_id,
+ 'answer_id' => $ex->answer_id,
+ 'status' => $ex->status->toInt(),
+ ];
+
+ return $this->conn
+ ->query()
+ ->insert('testcase_executions')
+ ->values([
+ 'testcase_id' => $ex->testcase_id,
+ 'answer_id' => $ex->answer_id,
+ 'status' => $ex->status->toInt(),
+ ])
+ ->execute();
+ }
+
+ public function enqueueForAllAnswers(
+ int $quiz_id,
+ int $testcase_id,
+ ): void {
+ $this->conn
+ ->query()
+ ->insertFromSelect('testcase_executions')
+ ->fields(['testcase_id', 'answer_id', 'status'])
+ ->from($this->conn
+ ->query()
+ ->select('answers')
+ ->fields([':testcase_id', 'answer_id', ':status'])
+ ->where('quiz_id = :quiz_id'))
+ ->execute([
+ 'quiz_id' => $quiz_id,
+ 'testcase_id' => $testcase_id,
+ 'status' => ExecutionStatus::Pending->toInt(),
+ ]);
+ }
+
+ public function enqueueForSingleAnswer(
+ int $answer_id,
+ int $quiz_id,
+ ): void {
+ $this->conn
+ ->query()
+ ->insertFromSelect('testcase_executions')
+ ->fields(['testcase_id', 'answer_id', 'status'])
+ ->from($this->conn
+ ->query()
+ ->select('testcases')
+ ->fields(['testcase_id', ':answer_id', ':status'])
+ ->where('quiz_id = :quiz_id'))
+ ->execute([
+ 'quiz_id' => $quiz_id,
+ 'answer_id' => $answer_id,
+ 'status' => ExecutionStatus::Pending->toInt(),
+ ]);
+ }
+
+ public function markAllAsPendingByQuizId(
+ int $quiz_id,
+ ): void {
+ $this->conn
+ ->query()
+ ->update('testcase_executions')
+ ->set([
+ 'status' => ExecutionStatus::Pending->toInt(),
+ 'stdout' => '',
+ 'stderr' => '',
+ ])
+ ->where('answer_id IN (SELECT answer_id FROM answers WHERE quiz_id = :quiz_id)')
+ ->execute(['quiz_id' => $quiz_id]);
+ }
+
+ public function markAllAsPendingByAnswerId(
+ int $answer_id,
+ ): void {
+ $this->conn
+ ->query()
+ ->update('testcase_executions')
+ ->set([
+ 'status' => ExecutionStatus::Pending->toInt(),
+ 'stdout' => '',
+ 'stderr' => '',
+ ])
+ ->where('answer_id = :answer_id')
+ ->execute(['answer_id' => $answer_id]);
+ }
+
+ public function markAllAsPendingByTestcaseId(
+ int $testcase_id,
+ ): void {
+ $this->conn
+ ->query()
+ ->update('testcase_executions')
+ ->set([
+ 'status' => ExecutionStatus::Pending->toInt(),
+ 'stdout' => '',
+ 'stderr' => '',
+ ])
+ ->where('testcase_id = :testcase_id')
+ ->execute(['testcase_id' => $testcase_id]);
+ }
+
+ public function markAsPending(int $testcase_execution_id): void
+ {
+ $this->update($testcase_execution_id, ExecutionStatus::Pending, '', '');
+ }
+
+ public function update(
+ int $testcase_execution_id,
+ ExecutionStatus $status,
+ ?string $stdout,
+ ?string $stderr,
+ ): void {
+ $values = [
+ 'status' => $status->toInt(),
+ ];
+ if ($stdout !== null) {
+ $values['stdout'] = $stdout;
+ }
+ if ($stderr !== null) {
+ $values['stderr'] = $stderr;
+ }
+
+ $this->conn
+ ->query()
+ ->update('testcase_executions')
+ ->set($values)
+ ->where('testcase_execution_id = :testcase_execution_id')
+ ->execute(['testcase_execution_id' => $testcase_execution_id]);
+ }
+
+ public function deleteByTestcaseId(int $testcase_id): void
+ {
+ $this->conn
+ ->query()
+ ->delete('testcase_executions')
+ ->where('testcase_id = :testcase_id')
+ ->execute(['testcase_id' => $testcase_id]);
+ }
+
+ /**
+ * @param array<string, string> $row
+ */
+ private function mapRawRowToTestcaseExecution(array $row): TestcaseExecution
+ {
+ assert(isset($row['testcase_execution_id']));
+ assert(isset($row['testcase_id']));
+ assert(isset($row['answer_id']));
+ assert(isset($row['status']));
+
+ $testcase_execution_id = (int) $row['testcase_execution_id'];
+ $testcase_id = (int) $row['testcase_id'];
+ $answer_id = (int) $row['answer_id'];
+
+ return new TestcaseExecution(
+ testcase_execution_id: $testcase_execution_id,
+ testcase_id: $testcase_id,
+ answer_id: $answer_id,
+ status: ExecutionStatus::fromInt((int)$row['status']),
+ stdout: $row['stdout'] ?? null,
+ stderr: $row['stderr'] ?? null,
+ );
+ }
+}
diff --git a/services/app/src/Repositories/TestcaseRepository.php b/services/app/src/Repositories/TestcaseRepository.php
new file mode 100644
index 0000000..4fd3297
--- /dev/null
+++ b/services/app/src/Repositories/TestcaseRepository.php
@@ -0,0 +1,143 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Nsfisis\Albatross\Repositories;
+
+use Nsfisis\Albatross\Database\Connection;
+use Nsfisis\Albatross\Exceptions\EntityValidationException;
+use Nsfisis\Albatross\Models\Testcase;
+use PDOException;
+
+final class TestcaseRepository
+{
+ private const TESTCASE_FIELDS = [
+ 'testcase_id',
+ 'quiz_id',
+ 'input',
+ 'expected_result',
+ ];
+
+ public function __construct(
+ private readonly Connection $conn,
+ ) {
+ }
+
+ public function findByQuizIdAndTestcaseId(
+ int $quiz_id,
+ int $testcase_id,
+ ): ?Testcase {
+ $result = $this->conn
+ ->query()
+ ->select('testcases')
+ ->fields(self::TESTCASE_FIELDS)
+ ->where('quiz_id = :quiz_id AND testcase_id = :testcase_id')
+ ->first()
+ ->execute([
+ 'quiz_id' => $quiz_id,
+ 'testcase_id' => $testcase_id,
+ ]);
+ return isset($result) ? $this->mapRawRowToTestcase($result) : null;
+ }
+
+ /**
+ * @return Testcase[]
+ */
+ public function listByQuizId(int $quiz_id): array
+ {
+ $result = $this->conn
+ ->query()
+ ->select('testcases')
+ ->fields(self::TESTCASE_FIELDS)
+ ->where('quiz_id = :quiz_id')
+ ->orderBy([['testcase_id', 'ASC']])
+ ->execute(['quiz_id' => $quiz_id]);
+ return array_map($this->mapRawRowToTestcase(...), $result);
+ }
+
+ public function create(
+ int $quiz_id,
+ string $input,
+ string $expected_result,
+ ): int {
+ $testcase = Testcase::create(
+ quiz_id: $quiz_id,
+ input: $input,
+ expected_result: $expected_result,
+ );
+
+ $values = [
+ 'quiz_id' => $testcase->quiz_id,
+ 'input' => $testcase->input,
+ 'expected_result' => $testcase->expected_result,
+ ];
+
+ try {
+ return $this->conn
+ ->query()
+ ->insert('testcases')
+ ->values($values)
+ ->execute();
+ } catch (PDOException $e) {
+ throw new EntityValidationException(
+ message: 'テストケースの作成に失敗しました',
+ previous: $e,
+ );
+ }
+ }
+
+ public function update(
+ int $testcase_id,
+ string $input,
+ string $expected_result,
+ ): void {
+ try {
+ $this->conn
+ ->query()
+ ->update('testcases')
+ ->set([
+ 'input' => $input,
+ 'expected_result' => $expected_result,
+ ])
+ ->where('testcase_id = :testcase_id')
+ ->execute([
+ 'testcase_id' => $testcase_id,
+ ]);
+ } catch (PDOException $e) {
+ throw new EntityValidationException(
+ message: 'テストケースの更新に失敗しました',
+ previous: $e,
+ );
+ }
+ }
+
+ public function delete(int $testcase_id): void
+ {
+ $this->conn
+ ->query()
+ ->delete('testcases')
+ ->where('testcase_id = :testcase_id')
+ ->execute(['testcase_id' => $testcase_id]);
+ }
+
+ /**
+ * @param array<string, string> $row
+ */
+ private function mapRawRowToTestcase(array $row): Testcase
+ {
+ assert(isset($row['testcase_id']));
+ assert(isset($row['quiz_id']));
+ assert(isset($row['input']));
+ assert(isset($row['expected_result']));
+
+ $testcase_id = (int) $row['testcase_id'];
+ $quiz_id = (int) $row['quiz_id'];
+
+ return new Testcase(
+ testcase_id: $testcase_id,
+ quiz_id: $quiz_id,
+ input: $row['input'],
+ expected_result: $row['expected_result'],
+ );
+ }
+}
diff --git a/services/app/src/Repositories/UserRepository.php b/services/app/src/Repositories/UserRepository.php
new file mode 100644
index 0000000..adca397
--- /dev/null
+++ b/services/app/src/Repositories/UserRepository.php
@@ -0,0 +1,127 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Nsfisis\Albatross\Repositories;
+
+use Nsfisis\Albatross\Database\Connection;
+use Nsfisis\Albatross\Exceptions\EntityValidationException;
+use Nsfisis\Albatross\Models\User;
+use PDOException;
+
+final class UserRepository
+{
+ public function __construct(
+ private readonly Connection $conn,
+ ) {
+ }
+
+ /**
+ * @return User[]
+ */
+ public function listAll(): array
+ {
+ $result = $this->conn
+ ->query()
+ ->select('users')
+ ->fields(['user_id', 'username', 'is_admin'])
+ ->orderBy([['user_id', 'ASC']])
+ ->execute();
+ return array_map($this->mapRawRowToUser(...), $result);
+ }
+
+ public function findById(int $user_id): ?User
+ {
+ $result = $this->conn
+ ->query()
+ ->select('users')
+ ->fields(['user_id', 'username', 'is_admin'])
+ ->where('user_id = :user_id')
+ ->first()
+ ->execute(['user_id' => $user_id]);
+ return isset($result) ? $this->mapRawRowToUser($result) : null;
+ }
+
+ public function findByUsername(string $username): ?User
+ {
+ $result = $this->conn
+ ->query()
+ ->select('users')
+ ->fields(['user_id', 'username', 'is_admin'])
+ ->where('username = :username')
+ ->first()
+ ->execute(['username' => $username]);
+ return isset($result) ? $this->mapRawRowToUser($result) : null;
+ }
+
+ /**
+ * @return positive-int
+ */
+ public function create(
+ string $username,
+ bool $is_admin,
+ ): int {
+ $user = User::create(
+ username: $username,
+ is_admin: $is_admin,
+ );
+
+ try {
+ return $this->conn
+ ->query()
+ ->insert('users')
+ ->values([
+ 'username' => $user->username,
+ 'is_admin' => +$user->is_admin,
+ ])
+ ->execute();
+ } catch (PDOException $e) {
+ throw new EntityValidationException(
+ message: 'ユーザの作成に失敗しました',
+ previous: $e,
+ );
+ }
+ }
+
+ public function update(
+ int $user_id,
+ bool $is_admin,
+ ): void {
+ $this->conn
+ ->query()
+ ->update('users')
+ ->set([
+ 'is_admin' => +$is_admin,
+ ])
+ ->where('user_id = :user_id')
+ ->execute(['user_id' => $user_id]);
+ }
+
+ public function delete(int $user_id): void
+ {
+ $this->conn
+ ->query()
+ ->delete('users')
+ ->where('user_id = :user_id')
+ ->execute(['user_id' => $user_id]);
+ }
+
+ /**
+ * @param array<string, string> $row
+ */
+ private function mapRawRowToUser(array $row): User
+ {
+ assert(isset($row['user_id']));
+ assert(isset($row['username']));
+ assert(isset($row['is_admin']));
+
+ $user_id = (int) $row['user_id'];
+ $is_admin = (bool) $row['is_admin'];
+
+ return new User(
+ user_id: $user_id,
+ username: $row['username'],
+ is_admin: $is_admin,
+ );
+ }
+}