Update database handling code (#247)

This commit is contained in:
2026-08-25 13:42:51 -04:00
committed by GitHub
parent 03ca966708
commit 8a047e8d9f
37 changed files with 386 additions and 405 deletions

View File

@@ -17,7 +17,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" />

View File

@@ -17,6 +17,7 @@ WORKDIR /src
COPY ["Biergarten.API.slnx", "./"]
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Database/Database.Connection/Database.Connection.csproj", "Database/Database.Connection/"]
COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
@@ -35,7 +36,6 @@ COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infras
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Biergarten.API.slnx"

View File

@@ -1,5 +1,6 @@
using API.Core;
using API.Core.Authentication;
using Database.Connection.DependencyInjection;
using Features.Auth.Controllers;
using Features.Auth.DependencyInjection;
using Features.Breweries.Controllers;
@@ -11,7 +12,6 @@ using Features.UserManagement.DependencyInjection;
using FluentValidation;
using FluentValidation.AspNetCore;
using Infrastructure.Jwt;
using Infrastructure.Sql.DependencyInjection;
using Microsoft.OpenApi.Models;
using Shared.Application.Behaviors;
@@ -87,7 +87,7 @@ builder.Logging.AddConsole();
if (!builder.Environment.IsProduction())
builder.Logging.AddDebug();
builder.Services.AddInfrastructureSql();
builder.Services.AddDatabaseConnection();
builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUserManagement();

View File

@@ -7,6 +7,7 @@ WORKDIR /src
COPY ["Biergarten.API.slnx", "./"]
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Database/Database.Connection/Database.Connection.csproj", "Database/Database.Connection/"]
COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
@@ -25,7 +26,6 @@ COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infras
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Biergarten.API.slnx"

View File

@@ -6,6 +6,7 @@
<Folder Name="/Database/">
<Project Path="Database/Database.Migrations/Database.Migrations.csproj" />
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
<Project Path="Database/Database.Connection/Database.Connection.csproj" />
</Folder>
<Folder Name="/Domain/">
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
@@ -16,7 +17,6 @@
<Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
<Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj" />
</Folder>
<Folder Name="/Features/">
<Project Path="Features/Features.Breweries/Features.Breweries.csproj" />

View File

@@ -0,0 +1,10 @@
using Microsoft.Extensions.Configuration;
namespace Database.Connection;
public static class ConfigurationHelpers
{
public static string GetKeyOrThrow(IConfiguration configuration, string key) =>
configuration[key]
?? throw new InvalidOperationException($"The {key} environment variable is not set.");
}

View File

@@ -3,7 +3,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.Sql</RootNamespace>
<RootNamespace>Database.Connection</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.79" />

View File

@@ -0,0 +1,46 @@
using System.Data.Common;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Database.Connection;
/// <summary>
/// Default <see cref="DefaultSqlConnectionFactory" /> implementation that creates SQL Server connections,
/// resolving the connection string from environment variables or application configuration.
/// </summary>
public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlConnectionFactory
{
/// <summary>
/// The resolved SQL Server connection string, preferring (in order): the <c>DB_CONNECTION_STRING</c>
/// configuration value, and a connection string built from individual <c>DB_*</c> configuration values.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when no connection string can be resolved from any source.</exception>
public string ConnectionString
{
get
{
string? fullConnectionString = configuration["DB_CONNECTION_STRING"];
if (!string.IsNullOrEmpty(fullConnectionString))
return fullConnectionString;
// Try to build from individual configuration values (preferred method for Docker)
if (
SqlConnectionStringHelper.TryGetStandardDbConnectionString(
configuration,
out var connectionString
)
)
return connectionString;
throw new InvalidOperationException(
"Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars."
);
}
}
/// <inheritdoc />
public SqlConnection CreateConnection()
{
return new SqlConnection(ConnectionString);
}
}

View File

@@ -1,13 +1,13 @@
using Microsoft.Extensions.DependencyInjection;
namespace Infrastructure.Sql.DependencyInjection;
namespace Database.Connection.DependencyInjection;
/// <summary>Extension methods for registering SQL infrastructure services with dependency injection.</summary>
public static class InfrastructureSqlServiceCollectionExtensions
/// <summary>Extension methods for registering database connection services with dependency injection.</summary>
public static class DatabaseConnectionServiceCollectionExtensions
{
/// <summary>Registers <see cref="DefaultSqlConnectionFactory" /> as a singleton <see cref="ISqlConnectionFactory" />.</summary>
/// <returns>The same <paramref name="services" /> instance, for chaining.</returns>
public static IServiceCollection AddInfrastructureSql(this IServiceCollection services)
public static IServiceCollection AddDatabaseConnection(this IServiceCollection services)
{
services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
return services;

View File

@@ -0,0 +1,10 @@
using Microsoft.Data.SqlClient;
namespace Database.Connection;
public interface ISqlConnectionFactory
{
public string ConnectionString { get; }
public SqlConnection CreateConnection();
}

View File

@@ -1,13 +1,12 @@
using System.Data.Common;
namespace Infrastructure.Sql;
namespace Database.Connection;
/// <summary>
/// Base class for Dapper-based repositories, providing shared connection creation for derived
/// repository implementations.
/// </summary>
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
where T : class
public abstract class DapperRepository(ISqlConnectionFactory connectionFactory)
{
/// <summary>
/// Creates and opens a new database connection using the configured <see cref="ISqlConnectionFactory" />.

View File

@@ -0,0 +1,102 @@
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Database.Connection;
/// <summary>
/// Helper for building SQL Server connection strings from configuration
/// (backed by the <c>DB_*</c> environment variables).
/// </summary>
public static class SqlConnectionStringHelper
{
private static SqlConnectionStringBuilder MakeConfiguredConnectionBuilder(
IConfiguration configuration
) =>
new()
{
DataSource = ConfigurationHelpers.GetKeyOrThrow(configuration, "DB_SERVER"),
UserID = ConfigurationHelpers.GetKeyOrThrow(configuration, "DB_USER"),
Password = ConfigurationHelpers.GetKeyOrThrow(configuration, "DB_PASSWORD"),
TrustServerCertificate = ResolveTrustServerCertificate(configuration),
Encrypt = true,
};
/// <summary>
/// Builds a SQL Server connection string from configuration.
/// </summary>
/// <remarks>
/// Returns <c>false</c> if DB_SERVER, DB_NAME, DB_USER, or DB_PASSWORD is unset.
/// </remarks>
/// <param name="configuration">The configuration to read the <c>DB_*</c> values from.</param>
/// <param name="connectionString">The resolved connection string, if successful.</param>
public static bool TryGetStandardDbConnectionString(
IConfiguration configuration,
out string connectionString
)
{
connectionString = string.Empty;
try
{
SqlConnectionStringBuilder builder = MakeConfiguredConnectionBuilder(configuration);
builder.InitialCatalog = ConfigurationHelpers.GetKeyOrThrow(configuration, "DB_NAME");
connectionString = builder.ConnectionString;
return true;
}
catch
{
return false;
}
}
public static bool TryGetMasterConnectionString(
IConfiguration configuration,
out string connectionString
)
{
connectionString = string.Empty;
try
{
var builder = MakeConfiguredConnectionBuilder(configuration);
builder.InitialCatalog = "master";
connectionString = builder.ConnectionString;
return true;
}
catch
{
return false;
}
}
public static bool TryGetDatabaseNameFromConnectionString(
string connectionString,
out string databaseName
)
{
databaseName = string.Empty;
try
{
var builder = new SqlConnectionStringBuilder(connectionString);
databaseName = builder.InitialCatalog;
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Resolves the <c>DB_TRUST_SERVER_CERTIFICATE</c> configuration value, defaulting to
/// <c>true</c> when it is unset or not a valid boolean.
/// </summary>
private static bool ResolveTrustServerCertificate(IConfiguration configuration)
{
return !bool.TryParse(
configuration["DB_TRUST_SERVER_CERTIFICATE"],
out bool trustServerCertificate
) || trustServerCertificate;
}
}

View File

@@ -18,7 +18,7 @@
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="scripts/**/*.sql" />

View File

@@ -0,0 +1,113 @@
using System.Reflection;
using Dapper;
using Database.Connection;
using DbUp;
using DbUp.Engine;
using Microsoft.Data.SqlClient;
namespace Database.Migrations;
/// <summary>
/// Owns the configuration and state for a single database migration run,
/// and performs the drop/create/deploy steps against SQL Server.
/// </summary>
internal sealed class DbMigrator(
ISqlConnectionFactory standardConnectionStandardConnectionFactory,
ISqlConnectionFactory masterConnectionConnectionFactory,
bool clearDatabase)
{
/// <summary>
/// Runs the full migration workflow: optional drop, create-if-not-exists,
/// then deploy DbUp scripts. Throws if the upgrade fails.
/// </summary>
public async Task RunAsync(CancellationToken cancellationToken = default)
{
if (clearDatabase)
{
await DropDatabaseIfExistsAsync(cancellationToken);
}
await CreateDatabaseIfNotExistsAsync(cancellationToken);
DatabaseUpgradeResult result = DeployMigrations();
if (!result.Successful)
{
throw result.Error;
}
}
private async Task DropDatabaseIfExistsAsync(CancellationToken cancellationToken = default)
{
string databaseName = ResolveDatabaseName();
await using SqlConnection connection = masterConnectionConnectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken);
await connection.ExecuteAsync(
new CommandDefinition(
"""
DECLARE @statement nvarchar(max) =
N'ALTER DATABASE ' + QUOTENAME(@databaseName) + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE;'
+ N' DROP DATABASE ' + QUOTENAME(@databaseName) + N';';
IF EXISTS (SELECT 1 FROM sys.databases WHERE name = @databaseName)
EXEC sys.sp_executesql @statement;
""",
new { databaseName },
cancellationToken: cancellationToken
)
);
Console.WriteLine($"Database '{databaseName}' dropped, or did not exist.");
}
private async Task CreateDatabaseIfNotExistsAsync(CancellationToken cancellationToken = default)
{
string databaseName = ResolveDatabaseName();
await using SqlConnection connection = masterConnectionConnectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken);
await connection.ExecuteAsync(
new CommandDefinition(
"""
DECLARE @statement nvarchar(max);
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
""",
new { databaseName },
cancellationToken: cancellationToken
)
);
Console.WriteLine($"Database '{databaseName}' is present.");
}
private DatabaseUpgradeResult DeployMigrations()
{
UpgradeEngine upgrader = DeployChanges
.To.SqlDatabase(standardConnectionStandardConnectionFactory.ConnectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.WithTransactionPerScript()
.LogToConsole()
.Build();
return upgrader.PerformUpgrade();
}
private string ResolveDatabaseName()
{
return !SqlConnectionStringHelper.TryGetDatabaseNameFromConnectionString(
standardConnectionStandardConnectionFactory.ConnectionString,
out var databaseName
)
? throw new InvalidOperationException(
"Unable to resolve a database name from the connection string."
)
: databaseName;
}
}

View File

@@ -3,7 +3,7 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# Copy everything from the context (src/Core), matching Database.Seed/Dockerfile's context so
# ProjectReference paths outside Database/ (e.g. Infrastructure.Sql) resolve.
# ProjectReference paths resolve.
COPY . .
RUN dotnet restore "./Database/Database.Migrations/Database.Migrations.csproj"
RUN dotnet build "./Database/Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/build

View File

@@ -0,0 +1,37 @@
using Database.Connection;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Database.Migrations;
/// <summary>
/// <see cref="ISqlConnectionFactory" /> implementation that connects to the <c>master</c> database,
/// used for create and drop operations on the target application database.
/// </summary>
public class MasterSqlConnectionFactory(IConfiguration configuration) : ISqlConnectionFactory
{
/// <inheritdoc />
public string ConnectionString
{
get
{
if (
SqlConnectionStringHelper.TryGetMasterConnectionString(
configuration,
out var connectionString
)
)
return connectionString;
throw new InvalidOperationException(
"Database connection string not configured. Set DB_SERVER, DB_USER, DB_PASSWORD env vars."
);
}
}
/// <inheritdoc />
public SqlConnection CreateConnection()
{
return new SqlConnection(ConnectionString);
}
}

View File

@@ -1,28 +1,16 @@
using System.Data;
using System.Reflection;
using DbUp;
using DbUp.Engine;
using Infrastructure.Sql;
using Database.Connection;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Database.Migrations;
/// <summary>
/// Entry point for the database migration runner. Reads connection details from configuration
/// (backed by environment variables), optionally drops and recreates the target database, and
/// applies all SQL migration scripts embedded in this assembly using DbUp.
/// Application entry point. Responsible only for wiring up configuration
/// and delegating the migration run to <see cref="DbMigrator"/>.
/// </summary>
public static class Program
internal static class Program
{
/// <summary>
/// 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()
private static async Task Main(string[] args)
{
Console.WriteLine("Starting database migrations...");
@@ -31,150 +19,6 @@ public static class Program
IConfiguration configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
DatabaseSettings settings = DatabaseSettings.FromConfiguration(configuration);
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>
/// Drops the target database if it exists, first forcing it into single-user mode with
/// rollback immediate so that any existing connections are terminated.
/// </summary>
/// <param name="settings">The resolved database settings.</param>
/// <exception cref="SqlException">Thrown if the connection or the drop operation fails.</exception>
private static async Task DropDatabaseIfExistsAsync(DatabaseSettings settings)
{
const string sql = """
DECLARE @statement nvarchar(max);
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;
SET @statement = N'DROP DATABASE ' + QUOTENAME(@databaseName) + N';';
EXEC sys.sp_executesql @statement;
END
""";
await ExecuteNonQueryAsync(settings.MasterConnectionString, sql, settings.DatabaseName);
Console.WriteLine($"Database '{settings.DatabaseName}' dropped, or did not exist.");
}
/// <summary>
/// Creates the target database on the <c>master</c> connection if it does not already exist.
/// </summary>
/// <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)
{
const string sql = """
DECLARE @statement nvarchar(max);
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.");
}
/// <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>
/// Applies all pending SQL migration scripts embedded in this assembly to the target
/// database using DbUp, logging progress to the console.
/// </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>
private static bool DeployMigrations(string connectionString)
{
UpgradeEngine upgrader = DeployChanges
.To.SqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.WithTransactionPerScript()
.LogToConsole()
.Build();
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;
}
/// <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
)
{
/// <summary>
/// Builds the settings from the <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>,
/// <c>DB_PASSWORD</c>, <c>DB_TRUST_SERVER_CERTIFICATE</c>, and <c>CLEAR_DATABASE</c>
/// configuration values.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when a required configuration value is not set.
/// </exception>
public static DatabaseSettings FromConfiguration(IConfiguration configuration)
{
string databaseName =
configuration["DB_NAME"]
?? throw new InvalidOperationException(
"The DB_NAME environment variable is not set."
);
bool clearDatabase = string.Equals(
configuration["CLEAR_DATABASE"],
@@ -182,12 +26,17 @@ public static class Program
StringComparison.OrdinalIgnoreCase
);
return new DatabaseSettings(
SqlConnectionStringHelper.BuildConnectionString(configuration, databaseName),
SqlConnectionStringHelper.BuildMasterConnectionString(configuration),
databaseName,
DbMigrator migrator = new(
new DefaultSqlConnectionFactory(configuration),
new MasterSqlConnectionFactory(configuration),
clearDatabase
);
await migrator.RunAsync();
}
catch (Exception ex) when (ex is InvalidOperationException or SqlException)
{
Console.Error.WriteLine("An error occurred during database migrations:");
Console.Error.WriteLine(ex.Message);
}
}
}

View File

@@ -16,7 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />

View File

@@ -1,16 +1,23 @@
#region
using Infrastructure.Sql;
using Database.Connection;
using Microsoft.Extensions.Configuration;
#endregion
namespace Database.Seed.DatabaseHelpers;
public class ConnectionStrings
public static class ConnectionStrings
{
public static string GetSqlServerConnectionString(IConfiguration configuration) =>
SqlConnectionStringHelper.BuildConnectionString(configuration);
SqlConnectionStringHelper.TryGetStandardDbConnectionString(
configuration,
out string connectionString
)
? connectionString
: throw new InvalidOperationException(
"Database connection string not configured. Set DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars."
);
public static string SqliteConnectionString =>
"Data Source=SeedData/biergarten_seed_2026-08-24T01-52-09.841693Z.sqlite";

View File

@@ -1,3 +1,4 @@
using Database.Connection.DependencyInjection;
using Database.Seed.DatabaseHelpers;
using Database.Seed.Geocoding;
using Database.Seed.PipelineData;
@@ -11,7 +12,6 @@ using Features.Locations.DependencyInjection;
using Features.Locations.Dtos;
using Features.Locations.Repository;
using idunno.Password;
using Infrastructure.Sql.DependencyInjection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -30,7 +30,7 @@ static async Task<int> RunAsync()
ServiceCollection services = [];
services.AddSingleton(configuration);
services.AddInfrastructureSql();
services.AddDatabaseConnection();
services.AddFeaturesBreweries();
services.AddFeaturesLocations();
services.AddFeaturesAuth();

View File

@@ -38,42 +38,6 @@ public class TokenServiceValidationTests
);
}
[Fact]
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-access-token";
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act
ValidatedToken result = await _tokenService.ValidateAccessTokenAsync(token);
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
result.Principal.Should().NotBeNull();
result
.Principal.FindFirst(JwtRegisteredClaimNames.Sub)
?.Value.Should()
.Be(userId.ToString());
}
[Fact]
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
{
@@ -137,24 +101,7 @@ public class TokenServiceValidationTests
}
[Fact]
public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "invalid-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
public async Task ValidateRefreshTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "expired-token";
@@ -165,13 +112,13 @@ public class TokenServiceValidationTests
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
public async Task ValidateRefreshTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
{
// Arrange
const string username = "testuser";
@@ -193,14 +140,14 @@ public class TokenServiceValidationTests
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*");
}
[Fact]
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
public async Task ValidateRefreshTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
{
// Arrange
Guid userId = Guid.NewGuid();
@@ -222,14 +169,14 @@ public class TokenServiceValidationTests
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*");
}
[Fact]
public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
public async Task ValidateRefreshTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
{
// Arrange
const string username = "testuser";
@@ -252,7 +199,7 @@ public class TokenServiceValidationTests
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*malformed user ID*");

View File

@@ -16,7 +16,7 @@
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>

View File

@@ -1,6 +1,6 @@
using System.Data.Common;
using Dapper;
using Infrastructure.Sql;
using Database.Connection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.SqlClient;
@@ -14,7 +14,7 @@ namespace Features.Auth.Identity;
/// existing unique indexes are already case-insensitive under SQL Server's default collation).
/// </summary>
public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
: Repository<ApplicationUser>(connectionFactory),
: DapperRepository(connectionFactory),
IUserStore<ApplicationUser>,
IUserPasswordStore<ApplicationUser>,
IUserEmailStore<ApplicationUser>

View File

@@ -66,12 +66,6 @@ public interface ITokenService
/// </summary>
string GenerateConfirmationToken(Guid userId, string username);
/// <summary>Validates an access token's signature and expiration and extracts the caller's identity.</summary>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception>
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
/// <summary>Validates a refresh token's signature and expiration and extracts the caller's identity.</summary>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.

View File

@@ -82,12 +82,6 @@ public class TokenService : ITokenService
);
}
/// <inheritdoc />
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
{
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
}
/// <inheritdoc />
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
{

View File

@@ -13,7 +13,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>

View File

@@ -13,7 +13,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>

View File

@@ -1,8 +1,8 @@
using System.Data.Common;
using Dapper;
using Database.Connection;
using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.Sql;
namespace Features.Breweries.Repository;
@@ -17,7 +17,7 @@ namespace Features.Breweries.Repository;
/// yields the UDT's serialized bytes as a plain <c>varbinary</c> value that Dapper can bind directly.
/// </remarks>
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: Repository<BreweryPost>(connectionFactory),
: Database.Connection.DapperRepository(connectionFactory),
IBreweryRepository
{
/// <inheritdoc/>

View File

@@ -13,7 +13,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>

View File

@@ -1,9 +1,8 @@
using System.Data.Common;
using Dapper;
using Domain.Entities;
using Database.Connection;
using Domain.Exceptions;
using Features.Locations.Dtos;
using Infrastructure.Sql;
namespace Features.Locations.Repository;
@@ -11,7 +10,7 @@ namespace Features.Locations.Repository;
/// Dapper-based implementation of <see cref="ILocationRepository" />.
/// </summary>
public class LocationRepository(ISqlConnectionFactory connectionFactory)
: Repository<City>(connectionFactory),
: Database.Connection.DapperRepository(connectionFactory),
ILocationRepository
{
/// <inheritdoc />

View File

@@ -13,7 +13,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>

View File

@@ -1,8 +1,8 @@
using System.Data.Common;
using Dapper;
using Database.Connection;
using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.Sql;
namespace Features.UserManagement.Repository;
@@ -10,7 +10,7 @@ namespace Features.UserManagement.Repository;
/// Dapper-based implementation of <see cref="IUserAccountRepository" />.
/// </summary>
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<UserAccount>(connectionFactory),
: Database.Connection.DapperRepository(connectionFactory),
IUserAccountRepository
{
/// <inheritdoc />

View File

@@ -1,50 +0,0 @@
using System.Data.Common;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Infrastructure.Sql;
/// <summary>
/// Default <see cref="ISqlConnectionFactory" /> implementation that creates SQL Server connections,
/// resolving the connection string from environment variables or application configuration.
/// </summary>
public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlConnectionFactory
{
private readonly string _connectionString = GetConnectionString(configuration);
/// <inheritdoc />
public DbConnection CreateConnection()
{
return new SqlConnection(_connectionString);
}
/// <summary>
/// Resolves the SQL Server connection string, preferring (in order): the <c>DB_CONNECTION_STRING</c>
/// configuration value, a connection string built from individual <c>DB_*</c> configuration values,
/// and finally the <c>"Default"</c> connection string from configuration.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when no connection string can be resolved from any source.</exception>
private static string GetConnectionString(IConfiguration configuration)
{
string? fullConnectionString = configuration["DB_CONNECTION_STRING"];
if (!string.IsNullOrEmpty(fullConnectionString))
return fullConnectionString;
// Try to build from individual configuration values (preferred method for Docker)
try
{
return SqlConnectionStringHelper.BuildConnectionString(configuration);
}
catch (InvalidOperationException)
{
// Fall back to configuration-based connection string if env vars are not set
string? connString = configuration.GetConnectionString("Default");
if (!string.IsNullOrEmpty(connString))
return connString;
throw new InvalidOperationException(
"Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars or ConnectionStrings:Default."
);
}
}
}

View File

@@ -1,14 +0,0 @@
using System.Data.Common;
namespace Infrastructure.Sql;
/// <summary>
/// Factory for creating database connections used by repositories.
/// </summary>
public interface ISqlConnectionFactory
{
/// <summary>
/// Creates a new, unopened database connection.
/// </summary>
DbConnection CreateConnection();
}

View File

@@ -1,61 +0,0 @@
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Infrastructure.Sql;
/// <summary>
/// Helper for building SQL Server connection strings from configuration
/// (backed by the <c>DB_*</c> environment variables).
/// </summary>
public static class SqlConnectionStringHelper
{
/// <summary>
/// Builds a SQL Server connection string from configuration.
/// </summary>
/// <remarks>Throws <see cref="InvalidOperationException" /> if DB_SERVER, DB_NAME, DB_USER, or DB_PASSWORD is unset.</remarks>
/// <param name="configuration">The configuration to read the <c>DB_*</c> values from.</param>
/// <param name="databaseName">If null, falls back to the DB_NAME configuration value.</param>
public static string BuildConnectionString(
IConfiguration configuration,
string? databaseName = null
)
{
string server = Required(configuration, "DB_SERVER");
string dbName = databaseName ?? Required(configuration, "DB_NAME");
string user = Required(configuration, "DB_USER");
string password = Required(configuration, "DB_PASSWORD");
SqlConnectionStringBuilder builder = new()
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = ResolveTrustServerCertificate(configuration),
Encrypt = true,
};
return builder.ConnectionString;
}
public static string BuildMasterConnectionString(IConfiguration configuration)
{
return BuildConnectionString(configuration, "master");
}
/// <summary>
/// Resolves the <c>DB_TRUST_SERVER_CERTIFICATE</c> configuration value, defaulting to
/// <c>true</c> when it is unset or not a valid boolean.
/// </summary>
private static bool ResolveTrustServerCertificate(IConfiguration configuration)
{
return !bool.TryParse(
configuration["DB_TRUST_SERVER_CERTIFICATE"],
out bool trustServerCertificate
) || trustServerCertificate;
}
private static string Required(IConfiguration configuration, string key) =>
configuration[key]
?? throw new InvalidOperationException($"The {key} environment variable is not set.");
}

View File

@@ -1 +0,0 @@
namespace Infrastructure.Sql;