Added preferences backend (#465)

* Added preferences backend

* Gender -> Sex

* ClientPreferencesManager properties

* Username validation

* Fixed client init

* WIP db

* Actually working sqlite db

* Dropped shitty sqlite libraries, dropped DbUp, added MigrationManager

* Added profile deletion, test

* Docs, sanity, tests, cleanup

* Cleaned up profile and appearance, fixed running on .net core

Co-authored-by: Pieter-Jan Briers <pieterjan.briers@gmail.com>
This commit is contained in:
DamianX
2019-12-22 13:47:34 +01:00
committed by Pieter-Jan Briers
parent c1b9bb348d
commit f19795edaf
22 changed files with 1009 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS "HumanoidCharacterProfiles" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
"Player" INTEGER NOT NULL,
"Slot" INTEGER NOT NULL,
"Name" TEXT NOT NULL,
"Age" INTEGER NOT NULL,
"Sex" TEXT NOT NULL,
"HairStyleName" TEXT NOT NULL,
"HairColor" TEXT NOT NULL,
"FacialHairStyleName" TEXT NOT NULL,
"FacialHairColor" TEXT NOT NULL,
"EyeColor" TEXT NOT NULL,
"SkinColor" TEXT NOT NULL,
FOREIGN KEY("Player") REFERENCES "PlayerPreferences"("Id")
);
CREATE TABLE IF NOT EXISTS "PlayerPreferences" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
"Username" TEXT NOT NULL UNIQUE,
"SelectedCharacterIndex" INTEGER NOT NULL
);

View File

@@ -0,0 +1,169 @@
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Dapper;
using JetBrains.Annotations;
using Microsoft.Data.Sqlite;
using Robust.Shared.Log;
namespace Content.Server.Preferences.Migrations
{
/// <summary>
/// Ensures database schemas are up to date.
/// </summary>
public static class MigrationManager
{
/// <summary>
/// Ensures the database schema for the given connection string is up to date.
/// </summary>
public static void PerformUpgrade(string connectionString)
{
using (var connection = new SqliteConnection(connectionString))
{
EnsureSchemaVersionTableExists(connection);
foreach (var migrationToRun in MigrationsToRun(connection))
{
Logger.InfoS("db", "Running migration {0}", migrationToRun.Id);
migrationToRun.Run(connection);
}
}
}
/// <summary>
/// Generated for each SQL file found.
/// </summary>
private class Migration
{
public readonly string Id;
private readonly string _sql;
public Migration(string id, string sql)
{
Id = id;
_sql = sql;
}
/// <summary>
/// Executes the query in <see cref="_sql"/> and logs this in the SchemaVersion table.
/// </summary>
public void Run(IDbConnection connection)
{
connection.Execute(_sql);
InsertMigrationLog(connection, Id);
}
}
private const string InsertMigrationLogQuery =
@"INSERT INTO SchemaVersion (Id) VALUES (@Id)";
/// <summary>
/// Inserts a <see cref="MigrationLog"/> in the SchemaVersion table.
/// </summary>
private static void InsertMigrationLog(IDbConnection connection, string id)
{
Logger.InfoS("db", "Completing migration {0}", id);
connection.Execute(InsertMigrationLogQuery, new {Id = id});
}
/// <summary>
/// An entry in the SchemaVersion table.
/// </summary>
[UsedImplicitly]
private class MigrationLog
{
public string Id;
public string Timestamp;
}
private const string GetRanMigrationsQuery =
@"SELECT Id, Timestamp FROM SchemaVersion ORDER BY Id COLLATE NOCASE";
/// <summary>
/// Fetches a collection of <see cref="MigrationLog"/> from the SchemaVersion table and returns it.
/// </summary>
private static IEnumerable<MigrationLog> RanMigrations(IDbConnection connection)
{
return connection.Query<MigrationLog>(GetRanMigrationsQuery);
}
/// <summary>
/// Finds all available migrations, returns those that haven't been run yet.
/// </summary>
private static List<Migration> MigrationsToRun(IDbConnection connection)
{
var discoveredMigrations = DiscoverMigrations(connection);
if (discoveredMigrations.Count == 0)
{
// No migrations found.
return null;
}
var ranMigrations = RanMigrations(connection);
// Filter out migrations that have already been executed
discoveredMigrations
.RemoveAll(migration => ranMigrations.Any(ranMigration => migration.Id == ranMigration.Id));
return discoveredMigrations;
}
/// <summary>
/// Given an embedded resource's full path returns its contents as a string.
/// </summary>
private static string ResourceAssemblyToString(string resourceName)
{
using (var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream(resourceName))
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
/// <summary>
/// Searches the current assembly for SQL migration files.
/// TODO: Filter by subfolder so that different databases use different sets of migrations.
/// </summary>
[NotNull]
private static List<Migration> DiscoverMigrations(IDbConnection connection)
{
var results = new List<Migration>();
var assembly = Assembly.GetExecutingAssembly();
foreach (var sqlResourceName in assembly
.GetManifestResourceNames()
.Where(IsValidMigrationFileName))
{
var splitName = sqlResourceName.Split('.');
// The second to last string in the list is the actual file name without the final ".sql"
var migrationId = splitName[splitName.Length - 2];
var sqlContents = ResourceAssemblyToString(sqlResourceName);
results.Add(new Migration(migrationId, sqlContents));
}
return results;
}
/// <summary>
/// A valid file name is "000_Initial.sql". A dot (from the path, not to be included in the filename itself),
/// three digits, a mandatory underscore, any number of characters, a mandatory ".sql".
/// </summary>
private static bool IsValidMigrationFileName(string name)
{
return Regex.IsMatch(name, @"\.\d\d\d_[a-zA-Z]+\.sql$");
}
private const string EnsureSchemaVersionTableExistsQuery =
@"CREATE TABLE IF NOT EXISTS SchemaVersion (
Id TEXT NOT NULL UNIQUE,
Timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)";
/// <summary>
/// Creates the SchemaVersion table if it doesn't exist.
/// </summary>
private static void EnsureSchemaVersionTableExists(IDbConnection connection)
{
connection.Execute(EnsureSchemaVersionTableExistsQuery);
}
}
}