Implement SQLite database file initialization and migration

This commit is contained in:
Crypto137 2024-08-28 18:33:57 +03:00
parent 92c2670b0b
commit f0b5b080ba
8 changed files with 296 additions and 11 deletions

View file

@ -11,6 +11,14 @@ namespace MHServerEmu.Core.Helpers
public static readonly string ServerRoot = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
public static readonly string DataDirectory = Path.Combine(ServerRoot, "Data");
/// <summary>
/// Returns a path relative to server root directory.
/// </summary>
public static string GetRelativePath(string filePath)
{
return Path.GetRelativePath(ServerRoot, filePath);
}
/// <summary>
/// Deserializes a <typeparamref name="T"/> from a JSON file located at the specified path.
/// </summary>

View file

@ -0,0 +1,72 @@
-- Initialize a new database file using the current schema version
PRAGMA user_version = 1;
CREATE TABLE "Account" (
"Id" INTEGER NOT NULL UNIQUE,
"Email" TEXT NOT NULL UNIQUE,
"PlayerName" TEXT NOT NULL UNIQUE,
"PasswordHash" BLOB NOT NULL,
"Salt" BLOB NOT NULL,
"UserLevel" INTEGER NOT NULL,
"IsBanned" INTEGER NOT NULL,
"IsArchived" INTEGER NOT NULL,
"IsPasswordExpired" INTEGER NOT NULL,
PRIMARY KEY("Id")
);
CREATE TABLE "Player" (
"DbGuid" INTEGER NOT NULL UNIQUE,
"ArchiveData" BLOB,
"StartTarget" INTEGER,
"StartTargetRegionOverride" INTEGER,
"AOIVolume" INTEGER,
FOREIGN KEY("DbGuid") REFERENCES "Account"("Id") ON DELETE CASCADE,
PRIMARY KEY("DbGuid")
);
CREATE TABLE "Avatar" (
"DbGuid" INTEGER NOT NULL UNIQUE,
"ContainerDbGuid" INTEGER,
"InventoryProtoGuid" INTEGER,
"Slot" INTEGER,
"EntityProtoGuid" INTEGER,
"ArchiveData" BLOB,
FOREIGN KEY("ContainerDbGuid") REFERENCES "Player"("DbGuid") ON DELETE CASCADE,
PRIMARY KEY("DbGuid")
);
CREATE TABLE "TeamUp" (
"DbGuid" INTEGER NOT NULL UNIQUE,
"ContainerDbGuid" INTEGER,
"InventoryProtoGuid" INTEGER,
"Slot" INTEGER,
"EntityProtoGuid" INTEGER,
"ArchiveData" BLOB,
FOREIGN KEY("ContainerDbGuid") REFERENCES "Player"("DbGuid") ON DELETE CASCADE,
PRIMARY KEY("DbGuid")
);
CREATE TABLE "Item" (
"DbGuid" INTEGER NOT NULL UNIQUE,
"ContainerDbGuid" INTEGER,
"InventoryProtoGuid" INTEGER,
"Slot" INTEGER,
"EntityProtoGuid" INTEGER,
"ArchiveData" BLOB,
FOREIGN KEY("ContainerDbGuid") REFERENCES "Player"("DbGuid") ON DELETE CASCADE,
FOREIGN KEY("ContainerDbGuid") REFERENCES "Avatar"("DbGuid") ON DELETE CASCADE,
FOREIGN KEY("ContainerDbGuid") REFERENCES "TeamUp"("DbGuid") ON DELETE CASCADE,
PRIMARY KEY("DbGuid")
);
CREATE TABLE "ControlledEntity" (
"DbGuid" INTEGER NOT NULL UNIQUE,
"ContainerDbGuid" INTEGER,
"InventoryProtoGuid" INTEGER,
"Slot" INTEGER,
"EntityProtoGuid" INTEGER,
"ArchiveData" BLOB,
FOREIGN KEY("ContainerDbGuid") REFERENCES "Avatar"("DbGuid") ON DELETE CASCADE,
PRIMARY KEY("DbGuid")
);

View file

@ -0,0 +1,60 @@
-- Delete old placeholder tables while keeping existing account records
DROP TABLE Avatar;
DROP TABLE Player;
-- Initialize new tables for storing persistent entities
CREATE TABLE "Player" (
"DbGuid" INTEGER NOT NULL UNIQUE,
"ArchiveData" BLOB,
"StartTarget" INTEGER,
"StartTargetRegionOverride" INTEGER,
"AOIVolume" INTEGER,
FOREIGN KEY("DbGuid") REFERENCES "Account"("Id") ON DELETE CASCADE,
PRIMARY KEY("DbGuid")
);
CREATE TABLE "Avatar" (
"DbGuid" INTEGER NOT NULL UNIQUE,
"ContainerDbGuid" INTEGER,
"InventoryProtoGuid" INTEGER,
"Slot" INTEGER,
"EntityProtoGuid" INTEGER,
"ArchiveData" BLOB,
FOREIGN KEY("ContainerDbGuid") REFERENCES "Player"("DbGuid") ON DELETE CASCADE,
PRIMARY KEY("DbGuid")
);
CREATE TABLE "TeamUp" (
"DbGuid" INTEGER NOT NULL UNIQUE,
"ContainerDbGuid" INTEGER,
"InventoryProtoGuid" INTEGER,
"Slot" INTEGER,
"EntityProtoGuid" INTEGER,
"ArchiveData" BLOB,
FOREIGN KEY("ContainerDbGuid") REFERENCES "Player"("DbGuid") ON DELETE CASCADE,
PRIMARY KEY("DbGuid")
);
CREATE TABLE "Item" (
"DbGuid" INTEGER NOT NULL UNIQUE,
"ContainerDbGuid" INTEGER,
"InventoryProtoGuid" INTEGER,
"Slot" INTEGER,
"EntityProtoGuid" INTEGER,
"ArchiveData" BLOB,
FOREIGN KEY("ContainerDbGuid") REFERENCES "Player"("DbGuid") ON DELETE CASCADE,
FOREIGN KEY("ContainerDbGuid") REFERENCES "Avatar"("DbGuid") ON DELETE CASCADE,
FOREIGN KEY("ContainerDbGuid") REFERENCES "TeamUp"("DbGuid") ON DELETE CASCADE,
PRIMARY KEY("DbGuid")
);
CREATE TABLE "ControlledEntity" (
"DbGuid" INTEGER NOT NULL UNIQUE,
"ContainerDbGuid" INTEGER,
"InventoryProtoGuid" INTEGER,
"Slot" INTEGER,
"EntityProtoGuid" INTEGER,
"ArchiveData" BLOB,
FOREIGN KEY("ContainerDbGuid") REFERENCES "Avatar"("DbGuid") ON DELETE CASCADE,
PRIMARY KEY("DbGuid")
);

View file

@ -35,7 +35,7 @@ namespace MHServerEmu.DatabaseAccess
public bool UpdateAccount(DBAccount account);
/// <summary>
/// Updates the Player and Avatar tables in the database with the data from the provided <see cref="DBAccount"/>.
/// Updates persistent game data for the provided <see cref="DBAccount"/>.
/// </summary>
public bool UpdateAccountData(DBAccount account);

View file

@ -27,7 +27,10 @@
</ItemGroup>
<ItemGroup>
<None Update="Data\Account.db">
<None Update="Data\SQLite\InitializeDatabase.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Data\SQLite\Migrations\0.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="SQLite.Interop.dll">

View file

@ -8,6 +8,8 @@ namespace MHServerEmu.DatabaseAccess.SQLite
{
public class SQLiteDBManager : IDBManager
{
private const int CurrentSchemaVersion = 1;
private static readonly Logger Logger = LogManager.CreateLogger();
private string _connectionString;
@ -19,10 +21,20 @@ namespace MHServerEmu.DatabaseAccess.SQLite
public bool Initialize()
{
string dbPath = Path.Combine(FileHelper.DataDirectory, "Account.db");
if (File.Exists(dbPath) == false) return Logger.FatalReturn(false, $"Initialize(): {dbPath} not found");
_connectionString = $"Data Source={dbPath}";
Logger.Info("Established database connection");
if (File.Exists(dbPath) == false)
{
if (InitializeDatabaseFile(dbPath) == false)
return false;
}
else
{
if (MigrateDatabaseFileToCurrentSchema(dbPath) == false)
return false;
}
Logger.Info($"Initialized database");
return true;
}
@ -44,7 +56,8 @@ namespace MHServerEmu.DatabaseAccess.SQLite
public bool QueryIsPlayerNameTaken(string playerName)
{
using SQLiteConnection connection = new(_connectionString);
using SQLiteConnection connection = GetConnection();
// This check is case insensitive (COLLATE NOCASE)
var results = connection.Query<string>("SELECT PlayerName FROM Account WHERE PlayerName = @PlayerName COLLATE NOCASE", new { PlayerName = playerName });
return results.Any();
@ -52,8 +65,7 @@ namespace MHServerEmu.DatabaseAccess.SQLite
public bool InsertAccount(DBAccount account)
{
using SQLiteConnection connection = new(_connectionString);
connection.Open();
using SQLiteConnection connection = GetConnection();
try
{
@ -70,7 +82,7 @@ namespace MHServerEmu.DatabaseAccess.SQLite
public bool UpdateAccount(DBAccount account)
{
using SQLiteConnection connection = new(_connectionString);
using SQLiteConnection connection = GetConnection();
try
{
@ -87,8 +99,7 @@ namespace MHServerEmu.DatabaseAccess.SQLite
public bool UpdateAccountData(DBAccount account)
{
using SQLiteConnection connection = new(_connectionString);
connection.Open();
using SQLiteConnection connection = GetConnection();
// Use a transaction to make sure all data is saved
using (var transaction = connection.BeginTransaction())
@ -143,6 +154,103 @@ namespace MHServerEmu.DatabaseAccess.SQLite
}
}
/// <summary>
/// Creates and opens a new <see cref="SQLiteConnection"/>.
/// </summary>
private SQLiteConnection GetConnection()
{
SQLiteConnection connection = new(_connectionString);
connection.Open();
return connection;
}
private bool InitializeDatabaseFile(string dbPath)
{
// Create a new database file if it does not exist
string initializationScript = SQLiteScripts.GetInitializationScript();
if (initializationScript == string.Empty)
return Logger.ErrorReturn(false, "InitializeDatabaseFile(): Failed to get database initialization script");
SQLiteConnection.CreateFile(dbPath);
using SQLiteConnection connection = GetConnection();
connection.Execute(initializationScript);
Logger.Info($"Initialized a new database file at {Path.GetRelativePath(FileHelper.ServerRoot, dbPath)} using schema version {CurrentSchemaVersion}");
return true;
}
private bool MigrateDatabaseFileToCurrentSchema(string dbPath)
{
// Migrate existing database if needed
int schemaVersion = GetSchemaVersion();
if (schemaVersion > CurrentSchemaVersion)
return Logger.ErrorReturn(false, $"Initialize(): Existing database file uses unsupported schema version {schemaVersion} (current = {CurrentSchemaVersion})");
Logger.Info($"Found existing database file with schema version {schemaVersion} (current = {CurrentSchemaVersion})");
if (schemaVersion == CurrentSchemaVersion)
return true;
// Create a back to fall back to if something goes wrong
string backupDbPath = $"{dbPath}.backup";
File.Copy(dbPath, backupDbPath);
using SQLiteConnection connection = GetConnection();
bool success = true;
while (schemaVersion < CurrentSchemaVersion)
{
Logger.Info($"Migrating version {schemaVersion} => {schemaVersion + 1}...");
string migrationScript = SQLiteScripts.GetMigrationScript(schemaVersion);
if (migrationScript == string.Empty)
{
Logger.Error($"MigrateDatabaseFileToCurrentSchema(): Failed to get database migration script for version {schemaVersion}");
success = false;
break;
}
connection.Execute(migrationScript);
connection.Execute($"PRAGMA user_version = {++schemaVersion}");
}
success &= GetSchemaVersion() == CurrentSchemaVersion;
if (success == false)
{
// Restore backup
File.Delete(dbPath);
File.Move(backupDbPath, dbPath);
return Logger.ErrorReturn(false, "MigrateDatabaseFileToCurrentSchema(): Migration failed, backup restored");
}
Logger.Info($"Successfully migrated to schema version {CurrentSchemaVersion}");
return true;
}
/// <summary>
/// Returns the user_version value of the current database.
/// </summary>
private int GetSchemaVersion()
{
using SQLiteConnection connection = GetConnection();
var queryResult = connection.Query<int>("PRAGMA user_version");
if (queryResult.Any())
return queryResult.First();
return Logger.WarnReturn(-1, "GetSchemaVersion(): Failed to query user_version from the DB");
}
/// <summary>
/// Sets the user_version value of the current database.
/// </summary>
private void SetSchemaVersion(int version)
{
using SQLiteConnection connection = GetConnection();
connection.Execute($"PRAGMA user_version = {version}");
}
/// <summary>
/// Loads account data for the specified <see cref="DBAccount"/> and maps relations.
/// </summary>
@ -177,12 +285,18 @@ namespace MHServerEmu.DatabaseAccess.SQLite
}
}
/// <summary>
/// Loads <see cref="DBEntity"/> instances belonging to the specified container from the specified table.
/// </summary>
private static IEnumerable<DBEntity> LoadEntitiesFromTable(SQLiteConnection connection, string tableName, long containerDbGuid)
{
var @params = new { ContainerDbGuid = containerDbGuid };
return connection.Query<DBEntity>($"SELECT * FROM {tableName} WHERE ContainerDbGuid = @ContainerDbGuid", @params);
}
/// <summary>
/// Updates <see cref="DBEntity"/> instances belonging to the specified container in the specified table using the provided <see cref="DBEntityCollection"/>.
/// </summary>
private static void UpdateEntityTable(SQLiteConnection connection, SQLiteTransaction transaction, string tableName,
long containerDbGuid, DBEntityCollection dbEntityCollection)
{

View file

@ -0,0 +1,28 @@
using MHServerEmu.Core.Helpers;
using MHServerEmu.Core.Logging;
namespace MHServerEmu.DatabaseAccess.SQLite
{
public static class SQLiteScripts
{
private static readonly Logger Logger = LogManager.CreateLogger();
public static string GetInitializationScript()
{
string filePath = Path.Combine(FileHelper.DataDirectory, "SQLite", "InitializeDatabase.sql");
if (File.Exists(filePath) == false)
return Logger.WarnReturn(string.Empty, $"GetDatabaseInitializationScript(): Initialization script file not found at {FileHelper.GetRelativePath(filePath)}");
return File.ReadAllText(filePath);
}
public static string GetMigrationScript(int currentVersion)
{
string filePath = Path.Combine(FileHelper.DataDirectory, "SQLite", "Migrations", $"{currentVersion}.sql");
if (File.Exists(filePath) == false)
return Logger.WarnReturn(string.Empty, $"GetMigrationScript(): Migration script for version {currentVersion} not found at {FileHelper.GetRelativePath(filePath)}");
return File.ReadAllText(filePath);
}
}
}