mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
refactor(database): update seed pipeline to use dapper (#277)
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
|
||||||
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
||||||
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
|
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
|
||||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ using Features.Emails.DependencyInjection;
|
|||||||
using Features.Emails.Services;
|
using Features.Emails.Services;
|
||||||
using Features.ImageUploads.Commands.UploadPhoto;
|
using Features.ImageUploads.Commands.UploadPhoto;
|
||||||
using Features.ImageUploads.DependencyInjection;
|
using Features.ImageUploads.DependencyInjection;
|
||||||
|
using Features.Locations.Controllers;
|
||||||
|
using Features.Locations.DependencyInjection;
|
||||||
using Features.Users.DependencyInjection;
|
using Features.Users.DependencyInjection;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using FluentValidation.AspNetCore;
|
using FluentValidation.AspNetCore;
|
||||||
@@ -26,6 +28,7 @@ internal static class ServiceCollectionExtensions
|
|||||||
.AddControllers(options => options.Filters.Add<GlobalExceptionFilter>())
|
.AddControllers(options => options.Filters.Add<GlobalExceptionFilter>())
|
||||||
.AddApplicationPart(typeof(BreweryController).Assembly)
|
.AddApplicationPart(typeof(BreweryController).Assembly)
|
||||||
.AddApplicationPart(typeof(AuthController).Assembly)
|
.AddApplicationPart(typeof(AuthController).Assembly)
|
||||||
|
.AddApplicationPart(typeof(CityController).Assembly)
|
||||||
.Services;
|
.Services;
|
||||||
|
|
||||||
public IServiceCollection AddValidationAndMediatR()
|
public IServiceCollection AddValidationAndMediatR()
|
||||||
@@ -44,6 +47,7 @@ internal static class ServiceCollectionExtensions
|
|||||||
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
|
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
|
||||||
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
|
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
|
||||||
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
|
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(CityController).Assembly);
|
||||||
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,7 +100,8 @@ internal static class ServiceCollectionExtensions
|
|||||||
.AddFeaturesBreweries()
|
.AddFeaturesBreweries()
|
||||||
.AddFeaturesUsers()
|
.AddFeaturesUsers()
|
||||||
.AddFeaturesEmails()
|
.AddFeaturesEmails()
|
||||||
.AddFeaturesPhotoUpload();
|
.AddFeaturesPhotoUpload()
|
||||||
|
.AddFeaturesLocations();
|
||||||
|
|
||||||
public IServiceCollection AddCoreInfrastructure() =>
|
public IServiceCollection AddCoreInfrastructure() =>
|
||||||
services
|
services
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Database.Connection;
|
using Database.Connection;
|
||||||
using Features.Locations.Dtos;
|
|
||||||
using Features.Locations.Repository;
|
using Features.Locations.Repository;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -23,11 +22,6 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
|||||||
private const string FixtureStateProvinceIsoCode = "FX-TS";
|
private const string FixtureStateProvinceIsoCode = "FX-TS";
|
||||||
private const string FixtureCityName = "Fixture City";
|
private const string FixtureCityName = "Fixture City";
|
||||||
|
|
||||||
// GetOrCreateCityIdAsync is not fully race-safe under concurrent callers creating the same
|
|
||||||
// city (see its remarks); scenarios run in parallel across feature classes, so provisioning
|
|
||||||
// the shared fixture city is serialized the same way AuthSteps serializes its fixture account.
|
|
||||||
private static readonly SemaphoreSlim CityProvisioningLock = new(1, 1);
|
|
||||||
|
|
||||||
private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url)
|
private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url)
|
||||||
{
|
{
|
||||||
HttpRequestMessage requestMessage = new(method, url);
|
HttpRequestMessage requestMessage = new(method, url);
|
||||||
@@ -53,24 +47,21 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
|||||||
.Services.GetRequiredService<ISqlConnectionFactory>();
|
.Services.GetRequiredService<ISqlConnectionFactory>();
|
||||||
LocationRepository repository = new(connectionFactory);
|
LocationRepository repository = new(connectionFactory);
|
||||||
|
|
||||||
CityLocation fixtureCity = new(
|
Guid countryId =
|
||||||
FixtureCityName,
|
await repository.GetCountryIdAsync(FixtureCountryIsoCode)
|
||||||
FixtureStateProvinceName,
|
?? await repository.CreateCountryAsync(FixtureCountryName, FixtureCountryIsoCode);
|
||||||
FixtureStateProvinceIsoCode,
|
|
||||||
FixtureCountryName,
|
|
||||||
FixtureCountryIsoCode
|
|
||||||
);
|
|
||||||
|
|
||||||
await CityProvisioningLock.WaitAsync();
|
Guid stateProvinceId =
|
||||||
Guid cityId;
|
await repository.GetStateProvinceIdAsync(FixtureStateProvinceIsoCode)
|
||||||
try
|
?? await repository.CreateStateProvinceAsync(
|
||||||
{
|
FixtureStateProvinceName,
|
||||||
cityId = await repository.GetOrCreateCityIdAsync(fixtureCity);
|
FixtureStateProvinceIsoCode,
|
||||||
}
|
countryId
|
||||||
finally
|
);
|
||||||
{
|
|
||||||
CityProvisioningLock.Release();
|
Guid cityId =
|
||||||
}
|
await repository.GetCityIdAsync(FixtureCityName, FixtureStateProvinceIsoCode)
|
||||||
|
?? await repository.CreateCityAsync(FixtureCityName, stateProvinceId);
|
||||||
|
|
||||||
Scenario[CityIdKey] = cityId;
|
Scenario[CityIdKey] = cityId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ CREATE TABLE Geolocation.StateProvince
|
|||||||
|
|
||||||
StateProvinceName NVARCHAR(100) NOT NULL,
|
StateProvinceName NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
ISO3166_2 CHAR(6) NOT NULL,
|
|
||||||
-- eg 'US-CA' for California, 'CA-ON' for Ontario
|
|
||||||
|
|
||||||
CountryID UNIQUEIDENTIFIER NOT NULL,
|
CountryID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
|
-- eg 'US-CA' for California, 'CA-ON' for Ontario
|
||||||
|
ISO3166_2 CHAR(6) NOT NULL,
|
||||||
|
|
||||||
RowVersion ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
@@ -18,6 +18,9 @@ CREATE TABLE Geolocation.StateProvince
|
|||||||
CONSTRAINT AK_StateProvince_ISO3166_2
|
CONSTRAINT AK_StateProvince_ISO3166_2
|
||||||
UNIQUE (ISO3166_2),
|
UNIQUE (ISO3166_2),
|
||||||
|
|
||||||
|
CONSTRAINT AK_StateProvince_Country
|
||||||
|
UNIQUE (StateProvinceName, CountryID),
|
||||||
|
|
||||||
CONSTRAINT FK_StateProvince_Country
|
CONSTRAINT FK_StateProvince_Country
|
||||||
FOREIGN KEY (CountryID)
|
FOREIGN KEY (CountryID)
|
||||||
REFERENCES Geolocation.Country (CountryID)
|
REFERENCES Geolocation.Country (CountryID)
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ CREATE TABLE Geolocation.City
|
|||||||
|
|
||||||
CONSTRAINT FK_City_StateProvince
|
CONSTRAINT FK_City_StateProvince
|
||||||
FOREIGN KEY (StateProvinceID)
|
FOREIGN KEY (StateProvinceID)
|
||||||
REFERENCES Geolocation.StateProvince (StateProvinceID)
|
REFERENCES Geolocation.StateProvince (StateProvinceID),
|
||||||
|
|
||||||
|
CONSTRAINT AK_CityStateProvince UNIQUE (CityName, StateProvinceID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
|
|||||||
371
web/backend/Database/Database.Seed/BiergartenDataSeeder.cs
Normal file
371
web/backend/Database/Database.Seed/BiergartenDataSeeder.cs
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
using Database.Seed.DatabaseHelpers;
|
||||||
|
using Database.Seed.Sqlite;
|
||||||
|
using Database.Seed.SourceDataModels;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Features.Auth.Commands.Authentication.RegisterUser;
|
||||||
|
using Features.Auth.Commands.Profile.UpdateBiography;
|
||||||
|
using Features.Auth.Commands.Profile.UploadAvatar;
|
||||||
|
using Features.Auth.Dtos;
|
||||||
|
using Features.Breweries.Commands.CreateBrewery;
|
||||||
|
using Features.Locations.Commands.CreateCity;
|
||||||
|
using Features.Locations.Commands.CreateCountry;
|
||||||
|
using Features.Locations.Commands.CreateStateProvince;
|
||||||
|
using Features.Locations.Queries.GetCity;
|
||||||
|
using Features.Locations.Queries.GetCountry;
|
||||||
|
using Features.Locations.Queries.GetStateProvince;
|
||||||
|
using idunno.Password;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Spectre.Console;
|
||||||
|
using City = Database.Seed.SourceDataModels.City;
|
||||||
|
|
||||||
|
namespace Database.Seed;
|
||||||
|
|
||||||
|
public class BiergartenDataSeeder
|
||||||
|
{
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
private readonly SeedRepository _reader;
|
||||||
|
|
||||||
|
public BiergartenDataSeeder(IMediator mediator, SeedRepository reader)
|
||||||
|
{
|
||||||
|
_mediator = mediator;
|
||||||
|
_reader = reader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Run(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified());
|
||||||
|
AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]");
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
SeedData seedData = null!;
|
||||||
|
IReadOnlyList<Guid> userIds = [];
|
||||||
|
|
||||||
|
await AnsiConsole
|
||||||
|
.Status()
|
||||||
|
.Spinner(Spinner.Known.Dots)
|
||||||
|
.SpinnerStyle(Style.Parse("green"))
|
||||||
|
.StartAsync(
|
||||||
|
"Loading seed data...",
|
||||||
|
async ctx =>
|
||||||
|
{
|
||||||
|
seedData = await _reader.ReadSeedDataAsync(cancellationToken);
|
||||||
|
ctx.Status($"Done.");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine(
|
||||||
|
$"[green]✓[/] Loaded [bold]{seedData.Breweries.Count}[/] breweries."
|
||||||
|
);
|
||||||
|
AnsiConsole.Write(BuildBreweryDisplayTable(seedData.Breweries));
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine($"[green]✓[/] Loaded [bold]{seedData.Users.Count}[/] users.");
|
||||||
|
AnsiConsole.Write(BuildUserDisplayTable(seedData.Users));
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine($"[green]✓[/] Loaded [bold]{seedData.Cities.Count}[/] cities.");
|
||||||
|
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
AnsiConsole.WriteLine("Seed data loaded successfully.");
|
||||||
|
|
||||||
|
AnsiConsole.Write(
|
||||||
|
new Rule("[bold green]Loading seed data into target database.[/]").LeftJustified()
|
||||||
|
);
|
||||||
|
|
||||||
|
await AnsiConsole
|
||||||
|
.Status()
|
||||||
|
.Spinner(Spinner.Known.Dots)
|
||||||
|
.SpinnerStyle(Style.Parse("green"))
|
||||||
|
.StartAsync(
|
||||||
|
"Loading user data into target database...",
|
||||||
|
async ctx =>
|
||||||
|
{
|
||||||
|
userIds = await LoadUsersIntoDatabaseAsync(seedData.Users, cancellationToken);
|
||||||
|
ctx.Status("User data loaded into target database.");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await AnsiConsole
|
||||||
|
.Status()
|
||||||
|
.Spinner(Spinner.Known.Dots)
|
||||||
|
.SpinnerStyle(Style.Parse("green"))
|
||||||
|
.StartAsync(
|
||||||
|
"Loading avatars into target database...",
|
||||||
|
async ctx =>
|
||||||
|
{
|
||||||
|
await LoadAvatarsIntoDatabaseAsync(
|
||||||
|
userIds,
|
||||||
|
seedData.Users,
|
||||||
|
ctx,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
ctx.Status("Avatar data loaded into target database.");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await AnsiConsole
|
||||||
|
.Status()
|
||||||
|
.Spinner(Spinner.Known.Dots)
|
||||||
|
.SpinnerStyle(Style.Parse("green"))
|
||||||
|
.StartAsync(
|
||||||
|
"Loading city data into target database...",
|
||||||
|
async ctx =>
|
||||||
|
{
|
||||||
|
await LoadCitiesIntoDatabaseAsync(seedData.Cities, ctx, cancellationToken);
|
||||||
|
ctx.Status("City data loaded into target database.");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await AnsiConsole
|
||||||
|
.Status()
|
||||||
|
.Spinner(Spinner.Known.Dots)
|
||||||
|
.SpinnerStyle(Style.Parse("green"))
|
||||||
|
.StartAsync(
|
||||||
|
"Loading brewery data into target database...",
|
||||||
|
async ctx =>
|
||||||
|
{
|
||||||
|
await LoadBreweriesIntoDatabaseAsync(
|
||||||
|
seedData.Breweries,
|
||||||
|
userIds,
|
||||||
|
ctx,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
ctx.Status("Brewery data loaded into target database.");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
|
||||||
|
IReadOnlyList<UserRecord> users,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
List<Guid> userAccountIds = [];
|
||||||
|
|
||||||
|
foreach (UserRecord userRecord in users)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
// allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible
|
||||||
|
// digit characters (0-9)
|
||||||
|
string password = PasswordGenerator.Generate(
|
||||||
|
length: 64,
|
||||||
|
numberOfDigits: 12,
|
||||||
|
numberOfSymbols: 12,
|
||||||
|
allowRepeatedCharacters: true
|
||||||
|
);
|
||||||
|
|
||||||
|
RegistrationPayload registration = await _mediator.Send(
|
||||||
|
new RegisterUserCommand(
|
||||||
|
userRecord.User.Username,
|
||||||
|
userRecord.User.FirstName,
|
||||||
|
userRecord.User.LastName,
|
||||||
|
userRecord.Email,
|
||||||
|
DateTime.Parse(userRecord.DateOfBirth),
|
||||||
|
password
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
userAccountIds.Add(registration.UserAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return userAccountIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAvatarsIntoDatabaseAsync(
|
||||||
|
IReadOnlyList<Guid> userIds,
|
||||||
|
IReadOnlyList<UserRecord> users,
|
||||||
|
StatusContext ctx,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < userIds.Count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
ctx.Status($"Loading avatar {i + 1}/{userIds.Count} into target database...");
|
||||||
|
|
||||||
|
Guid userId = userIds[i];
|
||||||
|
|
||||||
|
await _mediator.Send(
|
||||||
|
new UpdateBiographyCommand(userId, users[i].User.Bio),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
byte[] avatarPng = AvatarGenerator.GeneratePng(userId);
|
||||||
|
|
||||||
|
await using MemoryStream stream = new(avatarPng);
|
||||||
|
IFormFile file = new FormFile(stream, 0, stream.Length, "file", $"{userId}.png")
|
||||||
|
{
|
||||||
|
Headers = new HeaderDictionary(),
|
||||||
|
ContentType = "image/png",
|
||||||
|
};
|
||||||
|
|
||||||
|
await _mediator.Send(new UploadAvatarCommand(userId, file), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadCitiesIntoDatabaseAsync(
|
||||||
|
IReadOnlyList<City> cities,
|
||||||
|
StatusContext ctx,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Dictionary<string, Guid> countryIdsByIso31661 = [];
|
||||||
|
Dictionary<string, Guid> stateProvinceIdsByIso31662 = [];
|
||||||
|
|
||||||
|
foreach (City city in cities)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
ctx.Status(
|
||||||
|
$"Loading city {city.CityName}, {city.StateProvinceName}, {city.CountryName}..."
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!countryIdsByIso31661.TryGetValue(city.ISO_3166_1, out Guid countryId))
|
||||||
|
{
|
||||||
|
countryId =
|
||||||
|
await _mediator.Send(new GetCountryQuery(city.ISO_3166_1), cancellationToken)
|
||||||
|
?? await _mediator.Send(
|
||||||
|
new CreateCountryCommand(city.CountryName, city.ISO_3166_1),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
countryIdsByIso31661[city.ISO_3166_1] = countryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stateProvinceIdsByIso31662.TryGetValue(city.ISO_3166_2, out Guid stateProvinceId))
|
||||||
|
{
|
||||||
|
stateProvinceId =
|
||||||
|
await _mediator.Send(
|
||||||
|
new GetStateProvinceQuery(city.ISO_3166_2),
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
?? await _mediator.Send(
|
||||||
|
new CreateStateProvinceCommand(
|
||||||
|
city.StateProvinceName,
|
||||||
|
city.ISO_3166_2,
|
||||||
|
countryId
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
stateProvinceIdsByIso31662[city.ISO_3166_2] = stateProvinceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _mediator.Send(
|
||||||
|
new CreateCityCommand(city.CityName, stateProvinceId),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadBreweriesIntoDatabaseAsync(
|
||||||
|
IReadOnlyList<BreweryResult> breweries,
|
||||||
|
IReadOnlyList<Guid> posterUserIds,
|
||||||
|
StatusContext ctx,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (posterUserIds.Count == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Cannot load breweries without any registered users."
|
||||||
|
);
|
||||||
|
|
||||||
|
for (int i = 0; i < breweries.Count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
BreweryResult breweryRecord = breweries[i];
|
||||||
|
BreweryAddress address =
|
||||||
|
breweryRecord.Address
|
||||||
|
?? throw new InvalidOperationException("Brewery address was not loaded.");
|
||||||
|
City city =
|
||||||
|
address.City ?? throw new InvalidOperationException("Brewery city was not loaded.");
|
||||||
|
|
||||||
|
ctx.Status($"Loading brewery {i + 1}/{breweries.Count} into target database...");
|
||||||
|
|
||||||
|
Guid cityId =
|
||||||
|
await _mediator.Send(
|
||||||
|
new GetCityQuery(city.CityName, city.ISO_3166_2),
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"City '{city.CityName}' "
|
||||||
|
+ $"({city.ISO_3166_2}) was not found. "
|
||||||
|
+ "Cities must be loaded before breweries."
|
||||||
|
);
|
||||||
|
|
||||||
|
await _mediator.Send(
|
||||||
|
new CreateBreweryCommand(
|
||||||
|
posterUserIds[i % posterUserIds.Count],
|
||||||
|
breweryRecord.NameEn,
|
||||||
|
breweryRecord.DescriptionEn,
|
||||||
|
new CreateBreweryLocation(
|
||||||
|
cityId,
|
||||||
|
address.AddressLine1,
|
||||||
|
null,
|
||||||
|
address.PostalCode,
|
||||||
|
new CoordinateData(address.Latitude, address.Longitude)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Table BuildBreweryDisplayTable(IReadOnlyList<BreweryResult> breweries)
|
||||||
|
{
|
||||||
|
Table table = new Table()
|
||||||
|
.AddColumn("Brewery Name (EN)")
|
||||||
|
.AddColumn("Brewery Name (Local)")
|
||||||
|
.AddColumn("City")
|
||||||
|
.AddColumn("State/Province")
|
||||||
|
.AddColumn("Country")
|
||||||
|
.AddColumn("Longitude")
|
||||||
|
.AddColumn("Latitude");
|
||||||
|
|
||||||
|
foreach (BreweryResult brewery in breweries)
|
||||||
|
{
|
||||||
|
BreweryAddress address =
|
||||||
|
brewery.Address
|
||||||
|
?? throw new InvalidOperationException("Brewery address was not loaded.");
|
||||||
|
City city =
|
||||||
|
address.City ?? throw new InvalidOperationException("Brewery city was not loaded.");
|
||||||
|
|
||||||
|
table.AddRow(
|
||||||
|
brewery.NameEn,
|
||||||
|
brewery.NameLocal,
|
||||||
|
city.CityName,
|
||||||
|
city.StateProvinceName,
|
||||||
|
city.CountryName,
|
||||||
|
address.Longitude.ToString("F6"),
|
||||||
|
address.Latitude.ToString("F6")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Table BuildUserDisplayTable(IReadOnlyList<UserRecord> users)
|
||||||
|
{
|
||||||
|
Table table = new Table()
|
||||||
|
.AddColumn("Username")
|
||||||
|
.AddColumn("First Name")
|
||||||
|
.AddColumn("Last Name")
|
||||||
|
.AddColumn("Email")
|
||||||
|
.AddColumn("Date of Birth");
|
||||||
|
|
||||||
|
foreach (UserRecord user in users)
|
||||||
|
{
|
||||||
|
table.AddRow(
|
||||||
|
user.User.Username,
|
||||||
|
user.User.FirstName,
|
||||||
|
user.User.LastName,
|
||||||
|
user.Email,
|
||||||
|
user.DateOfBirth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@
|
|||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None CopyToOutputDirectory="PreserveNewest" Include="SeedData\biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite" />
|
<None
|
||||||
|
CopyToOutputDirectory="PreserveNewest"
|
||||||
|
Include="SeedData\biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite"
|
||||||
|
/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
#region
|
|
||||||
|
|
||||||
using Database.Connection;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
namespace Database.Seed.DatabaseHelpers;
|
|
||||||
|
|
||||||
public static class ConnectionStrings
|
|
||||||
{
|
|
||||||
public static string GetSqlServerConnectionString(IConfiguration 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-25T20-45-50.697244Z.sqlite";
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace Database.Seed.PipelineData;
|
|
||||||
|
|
||||||
public sealed record BreweryAddress
|
|
||||||
{
|
|
||||||
public required City City { get; init; }
|
|
||||||
|
|
||||||
public required double Longitude { get; init; }
|
|
||||||
|
|
||||||
public required double Latitude { get; init; }
|
|
||||||
|
|
||||||
public required string AddressLine1 { get; init; }
|
|
||||||
|
|
||||||
public required string PostalCode { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace Database.Seed.PipelineData;
|
|
||||||
|
|
||||||
public sealed record BreweryRecord
|
|
||||||
{
|
|
||||||
public required BreweryAddress Address { get; init; }
|
|
||||||
|
|
||||||
public required BreweryResult Brewery { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace Database.Seed.PipelineData;
|
|
||||||
|
|
||||||
public sealed record BreweryResult
|
|
||||||
{
|
|
||||||
public required string NameEn { get; init; }
|
|
||||||
|
|
||||||
public required string DescriptionEn { get; init; }
|
|
||||||
|
|
||||||
public required string NameLocal { get; init; }
|
|
||||||
|
|
||||||
public required string DescriptionLocal { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace Database.Seed.PipelineData;
|
|
||||||
|
|
||||||
public sealed record City
|
|
||||||
{
|
|
||||||
public required string CityName { get; init; }
|
|
||||||
|
|
||||||
public required string StateProvince { get; init; }
|
|
||||||
|
|
||||||
public required string Iso31662 { get; init; }
|
|
||||||
|
|
||||||
public required string Country { get; init; }
|
|
||||||
|
|
||||||
public required string Iso31661 { get; init; }
|
|
||||||
|
|
||||||
public IReadOnlyList<string> LocalLanguages { get; init; } = [];
|
|
||||||
|
|
||||||
public required PostalCodeSpec PostalCode { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Database.Seed.PipelineData;
|
|
||||||
|
|
||||||
public sealed record PostalCodeSpec
|
|
||||||
{
|
|
||||||
public required string CountryFormatRegex { get; init; }
|
|
||||||
|
|
||||||
public IReadOnlyList<string> CityRegexes { get; init; } = [];
|
|
||||||
|
|
||||||
public IReadOnlyList<string> Examples { get; init; } = [];
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Database.Seed.PipelineData;
|
|
||||||
|
|
||||||
public sealed record UserAddress
|
|
||||||
{
|
|
||||||
public required City City { get; init; }
|
|
||||||
|
|
||||||
public required double Longitude { get; init; }
|
|
||||||
|
|
||||||
public required double Latitude { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace Database.Seed.PipelineData;
|
|
||||||
|
|
||||||
public sealed record UserRecord
|
|
||||||
{
|
|
||||||
public required UserAddress Address { get; init; }
|
|
||||||
|
|
||||||
public required UserResult User { get; init; }
|
|
||||||
|
|
||||||
public required string Email { get; init; }
|
|
||||||
|
|
||||||
public required string DateOfBirth { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
namespace Database.Seed.PipelineData;
|
|
||||||
|
|
||||||
public sealed record UserResult
|
|
||||||
{
|
|
||||||
public required string FirstName { get; init; }
|
|
||||||
|
|
||||||
public required string LastName { get; init; }
|
|
||||||
|
|
||||||
public required string Gender { get; init; }
|
|
||||||
|
|
||||||
public required string Username { get; init; }
|
|
||||||
|
|
||||||
public required string Bio { get; init; }
|
|
||||||
|
|
||||||
public required float ActivityWeight { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,26 @@
|
|||||||
using Database.Connection.DependencyInjection;
|
using Database.Connection.DependencyInjection;
|
||||||
using Database.Seed.DatabaseHelpers;
|
using Database.Seed.DatabaseHelpers;
|
||||||
using Database.Seed.PipelineData;
|
|
||||||
using Database.Seed.Sqlite;
|
using Database.Seed.Sqlite;
|
||||||
using Domain.Entities;
|
|
||||||
using Features.Auth.Commands.Authentication.RegisterUser;
|
|
||||||
using Features.Auth.Commands.Profile.UpdateBiography;
|
|
||||||
using Features.Auth.Commands.Profile.UploadAvatar;
|
using Features.Auth.Commands.Profile.UploadAvatar;
|
||||||
using Features.Users.DependencyInjection;
|
|
||||||
using Features.Auth.Dtos;
|
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
using Features.Breweries.Commands.CreateBrewery;
|
using Features.Breweries.Commands.CreateBrewery;
|
||||||
using Features.Breweries.DependencyInjection;
|
using Features.Breweries.DependencyInjection;
|
||||||
using Features.ImageUploads.Commands.UploadPhoto;
|
using Features.ImageUploads.Commands.UploadPhoto;
|
||||||
using Features.ImageUploads.DependencyInjection;
|
using Features.ImageUploads.DependencyInjection;
|
||||||
using Features.Locations.Commands.GetOrCreateCity;
|
|
||||||
using Features.Locations.DependencyInjection;
|
using Features.Locations.DependencyInjection;
|
||||||
using Features.Locations.Dtos;
|
using Features.Locations.Queries.GetCountry;
|
||||||
using idunno.Password;
|
using Features.Users.DependencyInjection;
|
||||||
using Infrastructure.FileUpload;
|
using Infrastructure.FileUpload;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
return await RunAsync();
|
namespace Database.Seed;
|
||||||
|
|
||||||
static async Task<int> RunAsync()
|
internal class Program
|
||||||
{
|
{
|
||||||
try
|
private static IMediator CreateMediator()
|
||||||
{
|
{
|
||||||
IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build();
|
IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build();
|
||||||
|
|
||||||
@@ -43,260 +35,41 @@ static async Task<int> RunAsync()
|
|||||||
.AddScoped<ITokenService, NoOpTokenService>()
|
.AddScoped<ITokenService, NoOpTokenService>()
|
||||||
.AddMediatR(cfg =>
|
.AddMediatR(cfg =>
|
||||||
cfg.RegisterServicesFromAssemblyContaining<CreateBreweryCommand>()
|
cfg.RegisterServicesFromAssemblyContaining<CreateBreweryCommand>()
|
||||||
.RegisterServicesFromAssemblyContaining<GetOrCreateCityCommand>()
|
.RegisterServicesFromAssemblyContaining<GetCountryQuery>()
|
||||||
.RegisterServicesFromAssemblyContaining<UploadAvatarCommand>()
|
.RegisterServicesFromAssemblyContaining<UploadAvatarCommand>()
|
||||||
.RegisterServicesFromAssemblyContaining<UploadPhotoCommand>()
|
.RegisterServicesFromAssemblyContaining<UploadPhotoCommand>()
|
||||||
);
|
);
|
||||||
|
|
||||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
using ServiceProvider provider = services.BuildServiceProvider();
|
||||||
IMediator mediator = provider.GetRequiredService<IMediator>();
|
return provider.GetRequiredService<IMediator>();
|
||||||
|
|
||||||
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified());
|
|
||||||
AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]");
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString);
|
|
||||||
|
|
||||||
SeedData seedData = null!;
|
|
||||||
IReadOnlyList<Guid> userIds = [];
|
|
||||||
|
|
||||||
await AnsiConsole
|
|
||||||
.Status()
|
|
||||||
.Spinner(Spinner.Known.Dots)
|
|
||||||
.SpinnerStyle(Style.Parse("green"))
|
|
||||||
.StartAsync(
|
|
||||||
"Loading seed data...",
|
|
||||||
async ctx =>
|
|
||||||
{
|
|
||||||
seedData = await reader.ReadSeedDataAsync();
|
|
||||||
ctx.Status(
|
|
||||||
$"Loaded {seedData.Breweries.Count} breweries and {seedData.Users.Count} users."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
AnsiConsole.MarkupLine(
|
|
||||||
$"[green]✓[/] Loaded [bold]{seedData.Breweries.Count}[/] breweries."
|
|
||||||
);
|
|
||||||
AnsiConsole.Write(BuildBreweryTable(seedData.Breweries));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
AnsiConsole.MarkupLine($"[green]✓[/] Loaded [bold]{seedData.Users.Count}[/] users.");
|
|
||||||
AnsiConsole.Write(BuildUserTable(seedData.Users));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
AnsiConsole.WriteLine("Seed data loaded successfully.");
|
|
||||||
|
|
||||||
AnsiConsole.Write(
|
|
||||||
new Rule("[bold green]Loading seed data into target database.[/]").LeftJustified()
|
|
||||||
);
|
|
||||||
|
|
||||||
await AnsiConsole
|
|
||||||
.Status()
|
|
||||||
.Spinner(Spinner.Known.Dots)
|
|
||||||
.SpinnerStyle(Style.Parse("green"))
|
|
||||||
.StartAsync(
|
|
||||||
"Loading user data into target database...",
|
|
||||||
async ctx =>
|
|
||||||
{
|
|
||||||
userIds = await LoadUsersIntoDatabaseAsync(mediator, seedData.Users);
|
|
||||||
ctx.Status("User data loaded into target database.");
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await AnsiConsole
|
|
||||||
.Status()
|
|
||||||
.Spinner(Spinner.Known.Dots)
|
|
||||||
.SpinnerStyle(Style.Parse("green"))
|
|
||||||
.StartAsync(
|
|
||||||
"Loading avatars into target database...",
|
|
||||||
async ctx =>
|
|
||||||
{
|
|
||||||
await LoadAvatarsIntoDatabaseAsync(mediator, userIds, seedData.Users, ctx);
|
|
||||||
ctx.Status("Avatar data loaded into target database.");
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await AnsiConsole
|
|
||||||
.Status()
|
|
||||||
.Spinner(Spinner.Known.Dots)
|
|
||||||
.SpinnerStyle(Style.Parse("green"))
|
|
||||||
.StartAsync(
|
|
||||||
"Loading brewery data into target database...",
|
|
||||||
async ctx =>
|
|
||||||
{
|
|
||||||
await LoadBreweriesIntoDatabaseAsync(
|
|
||||||
mediator,
|
|
||||||
seedData.Breweries,
|
|
||||||
userIds,
|
|
||||||
ctx
|
|
||||||
);
|
|
||||||
ctx.Status("Brewery data loaded into target database.");
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine("[red]Seeding failed.[/]");
|
|
||||||
AnsiConsole.WriteException(ex);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
|
|
||||||
IMediator mediator,
|
|
||||||
IReadOnlyList<UserRecord> users
|
|
||||||
)
|
|
||||||
{
|
|
||||||
List<Guid> userAccountIds = [];
|
|
||||||
|
|
||||||
foreach (UserRecord userRecord in users)
|
|
||||||
{
|
|
||||||
// allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible
|
|
||||||
// digit characters (0-9), which idunno.Password rejects outright.
|
|
||||||
string password = PasswordGenerator.Generate(64, 12, 12, allowRepeatedCharacters: true);
|
|
||||||
|
|
||||||
RegistrationPayload registration = await mediator.Send(
|
|
||||||
new RegisterUserCommand(
|
|
||||||
userRecord.User.Username,
|
|
||||||
userRecord.User.FirstName,
|
|
||||||
userRecord.User.LastName,
|
|
||||||
userRecord.Email,
|
|
||||||
DateTime.Parse(userRecord.DateOfBirth),
|
|
||||||
password
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
userAccountIds.Add(registration.UserAccountId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return userAccountIds;
|
public static async Task Main()
|
||||||
}
|
|
||||||
|
|
||||||
static async Task LoadAvatarsIntoDatabaseAsync(
|
|
||||||
IMediator mediator,
|
|
||||||
IReadOnlyList<Guid> userIds,
|
|
||||||
IReadOnlyList<UserRecord> users,
|
|
||||||
StatusContext ctx
|
|
||||||
)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < userIds.Count; i++)
|
|
||||||
{
|
{
|
||||||
ctx.Status($"Loading avatar {i + 1}/{userIds.Count} into target database...");
|
using CancellationTokenSource cts = new();
|
||||||
|
Console.CancelKeyPress += (_, e) =>
|
||||||
Guid userId = userIds[i];
|
|
||||||
|
|
||||||
await mediator.Send(new UpdateBiographyCommand(userId, users[i].User.Bio));
|
|
||||||
|
|
||||||
byte[] avatarPng = AvatarGenerator.GeneratePng(userId);
|
|
||||||
|
|
||||||
await using MemoryStream stream = new(avatarPng);
|
|
||||||
IFormFile file = new FormFile(stream, 0, stream.Length, "file", $"{userId}.png")
|
|
||||||
{
|
{
|
||||||
Headers = new HeaderDictionary(),
|
e.Cancel = true;
|
||||||
ContentType = "image/png",
|
// ReSharper disable once AccessToDisposedClosure
|
||||||
|
cts.Cancel();
|
||||||
};
|
};
|
||||||
|
|
||||||
await mediator.Send(new UploadAvatarCommand(userId, file));
|
try
|
||||||
|
{
|
||||||
|
IMediator mediator = CreateMediator();
|
||||||
|
SeedRepository reader = new SeedRepository(
|
||||||
|
connectionString: "Data Source=SeedData/biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite"
|
||||||
|
);
|
||||||
|
await new BiergartenDataSeeder(mediator, reader).Run(cts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[yellow]Seeding cancelled.[/]");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[red]Seeding failed.[/]");
|
||||||
|
AnsiConsole.WriteException(ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task LoadBreweriesIntoDatabaseAsync(
|
|
||||||
IMediator mediator,
|
|
||||||
IReadOnlyList<BreweryRecord> breweries,
|
|
||||||
IReadOnlyList<Guid> posterUserIds,
|
|
||||||
StatusContext ctx
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (posterUserIds.Count == 0)
|
|
||||||
throw new InvalidOperationException("Cannot load breweries without any registered users.");
|
|
||||||
|
|
||||||
for (int i = 0; i < breweries.Count; i++)
|
|
||||||
{
|
|
||||||
BreweryRecord breweryRecord = breweries[i];
|
|
||||||
|
|
||||||
ctx.Status($"Loading brewery {i + 1}/{breweries.Count} into target database...");
|
|
||||||
|
|
||||||
Guid cityId = await mediator.Send(
|
|
||||||
new GetOrCreateCityCommand(
|
|
||||||
new CityLocation(
|
|
||||||
breweryRecord.Address.City.CityName,
|
|
||||||
breweryRecord.Address.City.StateProvince,
|
|
||||||
breweryRecord.Address.City.Iso31662,
|
|
||||||
breweryRecord.Address.City.Country,
|
|
||||||
breweryRecord.Address.City.Iso31661
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await mediator.Send(
|
|
||||||
new CreateBreweryCommand(
|
|
||||||
posterUserIds[i % posterUserIds.Count],
|
|
||||||
breweryRecord.Brewery.NameEn,
|
|
||||||
breweryRecord.Brewery.DescriptionEn,
|
|
||||||
new CreateBreweryLocation(
|
|
||||||
cityId,
|
|
||||||
breweryRecord.Address.AddressLine1,
|
|
||||||
null,
|
|
||||||
breweryRecord.Address.PostalCode,
|
|
||||||
new CoordinateData(
|
|
||||||
breweryRecord.Address.Latitude,
|
|
||||||
breweryRecord.Address.Longitude
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static Table BuildBreweryTable(IReadOnlyList<BreweryRecord> breweries)
|
|
||||||
{
|
|
||||||
Table table = new Table()
|
|
||||||
.AddColumn("Brewery Name (EN)")
|
|
||||||
.AddColumn("Brewery Name (Local)")
|
|
||||||
.AddColumn("City")
|
|
||||||
.AddColumn("State/Province")
|
|
||||||
.AddColumn("Country")
|
|
||||||
.AddColumn("Longitude")
|
|
||||||
.AddColumn("Latitude");
|
|
||||||
|
|
||||||
foreach (BreweryRecord brewery in breweries)
|
|
||||||
{
|
|
||||||
table.AddRow(
|
|
||||||
brewery.Brewery.NameEn,
|
|
||||||
brewery.Brewery.NameLocal,
|
|
||||||
brewery.Address.City.CityName,
|
|
||||||
brewery.Address.City.StateProvince,
|
|
||||||
brewery.Address.City.Country,
|
|
||||||
brewery.Address.Longitude.ToString("F6"),
|
|
||||||
brewery.Address.Latitude.ToString("F6")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return table;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Table BuildUserTable(IReadOnlyList<UserRecord> users)
|
|
||||||
{
|
|
||||||
Table table = new Table()
|
|
||||||
.AddColumn("Username")
|
|
||||||
.AddColumn("First Name")
|
|
||||||
.AddColumn("Last Name")
|
|
||||||
.AddColumn("Email")
|
|
||||||
.AddColumn("Date of Birth");
|
|
||||||
|
|
||||||
foreach (UserRecord user in users)
|
|
||||||
{
|
|
||||||
table.AddRow(
|
|
||||||
user.User.Username,
|
|
||||||
user.User.FirstName,
|
|
||||||
user.User.LastName,
|
|
||||||
user.Email,
|
|
||||||
user.DateOfBirth
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return table;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
namespace Database.Seed.SourceDataModels;
|
||||||
|
|
||||||
|
public sealed record PostalCodeSpec(
|
||||||
|
string CountryFormatRegex,
|
||||||
|
IReadOnlyList<string> CityRegexes
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed class City
|
||||||
|
{
|
||||||
|
// Identity
|
||||||
|
public int Id { get; init; }
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
public string CityName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
// Resharper disable InconsistentNaming
|
||||||
|
public string ISO_3166_1 { get; init; } = string.Empty;
|
||||||
|
public string ISO_3166_2 { get; init; } = string.Empty;
|
||||||
|
public string CountryName { get; init; } = string.Empty;
|
||||||
|
public string StateProvinceName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public double Longitude { get; init; }
|
||||||
|
public double Latitude { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<string> LocalLanguages { get; init; } = [];
|
||||||
|
public PostalCodeSpec? PostalCode { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BreweryAddress
|
||||||
|
{
|
||||||
|
// Identity
|
||||||
|
public int Id { get; init; }
|
||||||
|
|
||||||
|
// FK References
|
||||||
|
public int CityId { get; init; }
|
||||||
|
public int BreweryId { get; init; } // FK lives on this side in the DB (brewery_addresses.brewery_id)
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
public double Longitude { get; init; }
|
||||||
|
public double Latitude { get; init; }
|
||||||
|
public string AddressLine1 { get; init; } = string.Empty;
|
||||||
|
public string PostalCode { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
// Navigation properties
|
||||||
|
public City? City { get; init; }
|
||||||
|
public BreweryResult? Brewery { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BreweryResult
|
||||||
|
{
|
||||||
|
// Identity
|
||||||
|
public int Id { get; init; }
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
public string NameEn { get; init; } = string.Empty;
|
||||||
|
public string DescriptionEn { get; init; } = string.Empty;
|
||||||
|
public string NameLocal { get; init; } = string.Empty;
|
||||||
|
public string DescriptionLocal { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
// Navigation properties
|
||||||
|
public BreweryAddress? Address { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UserAddress
|
||||||
|
{
|
||||||
|
// Identity
|
||||||
|
public int Id { get; init; }
|
||||||
|
|
||||||
|
// FK References
|
||||||
|
public int CityId { get; init; }
|
||||||
|
public int UserId { get; init; }
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
public double Longitude { get; init; }
|
||||||
|
public double Latitude { get; init; }
|
||||||
|
|
||||||
|
// Navigation properties
|
||||||
|
public City? City { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UserResult
|
||||||
|
{
|
||||||
|
// Identity
|
||||||
|
public int Id { get; init; }
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
public string FirstName { get; init; } = string.Empty;
|
||||||
|
public string LastName { get; init; } = string.Empty;
|
||||||
|
public string Gender { get; init; } = string.Empty;
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
public string Bio { get; init; } = string.Empty;
|
||||||
|
public float ActivityWeight { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UserRecord
|
||||||
|
{
|
||||||
|
// Attributes
|
||||||
|
public string Email { get; init; } = string.Empty;
|
||||||
|
public string DateOfBirth { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
// Navigation properties
|
||||||
|
public UserAddress Address { get; init; } = new();
|
||||||
|
public UserResult User { get; init; } = new();
|
||||||
|
}
|
||||||
@@ -1,215 +1,259 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Database.Seed.PipelineData;
|
|
||||||
|
using Dapper;
|
||||||
|
|
||||||
|
using Database.Seed.SourceDataModels;
|
||||||
|
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
namespace Database.Seed.Sqlite;
|
namespace Database.Seed.Sqlite;
|
||||||
|
|
||||||
public sealed record SeedData(
|
public sealed record SeedData(
|
||||||
IReadOnlyList<BreweryRecord> Breweries,
|
IReadOnlyList<City> Cities,
|
||||||
|
IReadOnlyList<BreweryResult> Breweries,
|
||||||
IReadOnlyList<UserRecord> Users
|
IReadOnlyList<UserRecord> Users
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed class PipelineSeedDataReader
|
public sealed class SeedRepository : IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _connectionString;
|
private readonly SqliteConnection connection;
|
||||||
|
public SeedRepository(string connectionString)
|
||||||
public PipelineSeedDataReader(string connectionString)
|
|
||||||
{
|
{
|
||||||
_connectionString = connectionString;
|
connection = new(connectionString);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
connection.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads breweries and users in a single connection, loading the cities
|
/// Reads breweries and users in a single connection, loading the cities
|
||||||
/// lookup only once and sharing it between both reads.
|
/// lookup only once and sharing it between both reads.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<SeedData> ReadSeedDataAsync(CancellationToken cancellationToken = default)
|
public async Task<SeedData> ReadSeedDataAsync(
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
{
|
{
|
||||||
await using SqliteConnection connection = new(_connectionString);
|
|
||||||
await connection.OpenAsync(cancellationToken);
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
IReadOnlyDictionary<long, City> cities = await ReadCitiesAsync(
|
IReadOnlyDictionary<int, City> cities = await ReadCitiesAsync(
|
||||||
connection,
|
connection,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
IReadOnlyList<BreweryRecord> breweries = await ReadBreweryRecordsAsync(
|
IReadOnlyList<BreweryResult> breweries = await ReadBreweryRecordsAsync(
|
||||||
connection,
|
connection,
|
||||||
cities,
|
cities,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
IReadOnlyList<UserRecord> users = await ReadUserRecordsAsync(
|
IReadOnlyList<UserRecord> users = await ReadUserRecordsAsync(
|
||||||
connection,
|
connection,
|
||||||
cities,
|
cities,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
return new SeedData(breweries, users);
|
return new SeedData([.. cities.Values], breweries, users);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IReadOnlyList<BreweryRecord>> ReadBreweryRecordsAsync(
|
private sealed record BreweryRow(
|
||||||
|
int Id,
|
||||||
|
string NameEn,
|
||||||
|
string DescriptionEn,
|
||||||
|
string NameLocal,
|
||||||
|
string DescriptionLocal,
|
||||||
|
int AddressId,
|
||||||
|
int CityId,
|
||||||
|
double Longitude,
|
||||||
|
double Latitude,
|
||||||
|
string AddressLine1,
|
||||||
|
string PostalCode
|
||||||
|
);
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<BreweryResult>> ReadBreweryRecordsAsync(
|
||||||
SqliteConnection connection,
|
SqliteConnection connection,
|
||||||
IReadOnlyDictionary<long, City> cities,
|
IReadOnlyDictionary<int, City> cities,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT b.name_en, b.description_en, b.name_local, b.description_local,
|
SELECT
|
||||||
ba.city_id, ba.longitude, ba.latitude, ba.address_line1, ba.postal_code
|
b.id AS Id,
|
||||||
FROM breweries b
|
b.name_en AS NameEn,
|
||||||
JOIN brewery_addresses ba ON ba.brewery_id = b.id;
|
b.description_en AS DescriptionEn,
|
||||||
|
b.name_local AS NameLocal,
|
||||||
|
b.description_local AS DescriptionLocal,
|
||||||
|
ba.id AS AddressId,
|
||||||
|
ba.city_id AS CityId,
|
||||||
|
ba.longitude AS Longitude,
|
||||||
|
ba.latitude AS Latitude,
|
||||||
|
ba.address_line1 AS AddressLine1,
|
||||||
|
ba.postal_code AS PostalCode
|
||||||
|
FROM
|
||||||
|
breweries b
|
||||||
|
INNER JOIN
|
||||||
|
brewery_addresses ba ON ba.brewery_id = b.id;
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using SqliteCommand command = new(sql, connection);
|
CommandDefinition command = new(sql, cancellationToken: cancellationToken);
|
||||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
|
IEnumerable<BreweryRow> rows = await connection.QueryAsync<BreweryRow>(command);
|
||||||
|
|
||||||
const int nameEnIndex = 0;
|
return [.. rows.Select(row => new BreweryResult
|
||||||
const int descriptionEnIndex = 1;
|
{
|
||||||
const int nameLocalIndex = 2;
|
Id = row.Id,
|
||||||
const int descriptionLocalIndex = 3;
|
NameEn = row.NameEn,
|
||||||
const int cityIdIndex = 4;
|
DescriptionEn = row.DescriptionEn,
|
||||||
const int longitudeIndex = 5;
|
NameLocal = row.NameLocal,
|
||||||
const int latitudeIndex = 6;
|
DescriptionLocal = row.DescriptionLocal,
|
||||||
const int addressLine1Index = 7;
|
Address = new BreweryAddress
|
||||||
const int postalCodeIndex = 8;
|
|
||||||
|
|
||||||
List<BreweryRecord> records = [];
|
|
||||||
while (await reader.ReadAsync(cancellationToken))
|
|
||||||
records.Add(
|
|
||||||
new BreweryRecord
|
|
||||||
{
|
{
|
||||||
Brewery = new BreweryResult
|
Id = row.AddressId,
|
||||||
{
|
CityId = row.CityId,
|
||||||
NameEn = reader.GetString(nameEnIndex),
|
BreweryId = row.Id,
|
||||||
DescriptionEn = reader.GetString(descriptionEnIndex),
|
Longitude = row.Longitude,
|
||||||
NameLocal = reader.GetString(nameLocalIndex),
|
Latitude = row.Latitude,
|
||||||
DescriptionLocal = reader.GetString(descriptionLocalIndex),
|
AddressLine1 = row.AddressLine1,
|
||||||
},
|
PostalCode = row.PostalCode,
|
||||||
Address = new BreweryAddress
|
City = cities[row.CityId],
|
||||||
{
|
},
|
||||||
City = cities[reader.GetInt64(cityIdIndex)],
|
})];
|
||||||
Longitude = reader.GetDouble(longitudeIndex),
|
|
||||||
Latitude = reader.GetDouble(latitudeIndex),
|
|
||||||
AddressLine1 = reader.GetString(addressLine1Index),
|
|
||||||
PostalCode = reader.GetString(postalCodeIndex),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return records;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record UserRow(
|
||||||
|
int Id,
|
||||||
|
string FirstName,
|
||||||
|
string LastName,
|
||||||
|
string Gender,
|
||||||
|
string Username,
|
||||||
|
string Bio,
|
||||||
|
double ActivityWeight,
|
||||||
|
string Email,
|
||||||
|
string DateOfBirth,
|
||||||
|
int AddressId,
|
||||||
|
int CityId,
|
||||||
|
double Longitude,
|
||||||
|
double Latitude
|
||||||
|
);
|
||||||
|
|
||||||
private static async Task<IReadOnlyList<UserRecord>> ReadUserRecordsAsync(
|
private static async Task<IReadOnlyList<UserRecord>> ReadUserRecordsAsync(
|
||||||
SqliteConnection connection,
|
SqliteConnection connection,
|
||||||
IReadOnlyDictionary<long, City> cities,
|
IReadOnlyDictionary<int, City> cities,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT u.first_name, u.last_name, u.gender, u.username, u.bio,
|
SELECT
|
||||||
u.activity_weight, u.email, u.date_of_birth,
|
u.id AS Id,
|
||||||
ua.city_id, ua.longitude, ua.latitude
|
u.first_name AS FirstName,
|
||||||
FROM users u
|
u.last_name AS LastName,
|
||||||
JOIN user_addresses ua ON ua.user_id = u.id;
|
u.gender AS Gender,
|
||||||
|
u.username AS Username,
|
||||||
|
u.bio AS Bio,
|
||||||
|
u.activity_weight AS ActivityWeight,
|
||||||
|
u.email AS Email,
|
||||||
|
u.date_of_birth AS DateOfBirth,
|
||||||
|
ua.id AS AddressId,
|
||||||
|
ua.city_id AS CityId,
|
||||||
|
ua.longitude AS Longitude,
|
||||||
|
ua.latitude AS Latitude
|
||||||
|
FROM
|
||||||
|
users u
|
||||||
|
INNER JOIN
|
||||||
|
user_addresses ua ON ua.user_id = u.id;
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using SqliteCommand command = new(sql, connection);
|
CommandDefinition command = new(sql, cancellationToken: cancellationToken);
|
||||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
|
IEnumerable<UserRow> rows = await connection.QueryAsync<UserRow>(command);
|
||||||
|
|
||||||
const int firstNameIndex = 0;
|
return [.. rows.Select(row => new UserRecord
|
||||||
const int lastNameIndex = 1;
|
{
|
||||||
const int genderIndex = 2;
|
Email = row.Email,
|
||||||
const int usernameIndex = 3;
|
DateOfBirth = row.DateOfBirth,
|
||||||
const int bioIndex = 4;
|
Address = new UserAddress
|
||||||
const int activityWeightIndex = 5;
|
|
||||||
const int emailIndex = 6;
|
|
||||||
const int dateOfBirthIndex = 7;
|
|
||||||
const int cityIdIndex = 8;
|
|
||||||
const int longitudeIndex = 9;
|
|
||||||
const int latitudeIndex = 10;
|
|
||||||
|
|
||||||
List<UserRecord> records = [];
|
|
||||||
while (await reader.ReadAsync(cancellationToken))
|
|
||||||
records.Add(
|
|
||||||
new UserRecord
|
|
||||||
{
|
{
|
||||||
User = new UserResult
|
Id = row.AddressId,
|
||||||
{
|
CityId = row.CityId,
|
||||||
FirstName = reader.GetString(firstNameIndex),
|
UserId = row.Id,
|
||||||
LastName = reader.GetString(lastNameIndex),
|
Longitude = row.Longitude,
|
||||||
Gender = reader.GetString(genderIndex),
|
Latitude = row.Latitude,
|
||||||
Username = reader.GetString(usernameIndex),
|
City = cities[row.CityId],
|
||||||
Bio = reader.GetString(bioIndex),
|
},
|
||||||
ActivityWeight = (float)reader.GetDouble(activityWeightIndex),
|
User = new UserResult
|
||||||
},
|
{
|
||||||
Email = reader.GetString(emailIndex),
|
Id = row.Id,
|
||||||
DateOfBirth = reader.GetString(dateOfBirthIndex),
|
FirstName = row.FirstName,
|
||||||
Address = new UserAddress
|
LastName = row.LastName,
|
||||||
{
|
Gender = row.Gender,
|
||||||
City = cities[reader.GetInt64(cityIdIndex)],
|
Username = row.Username,
|
||||||
Longitude = reader.GetDouble(longitudeIndex),
|
Bio = row.Bio,
|
||||||
Latitude = reader.GetDouble(latitudeIndex),
|
ActivityWeight = (float)row.ActivityWeight,
|
||||||
},
|
},
|
||||||
}
|
})];
|
||||||
);
|
|
||||||
|
|
||||||
return records;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IReadOnlyDictionary<long, City>> ReadCitiesAsync(
|
private sealed record CityRow(
|
||||||
|
int Id,
|
||||||
|
string CityName,
|
||||||
|
string StateProvince,
|
||||||
|
string Iso31662,
|
||||||
|
string Country,
|
||||||
|
string Iso31661,
|
||||||
|
double Longitude,
|
||||||
|
double Latitude,
|
||||||
|
string LocalLanguagesJson,
|
||||||
|
string PostalCodeCountryFormatRegex,
|
||||||
|
string PostalCodeCityRegexesJson
|
||||||
|
);
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyDictionary<int, City>> ReadCitiesAsync(
|
||||||
SqliteConnection connection,
|
SqliteConnection connection,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id AS Id,
|
||||||
city,
|
city AS CityName,
|
||||||
state_province,
|
state_province AS StateProvince,
|
||||||
iso3166_2,
|
iso3166_2 AS Iso31662,
|
||||||
country,
|
country AS Country,
|
||||||
iso3166_1,
|
iso3166_1 AS Iso31661,
|
||||||
local_languages_json,
|
longitude AS Longitude,
|
||||||
postal_code_country_format_regex,
|
latitude AS Latitude,
|
||||||
postal_code_city_regex_json
|
local_languages_json AS LocalLanguagesJson,
|
||||||
|
postal_code_country_format_regex AS PostalCodeCountryFormatRegex,
|
||||||
|
postal_code_city_regex_json AS PostalCodeCityRegexesJson
|
||||||
FROM cities;
|
FROM cities;
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using SqliteCommand command = new(sql, connection);
|
CommandDefinition command = new(sql, cancellationToken: cancellationToken);
|
||||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
|
IEnumerable<CityRow> rows = await connection.QueryAsync<CityRow>(command);
|
||||||
|
|
||||||
Dictionary<long, City> cities = [];
|
return rows.ToDictionary(
|
||||||
|
row => row.Id,
|
||||||
const int idIndex = 0;
|
row => new City
|
||||||
const int cityNameIndex = 1;
|
|
||||||
const int stateProvinceIndex = 2;
|
|
||||||
const int iso31662Index = 3;
|
|
||||||
const int countryIndex = 4;
|
|
||||||
const int iso31661Index = 5;
|
|
||||||
const int localLanguagesIndex = 6;
|
|
||||||
const int countryFormatRegexIndex = 7;
|
|
||||||
const int cityRegexesIndex = 8;
|
|
||||||
|
|
||||||
while (await reader.ReadAsync(cancellationToken))
|
|
||||||
cities[reader.GetInt64(idIndex)] = new City
|
|
||||||
{
|
{
|
||||||
CityName = reader.GetString(cityNameIndex),
|
Id = row.Id,
|
||||||
StateProvince = reader.GetString(stateProvinceIndex),
|
CityName = row.CityName,
|
||||||
Iso31662 = reader.GetString(iso31662Index),
|
StateProvinceName = row.StateProvince,
|
||||||
Country = reader.GetString(countryIndex),
|
ISO_3166_2 = row.Iso31662,
|
||||||
Iso31661 = reader.GetString(iso31661Index),
|
CountryName = row.Country,
|
||||||
LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesIndex)),
|
ISO_3166_1 = row.Iso31661,
|
||||||
PostalCode = new PostalCodeSpec
|
Longitude = row.Longitude,
|
||||||
{
|
Latitude = row.Latitude,
|
||||||
CountryFormatRegex = reader.GetString(countryFormatRegexIndex),
|
LocalLanguages = DeserializeStringArray(row.LocalLanguagesJson),
|
||||||
CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesIndex)),
|
PostalCode = new PostalCodeSpec(
|
||||||
},
|
CountryFormatRegex: row.PostalCodeCountryFormatRegex,
|
||||||
};
|
CityRegexes: DeserializeStringArray(row.PostalCodeCityRegexesJson)
|
||||||
|
),
|
||||||
return cities;
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<string> DeserializeStringArray(string json)
|
private static IReadOnlyList<string> DeserializeStringArray(string json)
|
||||||
{
|
{
|
||||||
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
|
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Commands.CreateCity;
|
||||||
|
|
||||||
|
public record CreateCityCommand(string CityName, Guid StateProvinceId) : IRequest<Guid>;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Features.Locations.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Commands.CreateCity;
|
||||||
|
|
||||||
|
public class CreateCityHandler(ILocationRepository repository)
|
||||||
|
: IRequestHandler<CreateCityCommand, Guid>
|
||||||
|
{
|
||||||
|
public Task<Guid> Handle(CreateCityCommand request, CancellationToken cancellationToken) =>
|
||||||
|
repository.CreateCityAsync(request.CityName, request.StateProvinceId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Commands.CreateCountry;
|
||||||
|
|
||||||
|
public record CreateCountryCommand(string CountryName, string IsoCode) : IRequest<Guid>;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Features.Locations.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Commands.CreateCountry;
|
||||||
|
|
||||||
|
public class CreateCountryHandler(ILocationRepository repository)
|
||||||
|
: IRequestHandler<CreateCountryCommand, Guid>
|
||||||
|
{
|
||||||
|
public Task<Guid> Handle(CreateCountryCommand request, CancellationToken cancellationToken) =>
|
||||||
|
repository.CreateCountryAsync(request.CountryName, request.IsoCode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Commands.CreateStateProvince;
|
||||||
|
|
||||||
|
public record CreateStateProvinceCommand(string StateProvinceName, string IsoCode, Guid CountryId)
|
||||||
|
: IRequest<Guid>;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Features.Locations.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Commands.CreateStateProvince;
|
||||||
|
|
||||||
|
public class CreateStateProvinceHandler(ILocationRepository repository)
|
||||||
|
: IRequestHandler<CreateStateProvinceCommand, Guid>
|
||||||
|
{
|
||||||
|
public Task<Guid> Handle(
|
||||||
|
CreateStateProvinceCommand request,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
) =>
|
||||||
|
repository.CreateStateProvinceAsync(
|
||||||
|
request.StateProvinceName,
|
||||||
|
request.IsoCode,
|
||||||
|
request.CountryId
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
using Features.Locations.Dtos;
|
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace Features.Locations.Commands.GetOrCreateCity;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resolves <see cref="City" /> to a City ID, creating the Country, StateProvince, and/or City rows
|
|
||||||
/// if any part of the chain doesn't exist yet. Sent by other features' handlers (e.g. brewery
|
|
||||||
/// creation) rather than bound from a request directly, since this feature exposes no controller of
|
|
||||||
/// its own.
|
|
||||||
/// </summary>
|
|
||||||
public record GetOrCreateCityCommand(CityLocation City) : IRequest<Guid>;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
using Features.Locations.Repository;
|
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace Features.Locations.Commands.GetOrCreateCity;
|
|
||||||
|
|
||||||
/// <summary>Handles <see cref="GetOrCreateCityCommand" /> by delegating to <see cref="ILocationRepository" />.</summary>
|
|
||||||
public class GetOrCreateCityHandler(ILocationRepository repository)
|
|
||||||
: IRequestHandler<GetOrCreateCityCommand, Guid>
|
|
||||||
{
|
|
||||||
public Task<Guid> Handle(GetOrCreateCityCommand request, CancellationToken cancellationToken) =>
|
|
||||||
repository.GetOrCreateCityIdAsync(request.City);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Features.Locations.Dtos;
|
||||||
|
using Features.Locations.Queries.GetAllCities;
|
||||||
|
using Features.Locations.Queries.GetCityById;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Shared.Contracts;
|
||||||
|
|
||||||
|
namespace Features.Locations.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
|
public class CityController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<ResponseBody<CityDto>>> GetById(Guid id)
|
||||||
|
{
|
||||||
|
CityDto? city = await mediator.Send(new GetCityByIdQuery(id));
|
||||||
|
if (city is null)
|
||||||
|
return NotFound(new ResponseBody { Message = $"City with ID {id} not found." });
|
||||||
|
|
||||||
|
return Ok(
|
||||||
|
new ResponseBody<CityDto> { Message = "City retrieved successfully.", Payload = city }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<ResponseBody<IEnumerable<CityDto>>>> GetAll(
|
||||||
|
[FromQuery] int? limit,
|
||||||
|
[FromQuery] int? offset
|
||||||
|
)
|
||||||
|
{
|
||||||
|
IEnumerable<CityDto> cities = await mediator.Send(new GetAllCitiesQuery(limit, offset));
|
||||||
|
return Ok(
|
||||||
|
new ResponseBody<IEnumerable<CityDto>>
|
||||||
|
{
|
||||||
|
Message = "Cities retrieved successfully.",
|
||||||
|
Payload = cities,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,15 +3,8 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
|
|
||||||
namespace Features.Locations.DependencyInjection;
|
namespace Features.Locations.DependencyInjection;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Registers the services used by the Features.Locations slice.
|
|
||||||
/// </summary>
|
|
||||||
public static class FeaturesLocationsServiceCollectionExtensions
|
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)
|
public static IServiceCollection AddFeaturesLocations(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddScoped<ILocationRepository, LocationRepository>();
|
services.AddScoped<ILocationRepository, LocationRepository>();
|
||||||
|
|||||||
11
web/backend/Features/Features.Locations/Dtos/CityDto.cs
Normal file
11
web/backend/Features/Features.Locations/Dtos/CityDto.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Features.Locations.Dtos;
|
||||||
|
|
||||||
|
public record CityDto(
|
||||||
|
Guid CityId,
|
||||||
|
string CityName,
|
||||||
|
string StateProvinceName,
|
||||||
|
string StateProvinceCode,
|
||||||
|
string CountryName,
|
||||||
|
string CountryCode,
|
||||||
|
int BreweryCount
|
||||||
|
);
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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,14 @@
|
|||||||
|
using Features.Locations.Dtos;
|
||||||
|
using Features.Locations.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Queries.GetAllCities;
|
||||||
|
|
||||||
|
public class GetAllCitiesHandler(ILocationRepository repository)
|
||||||
|
: IRequestHandler<GetAllCitiesQuery, IEnumerable<CityDto>>
|
||||||
|
{
|
||||||
|
public Task<IEnumerable<CityDto>> Handle(
|
||||||
|
GetAllCitiesQuery request,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
) => repository.GetAllCitiesAsync(request.Limit, request.Offset);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using Features.Locations.Dtos;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Queries.GetAllCities;
|
||||||
|
|
||||||
|
public record GetAllCitiesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<CityDto>>;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Features.Locations.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Queries.GetCity;
|
||||||
|
|
||||||
|
public class GetCityHandler(ILocationRepository repository) : IRequestHandler<GetCityQuery, Guid?>
|
||||||
|
{
|
||||||
|
public Task<Guid?> Handle(GetCityQuery request, CancellationToken cancellationToken) =>
|
||||||
|
repository.GetCityIdAsync(request.CityName, request.StateProvinceIsoCode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Queries.GetCity;
|
||||||
|
|
||||||
|
public record GetCityQuery(string CityName, string StateProvinceIsoCode) : IRequest<Guid?>;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Features.Locations.Dtos;
|
||||||
|
using Features.Locations.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Queries.GetCityById;
|
||||||
|
|
||||||
|
public class GetCityByIdHandler(ILocationRepository repository)
|
||||||
|
: IRequestHandler<GetCityByIdQuery, CityDto?>
|
||||||
|
{
|
||||||
|
public Task<CityDto?> Handle(GetCityByIdQuery request, CancellationToken cancellationToken) =>
|
||||||
|
repository.GetCityByIdAsync(request.CityId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using Features.Locations.Dtos;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Queries.GetCityById;
|
||||||
|
|
||||||
|
public record GetCityByIdQuery(Guid CityId) : IRequest<CityDto?>;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Features.Locations.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Queries.GetCountry;
|
||||||
|
|
||||||
|
public class GetCountryHandler(ILocationRepository repository)
|
||||||
|
: IRequestHandler<GetCountryQuery, Guid?>
|
||||||
|
{
|
||||||
|
public Task<Guid?> Handle(GetCountryQuery request, CancellationToken cancellationToken) =>
|
||||||
|
repository.GetCountryIdAsync(request.IsoCode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Queries.GetCountry;
|
||||||
|
|
||||||
|
public record GetCountryQuery(string IsoCode) : IRequest<Guid?>;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Features.Locations.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Queries.GetStateProvince;
|
||||||
|
|
||||||
|
public class GetStateProvinceHandler(ILocationRepository repository)
|
||||||
|
: IRequestHandler<GetStateProvinceQuery, Guid?>
|
||||||
|
{
|
||||||
|
public Task<Guid?> Handle(GetStateProvinceQuery request, CancellationToken cancellationToken) =>
|
||||||
|
repository.GetStateProvinceIdAsync(request.IsoCode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Locations.Queries.GetStateProvince;
|
||||||
|
|
||||||
|
public record GetStateProvinceQuery(string IsoCode) : IRequest<Guid?>;
|
||||||
@@ -2,15 +2,21 @@ using Features.Locations.Dtos;
|
|||||||
|
|
||||||
namespace Features.Locations.Repository;
|
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
|
public interface ILocationRepository
|
||||||
{
|
{
|
||||||
/// <summary>
|
Task<CityDto?> GetCityByIdAsync(Guid cityId);
|
||||||
/// 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.
|
Task<IEnumerable<CityDto>> GetAllCitiesAsync(int? limit, int? offset);
|
||||||
/// </summary>
|
|
||||||
Task<Guid> GetOrCreateCityIdAsync(CityLocation location);
|
Task<Guid?> GetCountryIdAsync(string isoCode);
|
||||||
|
|
||||||
|
Task<Guid> CreateCountryAsync(string countryName, string isoCode);
|
||||||
|
|
||||||
|
Task<Guid?> GetStateProvinceIdAsync(string isoCode);
|
||||||
|
|
||||||
|
Task<Guid> CreateStateProvinceAsync(string stateProvinceName, string isoCode, Guid countryId);
|
||||||
|
|
||||||
|
Task<Guid?> GetCityIdAsync(string cityName, string stateProvinceIsoCode);
|
||||||
|
|
||||||
|
Task<Guid> CreateCityAsync(string cityName, Guid stateProvinceId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,153 +1,61 @@
|
|||||||
using System.Data.Common;
|
using System.Data.Common;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using Database.Connection;
|
using Database.Connection;
|
||||||
using Domain.Exceptions;
|
|
||||||
using Features.Locations.Dtos;
|
using Features.Locations.Dtos;
|
||||||
|
|
||||||
namespace Features.Locations.Repository;
|
namespace Features.Locations.Repository;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dapper-based implementation of <see cref="ILocationRepository" />.
|
|
||||||
/// </summary>
|
|
||||||
public class LocationRepository(ISqlConnectionFactory connectionFactory)
|
public class LocationRepository(ISqlConnectionFactory connectionFactory)
|
||||||
: DapperRepository(connectionFactory),
|
: DapperRepository(connectionFactory),
|
||||||
ILocationRepository
|
ILocationRepository
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
private const string CityDtoSelect = """
|
||||||
/// <remarks>
|
SELECT
|
||||||
/// Not fully race-safe under concurrent callers for the same new city (neither was the stored
|
c.CityID AS CityId,
|
||||||
/// procedure this replaces, since <c>City</c> has no unique constraint on name+state): a
|
c.CityName,
|
||||||
/// concurrent duplicate insert can succeed rather than being rejected. Country/StateProvince
|
sp.StateProvinceName,
|
||||||
/// creation guards against this the same way the original stored procedures did, via an
|
sp.ISO3166_2 AS StateProvinceCode,
|
||||||
/// existence pre-check immediately before the insert.
|
co.CountryName,
|
||||||
/// </remarks>
|
co.ISO3166_1 AS CountryCode,
|
||||||
public async Task<Guid> GetOrCreateCityIdAsync(CityLocation location)
|
COUNT(bpl.BreweryPostLocationID) AS BreweryCount
|
||||||
|
FROM Geolocation.City c
|
||||||
|
INNER JOIN Geolocation.StateProvince sp ON sp.StateProvinceID = c.StateProvinceID
|
||||||
|
INNER JOIN Geolocation.Country co ON co.CountryID = sp.CountryID
|
||||||
|
LEFT JOIN Brewery.BreweryPostLocation bpl ON bpl.CityID = c.CityID
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string CityDtoGroupBy = """
|
||||||
|
GROUP BY c.CityID, c.CityName, sp.StateProvinceName, sp.ISO3166_2, co.CountryName, co.ISO3166_1
|
||||||
|
""";
|
||||||
|
|
||||||
|
public async Task<CityDto?> GetCityByIdAsync(Guid cityId)
|
||||||
{
|
{
|
||||||
await EnsureCountryExistsAsync(location.CountryName, location.CountryIsoCode);
|
await using DbConnection connection = await CreateConnection();
|
||||||
await EnsureStateProvinceExistsAsync(
|
return await connection.QuerySingleOrDefaultAsync<CityDto>(
|
||||||
location.StateProvinceName,
|
$"""
|
||||||
location.StateProvinceIsoCode,
|
{CityDtoSelect}
|
||||||
location.CountryIsoCode
|
WHERE c.CityID = @CityId
|
||||||
|
{CityDtoGroupBy}
|
||||||
|
""",
|
||||||
|
new { CityId = cityId }
|
||||||
);
|
);
|
||||||
|
|
||||||
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 Geolocation.City
|
|
||||||
WHERE CityName = @CityName AND StateProvinceID = @StateProvinceId
|
|
||||||
""",
|
|
||||||
new { location.CityName, StateProvinceId = stateProvinceId }
|
|
||||||
)
|
|
||||||
is not null;
|
|
||||||
|
|
||||||
if (!cityExists)
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
INSERT INTO Geolocation.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)
|
public async Task<IEnumerable<CityDto>> GetAllCitiesAsync(int? limit, int? offset)
|
||||||
{
|
{
|
||||||
if (await GetCountryIdAsync(isoCode) is not null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
|
return await connection.QueryAsync<CityDto>(
|
||||||
bool exists =
|
$"""
|
||||||
await connection.ExecuteScalarAsync<int?>(
|
{CityDtoSelect}
|
||||||
"""
|
{CityDtoGroupBy}
|
||||||
SELECT 1
|
ORDER BY c.CityName
|
||||||
FROM Geolocation.Country
|
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
||||||
WHERE ISO3166_1 = @ISO3166_1
|
""",
|
||||||
""",
|
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
|
||||||
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 Geolocation.Country (CountryName, ISO3166_1)
|
|
||||||
VALUES (@CountryName, @ISO3166_1)
|
|
||||||
""",
|
|
||||||
new { CountryName = countryName, ISO3166_1 = isoCode }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<Guid?> GetCountryIdAsync(string isoCode)
|
||||||
/// 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 Geolocation.StateProvince
|
|
||||||
WHERE ISO3166_2 = @ISO3166_2
|
|
||||||
""",
|
|
||||||
new { ISO3166_2 = isoCode }
|
|
||||||
)
|
|
||||||
is not null;
|
|
||||||
|
|
||||||
if (!exists)
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
INSERT INTO Geolocation.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();
|
await using DbConnection connection = await CreateConnection();
|
||||||
return await connection.ExecuteScalarAsync<Guid?>(
|
return await connection.ExecuteScalarAsync<Guid?>(
|
||||||
@@ -160,7 +68,20 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Guid?> GetStateProvinceIdAsync(string isoCode)
|
public async Task<Guid> CreateCountryAsync(string countryName, string isoCode)
|
||||||
|
{
|
||||||
|
await using DbConnection connection = await CreateConnection();
|
||||||
|
return await connection.ExecuteScalarAsync<Guid>(
|
||||||
|
"""
|
||||||
|
INSERT INTO Geolocation.Country (CountryName, ISO3166_1)
|
||||||
|
OUTPUT INSERTED.CountryID
|
||||||
|
VALUES (@CountryName, @ISO3166_1)
|
||||||
|
""",
|
||||||
|
new { CountryName = countryName, ISO3166_1 = isoCode }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid?> GetStateProvinceIdAsync(string isoCode)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
return await connection.ExecuteScalarAsync<Guid?>(
|
return await connection.ExecuteScalarAsync<Guid?>(
|
||||||
@@ -173,7 +94,29 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Guid?> GetCityIdAsync(string cityName, string stateProvinceIsoCode)
|
public async Task<Guid> CreateStateProvinceAsync(
|
||||||
|
string stateProvinceName,
|
||||||
|
string isoCode,
|
||||||
|
Guid countryId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
await using DbConnection connection = await CreateConnection();
|
||||||
|
return await connection.ExecuteScalarAsync<Guid>(
|
||||||
|
"""
|
||||||
|
INSERT INTO Geolocation.StateProvince (StateProvinceName, ISO3166_2, CountryID)
|
||||||
|
OUTPUT INSERTED.StateProvinceID
|
||||||
|
VALUES (@StateProvinceName, @ISO3166_2, @CountryId)
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
StateProvinceName = stateProvinceName,
|
||||||
|
ISO3166_2 = isoCode,
|
||||||
|
CountryId = countryId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid?> GetCityIdAsync(string cityName, string stateProvinceIsoCode)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
return await connection.ExecuteScalarAsync<Guid?>(
|
return await connection.ExecuteScalarAsync<Guid?>(
|
||||||
@@ -186,4 +129,17 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
new { CityName = cityName, StateProvinceCode = stateProvinceIsoCode }
|
new { CityName = cityName, StateProvinceCode = stateProvinceIsoCode }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Guid> CreateCityAsync(string cityName, Guid stateProvinceId)
|
||||||
|
{
|
||||||
|
await using DbConnection connection = await CreateConnection();
|
||||||
|
return await connection.ExecuteScalarAsync<Guid>(
|
||||||
|
"""
|
||||||
|
INSERT INTO Geolocation.City (StateProvinceID, CityName)
|
||||||
|
OUTPUT INSERTED.CityID
|
||||||
|
VALUES (@StateProvinceId, @CityName)
|
||||||
|
""",
|
||||||
|
new { StateProvinceId = stateProvinceId, CityName = cityName }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user