refactor(database): update seed pipeline to use dapper (#277)

This commit is contained in:
Aaron Po
2026-09-05 00:37:14 -04:00
committed by GitHub
parent 78ad32e93a
commit d9411eb00e
43 changed files with 1023 additions and 735 deletions

View File

@@ -5,10 +5,10 @@ CREATE TABLE Geolocation.StateProvince
StateProvinceName NVARCHAR(100) NOT NULL,
ISO3166_2 CHAR(6) NOT NULL,
-- eg 'US-CA' for California, 'CA-ON' for Ontario
CountryID UNIQUEIDENTIFIER NOT NULL,
-- eg 'US-CA' for California, 'CA-ON' for Ontario
ISO3166_2 CHAR(6) NOT NULL,
RowVersion ROWVERSION,
@@ -18,6 +18,9 @@ CREATE TABLE Geolocation.StateProvince
CONSTRAINT AK_StateProvince_ISO3166_2
UNIQUE (ISO3166_2),
CONSTRAINT AK_StateProvince_Country
UNIQUE (StateProvinceName, CountryID),
CONSTRAINT FK_StateProvince_Country
FOREIGN KEY (CountryID)
REFERENCES Geolocation.Country (CountryID)

View File

@@ -14,7 +14,9 @@ CREATE TABLE Geolocation.City
CONSTRAINT FK_City_StateProvince
FOREIGN KEY (StateProvinceID)
REFERENCES Geolocation.StateProvince (StateProvinceID)
REFERENCES Geolocation.StateProvince (StateProvinceID),
CONSTRAINT AK_CityStateProvince UNIQUE (CityName, StateProvinceID)
);
CREATE

View 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;
}
}

View File

@@ -27,6 +27,9 @@
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
</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>
</Project>

View File

@@ -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";
}

View File

@@ -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; }
}

View File

@@ -1,8 +0,0 @@
namespace Database.Seed.PipelineData;
public sealed record BreweryRecord
{
public required BreweryAddress Address { get; init; }
public required BreweryResult Brewery { get; init; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; } = [];
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -1,34 +1,26 @@
using Database.Connection.DependencyInjection;
using Database.Seed.DatabaseHelpers;
using Database.Seed.PipelineData;
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.Users.DependencyInjection;
using Features.Auth.Dtos;
using Features.Auth.Services;
using Features.Breweries.Commands.CreateBrewery;
using Features.Breweries.DependencyInjection;
using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.DependencyInjection;
using Features.Locations.Commands.GetOrCreateCity;
using Features.Locations.DependencyInjection;
using Features.Locations.Dtos;
using idunno.Password;
using Features.Locations.Queries.GetCountry;
using Features.Users.DependencyInjection;
using Infrastructure.FileUpload;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
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();
@@ -43,260 +35,41 @@ static async Task<int> RunAsync()
.AddScoped<ITokenService, NoOpTokenService>()
.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<CreateBreweryCommand>()
.RegisterServicesFromAssemblyContaining<GetOrCreateCityCommand>()
.RegisterServicesFromAssemblyContaining<GetCountryQuery>()
.RegisterServicesFromAssemblyContaining<UploadAvatarCommand>()
.RegisterServicesFromAssemblyContaining<UploadPhotoCommand>()
);
await using ServiceProvider provider = services.BuildServiceProvider();
IMediator mediator = 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);
using ServiceProvider provider = services.BuildServiceProvider();
return provider.GetRequiredService<IMediator>();
}
return userAccountIds;
}
static async Task LoadAvatarsIntoDatabaseAsync(
IMediator mediator,
IReadOnlyList<Guid> userIds,
IReadOnlyList<UserRecord> users,
StatusContext ctx
)
{
for (int i = 0; i < userIds.Count; i++)
public static async Task Main()
{
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));
byte[] avatarPng = AvatarGenerator.GeneratePng(userId);
await using MemoryStream stream = new(avatarPng);
IFormFile file = new FormFile(stream, 0, stream.Length, "file", $"{userId}.png")
using CancellationTokenSource cts = new();
Console.CancelKeyPress += (_, e) =>
{
Headers = new HeaderDictionary(),
ContentType = "image/png",
e.Cancel = true;
// 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;
}

View File

@@ -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();
}

View File

@@ -1,215 +1,259 @@
using System.Text.Json;
using Database.Seed.PipelineData;
using Dapper;
using Database.Seed.SourceDataModels;
using Microsoft.Data.Sqlite;
namespace Database.Seed.Sqlite;
public sealed record SeedData(
IReadOnlyList<BreweryRecord> Breweries,
IReadOnlyList<City> Cities,
IReadOnlyList<BreweryResult> Breweries,
IReadOnlyList<UserRecord> Users
);
public sealed class PipelineSeedDataReader
public sealed class SeedRepository : IDisposable
{
private readonly string _connectionString;
public PipelineSeedDataReader(string connectionString)
private readonly SqliteConnection connection;
public SeedRepository(string connectionString)
{
_connectionString = connectionString;
connection = new(connectionString);
}
public void Dispose()
{
connection.Dispose();
}
/// <summary>
/// Reads breweries and users in a single connection, loading the cities
/// lookup only once and sharing it between both reads.
/// </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);
IReadOnlyDictionary<long, City> cities = await ReadCitiesAsync(
IReadOnlyDictionary<int, City> cities = await ReadCitiesAsync(
connection,
cancellationToken
);
IReadOnlyList<BreweryRecord> breweries = await ReadBreweryRecordsAsync(
IReadOnlyList<BreweryResult> breweries = await ReadBreweryRecordsAsync(
connection,
cities,
cancellationToken
);
IReadOnlyList<UserRecord> users = await ReadUserRecordsAsync(
connection,
cities,
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,
IReadOnlyDictionary<long, City> cities,
IReadOnlyDictionary<int, City> cities,
CancellationToken cancellationToken
)
{
const string sql = """
SELECT b.name_en, b.description_en, b.name_local, b.description_local,
ba.city_id, ba.longitude, ba.latitude, ba.address_line1, ba.postal_code
FROM breweries b
JOIN brewery_addresses ba ON ba.brewery_id = b.id;
SELECT
b.id AS Id,
b.name_en AS NameEn,
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);
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
CommandDefinition command = new(sql, cancellationToken: cancellationToken);
IEnumerable<BreweryRow> rows = await connection.QueryAsync<BreweryRow>(command);
const int nameEnIndex = 0;
const int descriptionEnIndex = 1;
const int nameLocalIndex = 2;
const int descriptionLocalIndex = 3;
const int cityIdIndex = 4;
const int longitudeIndex = 5;
const int latitudeIndex = 6;
const int addressLine1Index = 7;
const int postalCodeIndex = 8;
List<BreweryRecord> records = [];
while (await reader.ReadAsync(cancellationToken))
records.Add(
new BreweryRecord
return [.. rows.Select(row => new BreweryResult
{
Id = row.Id,
NameEn = row.NameEn,
DescriptionEn = row.DescriptionEn,
NameLocal = row.NameLocal,
DescriptionLocal = row.DescriptionLocal,
Address = new BreweryAddress
{
Brewery = new BreweryResult
{
NameEn = reader.GetString(nameEnIndex),
DescriptionEn = reader.GetString(descriptionEnIndex),
NameLocal = reader.GetString(nameLocalIndex),
DescriptionLocal = reader.GetString(descriptionLocalIndex),
},
Address = new BreweryAddress
{
City = cities[reader.GetInt64(cityIdIndex)],
Longitude = reader.GetDouble(longitudeIndex),
Latitude = reader.GetDouble(latitudeIndex),
AddressLine1 = reader.GetString(addressLine1Index),
PostalCode = reader.GetString(postalCodeIndex),
},
}
);
return records;
Id = row.AddressId,
CityId = row.CityId,
BreweryId = row.Id,
Longitude = row.Longitude,
Latitude = row.Latitude,
AddressLine1 = row.AddressLine1,
PostalCode = row.PostalCode,
City = cities[row.CityId],
},
})];
}
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(
SqliteConnection connection,
IReadOnlyDictionary<long, City> cities,
IReadOnlyDictionary<int, City> cities,
CancellationToken cancellationToken
)
{
const string sql = """
SELECT u.first_name, u.last_name, u.gender, u.username, u.bio,
u.activity_weight, u.email, u.date_of_birth,
ua.city_id, ua.longitude, ua.latitude
FROM users u
JOIN user_addresses ua ON ua.user_id = u.id;
SELECT
u.id AS Id,
u.first_name AS FirstName,
u.last_name AS LastName,
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);
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
CommandDefinition command = new(sql, cancellationToken: cancellationToken);
IEnumerable<UserRow> rows = await connection.QueryAsync<UserRow>(command);
const int firstNameIndex = 0;
const int lastNameIndex = 1;
const int genderIndex = 2;
const int usernameIndex = 3;
const int bioIndex = 4;
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
return [.. rows.Select(row => new UserRecord
{
Email = row.Email,
DateOfBirth = row.DateOfBirth,
Address = new UserAddress
{
User = new UserResult
{
FirstName = reader.GetString(firstNameIndex),
LastName = reader.GetString(lastNameIndex),
Gender = reader.GetString(genderIndex),
Username = reader.GetString(usernameIndex),
Bio = reader.GetString(bioIndex),
ActivityWeight = (float)reader.GetDouble(activityWeightIndex),
},
Email = reader.GetString(emailIndex),
DateOfBirth = reader.GetString(dateOfBirthIndex),
Address = new UserAddress
{
City = cities[reader.GetInt64(cityIdIndex)],
Longitude = reader.GetDouble(longitudeIndex),
Latitude = reader.GetDouble(latitudeIndex),
},
}
);
return records;
Id = row.AddressId,
CityId = row.CityId,
UserId = row.Id,
Longitude = row.Longitude,
Latitude = row.Latitude,
City = cities[row.CityId],
},
User = new UserResult
{
Id = row.Id,
FirstName = row.FirstName,
LastName = row.LastName,
Gender = row.Gender,
Username = row.Username,
Bio = row.Bio,
ActivityWeight = (float)row.ActivityWeight,
},
})];
}
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,
CancellationToken cancellationToken
)
{
const string sql = """
SELECT
id,
city,
state_province,
iso3166_2,
country,
iso3166_1,
local_languages_json,
postal_code_country_format_regex,
postal_code_city_regex_json
id AS Id,
city AS CityName,
state_province AS StateProvince,
iso3166_2 AS Iso31662,
country AS Country,
iso3166_1 AS Iso31661,
longitude AS Longitude,
latitude AS Latitude,
local_languages_json AS LocalLanguagesJson,
postal_code_country_format_regex AS PostalCodeCountryFormatRegex,
postal_code_city_regex_json AS PostalCodeCityRegexesJson
FROM cities;
""";
await using SqliteCommand command = new(sql, connection);
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
CommandDefinition command = new(sql, cancellationToken: cancellationToken);
IEnumerable<CityRow> rows = await connection.QueryAsync<CityRow>(command);
Dictionary<long, City> cities = [];
const int idIndex = 0;
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
return rows.ToDictionary(
row => row.Id,
row => new City
{
CityName = reader.GetString(cityNameIndex),
StateProvince = reader.GetString(stateProvinceIndex),
Iso31662 = reader.GetString(iso31662Index),
Country = reader.GetString(countryIndex),
Iso31661 = reader.GetString(iso31661Index),
LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesIndex)),
PostalCode = new PostalCodeSpec
{
CountryFormatRegex = reader.GetString(countryFormatRegexIndex),
CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesIndex)),
},
};
return cities;
Id = row.Id,
CityName = row.CityName,
StateProvinceName = row.StateProvince,
ISO_3166_2 = row.Iso31662,
CountryName = row.Country,
ISO_3166_1 = row.Iso31661,
Longitude = row.Longitude,
Latitude = row.Latitude,
LocalLanguages = DeserializeStringArray(row.LocalLanguagesJson),
PostalCode = new PostalCodeSpec(
CountryFormatRegex: row.PostalCodeCountryFormatRegex,
CityRegexes: DeserializeStringArray(row.PostalCodeCityRegexesJson)
),
}
);
}
private static IReadOnlyList<string> DeserializeStringArray(string json)
{
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
}