From 76b512939d6d9f824d839bdd36a29fc05348c71b Mon Sep 17 00:00:00 2001 From: anygen-build-bot Date: Thu, 12 Feb 2026 08:58:53 +0000 Subject: [PATCH] feat(backend): add sqlite migrations + app state + tests --- backend/CMakeLists.txt | 7 +- backend/include/csp/app_state.h | 24 +++++ backend/include/csp/db/sqlite_db.h | 37 ++++++++ backend/migrations/001_init.sql | 66 ++++++++++++++ backend/src/app_state.cc | 22 +++++ backend/src/db/sqlite_db.cc | 135 +++++++++++++++++++++++++++++ backend/src/main.cc | 15 +++- backend/tests/sqlite_db_test.cc | 33 +++++++ 8 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 backend/include/csp/app_state.h create mode 100644 backend/include/csp/db/sqlite_db.h create mode 100644 backend/migrations/001_init.sql create mode 100644 backend/src/app_state.cc create mode 100644 backend/src/db/sqlite_db.cc create mode 100644 backend/tests/sqlite_db_test.cc diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index 91f50e2..21b96f0 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -9,13 +9,17 @@ find_package(Catch2 3 REQUIRED) add_library(csp_core src/version.cc + src/db/sqlite_db.cc + src/app_state.cc ) target_include_directories(csp_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ) -# SQLite will be used via Drogon DB client in later iterations. +target_link_libraries(csp_core PUBLIC + SQLite3_lib +) add_executable(csp_server src/main.cc @@ -35,6 +39,7 @@ enable_testing() add_executable(csp_tests tests/test_main.cc tests/version_test.cc + tests/sqlite_db_test.cc ) target_link_libraries(csp_tests PRIVATE diff --git a/backend/include/csp/app_state.h b/backend/include/csp/app_state.h new file mode 100644 index 0000000..906dd84 --- /dev/null +++ b/backend/include/csp/app_state.h @@ -0,0 +1,24 @@ +#pragma once + +#include "csp/db/sqlite_db.h" + +#include +#include + +namespace csp { + +class AppState { + public: + static AppState& Instance(); + + void Init(const std::string& sqlite_path); + + db::SqliteDb& db(); + + private: + AppState() = default; + + std::unique_ptr db_; +}; + +} // namespace csp diff --git a/backend/include/csp/db/sqlite_db.h b/backend/include/csp/db/sqlite_db.h new file mode 100644 index 0000000..491e91a --- /dev/null +++ b/backend/include/csp/db/sqlite_db.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include +#include + +namespace csp::db { + +class SqliteDb { + public: + static SqliteDb OpenFile(const std::string& path); + static SqliteDb OpenMemory(); + + SqliteDb() = default; + ~SqliteDb(); + + SqliteDb(const SqliteDb&) = delete; + SqliteDb& operator=(const SqliteDb&) = delete; + + SqliteDb(SqliteDb&& other) noexcept; + SqliteDb& operator=(SqliteDb&& other) noexcept; + + sqlite3* raw() const { return db_; } + + void Exec(const std::string& sql); + + private: + explicit SqliteDb(sqlite3* db) : db_(db) {} + + sqlite3* db_ = nullptr; +}; + +// Apply SQL migrations in order. For now we ship a single init migration. +void ApplyMigrations(SqliteDb& db); + +} // namespace csp::db diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..63b2adf --- /dev/null +++ b/backend/migrations/001_init.sql @@ -0,0 +1,66 @@ +-- 001_init.sql +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_salt TEXT NOT NULL, + password_hash TEXT NOT NULL, + rating INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS problems ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + statement_md TEXT NOT NULL, + difficulty INTEGER NOT NULL DEFAULT 1, + source TEXT NOT NULL DEFAULT "", + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS problem_tags ( + problem_id INTEGER NOT NULL, + tag TEXT NOT NULL, + PRIMARY KEY(problem_id, tag), + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS submissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + problem_id INTEGER NOT NULL, + language TEXT NOT NULL, + code TEXT NOT NULL, + status TEXT NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + time_ms INTEGER NOT NULL DEFAULT 0, + memory_kb INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS wrong_book ( + user_id INTEGER NOT NULL, + problem_id INTEGER NOT NULL, + last_submission_id INTEGER, + note TEXT NOT NULL DEFAULT "", + updated_at INTEGER NOT NULL, + PRIMARY KEY(user_id, problem_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE, + FOREIGN KEY(last_submission_id) REFERENCES submissions(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_submissions_user_created_at ON submissions(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_submissions_problem_created_at ON submissions(problem_id, created_at DESC); diff --git a/backend/src/app_state.cc b/backend/src/app_state.cc new file mode 100644 index 0000000..4a19579 --- /dev/null +++ b/backend/src/app_state.cc @@ -0,0 +1,22 @@ +#include "csp/app_state.h" + +#include + +namespace csp { + +AppState& AppState::Instance() { + static AppState inst; + return inst; +} + +void AppState::Init(const std::string& sqlite_path) { + db_ = std::make_unique(db::SqliteDb::OpenFile(sqlite_path)); + csp::db::ApplyMigrations(*db_); +} + +csp::db::SqliteDb& AppState::db() { + if (!db_) throw std::runtime_error("AppState not initialized"); + return *db_; +} + +} // namespace csp diff --git a/backend/src/db/sqlite_db.cc b/backend/src/db/sqlite_db.cc new file mode 100644 index 0000000..3a320aa --- /dev/null +++ b/backend/src/db/sqlite_db.cc @@ -0,0 +1,135 @@ +#include "csp/db/sqlite_db.h" + +#include +#include + +namespace csp::db { + +namespace { + +void ThrowSqlite(int rc, sqlite3* db, const char* what) { + if (rc == SQLITE_OK) return; + const char* msg = db ? sqlite3_errmsg(db) : ""; + throw std::runtime_error(std::string(what) + ": " + msg); +} + +} // namespace + +SqliteDb SqliteDb::OpenFile(const std::string& path) { + sqlite3* db = nullptr; + const int rc = sqlite3_open(path.c_str(), &db); + if (rc != SQLITE_OK) { + const char* msg = db ? sqlite3_errmsg(db) : ""; + if (db) sqlite3_close(db); + throw std::runtime_error(std::string("sqlite3_open failed: ") + msg); + } + return SqliteDb(db); +} + +SqliteDb SqliteDb::OpenMemory() { + sqlite3* db = nullptr; + const int rc = sqlite3_open(":memory:", &db); + ThrowSqlite(rc, db, "sqlite3_open(:memory:) failed"); + return SqliteDb(db); +} + +SqliteDb::~SqliteDb() { + if (db_) sqlite3_close(db_); +} + +SqliteDb::SqliteDb(SqliteDb&& other) noexcept : db_(other.db_) { + other.db_ = nullptr; +} + +SqliteDb& SqliteDb::operator=(SqliteDb&& other) noexcept { + if (this == &other) return *this; + if (db_) sqlite3_close(db_); + db_ = other.db_; + other.db_ = nullptr; + return *this; +} + +void SqliteDb::Exec(const std::string& sql) { + char* err = nullptr; + const int rc = sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &err); + if (rc != SQLITE_OK) { + std::string msg = err ? err : ""; + sqlite3_free(err); + ThrowSqlite(rc, db_, msg.c_str()); + } +} + +void ApplyMigrations(SqliteDb& db) { + // Keep it simple for MVP: apply the bundled init SQL. + // In later iterations we'll add a migrations table + incremental runner. + db.Exec("PRAGMA foreign_keys = ON;"); + + // 001_init.sql (embedded) + db.Exec(R"SQL( +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_salt TEXT NOT NULL, + password_hash TEXT NOT NULL, + rating INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS problems ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + statement_md TEXT NOT NULL, + difficulty INTEGER NOT NULL DEFAULT 1, + source TEXT NOT NULL DEFAULT "", + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS problem_tags ( + problem_id INTEGER NOT NULL, + tag TEXT NOT NULL, + PRIMARY KEY(problem_id, tag), + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS submissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + problem_id INTEGER NOT NULL, + language TEXT NOT NULL, + code TEXT NOT NULL, + status TEXT NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + time_ms INTEGER NOT NULL DEFAULT 0, + memory_kb INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS wrong_book ( + user_id INTEGER NOT NULL, + problem_id INTEGER NOT NULL, + last_submission_id INTEGER, + note TEXT NOT NULL DEFAULT "", + updated_at INTEGER NOT NULL, + PRIMARY KEY(user_id, problem_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE, + FOREIGN KEY(last_submission_id) REFERENCES submissions(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_submissions_user_created_at ON submissions(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_submissions_problem_created_at ON submissions(problem_id, created_at DESC); + )SQL"); +} + +} // namespace csp::db diff --git a/backend/src/main.cc b/backend/src/main.cc index a9c7e64..693e1c1 100644 --- a/backend/src/main.cc +++ b/backend/src/main.cc @@ -1,15 +1,22 @@ #include -int main(int argc, char** argv) { - (void)argc; - (void)argv; +#include "csp/app_state.h" + +#include + +int main(int argc, char** argv) { + const std::string db_path = (argc >= 2) ? argv[1] : std::string("data/csp.db"); + std::filesystem::create_directories(std::filesystem::path(db_path).parent_path()); + + csp::AppState::Instance().Init(db_path); - // Basic defaults. Will be moved to config-driven later. drogon::app() .addListener("0.0.0.0", 8080) .setThreadNum(4); LOG_INFO << "csp_server starting at http://0.0.0.0:8080"; + LOG_INFO << "sqlite db: " << db_path; + drogon::app().run(); return 0; } diff --git a/backend/tests/sqlite_db_test.cc b/backend/tests/sqlite_db_test.cc new file mode 100644 index 0000000..cb0ae78 --- /dev/null +++ b/backend/tests/sqlite_db_test.cc @@ -0,0 +1,33 @@ +#include + +#include "csp/db/sqlite_db.h" + +#include + +namespace { + +int CountTable(sqlite3* db, const char* table_name) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?"; + REQUIRE(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK); + REQUIRE(sqlite3_bind_text(stmt, 1, table_name, -1, SQLITE_TRANSIENT) == + SQLITE_OK); + REQUIRE(sqlite3_step(stmt) == SQLITE_ROW); + const int v = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + return v; +} + +} // namespace + +TEST_CASE("migrations create core tables") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + + REQUIRE(CountTable(db.raw(), "users") == 1); + REQUIRE(CountTable(db.raw(), "sessions") == 1); + REQUIRE(CountTable(db.raw(), "problems") == 1); + REQUIRE(CountTable(db.raw(), "submissions") == 1); + REQUIRE(CountTable(db.raw(), "wrong_book") == 1); +}