VoltMod
C++23 framework for CS2 server plugins
Loading...
Searching...
No Matches
Migrator.cpp
Go to the documentation of this file.
6#include <algorithm>
7#include <cctype>
8#include <filesystem>
9#include <string>
10#include <vector>
11
12namespace fs = std::filesystem;
13
14namespace VoltMod
15{
16
18{
20 std::string Name;
21 fs::path Path;
22};
23
24/** `[A-Za-z_][A-Za-z0-9_]*` - the table name is interpolated into SQL, so reject anything else. */
25static bool IsValidTableName(const std::string& name)
26{
27 if (name.empty())
28 {
29 return false;
30 }
31 if (!std::isalpha(static_cast<unsigned char>(name.front())) && name.front() != '_')
32 {
33 return false;
34 }
35 return std::all_of(name.begin(), name.end(), [](unsigned char c) { return std::isalnum(c) || c == '_'; });
36}
37
38static std::string HistoryTableDdl(const std::string& table)
39{
40 return "CREATE TABLE IF NOT EXISTS " + table +
41 " (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at BIGINT NOT NULL)";
42}
43
44template <class Conn>
45static void ApplyMigration(Conn& conn, const std::string& table, const Migration& migration,
46 const std::vector<std::string>& statements)
47{
48 for (const std::string& statement : statements)
49 {
51 }
52 // The timestamp is written explicitly: no DEFAULT for it is portable across the drivers.
53 conn("INSERT INTO " + table + " (version, name, applied_at) VALUES (" + std::to_string(migration.Version) + ", '" +
54 conn.escape(migration.Name) + "', " + std::to_string(Time::Now()) + ")");
55}
56
58{
59 if (!IsValidTableName(options.HistoryTable))
60 {
61 Log::Error("Invalid migration table name '{}'; refusing to run migrations.", options.HistoryTable);
62 return {};
63 }
64 const std::string& table = options.HistoryTable;
65
66 // Relative paths must resolve against the game dir, not the server process cwd.
67 const fs::path resolvedDir = ResolvePath(dir);
68 const Driver driver = db.GetDriver();
69
70 std::error_code ec;
71 if (!fs::exists(resolvedDir, ec))
72 {
73 Log::Warn("Migrations directory not found ({}); skipping schema setup.", resolvedDir.string());
74 return {.Success = true};
75 }
76
77 std::vector<Migration> migrations;
78 for (const auto& entry : fs::directory_iterator(resolvedDir, ec))
79 {
80 if (!entry.is_regular_file())
81 {
82 continue;
83 }
84 std::string name = entry.path().filename().string();
85 if (!name.ends_with(".sql"))
86 {
87 continue;
88 }
89 auto version = ParseMigrationVersion(name);
90 if (!version) // ignore stray files without a leading version (e.g. *.sql.bak)
91 {
92 continue;
93 }
94 migrations.push_back({*version, name, entry.path()});
95 }
96 std::sort(migrations.begin(), migrations.end(),
97 [](const Migration& a, const Migration& b) { return a.Version < b.Version; });
98
99 // Runs on the database worker via the blocking Run - load-time only.
100 auto outcome = db.Run("migrations", [&](auto& conn) -> MigrationResult {
101 using Conn = std::remove_cvref_t<decltype(conn)>;
102 const std::string lockKey = std::to_string(options.LockKey);
103
105
106 // Session-level lock held across the per-file transactions below; released when the
107 // connection drops. Serializes two plugin loads that race on the same database. SQLite
108 // has none: BEGIN IMMEDIATE below takes its single writer lock instead.
109 if constexpr (IsPostgres<Conn>)
110 {
111 conn("SELECT pg_advisory_lock(" + lockKey + ")");
112 }
113 else if constexpr (IsMariaDb<Conn>)
114 {
115 conn("DO GET_LOCK('voltmod_migrations_" + lockKey + "', 30)");
116 }
117
118 int current = 0;
119 for (const auto& row : conn(sqlpp::select(sqlpp::verbatim<sqlpp::integral>("MAX(version)").as(sqlpp::alias::a))
120 .from(sqlpp::verbatim_table(table))))
121 {
122 current = static_cast<int>(row.a.value_or(0));
123 }
124
125 MigrationResult result{.Success = true, .CurrentVersion = current};
126 for (const Migration& m : migrations)
127 {
128 if (m.Version <= current)
129 {
130 continue;
131 }
132 // An unreadable file must not be recorded as applied: an empty statement list would
133 // commit the version row having run nothing.
134 auto sql = ReadAllText(m.Path.string());
135 if (!sql)
136 {
137 Log::Error("Migration {} ({}) unreadable: {}", m.Version, m.Name, sql.error().Detail);
138 result.Success = false;
139 break;
140 }
141
142 const auto resolved = ResolveDialect(*sql, driver);
143 if (!resolved)
144 {
145 Log::Error("Migration {} ({}): {}.", m.Version, m.Name, resolved.error().Detail);
146 result.Success = false;
147 break;
148 }
149
150 const auto statements = SplitStatements(*resolved);
151
152 try
153 {
154 // MariaDB auto-commits every DDL statement, so a failed file there leaves the
155 // tables it already created; the version row is still the source of truth.
156 if constexpr (IsSqlite<Conn>)
157 {
158 conn("BEGIN IMMEDIATE");
159 try
160 {
162 conn("COMMIT");
163 }
164 catch (...)
165 {
166 conn("ROLLBACK");
167 throw;
168 }
169 }
170 else
171 {
172 auto transaction = sqlpp::start_transaction(conn);
174 transaction.commit();
175 }
176 ++result.Applied;
177 result.CurrentVersion = m.Version;
178 Log::Info("Applied migration {} ({}).", m.Version, m.Name);
179 }
180 catch (const std::exception& e)
181 {
182 Log::Error("Migration {} ({}) failed: {}", m.Version, m.Name, e.what());
183 result.Success = false;
184 break;
185 }
186 }
187
188 if constexpr (IsPostgres<Conn>)
189 {
190 conn("SELECT pg_advisory_unlock(" + lockKey + ")");
191 }
192 else if constexpr (IsMariaDb<Conn>)
193 {
194 conn("DO RELEASE_LOCK('voltmod_migrations_" + lockKey + "')");
195 }
196
197 if (result.Applied > 0)
198 {
199 Log::Info("Database schema up to date ({} migration(s) applied).", result.Applied);
200 }
201 return result;
202 });
203
204 if (!outcome)
205 {
206 Log::Error("Migration runner failed: {}", outcome.error().Detail);
207 return {};
208 }
209 return *outcome;
210}
211
212} // namespace VoltMod
Async database access over Postgres, MariaDB and SQLite.
Definition Client.hpp:40
static int64_t Now()
Definition Durations.cpp:10
void Error(std::format_string< Args... > fmt, Args &&... args)
Definition Log.hpp:97
void Info(std::format_string< Args... > fmt, Args &&... args)
Definition Log.hpp:79
void Warn(std::format_string< Args... > fmt, Args &&... args)
Definition Log.hpp:88
static bool IsValidTableName(const std::string &name)
Definition Migrator.cpp:25
std::filesystem::path ResolvePath(std::string_view relativePath)
a relative path against the base directory. If the path is already absolute, returns it as-is.
Definition Paths.cpp:14
std::optional< int > ParseMigrationVersion(std::string_view filename)
Definition Migrator.hpp:40
Result< std::string > ResolveDialect(std::string_view sql, Driver driver)
Definition Migrator.hpp:95
static std::string ReadFile(const std::filesystem::path &path)
Definition Loader.cpp:56
static void ApplyMigration(Conn &conn, const std::string &table, const Migration &migration, const std::vector< std::string > &statements)
Definition Migrator.cpp:45
std::vector< std::string > SplitStatements(std::string_view sql)
Definition Migrator.hpp:168
Result< std::string > ReadAllText(std::string_view path)
Read path (resolved via ResolvePath) into a string.
Definition File.cpp:12
MigrationResult RunMigrations(Database &db, std::string_view dir, const MigrationOptions &options={})
Definition Migrator.cpp:57
static std::string HistoryTableDdl(const std::string &table)
Definition Migrator.cpp:38
std::string Name
Definition Migrator.cpp:20