diff --git a/src/MHServerEmu.Core/Helpers/FileHelper.cs b/src/MHServerEmu.Core/Helpers/FileHelper.cs index 3c92b572..a21f7dcb 100644 --- a/src/MHServerEmu.Core/Helpers/FileHelper.cs +++ b/src/MHServerEmu.Core/Helpers/FileHelper.cs @@ -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"); + /// + /// Returns a path relative to server root directory. + /// + public static string GetRelativePath(string filePath) + { + return Path.GetRelativePath(ServerRoot, filePath); + } + /// /// Deserializes a from a JSON file located at the specified path. /// diff --git a/src/MHServerEmu.DatabaseAccess/Data/Account.db b/src/MHServerEmu.DatabaseAccess/Data/Account.db deleted file mode 100644 index 7c1a76f3..00000000 Binary files a/src/MHServerEmu.DatabaseAccess/Data/Account.db and /dev/null differ diff --git a/src/MHServerEmu.DatabaseAccess/Data/SQLite/InitializeDatabase.sql b/src/MHServerEmu.DatabaseAccess/Data/SQLite/InitializeDatabase.sql new file mode 100644 index 00000000..c35da365 --- /dev/null +++ b/src/MHServerEmu.DatabaseAccess/Data/SQLite/InitializeDatabase.sql @@ -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") +); \ No newline at end of file diff --git a/src/MHServerEmu.DatabaseAccess/Data/SQLite/Migrations/0.sql b/src/MHServerEmu.DatabaseAccess/Data/SQLite/Migrations/0.sql new file mode 100644 index 00000000..57864f82 --- /dev/null +++ b/src/MHServerEmu.DatabaseAccess/Data/SQLite/Migrations/0.sql @@ -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") +); diff --git a/src/MHServerEmu.DatabaseAccess/IDBManager.cs b/src/MHServerEmu.DatabaseAccess/IDBManager.cs index ef1398a7..2af1e505 100644 --- a/src/MHServerEmu.DatabaseAccess/IDBManager.cs +++ b/src/MHServerEmu.DatabaseAccess/IDBManager.cs @@ -35,7 +35,7 @@ namespace MHServerEmu.DatabaseAccess public bool UpdateAccount(DBAccount account); /// - /// Updates the Player and Avatar tables in the database with the data from the provided . + /// Updates persistent game data for the provided . /// public bool UpdateAccountData(DBAccount account); diff --git a/src/MHServerEmu.DatabaseAccess/MHServerEmu.DatabaseAccess.csproj b/src/MHServerEmu.DatabaseAccess/MHServerEmu.DatabaseAccess.csproj index 4be7efcd..7f01e441 100644 --- a/src/MHServerEmu.DatabaseAccess/MHServerEmu.DatabaseAccess.csproj +++ b/src/MHServerEmu.DatabaseAccess/MHServerEmu.DatabaseAccess.csproj @@ -27,7 +27,10 @@ - + + PreserveNewest + + PreserveNewest diff --git a/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteDBManager.cs b/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteDBManager.cs index f61b6068..8893ef3c 100644 --- a/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteDBManager.cs +++ b/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteDBManager.cs @@ -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("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 } } + /// + /// Creates and opens a new . + /// + 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; + } + + /// + /// Returns the user_version value of the current database. + /// + private int GetSchemaVersion() + { + using SQLiteConnection connection = GetConnection(); + + var queryResult = connection.Query("PRAGMA user_version"); + if (queryResult.Any()) + return queryResult.First(); + + return Logger.WarnReturn(-1, "GetSchemaVersion(): Failed to query user_version from the DB"); + } + + /// + /// Sets the user_version value of the current database. + /// + private void SetSchemaVersion(int version) + { + using SQLiteConnection connection = GetConnection(); + connection.Execute($"PRAGMA user_version = {version}"); + } + /// /// Loads account data for the specified and maps relations. /// @@ -177,12 +285,18 @@ namespace MHServerEmu.DatabaseAccess.SQLite } } + /// + /// Loads instances belonging to the specified container from the specified table. + /// private static IEnumerable LoadEntitiesFromTable(SQLiteConnection connection, string tableName, long containerDbGuid) { var @params = new { ContainerDbGuid = containerDbGuid }; return connection.Query($"SELECT * FROM {tableName} WHERE ContainerDbGuid = @ContainerDbGuid", @params); } + /// + /// Updates instances belonging to the specified container in the specified table using the provided . + /// private static void UpdateEntityTable(SQLiteConnection connection, SQLiteTransaction transaction, string tableName, long containerDbGuid, DBEntityCollection dbEntityCollection) { diff --git a/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteScripts.cs b/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteScripts.cs new file mode 100644 index 00000000..585e611f --- /dev/null +++ b/src/MHServerEmu.DatabaseAccess/SQLite/SQLiteScripts.cs @@ -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); + } + } +}