Compare commits

..

2 Commits

Author SHA1 Message Date
Aaron Po
cc7a6486d1 Update database migration and seeding logic; adjust Docker port mapping 2026-08-03 02:47:06 -04:00
Aaron Po
f6be2cf9a7 add wiring 2026-08-03 01:14:45 -04:00
16 changed files with 304 additions and 270 deletions

View File

@@ -7,215 +7,211 @@ using Microsoft.Data.SqlClient;
namespace Database.Migrations; namespace Database.Migrations;
/// <summary> /// <summary>
/// Entry point for the database migration runner. Reads connection details from /// Entry point for the database migration runner. Reads connection details from environment
/// environment variables, optionally clears and recreates the target database, and /// variables, optionally drops and recreates the target database, and applies all SQL
/// applies all SQL migration scripts embedded in this assembly using DbUp. /// migration scripts embedded in this assembly using DbUp.
/// </summary> /// </summary>
public static class Program public static class Program
{ {
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary> /// <summary>
private static readonly string connectionString = BuildConnectionString(); /// Migration runner entry point. Drops the target database when <c>CLEAR_DATABASE</c> is
/// set to <c>true</c>, ensures the database exists, then deploys all pending migrations.
/// </summary>
/// <returns>
/// <c>0</c> if migrations completed successfully; <c>1</c> if they failed or an error occurred.
/// </returns>
public static async Task<int> Main()
{
Console.WriteLine("Starting database migrations...");
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary> try
private static readonly string masterConnectionString = BuildConnectionString("master"); {
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;
}
}
/// <summary> /// <summary>
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>, /// Drops the target database if it exists, first forcing it into single-user mode with
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c> /// rollback immediate so that any existing connections are terminated.
/// environment variables.
/// </summary> /// </summary>
/// <param name="databaseName"> /// <param name="settings">The resolved database settings.</param>
/// The database (initial catalog) to connect to. When <c>null</c>, falls back to the /// <exception cref="SqlException">Thrown if the connection or the drop operation fails.</exception>
/// <c>DB_NAME</c> environment variable. private static async Task DropDatabaseIfExistsAsync(DatabaseSettings settings)
/// </param>
/// <returns>A fully built SQL Server connection string.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when <c>DB_SERVER</c>, <c>DB_USER</c>, <c>DB_PASSWORD</c>, or (when
/// <paramref name="databaseName" /> is <c>null</c>) <c>DB_NAME</c> is not set.
/// </exception>
private static string BuildConnectionString(string? databaseName = null)
{ {
string server = const string sql = """
Environment.GetEnvironmentVariable("DB_SERVER") DECLARE @statement nvarchar(max);
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
string dbName = IF EXISTS (SELECT 1 FROM sys.databases WHERE name = @databaseName)
databaseName BEGIN
?? Environment.GetEnvironmentVariable("DB_NAME") SET @statement =
?? throw new InvalidOperationException("DB_NAME environment variable is not set"); N'ALTER DATABASE ' + QUOTENAME(@databaseName)
+ N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE;';
EXEC sys.sp_executesql @statement;
string user = SET @statement = N'DROP DATABASE ' + QUOTENAME(@databaseName) + N';';
Environment.GetEnvironmentVariable("DB_USER") EXEC sys.sp_executesql @statement;
?? throw new InvalidOperationException("DB_USER environment variable is not set"); END
""";
string password = await ExecuteNonQueryAsync(settings.MasterConnectionString, sql, settings.DatabaseName);
Environment.GetEnvironmentVariable("DB_PASSWORD") Console.WriteLine($"Database '{settings.DatabaseName}' dropped, or did not exist.");
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set"); }
string trustServerCertificate = /// <summary>
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True"; /// Creates the target database on the <c>master</c> connection if it does not already exist.
/// </summary>
SqlConnectionStringBuilder builder = new() /// <param name="settings">The resolved database settings.</param>
/// <exception cref="SqlException">Thrown if the connection or the create operation fails.</exception>
private static async Task CreateDatabaseIfNotExistsAsync(DatabaseSettings settings)
{ {
DataSource = server, const string sql = """
InitialCatalog = dbName, DECLARE @statement nvarchar(max);
UserID = user,
Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true,
};
return builder.ConnectionString; 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
""";
await ExecuteNonQueryAsync(settings.MasterConnectionString, sql, settings.DatabaseName);
Console.WriteLine($"Database '{settings.DatabaseName}' is present.");
}
/// <summary>
/// Opens a connection, executes a non-query batch parameterised by database name, and
/// disposes both the command and the connection.
/// </summary>
/// <param name="connectionString">The connection string to open.</param>
/// <param name="sql">The Transact-SQL batch to execute.</param>
/// <param name="databaseName">The value bound to the <c>@databaseName</c> parameter.</param>
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();
} }
/// <summary> /// <summary>
/// Applies all pending SQL migration scripts embedded in this assembly to the target /// Applies all pending SQL migration scripts embedded in this assembly to the target
/// database using DbUp, logging progress to the console. /// database using DbUp, logging progress to the console.
/// </summary> /// </summary>
/// <param name="connectionString">The connection string for the target application database.</param>
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns> /// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
private static bool DeployMigrations() private static bool DeployMigrations(string connectionString)
{ {
UpgradeEngine? upgrader = DeployChanges UpgradeEngine upgrader = DeployChanges
.To.SqlDatabase(connectionString) .To.SqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly()) .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.WithTransactionPerScript()
.LogToConsole() .LogToConsole()
.Build(); .Build();
DatabaseUpgradeResult? result = upgrader.PerformUpgrade(); DatabaseUpgradeResult result = upgrader.PerformUpgrade();
return result.Successful;
}
/// <summary> if (result.Successful)
/// Drops the <c>Biergarten</c> database if it exists, first forcing it into single-user
/// mode with rollback to terminate any existing connections.
/// </summary>
/// <returns>
/// <c>true</c> if the database was dropped (or did not exist) without error; <c>false</c>
/// if an error occurred while connecting or dropping the database.
/// </returns>
private static bool ClearDatabase()
{
SqlConnection myConn = new(masterConnectionString);
try
{
myConn.Open();
// 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
);
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 true;
}
/// <summary>
/// Creates the <c>Biergarten</c> database on the master connection if it does not
/// already exist. Errors encountered while creating the database are logged but do
/// not stop execution.
/// </summary>
/// <returns><c>true</c> always; this method does not propagate database errors as a failure result.</returns>
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;
}
/// <summary>
/// Migration runner entry point. Optionally clears the existing database when the
/// <c>CLEAR_DATABASE</c> environment variable is set to <c>"true"</c>, ensures the
/// database exists, then deploys all pending migrations.
/// </summary>
/// <param name="args">Command-line arguments (unused).</param>
/// <returns><c>0</c> if migrations completed successfully; <c>1</c> if they failed or an error occurred.</returns>
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."); Console.WriteLine("Database migrations completed successfully.");
return 0; return true;
} }
Console.WriteLine("Database migrations failed."); Console.Error.WriteLine($"Database migrations failed: {result.Error}");
return 1; return false;
} }
catch (Exception ex)
/// <summary>
/// Connection details and runtime options resolved from environment variables.
/// </summary>
/// <param name="ConnectionString">The connection string for the target application database.</param>
/// <param name="MasterConnectionString">
/// The connection string for the <c>master</c> database, used for create and drop operations.
/// </param>
/// <param name="DatabaseName">The name of the target application database.</param>
/// <param name="ClearDatabase">Whether the target database should be dropped before migrating.</param>
private sealed record DatabaseSettings(
string ConnectionString,
string MasterConnectionString,
string DatabaseName,
bool ClearDatabase
)
{ {
Console.WriteLine("An error occurred during database migrations:"); /// <summary>
Console.WriteLine(ex.Message); /// Builds the settings from the <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>,
return 1; /// <c>DB_PASSWORD</c>, <c>DB_TRUST_SERVER_CERTIFICATE</c>, and <c>CLEAR_DATABASE</c>
} /// environment variables.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when a required environment variable is not set.
/// </exception>
public static DatabaseSettings FromEnvironment()
{
string server = Required("DB_SERVER");
string databaseName = Required("DB_NAME");
string user = Required("DB_USER");
string password = Required("DB_PASSWORD");
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
);
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;
}
private static string Required(string variableName) =>
Environment.GetEnvironmentVariable(variableName)
?? throw new InvalidOperationException(
$"The {variableName} environment variable is not set."
);
} }
} }

View File

@@ -23,11 +23,14 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/>
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj"/>
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- Pipeline-generated seed data, copied to the output directory so it can be opened via <!-- Pipeline-generated seed data, copied to the output directory so it can be opened via
ConnectionStrings.SqliteConnectionString at runtime. --> ConnectionStrings.SqliteConnectionString at runtime. -->
<None Include="SeedData\seed.sqlite" CopyToOutputDirectory="PreserveNewest" /> <None CopyToOutputDirectory="PreserveNewest" Include="SeedData\seed.sqlite"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,9 +1,16 @@
using Microsoft.Data.SqlClient; #region
using Microsoft.Data.SqlClient;
#endregion
namespace Database.Seed.DatabaseHelpers; namespace Database.Seed.DatabaseHelpers;
public class ConnectionStrings public class ConnectionStrings
{ {
public static string SqlServerConnectionString => GetSqlServerConnectionString();
public static string SqliteConnectionString => "Data Source=SeedData/seed.sqlite";
private static string GetSqlServerConnectionString() private static string GetSqlServerConnectionString()
{ {
string server = string server =
@@ -32,12 +39,9 @@ public class ConnectionStrings
UserID = user, UserID = user,
Password = password, Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate), TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true, Encrypt = true
}; };
return builder.ConnectionString; return builder.ConnectionString;
} }
public static string SqlServerConnectionString => GetSqlServerConnectionString();
public static string SqliteConnectionString => "Data Source=SeedData/seed.sqlite";
} }

View File

@@ -10,7 +10,7 @@ FROM build AS publish
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false RUN dotnet publish "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app WORKDIR /app
COPY --from=publish /app/publish . COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Database.Seed.dll"] ENTRYPOINT ["dotnet", "Database.Seed.dll"]

View File

@@ -1,14 +1,18 @@
using Database.Seed.DatabaseHelpers; using Database.Seed.DatabaseHelpers;
using Database.Seed.PipelineData; using Database.Seed.PipelineData;
using Database.Seed.Sqlite; using Database.Seed.Sqlite;
using Features.Auth.DependencyInjection;
using Features.Auth.Repository;
using Features.Breweries.DependencyInjection;
using Features.Breweries.Repository;
using Infrastructure.Sql;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Database.Seed;
public class Program
{
public static int Main()
{
IReadOnlyList<BreweryRecord> breweries; IReadOnlyList<BreweryRecord> breweries;
IReadOnlyList<UserRecord> users; IReadOnlyList<UserRecord> users;
try try
@@ -25,6 +29,28 @@ public class Program
return 1; return 1;
} }
ServiceCollection services = [];
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
services.AddFeaturesBreweries();
services.AddFeaturesAuth();
using ServiceProvider provider = services.BuildServiceProvider();
IBreweryRepository breweryRepository;
IAuthRepository authRepository;
try
{
breweryRepository = provider.GetRequiredService<IBreweryRepository>();
authRepository = provider.GetRequiredService<IAuthRepository>();
}
catch (InvalidOperationException ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error configuring database connection: {ex.Message}");
Console.ResetColor();
return 1;
}
Console.ForegroundColor = ConsoleColor.Green; Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine( Console.WriteLine(
@@ -35,8 +61,10 @@ public class Program
for (int i = 0; i < breweries.Count; i++) for (int i = 0; i < breweries.Count; i++)
{ {
BreweryRecord brewery = breweries[i]; BreweryRecord brewery = breweries[i];
Console.WriteLine($"{i + 1}:\t{brewery.Brewery.NameEn}\t({brewery.Address.City.CityName}, {brewery.Address.City.Country})"); Console.WriteLine(
$"{i + 1}:\t{brewery.Brewery.NameEn}\t({brewery.Address.City.CityName}, {brewery.Address.City.Country})");
} }
Console.ForegroundColor = ConsoleColor.Green; Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"Loaded {users.Count} users."); Console.WriteLine($"Loaded {users.Count} users.");
Console.ResetColor(); Console.ResetColor();
@@ -48,6 +76,3 @@ public class Program
} }
return 0; return 0;
}
}

View File

@@ -1,7 +1,11 @@
#region
using System.Text.Json; using System.Text.Json;
using Database.Seed.PipelineData; using Database.Seed.PipelineData;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
#endregion
namespace Database.Seed.Sqlite; namespace Database.Seed.Sqlite;
public sealed class PipelineSeedDataReader public sealed class PipelineSeedDataReader
@@ -40,13 +44,13 @@ public sealed class PipelineSeedDataReader
NameEn = reader.GetString(0), NameEn = reader.GetString(0),
DescriptionEn = reader.GetString(1), DescriptionEn = reader.GetString(1),
NameLocal = reader.GetString(2), NameLocal = reader.GetString(2),
DescriptionLocal = reader.GetString(3), DescriptionLocal = reader.GetString(3)
}, },
Address = new BreweryAddress Address = new BreweryAddress
{ {
City = cities[reader.GetInt64(4)], City = cities[reader.GetInt64(4)],
PostalCode = reader.GetString(5), PostalCode = reader.GetString(5)
}, }
} }
); );
@@ -83,15 +87,15 @@ public sealed class PipelineSeedDataReader
Gender = reader.GetString(2), Gender = reader.GetString(2),
Username = reader.GetString(3), Username = reader.GetString(3),
Bio = reader.GetString(4), Bio = reader.GetString(4),
ActivityWeight = (float)reader.GetDouble(5), ActivityWeight = (float)reader.GetDouble(5)
}, },
Email = reader.GetString(6), Email = reader.GetString(6),
DateOfBirth = reader.GetString(7), DateOfBirth = reader.GetString(7),
Address = new UserAddress Address = new UserAddress
{ {
City = cities[reader.GetInt64(8)], City = cities[reader.GetInt64(8)],
PostalCode = reader.GetString(9), PostalCode = reader.GetString(9)
}, }
} }
); );
@@ -123,13 +127,15 @@ public sealed class PipelineSeedDataReader
PostalCode = new PostalCodeSpec PostalCode = new PostalCodeSpec
{ {
CountryFormatRegex = reader.GetString(7), CountryFormatRegex = reader.GetString(7),
CityRegexes = DeserializeStringArray(reader.GetString(8)), CityRegexes = DeserializeStringArray(reader.GetString(8))
}, }
}; };
return cities; return cities;
} }
private static IReadOnlyList<string> DeserializeStringArray(string json) => private static IReadOnlyList<string> DeserializeStringArray(string json)
JsonSerializer.Deserialize<List<string>>(json) ?? []; {
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
} }

View File

@@ -9,7 +9,7 @@ services:
SA_PASSWORD: "${DB_PASSWORD}" SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_PID: "Express" MSSQL_PID: "Express"
ports: ports:
- "1433:1433" - "1434:1433"
volumes: volumes:
- sqlserverdata-dev:/var/opt/mssql - sqlserverdata-dev:/var/opt/mssql
healthcheck: healthcheck:

View File

@@ -9,7 +9,7 @@ services:
SA_PASSWORD: "${DB_PASSWORD}" SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_PID: "Express" MSSQL_PID: "Express"
ports: ports:
- "1433:1433" - "1434:1433"
volumes: volumes:
- sqlserverdata-dev:/var/opt/mssql - sqlserverdata-dev:/var/opt/mssql
healthcheck: healthcheck: