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)),
},
};