mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Migrate Auth/Brewery/Location repositories to pure Dapper; rename Timer to RowVersion (#237)
* Integrate Spectre.Console into seed application, use async data loading * Replace stored-procedure repositories with Dapper-based repositories Removes the USP_* stored procedures for Auth, Location, and Brewery data access in favour of services built with Dapper. Adds a new Features.Locations slice and optimistic concurrency support for the Brewery slice. * Add new documentation for Dapper migration/database schema * Update FluentAssertions to most recent open source ver * Update XML doc comment in backend projects * Modify brewery location dto to include denormalized data * - Updated column names from 'Timer' to 'RowVersion' in schema.sql for - Introduced new BeerPost entity and updated BreweryPost and BreweryPostLocation entities to use 'RowVersion' for concurrency. - Modified UserAccount, UserCredential, UserVerification, City, StateProvince, and Country entities to use 'RowVersion' instead of 'Timer'. - Updated tests and command handlers to reflect changes in concurrency token from 'Timer' to 'RowVersion'. - Adjusted BreweryDto and BreweryDtoMapper to include 'RowVersion' and updated mapping logic accordingly. - Ensured all repository methods and interfaces are aligned with the new concurrency handling approach. * updates * Add BeerStyle entity and related properties; refactor BreweryDto to remove BeerPosts * Fix Api.Specs test failures: stale Timer column refs and missing test.user fixture * Remove repository tests, usages of registration dto used in repo * archive old stored procs/functions * update stale docs * Add instructions for generating cryptographically secure base64 secrets on macOS/Linux and Windows
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
using Features.Locations.Repository;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.Locations.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the services used by the Features.Locations slice.
|
||||
/// </summary>
|
||||
public static class FeaturesLocationsServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers <see cref="ILocationRepository" /> and its Dapper-based implementation.
|
||||
/// </summary>
|
||||
/// <returns>The same <paramref name="services" /> instance, for chaining.</returns>
|
||||
public static IServiceCollection AddFeaturesLocations(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<ILocationRepository, LocationRepository>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
13
web/backend/Features/Features.Locations/Dtos/CityLocation.cs
Normal file
13
web/backend/Features/Features.Locations/Dtos/CityLocation.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Features.Locations.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a city's location for resolution by <c>ILocationRepository</c>, including the
|
||||
/// state/province and country it belongs to.
|
||||
/// </summary>
|
||||
public sealed record CityLocation(
|
||||
string CityName,
|
||||
string StateProvinceName,
|
||||
string StateProvinceIsoCode,
|
||||
string CountryName,
|
||||
string CountryIsoCode
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Features.Locations</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<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="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,16 @@
|
||||
using Features.Locations.Dtos;
|
||||
|
||||
namespace Features.Locations.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the Country/StateProvince/City location hierarchy shared by features that attach a
|
||||
/// location to a record (e.g. brewery posts, and eventually user accounts).
|
||||
/// </summary>
|
||||
public interface ILocationRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves <paramref name="location" /> to a City ID, creating the Country, StateProvince,
|
||||
/// and/or City rows if any part of the chain doesn't exist yet.
|
||||
/// </summary>
|
||||
Task<Guid> GetOrCreateCityIdAsync(CityLocation location);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Locations.Dtos;
|
||||
using Infrastructure.Sql;
|
||||
|
||||
namespace Features.Locations.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based implementation of <see cref="ILocationRepository" />.
|
||||
/// </summary>
|
||||
public class LocationRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<City>(connectionFactory),
|
||||
ILocationRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Not fully race-safe under concurrent callers for the same new city (neither was the stored
|
||||
/// procedure this replaces, since <c>City</c> has no unique constraint on name+state): a
|
||||
/// concurrent duplicate insert can succeed rather than being rejected. Country/StateProvince
|
||||
/// creation guards against this the same way the original stored procedures did, via an
|
||||
/// existence pre-check immediately before the insert.
|
||||
/// </remarks>
|
||||
public async Task<Guid> GetOrCreateCityIdAsync(CityLocation location)
|
||||
{
|
||||
await EnsureCountryExistsAsync(location.CountryName, location.CountryIsoCode);
|
||||
await EnsureStateProvinceExistsAsync(
|
||||
location.StateProvinceName,
|
||||
location.StateProvinceIsoCode,
|
||||
location.CountryIsoCode
|
||||
);
|
||||
|
||||
Guid? cityId = await GetCityIdAsync(location.CityName, location.StateProvinceIsoCode);
|
||||
if (cityId is not null)
|
||||
return cityId.Value;
|
||||
|
||||
Guid stateProvinceId =
|
||||
await GetStateProvinceIdAsync(location.StateProvinceIsoCode)
|
||||
?? throw new NotFoundException(
|
||||
$"State/province '{location.StateProvinceIsoCode}' not found."
|
||||
);
|
||||
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
bool cityExists =
|
||||
await connection.ExecuteScalarAsync<int?>(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM dbo.City
|
||||
WHERE CityName = @CityName AND StateProvinceID = @StateProvinceId
|
||||
""",
|
||||
new { location.CityName, StateProvinceId = stateProvinceId }
|
||||
) is not null;
|
||||
|
||||
if (!cityExists)
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO dbo.City (StateProvinceID, CityName)
|
||||
VALUES (@StateProvinceId, @CityName)
|
||||
""",
|
||||
new { StateProvinceId = stateProvinceId, location.CityName }
|
||||
);
|
||||
|
||||
return await GetCityIdAsync(location.CityName, location.StateProvinceIsoCode)
|
||||
?? throw new InvalidOperationException(
|
||||
$"City '{location.CityName}' was not found after creation."
|
||||
);
|
||||
}
|
||||
|
||||
private async Task EnsureCountryExistsAsync(string countryName, string isoCode)
|
||||
{
|
||||
if (await GetCountryIdAsync(isoCode) is not null)
|
||||
return;
|
||||
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
bool exists =
|
||||
await connection.ExecuteScalarAsync<int?>(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM dbo.Country
|
||||
WHERE ISO3166_1 = @ISO3166_1
|
||||
""",
|
||||
new { ISO3166_1 = isoCode }
|
||||
) is not null;
|
||||
|
||||
if (!exists)
|
||||
// A concurrent caller may create this country between the check above and here; that
|
||||
// race is tolerated (the resulting duplicate row, if any, is out of scope for this method).
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO dbo.Country (CountryName, ISO3166_1)
|
||||
VALUES (@CountryName, @ISO3166_1)
|
||||
""",
|
||||
new { CountryName = countryName, ISO3166_1 = isoCode }
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures a StateProvince row exists for <paramref name="isoCode" />.
|
||||
/// </summary>
|
||||
/// <exception cref="NotFoundException">
|
||||
/// Thrown when no Country exists for <paramref name="countryIsoCode" />. Not expected in normal
|
||||
/// operation, since callers ensure the Country exists first — retained as a defensive translation
|
||||
/// in case of a race between concurrent callers.
|
||||
/// </exception>
|
||||
private async Task EnsureStateProvinceExistsAsync(
|
||||
string stateProvinceName,
|
||||
string isoCode,
|
||||
string countryIsoCode
|
||||
)
|
||||
{
|
||||
if (await GetStateProvinceIdAsync(isoCode) is not null)
|
||||
return;
|
||||
|
||||
Guid countryId =
|
||||
await GetCountryIdAsync(countryIsoCode)
|
||||
?? throw new NotFoundException($"Country '{countryIsoCode}' not found.");
|
||||
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
bool exists =
|
||||
await connection.ExecuteScalarAsync<int?>(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM dbo.StateProvince
|
||||
WHERE ISO3166_2 = @ISO3166_2
|
||||
""",
|
||||
new { ISO3166_2 = isoCode }
|
||||
) is not null;
|
||||
|
||||
if (!exists)
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO dbo.StateProvince (StateProvinceName, ISO3166_2, CountryID)
|
||||
VALUES (@StateProvinceName, @ISO3166_2, @CountryId)
|
||||
""",
|
||||
new
|
||||
{
|
||||
StateProvinceName = stateProvinceName,
|
||||
ISO3166_2 = isoCode,
|
||||
CountryId = countryId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<Guid?> GetCountryIdAsync(string isoCode)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.ExecuteScalarAsync<Guid?>(
|
||||
"""
|
||||
SELECT CountryID
|
||||
FROM dbo.Country
|
||||
WHERE ISO3166_1 = @ISO3166_1
|
||||
""",
|
||||
new { ISO3166_1 = isoCode }
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<Guid?> GetStateProvinceIdAsync(string isoCode)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.ExecuteScalarAsync<Guid?>(
|
||||
"""
|
||||
SELECT StateProvinceID
|
||||
FROM dbo.StateProvince
|
||||
WHERE ISO3166_2 = @ISO3166_2
|
||||
""",
|
||||
new { ISO3166_2 = isoCode }
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<Guid?> GetCityIdAsync(string cityName, string stateProvinceIsoCode)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.ExecuteScalarAsync<Guid?>(
|
||||
"""
|
||||
SELECT c.CityID
|
||||
FROM dbo.City c
|
||||
INNER JOIN dbo.StateProvince sp ON sp.StateProvinceID = c.StateProvinceID
|
||||
WHERE c.CityName = @CityName AND sp.ISO3166_2 = @StateProvinceCode
|
||||
""",
|
||||
new { CityName = cityName, StateProvinceCode = stateProvinceIsoCode }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user