Modify brewery location dto to include denormalized data

This commit is contained in:
Aaron Po
2026-08-19 03:30:30 -04:00
parent 693f74bb5b
commit 81a76c9baf
17 changed files with 261 additions and 133 deletions

View File

@@ -27,6 +27,8 @@ COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Feature
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]

View File

@@ -17,6 +17,8 @@ COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Feature
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]

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

@@ -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,
Timer 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,
Timer 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,
Timer 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,
Timer 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,
Timer 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,
Timer 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,
Timer 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,
Timer 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,
Timer 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,
Timer 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,
Timer 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,
Timer 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,
Timer 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,
Timer 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,
Timer 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,
Timer ROWVERSION,
CONSTRAINT PK_BeerPostComment
PRIMARY KEY (BeerPostCommentID),

View File

@@ -186,11 +186,13 @@ static async Task LoadBreweriesIntoDatabaseAsync(
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,

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

View File

@@ -8,6 +8,7 @@ public class BreweryPostLocation
public string? AddressLine2 { get; set; }
public string PostalCode { get; set; } = string.Empty;
public Guid CityId { get; set; }
public City? City { get; set; }
public byte[]? Coordinates { get; set; }
public byte[]? Timer { get; set; }
}

View File

@@ -5,4 +5,34 @@ public class City
public Guid CityId { get; set; }
public string CityName { get; set; } = string.Empty;
public Guid StateProvinceId { get; set; }
public StateProvince? StateProvince { get; set; }
public byte[]? Timer { get; set; }
}
public class StateProvince
{
public Guid StateProvinceId { get; set; }
public string StateProvinceName { get; set; } = string.Empty;
// this is the iso 3166-2 code for the state/province, e.g. "CA" for California, "ON" for Ontario, etc.
// and is rendered like US-CA for California, CA-ON for Ontario, etc.
public string ISO3166_2 { get; set; } = string.Empty;
public Guid CountryId { get; set; }
public Country? Country { get; set; }
public byte[]? Timer { get; set; }
}
public class Country
{
public Guid CountryId { get; set; }
public string CountryName { get; set; } = string.Empty;
public string ISO3166_1 { get; set; } = string.Empty; // this is the iso 3166-1 alpha-2 code for the country, e.g. "US" for United States, "CA" for Canada, etc.
public byte[]? Timer { get; set; }
}

View File

@@ -20,8 +20,11 @@ public class BreweryRepositoryTests
MockDbConnection conn = new();
Guid locationId = Guid.NewGuid();
Guid cityId = Guid.NewGuid();
Guid stateProvinceId = Guid.NewGuid();
Guid countryId = Guid.NewGuid();
conn.Mocks.When(cmd => cmd.CommandText.Contains("INNER JOIN dbo.BreweryPostLocation"))
conn.Mocks.When(cmd => cmd.CommandText.Contains("LEFT JOIN dbo.BreweryPostLocation"))
.ReturnsTable(
MockTable
.WithColumns(
@@ -37,7 +40,14 @@ public class BreweryRepositoryTests
("AddressLine1", typeof(string)),
("AddressLine2", typeof(string)),
("PostalCode", typeof(string)),
("Coordinates", typeof(byte[]))
("Coordinates", typeof(byte[])),
("CityName", typeof(string)),
("StateProvinceId", typeof(Guid)),
("StateProvinceName", typeof(string)),
("ISO3166_2", typeof(string)),
("CountryId", typeof(Guid)),
("CountryName", typeof(string)),
("ISO3166_1", typeof(string))
)
.AddRow(
breweryId,
@@ -48,11 +58,18 @@ public class BreweryRepositoryTests
null,
null,
locationId,
Guid.NewGuid(),
cityId,
"123 Main St",
null,
"12345",
null
null,
"Portland",
stateProvinceId,
"Oregon",
"US-OR",
countryId,
"United States",
"US"
)
);
@@ -62,13 +79,19 @@ public class BreweryRepositoryTests
result!.BreweryPostId.Should().Be(breweryId);
result.Location.Should().NotBeNull();
result.Location!.BreweryPostLocationId.Should().Be(locationId);
result.Location.City.Should().NotBeNull();
result.Location.City.CityName.Should().Be("Portland");
result.Location.City.StateProvince.Should().NotBeNull();
result.Location.City.StateProvince.StateProvinceName.Should().Be("Oregon");
result.Location.City.StateProvince.Country.Should().NotBeNull();
result.Location.City.StateProvince.Country.CountryName.Should().Be("United States");
}
[Fact]
public async Task GetByIdAsync_ReturnsNull_WhenNotExists()
{
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText.Contains("INNER JOIN dbo.BreweryPostLocation"))
conn.Mocks.When(cmd => cmd.CommandText.Contains("LEFT JOIN dbo.BreweryPostLocation"))
.ReturnsTable(MockTable.Empty());
BreweryRepository repo = CreateRepo(conn);
BreweryPost? result = await repo.GetByIdAsync(Guid.NewGuid());
@@ -225,7 +248,7 @@ public class BreweryRepositoryTests
.ReturnsScalar(1);
// Repository re-fetches the row after a successful update to return the new Timer.
conn.Mocks.When(cmd => cmd.CommandText.Contains("INNER JOIN dbo.BreweryPostLocation"))
conn.Mocks.When(cmd => cmd.CommandText.Contains("LEFT JOIN dbo.BreweryPostLocation"))
.ReturnsTable(
MockTable
.WithColumns(
@@ -241,7 +264,14 @@ public class BreweryRepositoryTests
("AddressLine1", typeof(string)),
("AddressLine2", typeof(string)),
("PostalCode", typeof(string)),
("Coordinates", typeof(byte[]))
("Coordinates", typeof(byte[])),
("CityName", typeof(string)),
("StateProvinceId", typeof(Guid)),
("StateProvinceName", typeof(string)),
("ISO3166_2", typeof(string)),
("CountryId", typeof(Guid)),
("CountryName", typeof(string)),
("ISO3166_1", typeof(string))
)
.AddRow(
breweryId,
@@ -256,7 +286,14 @@ public class BreweryRepositoryTests
"123 Main St",
null,
"12345",
null
null,
"Portland",
Guid.NewGuid(),
"Oregon",
"US-OR",
Guid.NewGuid(),
"United States",
"US"
)
);

View File

@@ -4,10 +4,16 @@ namespace Features.Breweries.Dtos;
public class BreweryLocationDto
{
public Guid BreweryPostLocationId { get; set; }
public Guid BreweryPostId { get; set; }
public Guid CityId { get; set; }
public string CityName { get; set; } = string.Empty;
public string StateProvinceName { get; set; } = string.Empty;
public string StateProvinceCode { get; set; } = string.Empty;
public string CountryName { get; set; } = string.Empty;
public string CountryCode { get; set; } = string.Empty;
public string AddressLine1 { get; set; } = string.Empty;
@@ -41,3 +47,5 @@ public class BreweryDto
/// <summary>Gets or sets the brewery's location, or <see langword="null"/> if none has been set.</summary>
public BreweryLocationDto? Location { get; set; }
}

View File

@@ -25,8 +25,12 @@ public static class BreweryDtoMapper
: new BreweryLocationDto
{
BreweryPostLocationId = brewery.Location.BreweryPostLocationId,
BreweryPostId = brewery.Location.BreweryPostId,
CityId = brewery.Location.CityId,
CityName = brewery.Location.City?.CityName ?? string.Empty,
StateProvinceName = brewery.Location.City?.StateProvince?.StateProvinceName ?? string.Empty,
StateProvinceCode = brewery.Location.City?.StateProvince?.ISO3166_2 ?? string.Empty,
CountryName = brewery.Location.City?.StateProvince?.Country?.CountryName ?? string.Empty,
CountryCode = brewery.Location.City?.StateProvince?.Country?.ISO3166_1 ?? string.Empty,
AddressLine1 = brewery.Location.AddressLine1,
AddressLine2 = brewery.Location.AddressLine2,
PostalCode = brewery.Location.PostalCode,

View File

@@ -23,14 +23,45 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
IBreweryRepository
{
private const string SelectColumns =
"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";
"""
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,
c.CityName,
c.StateProvinceID,
sp.StateProvinceName,
sp.ISO3166_2,
sp.CountryID,
co.CountryName,
co.ISO3166_1
""";
/// <summary>
/// Joins from <c>dbo.BreweryPost</c> down to <c>dbo.Country</c> so a row carries everything needed
/// to populate <see cref="BreweryPost.Location" />'s full <c>City</c> → <c>StateProvince</c> →
/// <c>Country</c> chain. All joins are <c>LEFT JOIN</c> (matching the previous stored procedure's
/// behavior), since a brewery post may have no location.
/// </summary>
private const string FromJoins =
"""
dbo.BreweryPost bp
LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID
""";
/// <inheritdoc/>
/// <remarks>
/// Joins to the location table with an <c>INNER JOIN</c> (matching the previous stored procedure's
/// behavior), which is why a brewery with no location row is treated as not found.
/// </remarks>
public async Task<BreweryPost?> GetByIdAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
@@ -38,8 +69,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
command.CommandText =
$"""
SELECT {SelectColumns}
FROM dbo.BreweryPost bp
INNER JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
FROM {FromJoins}
WHERE bp.BreweryPostID = @BreweryPostId
""";
AddParameter(command, "@BreweryPostId", id);
@@ -56,8 +86,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
command.CommandText =
$"""
SELECT {SelectColumns}
FROM dbo.BreweryPost bp
LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
FROM {FromJoins}
ORDER BY bp.CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
""";
@@ -339,6 +368,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
BreweryPostLocationId = reader.GetGuid(ordLocationId),
BreweryPostId = brewery.BreweryPostId,
CityId = reader.GetGuid(reader.GetOrdinal("CityId")),
City = MapCity(reader),
AddressLine1 = reader.GetString(reader.GetOrdinal("AddressLine1")),
AddressLine2 = reader.IsDBNull(ordAddressLine2)
? null
@@ -353,6 +383,34 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
return brewery;
}
/// <summary>
/// Maps the current row's <c>City</c> → <c>StateProvince</c> → <c>Country</c> columns. The schema
/// enforces <c>NOT NULL</c> foreign keys down this whole chain, so whenever a location row was
/// joined in (the caller only calls this when it was), these columns are always populated too.
/// </summary>
private static City MapCity(DbDataReader reader)
{
return new City
{
CityId = reader.GetGuid(reader.GetOrdinal("CityId")),
CityName = reader.GetString(reader.GetOrdinal("CityName")),
StateProvinceId = reader.GetGuid(reader.GetOrdinal("StateProvinceId")),
StateProvince = new StateProvince
{
StateProvinceId = reader.GetGuid(reader.GetOrdinal("StateProvinceId")),
StateProvinceName = reader.GetString(reader.GetOrdinal("StateProvinceName")),
ISO3166_2 = reader.GetString(reader.GetOrdinal("ISO3166_2")),
CountryId = reader.GetGuid(reader.GetOrdinal("CountryId")),
Country = new Country
{
CountryId = reader.GetGuid(reader.GetOrdinal("CountryId")),
CountryName = reader.GetString(reader.GetOrdinal("CountryName")),
ISO3166_1 = reader.GetString(reader.GetOrdinal("ISO3166_1")),
},
},
};
}
private static void AddParameter(DbCommand command, string name, object? value)
{
DbParameter p = command.CreateParameter();

View File

@@ -32,8 +32,8 @@ services:
sqlserver:
condition: service_healthy
build:
context: ./backend/Database
dockerfile: Database.Migrations/Dockerfile
context: ./backend
dockerfile: Database/Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000

View File

@@ -32,8 +32,8 @@ services:
sqlserver:
condition: service_healthy
build:
context: ./backend/Database
dockerfile: Database.Migrations/Dockerfile
context: ./backend
dockerfile: Database/Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000

View File

@@ -32,8 +32,8 @@ services:
sqlserver:
condition: service_healthy
build:
context: ./backend/Database
dockerfile: Database.Migrations/Dockerfile
context: ./backend
dockerfile: Database/Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000

View File

@@ -31,8 +31,8 @@ services:
sqlserver:
condition: service_healthy
build:
context: ./backend/Database
dockerfile: Database.Migrations/Dockerfile
context: ./backend
dockerfile: Database/Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000

View File

@@ -32,8 +32,8 @@ services:
sqlserver:
condition: service_healthy
build:
context: ./backend/Database
dockerfile: Database.Migrations/Dockerfile
context: ./backend
dockerfile: Database/Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000