diff options
Diffstat (limited to 'src/database.cc')
| -rw-r--r-- | src/database.cc | 420 |
1 files changed, 420 insertions, 0 deletions
diff --git a/src/database.cc b/src/database.cc new file mode 100644 index 0000000..d9c2815 --- /dev/null +++ b/src/database.cc @@ -0,0 +1,420 @@ +#include "querymanager.hh" +#include "sqlite3.h" + +struct TCachedStatement{ + sqlite3_stmt *Stmt; + int64 LastUsed; + uint32 Hash; +}; + +static sqlite3 *g_Database = NULL; +static TCachedStatement *g_CachedStatements = NULL; + +// NOTE(fusion): SQLite's application id. We're currently setting it to ASCII +// "TiDB" for "Tibia Database". +constexpr int g_ApplicationID = 0x54694442; + +// Statement Cache +//============================================================================== +uint32 HashText(const char *Text){ + // FNV1a 32-bits + uint32 Hash = 0x811C9DC5U; + for(int i = 0; Text[i] != 0; i += 1){ + Hash ^= (uint32)Text[i]; + Hash *= 0x01000193U; + } + return Hash; +} + +sqlite3_stmt *PrepareQuery(const char *Text){ + sqlite3_stmt *Stmt = NULL; + int LeastRecentlyUsed = 0; + int64 LeastRecentlyUsedTime = g_CachedStatements[0].LastUsed; + uint32 Hash = HashText(Text); + for(int i = 0; i < g_MaxCachedStatements; i += 1){ + TCachedStatement *Entry = &g_CachedStatements[i]; + + if(Entry->LastUsed < LeastRecentlyUsedTime){ + LeastRecentlyUsed = i; + LeastRecentlyUsedTime = Entry->LastUsed; + } + + if(Entry->Stmt != NULL && Entry->Hash == Hash){ + const char *EntryText = sqlite3_sql(Entry->Stmt); + ASSERT(EntryText != NULL); + if(strcmp(EntryText, Text) == 0){ + Stmt = Entry->Stmt; + Entry->LastUsed = g_MonotonicTimeMS; + break; + } + } + } + + if(Stmt == NULL){ + if(sqlite3_prepare_v3(g_Database, Text, -1, + SQLITE_PREPARE_PERSISTENT, &Stmt, NULL) != SQLITE_OK){ + LOG_ERR("Failed to prepary query: %s", sqlite3_errmsg(g_Database)); + return NULL; + } + + TCachedStatement *Entry = &g_CachedStatements[LeastRecentlyUsed]; + if(Entry->Stmt != NULL){ + sqlite3_finalize(Entry->Stmt); + } + + Entry->Stmt = Stmt; + Entry->LastUsed = g_MonotonicTimeMS; + Entry->Hash = Hash; + }else{ + sqlite3_reset(Stmt); + sqlite3_clear_bindings(Stmt); + } + + return Stmt; +} + +bool InitStatementCache(void){ + ASSERT(g_CachedStatements == NULL); + g_CachedStatements = (TCachedStatement*)calloc( + g_MaxCachedStatements, sizeof(TCachedStatement)); + return true; +} + +void ExitStatementCache(void){ + if(g_CachedStatements != NULL){ + for(int i = 0; i < g_MaxCachedStatements; i += 1){ + TCachedStatement *Entry = &g_CachedStatements[i]; + if(Entry->Stmt != NULL){ + sqlite3_finalize(Entry->Stmt); + Entry->Stmt = NULL; + } + + Entry->LastUsed = 0; + Entry->Hash = 0; + } + + free(g_CachedStatements); + g_CachedStatements = NULL; + } +} + +// Queries +//============================================================================== + +bool LoadHouseOwners(const char *WorldName, DynamicArray<THouseOwner> *HouseOwners){ + ASSERT(WorldName && HouseOwners); + sqlite3_stmt *Stmt = PrepareQuery( + "SELECT HouseID, OwnerID, Characters.Name, PaidUntil" + " FROM HouseOwners" + " LEFT JOIN Characters ON Characters.CharacterID = OwnerID"); + if(Stmt == NULL){ + LOG_ERR("Failed to prepare query"); + return false; + } + + while(sqlite3_step(Stmt) == SQLITE_ROW){ + THouseOwner Owner = {}; + Owner.HouseID = sqlite3_column_int(Stmt, 0); + Owner.OwnerID = sqlite3_column_int(Stmt, 1); + StringCopy(Owner.OwnerName, sizeof(Owner.OwnerName), + (const char*)sqlite3_column_text(Stmt, 2)); + Owner.PaidUntil = sqlite3_column_int(Stmt, 3); + HouseOwners->Push(Owner); + } + + if(sqlite3_errcode(g_Database) != SQLITE_DONE){ + LOG_ERR("Failed to execute query: %s", sqlite3_errmsg(g_Database)); + return false; + } + + return true; +} + +bool LoadWorldConfig(const char *WorldName, TWorldConfig *WorldConfig){ + ASSERT(WorldName && WorldConfig); + sqlite3_stmt *Stmt = PrepareQuery( + "SELECT Type, RebootTime, Address, Port, MaxPlayers," + " PremiumPlayerBuffer, MaxNewbies, PremiumNewbieBuffer" + " FROM Worlds WHERE Name = ?1"); + if(Stmt == NULL){ + LOG_ERR("Failed to prepare query"); + return false; + } + + if(sqlite3_bind_text(Stmt, 1, WorldName, -1, NULL) != SQLITE_OK){ + LOG_ERR("Failed to bind WorldName: %s", sqlite3_errmsg(g_Database)); + return false; + } + + if(sqlite3_step(Stmt) != SQLITE_ROW){ + LOG_ERR("Failed to execute query: %s", sqlite3_errmsg(g_Database)); + return false; + } + + WorldConfig->Type = sqlite3_column_int(Stmt, 0); + WorldConfig->RebootTime = sqlite3_column_int(Stmt, 1); + WorldConfig->Address = sqlite3_column_int(Stmt, 2); + WorldConfig->Port = sqlite3_column_int(Stmt, 3); + WorldConfig->MaxPlayers = sqlite3_column_int(Stmt, 4); + WorldConfig->PremiumPlayerBuffer = sqlite3_column_int(Stmt, 5); + WorldConfig->MaxNewbies = sqlite3_column_int(Stmt, 6); + WorldConfig->PremiumNewbieBuffer = sqlite3_column_int(Stmt, 7); + return true; +} + +// Database Initialization +//============================================================================== +// NOTE(fusion): From `https://www.sqlite.org/pragma.html`: +// "Some pragmas take effect during the SQL compilation stage, not the execution +// stage. This means if using the C-language sqlite3_prepare(), sqlite3_step(), +// sqlite3_finalize() API (or similar in a wrapper interface), the pragma may run +// during the sqlite3_prepare() call, not during the sqlite3_step() call as normal +// SQL statements do. Or the pragma might run during sqlite3_step() just like normal +// SQL statements. Whether or not the pragma runs during sqlite3_prepare() or +// sqlite3_step() depends on the pragma and on the specific release of SQLite." +// +// Depending on the pragma, queries will fail in the sqlite3_prepare() stage when +// using bound parameters. This means we need to assemble the entire query before +// hand with snprintf or other similar formatting functions. In particular, this +// rule apply for `application_id` and `user_version` which we modify. + +bool FileExists(const char *FileName){ + FILE *File = fopen(FileName, "rb"); + bool Result = (File != NULL); + if(Result){ + fclose(File); + } + return Result; +} + +bool ExecFile(const char *FileName){ + FILE *File = fopen(FileName, "rb"); + if(File == NULL){ + LOG_ERR("Failed to open file \"%s\"", FileName); + return false; + } + + fseek(File, 0, SEEK_END); + usize FileSize = (usize)ftell(File); + fseek(File, 0, SEEK_SET); + + bool Result = true; + if(FileSize > 0){ + char *Text = (char*)malloc(FileSize + 1); + Text[FileSize] = 0; + if(Result && fread(Text, 1, FileSize, File) != FileSize){ + LOG_ERR("Failed to read \"%s\" (ferror: %d, feof: %d)", + FileName, ferror(File), feof(File)); + Result = false; + } + + if(Result && sqlite3_exec(g_Database, Text, NULL, NULL, NULL) != SQLITE_OK){ + LOG_ERR("Failed to execute \"%s\": %s", + FileName, sqlite3_errmsg(g_Database)); + Result = false; + } + + free(Text); + } + + fclose(File); + return Result; +} + +bool ExecInternal(const char *Format, ...) ATTR_PRINTF(1, 2); +bool ExecInternal(const char *Format, ...){ + va_list ap; + va_start(ap, Format); + char Text[1024]; + int Written = vsnprintf(Text, sizeof(Text), Format, ap); + va_end(ap); + + if(Written >= (int)sizeof(Text)){ + LOG_ERR("Query is too long"); + return false; + } + + if(sqlite3_exec(g_Database, Text, NULL, NULL, NULL) != SQLITE_OK){ + LOG_ERR("Failed to execute query: %s", sqlite3_errmsg(g_Database)); + return false; + } + + return true; +} + +bool GetPragmaInt(const char *Name, int *OutValue){ + char Text[1024]; + snprintf(Text, sizeof(Text), "PRAGMA %s", Name); + + sqlite3_stmt *Stmt; + if(sqlite3_prepare_v2(g_Database, Text, -1, &Stmt, NULL) != SQLITE_OK){ + LOG_ERR("Failed to retrieve %s (PREP): %s", Name, sqlite3_errmsg(g_Database)); + return false; + } + + bool Result = (sqlite3_step(Stmt) == SQLITE_ROW); + if(!Result){ + LOG_ERR("Failed to retrieve %s (PREP): %s", Name, sqlite3_errmsg(g_Database)); + }else if(OutValue){ + *OutValue = sqlite3_column_int(Stmt, 0); + } + + sqlite3_finalize(Stmt); + return Result; +} + +bool InitDatabaseSchema(void){ + bool Result = true; + + if(Result && !ExecInternal("BEGIN")){ + LOG_ERR("Failed to start schema transaction"); + Result = false; + } + + if(Result && !ExecFile("sql/schema.sql")){ + LOG_ERR("Failed to execute \"sql/schema.sql\""); + Result = false; + } + + if(Result && !ExecInternal("PRAGMA application_id = %d", g_ApplicationID)){ + LOG_ERR("Failed to set application id"); + Result = false; + } + + if(Result && !ExecInternal("PRAGMA user_version = 1")){ + LOG_ERR("Failed to set user version"); + Result = false; + } + + if(Result && !ExecInternal("COMMIT")){ + LOG_ERR("Failed to commit schema transaction"); + Result = false; + } + + if(!Result && !ExecInternal("ROLLBACK")){ + LOG_ERR("Failed to rollback schema transaction"); + } + + return Result; +} + +bool UpgradeDatabaseSchema(int UserVersion){ + char FileName[256]; + int NewVersion = UserVersion; + while(true){ + snprintf(FileName, sizeof(FileName), "sql/upgrade-%d.sql", NewVersion); + if(FileExists(FileName)){ + NewVersion += 1; + }else{ + break; + } + } + + bool Result = true; + if(UserVersion != NewVersion){ + LOG("Upgrading database schema to version %d", NewVersion); + + if(Result && !ExecInternal("BEGIN")){ + LOG_ERR("Failed to start upgrade transaction"); + Result = false; + } + + while(Result && UserVersion < NewVersion){ + snprintf(FileName, sizeof(FileName), "upgrade-%d.sql", UserVersion); + if(!ExecFile(FileName)){ + LOG_ERR("Failed to execute \"%s\"", FileName); + Result = false; + } + UserVersion += 1; + } + + if(Result && !ExecInternal("PRAGMA user_version = %d", UserVersion)){ + LOG_ERR("Failed to set user version"); + Result = false; + } + + if(Result && !ExecInternal("COMMIT")){ + LOG_ERR("Failed to commit upgrade transaction"); + Result = false; + } + + if(!Result && !ExecInternal("ROLLBACK")){ + LOG_ERR("Failed to rollback upgrade transaction"); + } + } + + return Result; +} + +bool CheckDatabaseSchema(void){ + int ApplicationID, UserVersion; + if(!GetPragmaInt("application_id", &ApplicationID) + || !GetPragmaInt("user_version", &UserVersion)){ + return false; + } + + if(ApplicationID != g_ApplicationID){ + if(ApplicationID != 0){ + LOG_ERR("Database has unknown application id %08X (expected %08X)", + ApplicationID, g_ApplicationID); + return false; + }else if(UserVersion != 0){ + LOG_ERR("Database has non zero user version %d", UserVersion); + return false; + }else if(!InitDatabaseSchema()){ + LOG_ERR("Failed to initialize database schema"); + return false; + } + + UserVersion = 1; + } + + if(!UpgradeDatabaseSchema(UserVersion)){ + LOG_ERR("Failed to upgrade database schema"); + return false; + } + + LOG("Database version: %d", UserVersion); + return true; +} + +bool InitDatabase(void){ + LOG("Database file: \"%s\"", g_DatabaseFile); + LOG("Max cached statements: %d", g_MaxCachedStatements); + + int Flags = SQLITE_OPEN_READWRITE + | SQLITE_OPEN_CREATE + | SQLITE_OPEN_NOMUTEX; + if(sqlite3_open_v2(g_DatabaseFile, &g_Database, Flags, NULL) != SQLITE_OK){ + LOG_ERR("Failed to open database at \"%s\": %s\n", + g_DatabaseFile, sqlite3_errmsg(g_Database)); + return false; + } + + if(!InitStatementCache()){ + LOG_ERR("Failed to initialize statement cache"); + return false; + } + + if(!CheckDatabaseSchema()){ + LOG_ERR("Failed to check database schema"); + return false; + } + + return true; +} + +void ExitDatabase(void){ + ExitStatementCache(); + + if(g_Database != NULL){ + // NOTE(fusion): `sqlite3_close` can only fail if there are associated + // prepared statements, blob handles, or backup objects that were not + // finalized. + if(sqlite3_close(g_Database) == SQLITE_OK){ + g_Database = NULL; + }else{ + LOG_ERR("Failed to close database: %s", sqlite3_errmsg(g_Database)); + } + } +} |
