Migrate Auth/Brewery/Location repositories to pure Dapper; rename Timer to RowVersion (#237)

* Integrate Spectre.Console into seed application, use async data loading

* Replace stored-procedure repositories with Dapper-based repositories

Removes the USP_* stored procedures for Auth, Location, and Brewery
data access in favour of services built with Dapper.

Adds a new Features.Locations slice and optimistic concurrency support
for the Brewery slice.

* Add new documentation for Dapper migration/database schema

* Update FluentAssertions to most recent open source ver

* Update XML doc comment in backend projects

* Modify brewery location dto to include denormalized data

* - Updated column names from 'Timer' to 'RowVersion' in schema.sql for
- Introduced new BeerPost entity and updated BreweryPost and BreweryPostLocation entities to use 'RowVersion' for concurrency.
- Modified UserAccount, UserCredential, UserVerification, City, StateProvince, and Country entities to use 'RowVersion' instead of 'Timer'.
- Updated tests and command handlers to reflect changes in concurrency token from 'Timer' to 'RowVersion'.
- Adjusted BreweryDto and BreweryDtoMapper to include 'RowVersion' and updated mapping logic accordingly.
- Ensured all repository methods and interfaces are aligned with the new concurrency handling approach.

* updates

* Add BeerStyle entity and related properties; refactor BreweryDto to remove BeerPosts

* Fix Api.Specs test failures: stale Timer column refs and missing test.user fixture

* Remove repository tests, usages of registration dto used in repo

* archive old stored procs/functions

* update stale docs

* Add instructions for generating cryptographically secure base64 secrets on macOS/Linux and Windows
This commit is contained in:
2026-08-21 13:09:13 -04:00
committed by Aaron Po
parent b27b5bdb40
commit de1e9ac02b
151 changed files with 2790 additions and 1988 deletions

View File

@@ -10,7 +10,10 @@
<ItemGroup>
<PackageReference Include="dbup" Version="5.0.41" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="scripts/**/*.sql" />

View File

@@ -2,14 +2,15 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# Copy everything from the context (src/Core/Database)
# Copy everything from the context (src/Core), matching Database.Seed/Dockerfile's context so
# ProjectReference paths outside Database/ (e.g. Infrastructure.Sql) resolve.
COPY . .
RUN dotnet restore "./Database.Migrations/Database.Migrations.csproj"
RUN dotnet build "./Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/build
RUN dotnet restore "./Database/Database.Migrations/Database.Migrations.csproj"
RUN dotnet build "./Database/Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
RUN dotnet publish "./Database/Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final
WORKDIR /app

View File

@@ -2,6 +2,7 @@
using System.Reflection;
using DbUp;
using DbUp.Engine;
using Infrastructure.Sql;
using Microsoft.Data.SqlClient;
namespace Database.Migrations;
@@ -96,12 +97,6 @@ public static class Program
Console.WriteLine($"Database '{settings.DatabaseName}' is present.");
}
/// <summary>
/// Opens a connection, executes a non-query batch parameterised by database name, and
/// disposes both the command and the connection.
/// </summary>
/// <param name="connectionString">The connection string to open.</param>
/// <param name="sql">The Transact-SQL batch to execute.</param>
/// <param name="databaseName">The value bound to the <c>@databaseName</c> parameter.</param>
private static async Task ExecuteNonQueryAsync(
string connectionString,
@@ -171,16 +166,11 @@ public static class Program
/// </exception>
public static DatabaseSettings FromEnvironment()
{
string server = Required("DB_SERVER");
string databaseName = Required("DB_NAME");
string user = Required("DB_USER");
string password = Required("DB_PASSWORD");
bool trustServerCertificate =
!bool.TryParse(
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE"),
out bool parsedTrust
) || parsedTrust;
string databaseName =
Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException(
"The DB_NAME environment variable is not set."
);
bool clearDatabase = string.Equals(
Environment.GetEnvironmentVariable("CLEAR_DATABASE"),
@@ -189,28 +179,11 @@ public static class Program
);
return new DatabaseSettings(
Build(databaseName),
Build("master"),
SqlConnectionStringHelper.BuildConnectionString(databaseName),
SqlConnectionStringHelper.BuildMasterConnectionString(),
databaseName,
clearDatabase
);
string Build(string initialCatalog) =>
new SqlConnectionStringBuilder
{
DataSource = server,
InitialCatalog = initialCatalog,
UserID = user,
Password = password,
Encrypt = true,
TrustServerCertificate = trustServerCertificate,
}.ConnectionString;
}
private static string Required(string variableName) =>
Environment.GetEnvironmentVariable(variableName)
?? throw new InvalidOperationException(
$"The {variableName} environment variable is not set."
);
}
}

View File

@@ -24,22 +24,22 @@ CREATE TABLE dbo.UserAccount
UserAccountID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
Username VARCHAR(64) NOT NULL,
Username VARCHAR(64) NOT NULL,
FirstName NVARCHAR(128) NOT NULL,
FirstName NVARCHAR(128) NOT NULL,
LastName NVARCHAR(128) NOT NULL,
LastName NVARCHAR(128) NOT NULL,
Email VARCHAR(128) NOT NULL,
Email VARCHAR(128) NOT NULL,
CreatedAt DATETIME NOT NULL
CreatedAt DATETIME NOT NULL
CONSTRAINT DF_UserAccount_CreatedAt DEFAULT GETDATE(),
UpdatedAt DATETIME,
UpdatedAt DATETIME,
DateOfBirth DATE NOT NULL,
DateOfBirth DATE NOT NULL,
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_UserAccount
PRIMARY KEY (UserAccountID),
@@ -56,18 +56,18 @@ CREATE TABLE dbo.UserAccount
CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos
(
PhotoID UNIQUEIDENTIFIER
PhotoID UNIQUEIDENTIFIER
CONSTRAINT DF_PhotoID DEFAULT NEWID(),
Hyperlink NVARCHAR(256),
Hyperlink NVARCHAR(256),
-- storage is handled via filesystem or cloud service
UploadedByID UNIQUEIDENTIFIER NOT NULL,
UploadedAt DATETIME NOT NULL
UploadedAt DATETIME NOT NULL
CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(),
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_Photo
PRIMARY KEY (PhotoID),
@@ -87,14 +87,14 @@ NONCLUSTERED INDEX IX_Photo_UploadedByID
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
(
UserAvatarID UNIQUEIDENTIFIER
UserAvatarID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
@@ -120,15 +120,15 @@ NONCLUSTERED INDEX IX_UserAvatar_UserAccount
CREATE TABLE UserVerification -- delete verification data when user account is deleted
(
UserVerificationID UNIQUEIDENTIFIER
UserVerificationID UNIQUEIDENTIFIER
CONSTRAINT DF_UserVerificationID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL,
UserAccountID UNIQUEIDENTIFIER NOT NULL,
VerificationDateTime DATETIME NOT NULL
VerificationDateTime DATETIME NOT NULL
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_UserVerification
PRIMARY KEY (UserVerificationID),
@@ -154,23 +154,23 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
UserCredentialID UNIQUEIDENTIFIER
CONSTRAINT DF_UserCredentialID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL,
UserAccountID UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME NOT NULL
CreatedAt DATETIME NOT NULL
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
Expiry DATETIME NOT NULL
Expiry DATETIME NOT NULL
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
Hash NVARCHAR(256) NOT NULL,
Hash NVARCHAR(256) NOT NULL,
-- uses argon2
IsRevoked BIT NOT NULL
IsRevoked BIT NOT NULL
CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0,
RevokedAt DATETIME NULL,
RevokedAt DATETIME NULL,
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_UserCredential
PRIMARY KEY (UserCredentialID),
@@ -195,17 +195,17 @@ NONCLUSTERED INDEX IX_UserCredential_Account_Active
CREATE TABLE UserFollow
(
UserFollowID UNIQUEIDENTIFIER
UserFollowID UNIQUEIDENTIFIER
CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL,
FollowingID UNIQUEIDENTIFIER NOT NULL,
FollowingID UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME NOT NULL
CreatedAt DATETIME NOT NULL
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_UserFollow
PRIMARY KEY (UserFollowID),
@@ -223,7 +223,7 @@ CREATE TABLE UserFollow
CONSTRAINT CK_CannotFollowOwnAccount
CHECK (UserAccountID != FollowingID
)
);
);
CREATE
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
@@ -238,14 +238,14 @@ NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
CREATE TABLE Country
(
CountryID UNIQUEIDENTIFIER
CountryID UNIQUEIDENTIFIER
CONSTRAINT DF_CountryID DEFAULT NEWID(),
CountryName NVARCHAR(100) NOT NULL,
ISO3166_1 CHAR(2) NOT NULL,
ISO3166_1 CHAR(2) NOT NULL,
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_Country
PRIMARY KEY (CountryID),
@@ -259,17 +259,17 @@ CREATE TABLE Country
CREATE TABLE StateProvince
(
StateProvinceID UNIQUEIDENTIFIER
StateProvinceID UNIQUEIDENTIFIER
CONSTRAINT DF_StateProvinceID DEFAULT NEWID(),
StateProvinceName NVARCHAR(100) NOT NULL,
ISO3166_2 CHAR(6) 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,
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_StateProvince
PRIMARY KEY (StateProvinceID),
@@ -291,14 +291,14 @@ NONCLUSTERED INDEX IX_StateProvince_Country
CREATE TABLE City
(
CityID UNIQUEIDENTIFIER
CityID UNIQUEIDENTIFIER
CONSTRAINT DF_CityID DEFAULT NEWID(),
CityName NVARCHAR(100) NOT NULL,
CityName NVARCHAR(100) NOT NULL,
StateProvinceID UNIQUEIDENTIFIER NOT NULL,
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_City
PRIMARY KEY (CityID),
@@ -320,18 +320,18 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
BreweryPostID UNIQUEIDENTIFIER
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
BreweryName NVARCHAR(256) NOT NULL,
BreweryName NVARCHAR(256) NOT NULL,
PostedByID UNIQUEIDENTIFIER NOT NULL,
PostedByID UNIQUEIDENTIFIER NOT NULL,
Description NVARCHAR(512) NOT NULL,
Description NVARCHAR(MAX) NOT NULL,
CreatedAt DATETIME NOT NULL
CreatedAt DATETIME NOT NULL
CONSTRAINT DF_BreweryPost_CreatedAt DEFAULT GETDATE(),
UpdatedAt DATETIME NULL,
UpdatedAt DATETIME NULL,
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_BreweryPost
PRIMARY KEY (BreweryPostID),
@@ -354,19 +354,19 @@ CREATE TABLE BreweryPostLocation
BreweryPostLocationID UNIQUEIDENTIFIER
CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(),
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
AddressLine1 NVARCHAR(256) NOT NULL,
AddressLine1 NVARCHAR(256) NOT NULL,
AddressLine2 NVARCHAR(256),
AddressLine2 NVARCHAR(256),
PostalCode NVARCHAR(20) NOT NULL,
PostalCode NVARCHAR(20) NOT NULL,
CityID UNIQUEIDENTIFIER NOT NULL,
CityID UNIQUEIDENTIFIER NOT NULL,
Coordinates GEOGRAPHY NULL,
Coordinates GEOGRAPHY NULL,
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_BreweryPostLocation
PRIMARY KEY (BreweryPostLocationID),
@@ -412,14 +412,14 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
BreweryPostPhotoID UNIQUEIDENTIFIER
CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(),
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
LinkedAt DATETIME NOT NULL
LinkedAt DATETIME NOT NULL
CONSTRAINT DF_BreweryPostPhoto_LinkedAt DEFAULT GETDATE(),
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_BreweryPostPhoto
PRIMARY KEY (BreweryPostPhotoID),
@@ -451,11 +451,11 @@ CREATE TABLE BeerStyle
BeerStyleID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),
StyleName NVARCHAR(100) NOT NULL,
StyleName NVARCHAR(100) NOT NULL,
Description NVARCHAR(MAX),
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_BeerStyle
PRIMARY KEY (BeerStyleID),
@@ -469,31 +469,31 @@ CREATE TABLE BeerStyle
CREATE TABLE BeerPost
(
BeerPostID UNIQUEIDENTIFIER
BeerPostID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
Name NVARCHAR(100) NOT NULL,
Name NVARCHAR(100) NOT NULL,
Description NVARCHAR(MAX) NOT NULL,
ABV DECIMAL(4, 2) NOT NULL,
ABV DECIMAL(4, 2) NOT NULL,
-- Alcohol By Volume (typically 0-67%)
IBU INT NOT NULL,
IBU INT NOT NULL,
-- International Bitterness Units (typically 0-120)
PostedByID UNIQUEIDENTIFIER NOT NULL,
PostedByID UNIQUEIDENTIFIER NOT NULL,
BeerStyleID UNIQUEIDENTIFIER NOT NULL,
BrewedByID UNIQUEIDENTIFIER NOT NULL,
BrewedByID UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME NOT NULL
CreatedAt DATETIME NOT NULL
CONSTRAINT DF_BeerPost_CreatedAt DEFAULT GETDATE(),
UpdatedAt DATETIME,
UpdatedAt DATETIME,
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_BeerPost
PRIMARY KEY (BeerPostID),
@@ -538,14 +538,14 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
BeerPostPhotoID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(),
BeerPostID UNIQUEIDENTIFIER NOT NULL,
BeerPostID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
LinkedAt DATETIME NOT NULL
LinkedAt DATETIME NOT NULL
CONSTRAINT DF_BeerPostPhoto_LinkedAt DEFAULT GETDATE(),
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_BeerPostPhoto
PRIMARY KEY (BeerPostPhotoID),
@@ -577,20 +577,20 @@ CREATE TABLE BeerPostComment
BeerPostCommentID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerPostComment DEFAULT NEWID(),
Comment NVARCHAR(250) NOT NULL,
Comment NVARCHAR(250) NOT NULL,
BeerPostID UNIQUEIDENTIFIER NOT NULL,
BeerPostID UNIQUEIDENTIFIER NOT NULL,
CommentedByID UNIQUEIDENTIFIER NOT NULL,
CommentedByID UNIQUEIDENTIFIER NOT NULL,
Rating INT NOT NULL,
Rating INT NOT NULL,
CreatedAt DATETIME NOT NULL
CreatedAt DATETIME NOT NULL
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
UpdatedAt DATETIME NULL,
UpdatedAt DATETIME NULL,
Timer ROWVERSION,
RowVersion ROWVERSION,
CONSTRAINT PK_BeerPostComment
PRIMARY KEY (BeerPostCommentID),

View File

@@ -1,15 +0,0 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode(
@CountryCode NVARCHAR(2)
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE
@CountryId UNIQUEIDENTIFIER;
SELECT @CountryId = CountryID
FROM dbo.Country
WHERE ISO3166_1 = @CountryCode;
RETURN @CountryId;
END;

View File

@@ -1,13 +0,0 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode(
@StateProvinceCode NVARCHAR(6)
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE
@StateProvinceId UNIQUEIDENTIFIER;
SELECT @StateProvinceId = StateProvinceID
FROM dbo.StateProvince
WHERE ISO3166_2 = @StateProvinceCode;
RETURN @StateProvinceId;
END;

View File

@@ -1,31 +0,0 @@
CREATE OR ALTER PROCEDURE usp_CreateUserAccount(
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
@Username VARCHAR(64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128)
)
AS
BEGIN
SET
NOCOUNT ON;
DECLARE
@Inserted TABLE
(
UserAccountID UNIQUEIDENTIFIER
);
INSERT INTO UserAccount
(Username,
FirstName,
LastName,
DateOfBirth,
Email)
OUTPUT INSERTED.UserAccountID INTO @Inserted
VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email);
SELECT @UserAccountId = UserAccountID
FROM @Inserted;
END;

View File

@@ -1,20 +0,0 @@
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
BEGIN
RAISERROR
('UserAccount with the specified ID does not exist.', 16,
1);
ROLLBACK TRANSACTION
RETURN
END
DELETE
FROM UserAccount
WHERE UserAccountId = @UserAccountId;
END;

View File

@@ -1,15 +0,0 @@
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
AS
BEGIN
SET NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount;
END;

View File

@@ -1,20 +0,0 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail(
@Email VARCHAR(128)
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Email = @Email;
END;

View File

@@ -1,20 +0,0 @@
CREATE OR ALTER PROCEDURE USP_GetUserAccountById(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId;
END

View File

@@ -1,20 +0,0 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername(
@Username VARCHAR(64)
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Username = @Username;
END;

View File

@@ -1,28 +0,0 @@
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount(
@Username VARCHAR(64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128),
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
UPDATE UserAccount
SET Username = @Username,
FirstName = @FirstName,
LastName = @LastName,
DateOfBirth = @DateOfBirth,
Email = @Email
WHERE UserAccountId = @UserAccountId;
IF
@@ROWCOUNT = 0
BEGIN
THROW
50001, 'UserAccount with the specified ID does not exist.', 1;
END
END;

View File

@@ -1,18 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT UserCredentialId,
UserAccountId,
Hash,
IsRevoked,
CreatedAt,
RevokedAt
FROM dbo.UserCredential
WHERE UserAccountId = @UserAccountId
AND IsRevoked = 0;
END;

View File

@@ -1,28 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
IF
@@ROWCOUNT = 0
THROW 50001, 'User account not found', 1;
-- invalidate all other credentials by setting them to revoked
UPDATE dbo.UserCredential
SET IsRevoked = 1,
RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_
AND IsRevoked != 1;
COMMIT TRANSACTION;
END;

View File

@@ -1,50 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
@Username VARCHAR(64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128),
@Hash NVARCHAR(MAX)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
DECLARE
@UserAccountId_ UNIQUEIDENTIFIER;
BEGIN
TRANSACTION;
EXEC usp_CreateUserAccount
@UserAccountId = @UserAccountId_ OUTPUT,
@Username = @Username,
@FirstName = @FirstName,
@LastName = @LastName,
@DateOfBirth = @DateOfBirth,
@Email = @Email;
IF
@UserAccountId_ IS NULL
BEGIN
THROW
50000, 'Failed to create user account.', 1;
END
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
IF
@@ROWCOUNT = 0
BEGIN
THROW
50002, 'Failed to create user credential.', 1;
END
COMMIT TRANSACTION;
SELECT @UserAccountId_ AS UserAccountId;
END

View File

@@ -1,31 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER,
@Hash NVARCHAR(MAX)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
IF @@ROWCOUNT = 0
THROW 50001, 'User account not found', 1;
-- invalidate all other credentials -- set them to revoked
UPDATE dbo.UserCredential
SET IsRevoked = 1,
RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_;
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
END;

View File

@@ -1,27 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
@VerificationDateTime DATETIME = NULL
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@VerificationDateTime IS NULL
SET @VerificationDateTime = GETDATE();
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
IF
@@ROWCOUNT = 0
THROW 50001, 'Could not find a user with that id', 1;
INSERT INTO dbo.UserVerification
(UserAccountId, VerificationDateTime)
VALUES (@UserAccountID_, @VerificationDateTime);
COMMIT TRANSACTION;
END

View File

@@ -1,38 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
@CityName NVARCHAR(100),
@StateProvinceCode NVARCHAR(6)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION
DECLARE
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF
@StateProvinceId IS NULL
BEGIN
THROW
50001, 'State/province does not exist', 1;
END
IF
EXISTS (SELECT 1
FROM dbo.City
WHERE CityName = @CityName
AND StateProvinceID = @StateProvinceId)
BEGIN
THROW
50002, 'City already exists.', 1;
END
INSERT INTO dbo.City
(StateProvinceID, CityName)
VALUES (@StateProvinceId, @CityName);
COMMIT TRANSACTION
END;

View File

@@ -1,24 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry(
@CountryName NVARCHAR(100),
@ISO3166_1 NVARCHAR(2)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
IF
EXISTS (SELECT 1
FROM dbo.Country
WHERE ISO3166_1 = @ISO3166_1)
THROW 50001, 'Country already exists', 1;
INSERT INTO dbo.Country
(CountryName, ISO3166_1)
VALUES (@CountryName, @ISO3166_1);
COMMIT TRANSACTION;
END;

View File

@@ -1,32 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
@StateProvinceName NVARCHAR(100),
@ISO3166_2 NVARCHAR(6),
@CountryCode NVARCHAR(2)
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
EXISTS (SELECT 1
FROM dbo.StateProvince
WHERE ISO3166_2 = @ISO3166_2)
RETURN;
DECLARE
@CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
IF
@CountryId IS NULL
BEGIN
THROW
50001, 'Country does not exist', 1;
END
INSERT INTO dbo.StateProvince
(StateProvinceName, ISO3166_2, CountryID)
VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
END;

View File

@@ -1,59 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@PostedByID UNIQUEIDENTIFIER,
@CityID UNIQUEIDENTIFIER,
@AddressLine1 NVARCHAR(256),
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20),
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.UserAccount
WHERE UserAccountID = @PostedByID)
THROW 50404, 'User not found.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
DECLARE
@NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
BEGIN
TRANSACTION;
INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID)
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
COMMIT TRANSACTION;
SELECT @NewBreweryID AS BreweryPostID,
@NewBrewerLocationID AS BreweryPostLocationID;
END

View File

@@ -1,17 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_DeleteBrewery @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SET
NOCOUNT ON;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
DELETE
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID;
END

View File

@@ -1,28 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetAllBreweries(
@Limit INT = NULL,
@Offset INT = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT bp.BreweryPostID,
bp.PostedByID,
bp.BreweryName,
bp.Description,
bp.CreatedAt,
bp.UpdatedAt,
bp.Timer,
bpl.BreweryPostLocationID,
bpl.CityID,
bpl.AddressLine1,
bpl.AddressLine2,
bpl.PostalCode,
bpl.Coordinates
FROM dbo.BreweryPost bp
LEFT JOIN dbo.BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
ORDER BY bp.CreatedAt DESC
OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
END

View File

@@ -1,9 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SELECT *
FROM BreweryPost bp
INNER JOIN BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
WHERE bp.BreweryPostID = @BreweryPostID;
END

View File

@@ -1,77 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
@BreweryPostID UNIQUEIDENTIFIER,
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@BreweryPostLocationID UNIQUEIDENTIFIER = NULL,
@CityID UNIQUEIDENTIFIER = NULL,
@AddressLine1 NVARCHAR(256) = NULL,
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20) = NULL,
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
IF
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
BEGIN
TRANSACTION;
UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName,
Description = @Description,
UpdatedAt = GETDATE()
WHERE BreweryPostID = @BreweryPostID;
IF
@CityID IS NULL
BEGIN
-- No location supplied: clear any existing location for this brewery.
DELETE
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID;
END
ELSE
IF EXISTS (SELECT 1
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID)
BEGIN
UPDATE dbo.BreweryPostLocation
SET CityID = @CityID,
AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2,
PostalCode = @PostalCode,
Coordinates = @Coordinates
WHERE BreweryPostID = @BreweryPostID;
END
ELSE
BEGIN
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
@PostalCode, @Coordinates);
END
COMMIT TRANSACTION;
END

View File

@@ -10,13 +10,8 @@
<ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<!--
remove this override once microsoft ships official fix to vulnerability
https:
-->
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<PackageReference Include="Spectre.Console" Version="0.57.2" />
</ItemGroup>
<ItemGroup>
@@ -24,14 +19,9 @@
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Pipeline-generated seed data, copied to the output directory so it can be opened via
ConnectionStrings.SqliteConnectionString at runtime. -->
<None
CopyToOutputDirectory="PreserveNewest"
Include="SeedData\biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite"
/>
<None CopyToOutputDirectory="PreserveNewest" Include="SeedData\biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,6 @@
#region
#region
using Microsoft.Data.SqlClient;
using Infrastructure.Sql;
#endregion
@@ -8,41 +8,7 @@ namespace Database.Seed.DatabaseHelpers;
public class ConnectionStrings
{
public static string SqlServerConnectionString => GetSqlServerConnectionString();
public static string SqlServerConnectionString => SqlConnectionStringHelper.BuildConnectionString();
public static string SqliteConnectionString =>
"Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
private static string GetSqlServerConnectionString()
{
string server =
Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
string dbName =
Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
string user =
Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set");
string password =
Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
string trustServerCertificate =
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
SqlConnectionStringBuilder builder = new()
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true,
};
return builder.ConnectionString;
}
}

View File

@@ -7,112 +7,257 @@ 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 Infrastructure.Sql.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
static async Task<int> main()
return await RunAsync();
static async Task<int> RunAsync()
{
IReadOnlyList<BreweryRecord> breweries;
IReadOnlyList<UserRecord> users;
try
{
PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString);
breweries = reader.ReadBreweryRecords();
users = reader.ReadUserRecords();
ServiceCollection services = [];
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().Build());
services.AddInfrastructureSql();
services.AddFeaturesBreweries();
services.AddFeaturesLocations();
services.AddFeaturesAuth();
await using ServiceProvider provider = services.BuildServiceProvider();
IBreweryRepository breweryRepository =
provider.GetRequiredService<IBreweryRepository>();
ILocationRepository locationRepository =
provider.GetRequiredService<ILocationRepository>();
IAuthRepository authRepository =
provider.GetRequiredService<IAuthRepository>();
IPasswordInfrastructure passwordInfrastructure =
provider.GetRequiredService<IPasswordInfrastructure>();
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> 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 =>
{
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<IConfiguration>(new ConfigurationBuilder().Build());
services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
services.AddFeaturesBreweries();
services.AddFeaturesAuth();
static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
IAuthRepository authRepository,
IPasswordInfrastructure passwordInfrastructure,
IReadOnlyList<UserRecord> users
)
{
List<Guid> userAccountIds = [];
using ServiceProvider provider = services.BuildServiceProvider();
IBreweryRepository breweryRepository;
IAuthRepository authRepository;
IPasswordInfrastructure passwordInfrastructure;
try
foreach (UserRecord userRecord in users)
{
breweryRepository = provider.GetRequiredService<IBreweryRepository>();
authRepository = provider.GetRequiredService<IAuthRepository>();
passwordInfrastructure = provider.GetRequiredService<IPasswordInfrastructure>();
}
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 UserAccount()
{
FirstName = userRecord.User.FirstName,
LastName = userRecord.User.LastName,
DateOfBirth = DateTime.Parse(userRecord.DateOfBirth),
Username = userRecord.User.Username,
Email = userRecord.Email,
UserCredential = new UserCredential
{
Hash = passwordInfrastructure.Hash(
password: PasswordGenerator.Generate(64, 12, 12))
}
}
);
userAccountIds.Add(userAccount.UserAccountId);
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"Loaded {breweries.Count} breweries.");
Console.ResetColor();
return userAccountIds;
}
/// <summary>
/// Persists each <paramref name="breweries" /> record, resolving (and creating, if needed) its
/// Country/StateProvince/City chain via <paramref name="locationRepository" />. The pipeline
/// data has no street-level address or a specific user tied to each brewery, so a placeholder
/// address is used and <c>PostedById</c> is assigned round-robin from <paramref name="posterUserIds" />
/// to keep seeding deterministic across runs.
/// </summary>
static async Task LoadBreweriesIntoDatabaseAsync(
IBreweryRepository breweryRepository,
ILocationRepository locationRepository,
IReadOnlyList<BreweryRecord> breweries,
IReadOnlyList<Guid> 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
{
BreweryPostId = Guid.NewGuid(),
BreweryName = breweryRecord.Brewery.NameEn,
Description = breweryRecord.Brewery.DescriptionEn,
PostedById = posterUserIds[i % posterUserIds.Count],
Location = new BreweryPostLocation
{
BreweryPostLocationId = Guid.NewGuid(),
CityId = cityId,
AddressLine1 = "Address unavailable",
PostalCode = breweryRecord.Address.PostalCode,
},
}
);
}
}
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("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<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)
{
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 await main();
return table;
}

View File

@@ -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<BreweryRecord> Breweries,
IReadOnlyList<UserRecord> Users
);
public sealed class PipelineSeedDataReader
{
private readonly string _connectionString;
@@ -17,13 +18,40 @@ public sealed class PipelineSeedDataReader
_connectionString = connectionString;
}
public IReadOnlyList<BreweryRecord> ReadBreweryRecords()
/// <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)
{
using SqliteConnection connection = new(_connectionString);
connection.Open();
await using SqliteConnection connection = new(_connectionString);
await connection.OpenAsync(cancellationToken);
IReadOnlyDictionary<long, City> cities = ReadCities(connection);
IReadOnlyDictionary<long, City> cities = await ReadCitiesAsync(
connection,
cancellationToken
);
IReadOnlyList<BreweryRecord> breweries = await ReadBreweryRecordsAsync(
connection,
cities,
cancellationToken
);
IReadOnlyList<UserRecord> users = await ReadUserRecordsAsync(
connection,
cities,
cancellationToken
);
return new SeedData(breweries, users);
}
private static async Task<IReadOnlyList<BreweryRecord>> ReadBreweryRecordsAsync(
SqliteConnection connection,
IReadOnlyDictionary<long, City> 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<BreweryRecord> 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<UserRecord> ReadUserRecords()
private static async Task<IReadOnlyList<UserRecord>> ReadUserRecordsAsync(
SqliteConnection connection,
IReadOnlyDictionary<long, City> cities,
CancellationToken cancellationToken
)
{
using SqliteConnection connection = new(_connectionString);
connection.Open();
IReadOnlyDictionary<long, City> 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<UserRecord> 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<long, City> ReadCities(SqliteConnection connection)
private static async Task<IReadOnlyDictionary<long, City>> 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<long, City> 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)),
},
};

View File

@@ -1,17 +0,0 @@
namespace Database.Seed;
public interface IExportDatabase
{
void SeedLocations(IEnumerable<Location> locations);
void SeedUsers(IEnumerable<User> users);
void SeedBreweries(IEnumerable<Brewery> breweries);
}
public interface IImportDatabase
{
IEnumerable<Location> GetLocations();
IEnumerable<User> GetUsers();
IEnumerable<Brewery> GetBreweries();
}