VoltMod
C++23 framework for CS2 server plugins
Loading...
Searching...
No Matches
Migrator.hpp
Go to the documentation of this file.
1#pragma once
2
5#include <algorithm>
6#include <charconv>
7#include <cstdint>
8#include <format>
9#include <optional>
10#include <string>
11#include <string_view>
12#include <vector>
13
14namespace VoltMod
15{
16
17/** Knobs for @ref RunMigrations; the defaults suit a single plugin owning its database. */
19{
20 /** Migration-history table. Must match `[A-Za-z_][A-Za-z0-9_]*` - it is interpolated into SQL. */
21 std::string HistoryTable = "schema_migrations";
22
23 /** Lock key serializing concurrent loads that share a database (a Postgres advisory lock, a
24 * MariaDB named lock; SQLite relies on its own write lock). Plugins sharing one database
25 * should use distinct table names AND distinct lock keys. */
26 int64_t LockKey = 727274;
27};
28
29/** Outcome of @ref RunMigrations. Contextually convertible to bool (success). */
31{
32 bool Success = false;
33 int Applied = 0; ///< Migrations applied by this run.
34 int CurrentVersion = 0; ///< Max version recorded in the history table after the run.
35
36 explicit operator bool() const { return Success; }
37};
38
39/** Leading `NNNN` version of a migration filename, or nullopt when it has none. */
40inline std::optional<int> ParseMigrationVersion(std::string_view filename)
41{
42 int version = 0;
43 const char* begin = filename.data();
44 const char* end = begin + filename.size();
45 auto [ptr, ec] = std::from_chars(begin, end, version);
46 if (ec != std::errc{} || ptr == begin)
47 {
48 return std::nullopt;
49 }
50 return version;
51}
52
53/**
54 * @brief The DDL spellings the three drivers disagree on, one per migration placeholder.
55 *
56 * The set is closed: anything beyond it belongs in a driver-specific migration.
57 */
58struct Dialect
59{
60 std::string_view AutoIncrementKey; ///< `@ID@`
61 std::string_view EpochNow; ///< `@NOW@`
62 std::string_view True; ///< `@TRUE@`
63 std::string_view False; ///< `@FALSE@`
64 std::string_view InsertIfAbsent; ///< `@INSERT_IF_ABSENT@`
65 /** Whether `@ON_CONFLICT(cols)@` renders. Postgres puts it at the end of the statement; the
66 * others carry the same meaning in their INSERT verb. */
68};
69
70/** The spellings @p driver wants. */
72{
73 switch (driver)
74 {
76 return {"BIGSERIAL PRIMARY KEY", "EXTRACT(EPOCH FROM NOW())::BIGINT", "TRUE", "FALSE", "INSERT INTO", true};
77 case Driver::MariaDb:
78 return {
79 "BIGINT AUTO_INCREMENT PRIMARY KEY", "(UNIX_TIMESTAMP())", "TRUE", "FALSE", "INSERT IGNORE INTO", false};
80 case Driver::Sqlite:
81 // 1/0 rather than TRUE/FALSE: it is what an existing database's stored schema text says.
82 return {
83 "INTEGER PRIMARY KEY AUTOINCREMENT", "(strftime('%s','now'))", "1", "0", "INSERT OR IGNORE INTO", false};
84 }
85 return {};
86}
87
88/**
89 * Substitute every dialect placeholder in @p sql for @p driver.
90 *
91 * A placeholder is `@NAME@` or `@NAME(args)@` with NAME in `[A-Z_]`, the same rule the CLI's
92 * renderer uses; any other `@`, such as one in an address literal, is ordinary text. An unknown
93 * placeholder fails rather than leaving a hole in a statement.
94 */
95inline Result<std::string> ResolveDialect(std::string_view sql, Driver driver)
96{
97 static constexpr std::string_view NameChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ_";
98 static constexpr std::string_view ConflictToken = "ON_CONFLICT(";
99 const Dialect dialect = DialectFor(driver);
100
101 std::string out;
102 out.reserve(sql.size());
103 size_t copied = 0;
104 for (size_t open = sql.find('@'); open != std::string_view::npos; open = sql.find('@', copied))
105 {
106 size_t close = std::min(sql.find_first_not_of(NameChars, open + 1), sql.size());
107 const bool named = close > open + 1;
108 if (named && close < sql.size() && sql[close] == '(')
109 {
110 const size_t args = sql.find_first_of(")@", close);
111 if (args != std::string_view::npos && sql[args] == ')')
112 {
113 close = args + 1;
114 }
115 }
116 if (!named || close >= sql.size() || sql[close] != '@')
117 {
118 out.append(sql.substr(copied, open + 1 - copied));
119 copied = open + 1;
120 continue;
121 }
122
123 out.append(sql.substr(copied, open - copied));
124 copied = close + 1;
125 const std::string_view token = sql.substr(open + 1, close - open - 1);
126 if (token == "ID")
127 {
128 out.append(dialect.AutoIncrementKey);
129 }
130 else if (token == "NOW")
131 {
132 out.append(dialect.EpochNow);
133 }
134 else if (token == "TRUE")
135 {
136 out.append(dialect.True);
137 }
138 else if (token == "FALSE")
139 {
140 out.append(dialect.False);
141 }
142 else if (token == "INSERT_IF_ABSENT")
143 {
144 out.append(dialect.InsertIfAbsent);
145 }
146 else if (token.starts_with(ConflictToken))
147 {
148 if (dialect.NeedsConflictClause)
149 {
150 out.append("ON CONFLICT (").append(token.substr(ConflictToken.size())).append(" DO NOTHING");
151 }
152 }
153 else
154 {
155 return std::unexpected(Error::Invalid(std::format("unknown migration placeholder @{}@", token)));
156 }
157 }
158 out.append(sql.substr(copied));
159 return out;
160}
161
162/**
163 * Split a migration file into single statements: `--` line comments are stripped, a `;` outside a
164 * single-quoted literal ends a statement, and blank statements are dropped. One statement per
165 * `;` - procedure bodies are not supported, because MariaDB and SQLite run raw SQL one statement
166 * at a time.
167 */
168inline std::vector<std::string> SplitStatements(std::string_view sql)
169{
170 static constexpr std::string_view Blanks = " \t\r\n";
171 std::vector<std::string> statements;
172 std::string current;
173 auto flush = [&] {
174 const size_t begin = current.find_first_not_of(Blanks);
175 if (begin != std::string::npos)
176 {
177 statements.push_back(current.substr(begin, current.find_last_not_of(Blanks) - begin + 1));
178 }
179 current.clear();
180 };
181
182 bool inLiteral = false;
183 for (size_t i = 0; i < sql.size(); ++i)
184 {
185 const char c = sql[i];
186 if (inLiteral)
187 {
188 // A doubled quote reads as close-then-reopen, which splits the same way.
189 inLiteral = c != '\'';
190 current.push_back(c);
191 }
192 else if (c == '-' && i + 1 < sql.size() && sql[i + 1] == '-')
193 {
194 while (i < sql.size() && sql[i] != '\n')
195 {
196 ++i;
197 }
198 current.push_back('\n');
199 }
200 else if (c == ';')
201 {
202 flush();
203 }
204 else
205 {
206 inLiteral = c == '\'';
207 current.push_back(c);
208 }
209 }
210 flush();
211 return statements;
212}
213
214} // namespace VoltMod
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
std::vector< std::string > SplitStatements(std::string_view sql)
Definition Migrator.hpp:168
std::expected< T, Error > Result
Definition Result.hpp:64
Dialect DialectFor(Driver driver)
Definition Migrator.hpp:71
The DDL spellings the three drivers disagree on, one per migration placeholder.
Definition Migrator.hpp:59
std::string_view EpochNow
@NOW@
Definition Migrator.hpp:61
std::string_view AutoIncrementKey
@ID@
Definition Migrator.hpp:60
std::string_view False
@FALSE@
Definition Migrator.hpp:63
std::string_view True
@TRUE@
Definition Migrator.hpp:62
std::string_view InsertIfAbsent
@INSERT_IF_ABSENT@
Definition Migrator.hpp:64
bool NeedsConflictClause
Definition Migrator.hpp:67
static Error Invalid(std::string detail)
Definition Result.hpp:51
int CurrentVersion
Max version recorded in the history table after the run.
Definition Migrator.hpp:34
int Applied
Migrations applied by this run.
Definition Migrator.hpp:33