From d469c0725090cd07c7f3041a90e1bd8bd26cff0f Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Mon, 3 Aug 2026 02:47:06 -0400 Subject: [PATCH] Update database migration and seeding logic; adjust Docker port mapping --- .../Database/Database.Migrations/Program.cs | 342 +++++++++--------- .../DatabaseHelpers/ConnectionStrings.cs | 16 +- web/backend/Database/Database.Seed/Program.cs | 117 +++--- .../Sqlite/PipelineSeedDataReader.cs | 4 + web/docker-compose.db.yaml | 2 +- web/docker-compose.dev.yaml | 2 +- 6 files changed, 241 insertions(+), 242 deletions(-) diff --git a/web/backend/Database/Database.Migrations/Program.cs b/web/backend/Database/Database.Migrations/Program.cs index 672648c8..5ce63a50 100644 --- a/web/backend/Database/Database.Migrations/Program.cs +++ b/web/backend/Database/Database.Migrations/Program.cs @@ -7,215 +7,211 @@ using Microsoft.Data.SqlClient; namespace Database.Migrations; /// -/// Entry point for the database migration runner. Reads connection details from -/// environment variables, optionally clears and recreates the target database, and -/// applies all SQL migration scripts embedded in this assembly using DbUp. +/// Entry point for the database migration runner. Reads connection details from environment +/// variables, optionally drops and recreates the target database, and applies all SQL +/// migration scripts embedded in this assembly using DbUp. /// public static class Program { - /// The connection string for the target application database (DB_NAME). - private static readonly string connectionString = BuildConnectionString(); + /// + /// Migration runner entry point. Drops the target database when CLEAR_DATABASE is + /// set to true, ensures the database exists, then deploys all pending migrations. + /// + /// + /// 0 if migrations completed successfully; 1 if they failed or an error occurred. + /// + public static async Task Main() + { + Console.WriteLine("Starting database migrations..."); - /// The connection string for the master database, used for create/drop operations. - private static readonly string masterConnectionString = BuildConnectionString("master"); + try + { + DatabaseSettings settings = DatabaseSettings.FromEnvironment(); + + if (settings.ClearDatabase) + { + Console.WriteLine( + $"CLEAR_DATABASE is enabled. Dropping database '{settings.DatabaseName}'..." + ); + await DropDatabaseIfExistsAsync(settings); + } + + await CreateDatabaseIfNotExistsAsync(settings); + + return DeployMigrations(settings.ConnectionString) ? 0 : 1; + } + catch (Exception ex) when (ex is InvalidOperationException or SqlException) + { + Console.Error.WriteLine("An error occurred during database migrations:"); + Console.Error.WriteLine(ex.Message); + return 1; + } + } /// - /// Builds a SQL Server connection string from the DB_SERVER, DB_NAME, - /// DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE - /// environment variables. + /// Drops the target database if it exists, first forcing it into single-user mode with + /// rollback immediate so that any existing connections are terminated. /// - /// - /// The database (initial catalog) to connect to. When null, falls back to the - /// DB_NAME environment variable. - /// - /// A fully built SQL Server connection string. - /// - /// Thrown when DB_SERVER, DB_USER, DB_PASSWORD, or (when - /// is null) DB_NAME is not set. - /// - private static string BuildConnectionString(string? databaseName = null) + /// The resolved database settings. + /// Thrown if the connection or the drop operation fails. + private static async Task DropDatabaseIfExistsAsync(DatabaseSettings settings) { - string server = - Environment.GetEnvironmentVariable("DB_SERVER") - ?? throw new InvalidOperationException("DB_SERVER environment variable is not set"); + const string sql = """ + DECLARE @statement nvarchar(max); - string dbName = - databaseName - ?? Environment.GetEnvironmentVariable("DB_NAME") - ?? throw new InvalidOperationException("DB_NAME environment variable is not set"); + IF EXISTS (SELECT 1 FROM sys.databases WHERE name = @databaseName) + BEGIN + SET @statement = + N'ALTER DATABASE ' + QUOTENAME(@databaseName) + + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE;'; + EXEC sys.sp_executesql @statement; - string user = - Environment.GetEnvironmentVariable("DB_USER") - ?? throw new InvalidOperationException("DB_USER environment variable is not set"); + SET @statement = N'DROP DATABASE ' + QUOTENAME(@databaseName) + N';'; + EXEC sys.sp_executesql @statement; + END + """; - string password = - Environment.GetEnvironmentVariable("DB_PASSWORD") - ?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set"); + await ExecuteNonQueryAsync(settings.MasterConnectionString, sql, settings.DatabaseName); + Console.WriteLine($"Database '{settings.DatabaseName}' dropped, or did not exist."); + } - string trustServerCertificate = - Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True"; + /// + /// Creates the target database on the master connection if it does not already exist. + /// + /// The resolved database settings. + /// Thrown if the connection or the create operation fails. + private static async Task CreateDatabaseIfNotExistsAsync(DatabaseSettings settings) + { + const string sql = """ + DECLARE @statement nvarchar(max); - SqlConnectionStringBuilder builder = new() - { - DataSource = server, - InitialCatalog = dbName, - UserID = user, - Password = password, - TrustServerCertificate = bool.Parse(trustServerCertificate), - Encrypt = true, - }; + IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = @databaseName) + BEGIN + SET @statement = N'CREATE DATABASE ' + QUOTENAME(@databaseName) + N';'; + EXEC sys.sp_executesql @statement; + END + """; - return builder.ConnectionString; + await ExecuteNonQueryAsync(settings.MasterConnectionString, sql, settings.DatabaseName); + Console.WriteLine($"Database '{settings.DatabaseName}' is present."); + } + + /// + /// Opens a connection, executes a non-query batch parameterised by database name, and + /// disposes both the command and the connection. + /// + /// The connection string to open. + /// The Transact-SQL batch to execute. + /// The value bound to the @databaseName parameter. + private static async Task ExecuteNonQueryAsync( + string connectionString, + string sql, + string databaseName + ) + { + await using SqlConnection connection = new(connectionString); + await connection.OpenAsync(); + + await using SqlCommand command = new(sql, connection); + command.Parameters.Add("@databaseName", SqlDbType.NVarChar, 128).Value = databaseName; + + await command.ExecuteNonQueryAsync(); } /// /// Applies all pending SQL migration scripts embedded in this assembly to the target /// database using DbUp, logging progress to the console. /// + /// The connection string for the target application database. /// true if the upgrade completed successfully; otherwise false. - private static bool DeployMigrations() + private static bool DeployMigrations(string connectionString) { - UpgradeEngine? upgrader = DeployChanges + UpgradeEngine upgrader = DeployChanges .To.SqlDatabase(connectionString) .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly()) + .WithTransactionPerScript() .LogToConsole() .Build(); - DatabaseUpgradeResult? result = upgrader.PerformUpgrade(); - return result.Successful; + DatabaseUpgradeResult result = upgrader.PerformUpgrade(); + + if (result.Successful) + { + Console.WriteLine("Database migrations completed successfully."); + return true; + } + + Console.Error.WriteLine($"Database migrations failed: {result.Error}"); + return false; } /// - /// Drops the Biergarten database if it exists, first forcing it into single-user - /// mode with rollback to terminate any existing connections. + /// Connection details and runtime options resolved from environment variables. /// - /// - /// true if the database was dropped (or did not exist) without error; false - /// if an error occurred while connecting or dropping the database. - /// - private static bool ClearDatabase() + /// The connection string for the target application database. + /// + /// The connection string for the master database, used for create and drop operations. + /// + /// The name of the target application database. + /// Whether the target database should be dropped before migrating. + private sealed record DatabaseSettings( + string ConnectionString, + string MasterConnectionString, + string DatabaseName, + bool ClearDatabase + ) { - SqlConnection myConn = new(masterConnectionString); - - try + /// + /// Builds the settings from the DB_SERVER, DB_NAME, DB_USER, + /// DB_PASSWORD, DB_TRUST_SERVER_CERTIFICATE, and CLEAR_DATABASE + /// environment variables. + /// + /// + /// Thrown when a required environment variable is not set. + /// + public static DatabaseSettings FromEnvironment() { - myConn.Open(); + string server = Required("DB_SERVER"); + string databaseName = Required("DB_NAME"); + string user = Required("DB_USER"); + string password = Required("DB_PASSWORD"); - // First, set the database to single user mode to close all connections - SqlCommand setModeCommand = new( - "IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " - + "ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;", - myConn + bool trustServerCertificate = + !bool.TryParse( + Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE"), + out bool parsedTrust + ) + || parsedTrust; + + bool clearDatabase = string.Equals( + Environment.GetEnvironmentVariable("CLEAR_DATABASE"), + "true", + StringComparison.OrdinalIgnoreCase ); - try - { - setModeCommand.ExecuteNonQuery(); - Console.WriteLine("Database set to single user mode."); - } - catch (Exception ex) - { - Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}"); - } - // Then drop the database - SqlCommand dropCommand = new("DROP DATABASE IF EXISTS [Biergarten];", myConn); - try - { - dropCommand.ExecuteNonQuery(); - Console.WriteLine("Database cleared successfully."); - } - catch (Exception ex) - { - Console.WriteLine($"Error dropping database: {ex}"); - return false; - } - } - catch (Exception ex) - { - Console.WriteLine($"Error clearing database: {ex}"); - return false; - } - finally - { - if (myConn.State == ConnectionState.Open) - myConn.Close(); + return new DatabaseSettings( + Build(databaseName), + Build("master"), + databaseName, + clearDatabase + ); + + string Build(string initialCatalog) => + new SqlConnectionStringBuilder + { + DataSource = server, + InitialCatalog = initialCatalog, + UserID = user, + Password = password, + Encrypt = true, + TrustServerCertificate = trustServerCertificate + }.ConnectionString; } - return true; + private static string Required(string variableName) => + Environment.GetEnvironmentVariable(variableName) + ?? throw new InvalidOperationException( + $"The {variableName} environment variable is not set." + ); } - - /// - /// Creates the Biergarten database on the master connection if it does not - /// already exist. Errors encountered while creating the database are logged but do - /// not stop execution. - /// - /// true always; this method does not propagate database errors as a failure result. - private static bool CreateDatabaseIfNotExists() - { - SqlConnection myConn = new(masterConnectionString); - - const string str = """ - IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') - CREATE DATABASE [Biergarten] - """; - - SqlCommand myCommand = new(str, myConn); - try - { - myConn.Open(); - myCommand.ExecuteNonQuery(); - Console.WriteLine("Database creation command executed successfully."); - } - catch (Exception ex) - { - Console.WriteLine($"Error creating database: {ex}"); - } - finally - { - if (myConn.State == ConnectionState.Open) - myConn.Close(); - } - - return true; - } - - /// - /// Migration runner entry point. Optionally clears the existing database when the - /// CLEAR_DATABASE environment variable is set to "true", ensures the - /// database exists, then deploys all pending migrations. - /// - /// Command-line arguments (unused). - /// 0 if migrations completed successfully; 1 if they failed or an error occurred. - public static int Main(string[] args) - { - Console.WriteLine("Starting database migrations..."); - - try - { - string? clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE"); - if (clearDatabase == "true") - { - Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database..."); - ClearDatabase(); - } - - CreateDatabaseIfNotExists(); - bool success = DeployMigrations(); - - if (success) - { - Console.WriteLine("Database migrations completed successfully."); - return 0; - } - - Console.WriteLine("Database migrations failed."); - return 1; - } - catch (Exception ex) - { - Console.WriteLine("An error occurred during database migrations:"); - Console.WriteLine(ex.Message); - return 1; - } - } -} +} \ No newline at end of file diff --git a/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs b/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs index 32bb3242..fa07f2cc 100644 --- a/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs +++ b/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs @@ -1,4 +1,8 @@ -using Microsoft.Data.SqlClient; +#region + +using Microsoft.Data.SqlClient; + +#endregion namespace Database.Seed.DatabaseHelpers; @@ -9,23 +13,23 @@ public class ConnectionStrings private static string GetSqlServerConnectionString() { - var server = + string server = Environment.GetEnvironmentVariable("DB_SERVER") ?? throw new InvalidOperationException("DB_SERVER environment variable is not set"); - var dbName = + string dbName = Environment.GetEnvironmentVariable("DB_NAME") ?? throw new InvalidOperationException("DB_NAME environment variable is not set"); - var user = + string user = Environment.GetEnvironmentVariable("DB_USER") ?? throw new InvalidOperationException("DB_USER environment variable is not set"); - var password = + string password = Environment.GetEnvironmentVariable("DB_PASSWORD") ?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set"); - var trustServerCertificate = + string trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True"; SqlConnectionStringBuilder builder = new() diff --git a/web/backend/Database/Database.Seed/Program.cs b/web/backend/Database/Database.Seed/Program.cs index 2ead26d6..9442365d 100644 --- a/web/backend/Database/Database.Seed/Program.cs +++ b/web/backend/Database/Database.Seed/Program.cs @@ -1,3 +1,5 @@ + + using Database.Seed.DatabaseHelpers; using Database.Seed.PipelineData; using Database.Seed.Sqlite; @@ -10,74 +12,67 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -namespace Database.Seed; -public class Program +IReadOnlyList breweries; +IReadOnlyList users; +try { - public static int Main() - { - IReadOnlyList breweries; - IReadOnlyList users; - try - { - PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString); - breweries = reader.ReadBreweryRecords(); - users = reader.ReadUserRecords(); - } - catch (SqliteException ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error opening database connection: {ex.Message}"); - Console.ResetColor(); - return 1; - } + PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString); + breweries = reader.ReadBreweryRecords(); + users = reader.ReadUserRecords(); +} +catch (SqliteException ex) +{ + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error opening database connection: {ex.Message}"); + Console.ResetColor(); + return 1; +} - ServiceCollection services = new(); - services.AddSingleton(new ConfigurationBuilder().Build()); - services.AddSingleton(); - services.AddFeaturesBreweries(); - services.AddFeaturesAuth(); +ServiceCollection services = []; +services.AddSingleton(new ConfigurationBuilder().Build()); +services.AddSingleton(); +services.AddFeaturesBreweries(); +services.AddFeaturesAuth(); - using ServiceProvider provider = services.BuildServiceProvider(); +using ServiceProvider provider = services.BuildServiceProvider(); - IBreweryRepository breweryRepository; - IAuthRepository authRepository; - try - { - breweryRepository = provider.GetRequiredService(); - authRepository = provider.GetRequiredService(); - } - catch (InvalidOperationException ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error configuring database connection: {ex.Message}"); - Console.ResetColor(); - return 1; - } +IBreweryRepository breweryRepository; +IAuthRepository authRepository; +try +{ + breweryRepository = provider.GetRequiredService(); + authRepository = provider.GetRequiredService(); +} +catch (InvalidOperationException ex) +{ + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error configuring database connection: {ex.Message}"); + Console.ResetColor(); + return 1; +} - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine( - $"Loaded {breweries.Count} breweries." - ); - Console.ResetColor(); +Console.ForegroundColor = ConsoleColor.Green; +Console.WriteLine( + $"Loaded {breweries.Count} breweries." +); +Console.ResetColor(); - for (var i = 0; i < breweries.Count; i++) - { - BreweryRecord brewery = breweries[i]; - Console.WriteLine( - $"{i + 1}:\t{brewery.Brewery.NameEn}\t({brewery.Address.City.CityName}, {brewery.Address.City.Country})"); - } +for (int i = 0; i < breweries.Count; i++) +{ + BreweryRecord brewery = breweries[i]; + Console.WriteLine( + $"{i + 1}:\t{brewery.Brewery.NameEn}\t({brewery.Address.City.CityName}, {brewery.Address.City.Country})"); +} - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"Loaded {users.Count} users."); - Console.ResetColor(); +Console.ForegroundColor = ConsoleColor.Green; +Console.WriteLine($"Loaded {users.Count} users."); +Console.ResetColor(); - for (var i = 0; i < users.Count; i++) - { - UserRecord user = users[i]; - Console.WriteLine($"{i + 1}:\t{user.User.FirstName} {user.User.LastName}\t ({user.Email})"); - } +for (int i = 0; i < users.Count; i++) +{ + UserRecord user = users[i]; + Console.WriteLine($"{i + 1}:\t{user.User.FirstName} {user.User.LastName}\t ({user.Email})"); +} - return 0; - } -} \ No newline at end of file +return 0; \ No newline at end of file diff --git a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs index 16b33d68..9f0ca70f 100644 --- a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs +++ b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs @@ -1,7 +1,11 @@ +#region + using System.Text.Json; using Database.Seed.PipelineData; using Microsoft.Data.Sqlite; +#endregion + namespace Database.Seed.Sqlite; public sealed class PipelineSeedDataReader diff --git a/web/docker-compose.db.yaml b/web/docker-compose.db.yaml index be5244ce..2681819a 100644 --- a/web/docker-compose.db.yaml +++ b/web/docker-compose.db.yaml @@ -9,7 +9,7 @@ services: SA_PASSWORD: "${DB_PASSWORD}" MSSQL_PID: "Express" ports: - - "1433:1433" + - "1434:1433" volumes: - sqlserverdata-dev:/var/opt/mssql healthcheck: diff --git a/web/docker-compose.dev.yaml b/web/docker-compose.dev.yaml index bcf31ac9..1882fd1a 100644 --- a/web/docker-compose.dev.yaml +++ b/web/docker-compose.dev.yaml @@ -9,7 +9,7 @@ services: SA_PASSWORD: "${DB_PASSWORD}" MSSQL_PID: "Express" ports: - - "1433:1433" + - "1434:1433" volumes: - sqlserverdata-dev:/var/opt/mssql healthcheck: