From e8499181cbec55f6804b9c61708630778c746a77 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Mon, 17 Aug 2026 20:50:13 -0400 Subject: [PATCH] Integrate Spectre.Console into seed application, use async data loading --- .../Database.Seed/Database.Seed.csproj | 8 +- web/backend/Database/Database.Seed/Program.cs | 285 +++++++++++++----- .../Sqlite/PipelineSeedDataReader.cs | 163 ++++++---- 3 files changed, 318 insertions(+), 138 deletions(-) diff --git a/web/backend/Database/Database.Seed/Database.Seed.csproj b/web/backend/Database/Database.Seed/Database.Seed.csproj index 1235ce07..224faadc 100644 --- a/web/backend/Database/Database.Seed/Database.Seed.csproj +++ b/web/backend/Database/Database.Seed/Database.Seed.csproj @@ -11,6 +11,8 @@ + + - + diff --git a/web/backend/Database/Database.Seed/Program.cs b/web/backend/Database/Database.Seed/Program.cs index 51366c78..5fa3ed16 100644 --- a/web/backend/Database/Database.Seed/Program.cs +++ b/web/backend/Database/Database.Seed/Program.cs @@ -7,112 +7,243 @@ using Features.Auth.Dtos; using Features.Auth.Repository; using Features.Breweries.DependencyInjection; using Features.Breweries.Repository; +using Features.Locations.DependencyInjection; +using Features.Locations.Dtos; +using Features.Locations.Repository; using idunno.Password; using Infrastructure.PasswordHashing; using Infrastructure.Sql; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; -static async Task main() +return await RunAsync(); + +static async Task RunAsync() { - IReadOnlyList breweries; - IReadOnlyList users; try { + ServiceCollection services = []; + services.AddSingleton(new ConfigurationBuilder().Build()); + services.AddSingleton(); + services.AddFeaturesBreweries(); + services.AddFeaturesLocations(); + services.AddFeaturesAuth(); + + await using ServiceProvider provider = services.BuildServiceProvider(); + + IBreweryRepository breweryRepository = provider.GetRequiredService(); + ILocationRepository locationRepository = provider.GetRequiredService(); + IAuthRepository authRepository = provider.GetRequiredService(); + IPasswordInfrastructure passwordInfrastructure = + provider.GetRequiredService(); + + 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); - breweries = reader.ReadBreweryRecords(); - users = reader.ReadUserRecords(); + + SeedData seedData = null!; + IReadOnlyList postedByIds = []; + + 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 => + { + Thread.Sleep(3000); // Simulate some work being done + postedByIds = await LoadUsersIntoDatabaseAsync( + authRepository, + passwordInfrastructure, + seedData.Users + ); + ctx.Status("User 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( + breweryRepository, + locationRepository, + seedData.Breweries, + postedByIds + ); + ctx.Status("Brewery data loaded into target database."); + } + ); + + return 0; } - catch (SqliteException ex) + catch (Exception ex) { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error opening database connection: {ex.Message}"); - Console.ResetColor(); + AnsiConsole.MarkupLine("[red]Seeding failed.[/]"); + AnsiConsole.WriteException(ex); return 1; } +} - ServiceCollection services = []; - services.AddSingleton(new ConfigurationBuilder().Build()); - services.AddSingleton(); - services.AddFeaturesBreweries(); - services.AddFeaturesAuth(); +static async Task> LoadUsersIntoDatabaseAsync( + IAuthRepository authRepository, + IPasswordInfrastructure passwordInfrastructure, + IReadOnlyList users +) +{ + List userAccountIds = []; - using ServiceProvider provider = services.BuildServiceProvider(); - - IBreweryRepository breweryRepository; - IAuthRepository authRepository; - IPasswordInfrastructure passwordInfrastructure; - try + foreach (UserRecord userRecord in users) { - breweryRepository = provider.GetRequiredService(); - authRepository = provider.GetRequiredService(); - passwordInfrastructure = provider.GetRequiredService(); - } - catch (InvalidOperationException ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error configuring database connection: {ex.Message}"); - Console.ResetColor(); - return 1; + UserAccount userAccount = await authRepository.RegisterUserAsync( + new UserRegistrationDto( + userRecord.User.Username, + userRecord.User.FirstName, + userRecord.User.LastName, + userRecord.Email, + DateTime.Parse(userRecord.DateOfBirth), + passwordInfrastructure.Hash( + PasswordGenerator.Generate(length: 64, numberOfDigits: 10, numberOfSymbols: 10) + ) + ) + ); + userAccountIds.Add(userAccount.UserAccountId); } - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"Loaded {breweries.Count} breweries."); - Console.ResetColor(); + return userAccountIds; +} + +/// +/// Persists each record, resolving (and creating, if needed) its +/// Country/StateProvince/City chain via . The pipeline +/// data has no street-level address or a specific user tied to each brewery, so a placeholder +/// address is used and PostedById is assigned round-robin from +/// to keep seeding deterministic across runs. +/// +static async Task LoadBreweriesIntoDatabaseAsync( + IBreweryRepository breweryRepository, + ILocationRepository locationRepository, + IReadOnlyList breweries, + IReadOnlyList posterUserIds +) +{ + if (posterUserIds.Count == 0) + throw new InvalidOperationException("Cannot load breweries without any registered users."); for (int i = 0; i < breweries.Count; i++) { - BreweryRecord brewery = breweries[i]; - Console.WriteLine( - $"{i + 1}:\t{brewery.Brewery.NameEn}\t({brewery.Address.City.CityName}, {brewery.Address.City.Country})" + BreweryRecord breweryRecord = breweries[i]; + + Guid cityId = await locationRepository.GetOrCreateCityIdAsync( + new CityLocation( + breweryRecord.Address.City.CityName, + breweryRecord.Address.City.StateProvince, + breweryRecord.Address.City.Iso31662, + breweryRecord.Address.City.Country, + breweryRecord.Address.City.Iso31661 + ) + ); + + await breweryRepository.CreateAsync( + new BreweryPost + { + BreweryName = breweryRecord.Brewery.NameEn, + Description = breweryRecord.Brewery.DescriptionEn, + PostedById = posterUserIds[i % posterUserIds.Count], + Location = new BreweryPostLocation + { + CityId = cityId, + AddressLine1 = "Address unavailable", + PostalCode = breweryRecord.Address.PostalCode, + }, + } + ); + } +} + +static Table BuildBreweryTable(IReadOnlyList breweries) +{ + Table table = new Table() + .AddColumn("Brewery Name (EN)") + .AddColumn("Brewery Name (Local)") + .AddColumn("City") + .AddColumn("State/Province") + .AddColumn("Country") + .AddColumn("Postal Code"); + + 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.PostalCode ); } - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"Loaded {users.Count} users."); - Console.ResetColor(); + return table; +} + +static Table BuildUserTable(IReadOnlyList users) +{ + Table table = new Table() + .AddColumn("Username") + .AddColumn("First Name") + .AddColumn("Last Name") + .AddColumn("Email") + .AddColumn("Date of Birth"); foreach (UserRecord user in users) { - string username = user.User.Username; - string firstName = user.User.FirstName; - string lastName = user.User.LastName; - string email = user.Email; - DateTime dateOfBirth = DateTime.Parse(user.DateOfBirth); - - string generatedPassword = PasswordGenerator.Generate( - length: 64, - numberOfDigits: 10, - numberOfSymbols: 10 + table.AddRow( + user.User.Username, + user.User.FirstName, + user.User.LastName, + user.Email, + user.DateOfBirth.ToString() ); - string hashedPassword = passwordInfrastructure.Hash(generatedPassword); - - UserRegistrationDto registrationDto = new( - username, - firstName, - lastName, - email, - dateOfBirth, - hashedPassword - ); - - try - { - UserAccount createdUser = await authRepository.RegisterUserAsync(registrationDto); - Console.WriteLine( - $"Created user: {createdUser.Username} ({createdUser.UserAccountId})" - ); - } - catch (Exception ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error creating user {username}: {ex.Message}"); - Console.ResetColor(); - } } - return 0; + return table; } - -return await main(); diff --git a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs index 050e416b..7bf69964 100644 --- a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs +++ b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs @@ -1,13 +1,14 @@ -#region - using System.Text.Json; using Database.Seed.PipelineData; using Microsoft.Data.Sqlite; -#endregion - namespace Database.Seed.Sqlite; +public sealed record SeedData( + IReadOnlyList Breweries, + IReadOnlyList Users +); + public sealed class PipelineSeedDataReader { private readonly string _connectionString; @@ -17,13 +18,40 @@ public sealed class PipelineSeedDataReader _connectionString = connectionString; } - public IReadOnlyList ReadBreweryRecords() + /// + /// Reads breweries and users in a single connection, loading the cities + /// lookup only once and sharing it between both reads. + /// + public async Task ReadSeedDataAsync(CancellationToken cancellationToken = default) { - using SqliteConnection connection = new(_connectionString); - connection.Open(); + await using SqliteConnection connection = new(_connectionString); + await connection.OpenAsync(cancellationToken); - IReadOnlyDictionary cities = ReadCities(connection); + IReadOnlyDictionary cities = await ReadCitiesAsync( + connection, + cancellationToken + ); + IReadOnlyList breweries = await ReadBreweryRecordsAsync( + connection, + cities, + cancellationToken + ); + IReadOnlyList users = await ReadUserRecordsAsync( + connection, + cities, + cancellationToken + ); + + return new SeedData(breweries, users); + } + + private static async Task> ReadBreweryRecordsAsync( + SqliteConnection connection, + IReadOnlyDictionary cities, + CancellationToken cancellationToken + ) + { const string sql = """ SELECT b.name_en, b.description_en, b.name_local, b.description_local, ba.city_id, ba.postal_code @@ -31,25 +59,32 @@ public sealed class PipelineSeedDataReader JOIN brewery_addresses ba ON ba.brewery_id = b.id; """; - using SqliteCommand command = new(sql, connection); - using SqliteDataReader reader = command.ExecuteReader(); + await using SqliteCommand command = new(sql, connection); + await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken); + + const int nameEnIndex = 0; + const int descriptionEnIndex = 1; + const int nameLocalIndex = 2; + const int descriptionLocalIndex = 3; + const int cityIdIndex = 4; + const int postalCodeIndex = 5; List records = []; - while (reader.Read()) + while (await reader.ReadAsync(cancellationToken)) records.Add( new BreweryRecord { Brewery = new BreweryResult { - NameEn = reader.GetString(0), - DescriptionEn = reader.GetString(1), - NameLocal = reader.GetString(2), - DescriptionLocal = reader.GetString(3), + NameEn = reader.GetString(nameEnIndex), + DescriptionEn = reader.GetString(descriptionEnIndex), + NameLocal = reader.GetString(nameLocalIndex), + DescriptionLocal = reader.GetString(descriptionLocalIndex), }, Address = new BreweryAddress { - City = cities[reader.GetInt64(4)], - PostalCode = reader.GetString(5), + City = cities[reader.GetInt64(cityIdIndex)], + PostalCode = reader.GetString(postalCodeIndex), }, } ); @@ -57,13 +92,12 @@ public sealed class PipelineSeedDataReader return records; } - public IReadOnlyList ReadUserRecords() + private static async Task> ReadUserRecordsAsync( + SqliteConnection connection, + IReadOnlyDictionary cities, + CancellationToken cancellationToken + ) { - using SqliteConnection connection = new(_connectionString); - connection.Open(); - - IReadOnlyDictionary cities = ReadCities(connection); - 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, @@ -72,29 +106,40 @@ public sealed class PipelineSeedDataReader JOIN user_addresses ua ON ua.user_id = u.id; """; - using SqliteCommand command = new(sql, connection); - using SqliteDataReader reader = command.ExecuteReader(); + await using SqliteCommand command = new(sql, connection); + await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken); + + 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 postalCodeIndex = 9; List records = []; - while (reader.Read()) + while (await reader.ReadAsync(cancellationToken)) records.Add( new UserRecord { User = new UserResult { - FirstName = reader.GetString(0), - LastName = reader.GetString(1), - Gender = reader.GetString(2), - Username = reader.GetString(3), - Bio = reader.GetString(4), - ActivityWeight = (float)reader.GetDouble(5), + 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(6), - DateOfBirth = reader.GetString(7), + Email = reader.GetString(emailIndex), + DateOfBirth = reader.GetString(dateOfBirthIndex), Address = new UserAddress { - City = cities[reader.GetInt64(8)], - PostalCode = reader.GetString(9), + City = cities[reader.GetInt64(cityIdIndex)], + PostalCode = reader.GetString(postalCodeIndex), }, } ); @@ -102,7 +147,10 @@ public sealed class PipelineSeedDataReader return records; } - private static IReadOnlyDictionary ReadCities(SqliteConnection connection) + private static async Task> ReadCitiesAsync( + SqliteConnection connection, + CancellationToken cancellationToken + ) { const string sql = """ SELECT @@ -118,33 +166,34 @@ public sealed class PipelineSeedDataReader FROM cities; """; - using SqliteCommand command = new(sql, connection); - using SqliteDataReader reader = command.ExecuteReader(); + await using SqliteCommand command = new(sql, connection); + await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken); Dictionary cities = []; - const int cityNameBindIndex = 1; - const int stateProvinceBindIndex = 2; - const int iso31662BindIndex = 3; - const int countryBindIndex = 4; - const int iso31661BindIndex = 5; - const int localLanguagesBindIndex = 6; - const int countryFormatRegexBindIndex = 7; - const int cityRegexesBindIndex = 8; + 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 (reader.Read()) - cities[reader.GetInt64(0)] = new City + while (await reader.ReadAsync(cancellationToken)) + cities[reader.GetInt64(idIndex)] = new City { - CityName = reader.GetString(cityNameBindIndex), - StateProvince = reader.GetString(stateProvinceBindIndex), - Iso31662 = reader.GetString(iso31662BindIndex), - Country = reader.GetString(countryBindIndex), - Iso31661 = reader.GetString(iso31661BindIndex), - LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesBindIndex)), + 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(countryFormatRegexBindIndex), - CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesBindIndex)), + CountryFormatRegex = reader.GetString(countryFormatRegexIndex), + CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesIndex)), }, };