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 { /// /// Ensures database schemas are up to date. /// public static class MigrationManager { /// /// Ensures the database schema for the given connection string is up to date. /// 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); } } } /// /// Generated for each SQL file found. /// private class Migration { public readonly string Id; private readonly string _sql; public Migration(string id, string sql) { Id = id; _sql = sql; } /// /// Executes the query in and logs this in the SchemaVersion table. /// public void Run(IDbConnection connection) { connection.Execute(_sql); InsertMigrationLog(connection, Id); } } private const string InsertMigrationLogQuery = @"INSERT INTO SchemaVersion (Id) VALUES (@Id)"; /// /// Inserts a in the SchemaVersion table. /// private static void InsertMigrationLog(IDbConnection connection, string id) { Logger.InfoS("db", "Completing migration {0}", id); connection.Execute(InsertMigrationLogQuery, new {Id = id}); } /// /// An entry in the SchemaVersion table. /// [UsedImplicitly] private class MigrationLog { public string Id; public string Timestamp; } private const string GetRanMigrationsQuery = @"SELECT Id, Timestamp FROM SchemaVersion ORDER BY Id COLLATE NOCASE"; /// /// Fetches a collection of from the SchemaVersion table and returns it. /// private static IEnumerable RanMigrations(IDbConnection connection) { return connection.Query(GetRanMigrationsQuery); } /// /// Finds all available migrations, returns those that haven't been run yet. /// private static List 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; } /// /// Given an embedded resource's full path returns its contents as a string. /// private static string ResourceAssemblyToString(string resourceName) { using (var stream = Assembly.GetExecutingAssembly() .GetManifestResourceStream(resourceName)) using (var reader = new StreamReader(stream)) { return reader.ReadToEnd(); } } /// /// Searches the current assembly for SQL migration files. /// TODO: Filter by subfolder so that different databases use different sets of migrations. /// [NotNull] private static List DiscoverMigrations(IDbConnection connection) { var results = new List(); 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; } /// /// 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". /// 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 )"; /// /// Creates the SchemaVersion table if it doesn't exist. /// private static void EnsureSchemaVersionTableExists(IDbConnection connection) { connection.Execute(EnsureSchemaVersionTableExistsQuery); } } }