- 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.
This commit is contained in:
Aaron Po
2026-08-19 16:15:34 -04:00
parent 81a76c9baf
commit ab265de54d
19 changed files with 290 additions and 184 deletions

View File

@@ -19,13 +19,13 @@ public record UpdateBreweryLocation(
/// <summary>
/// Updates an existing brewery post. Bound directly from the request body of <c>PUT /api/brewery/{id}</c>.
/// A <c>null</c> <see cref="Location" /> clears the brewery's location. <see cref="Timer" /> must be the
/// A <c>null</c> <see cref="Location" /> clears the brewery's location. <see cref="RowVersion" /> must be the
/// row-version last read for this brewery (e.g. from a prior <c>GET</c>); the update is rejected with a
/// <c>409 Conflict</c> if the brewery was modified since then.
/// </summary>
public record UpdateBreweryCommand(
Guid BreweryPostId,
byte[] Timer,
byte[] RowVersion,
Guid PostedById,
string BreweryName,
string Description,

View File

@@ -19,7 +19,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
/// </exception>
/// <exception cref="Domain.Exceptions.ConflictException">
/// Thrown when the brewery was modified by another request since <paramref name="request" />.
/// <c>Timer</c> was read.
/// <c>RowVersion</c> was read.
/// </exception>
public async Task<BreweryDto> Handle(
UpdateBreweryCommand request,
@@ -32,7 +32,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
PostedById = request.PostedById,
BreweryName = request.BreweryName,
Description = request.Description,
Timer = request.Timer,
RowVersion = request.RowVersion,
UpdatedAt = DateTime.UtcNow,
Location = request.Location is null
? null

View File

@@ -7,8 +7,8 @@ public class UpdateBreweryValidator : AbstractValidator<UpdateBreweryCommand>
{
public UpdateBreweryValidator()
{
RuleFor(x => x.Timer)
RuleFor(x => x.RowVersion)
.NotEmpty()
.WithMessage("Timer is required to detect conflicting concurrent updates.");
.WithMessage("RowVersion is required to detect conflicting concurrent updates.");
}
}

View File

@@ -87,7 +87,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// <returns>
/// <c>200 OK</c> with the updated brewery; <c>400 Bad Request</c> if the route ID does not match the
/// payload ID; <c>404 Not Found</c> if the brewery or its <c>CityId</c> does not exist; or
/// <c>409 Conflict</c> if the brewery was modified since <c>command.Timer</c> was read.
/// <c>409 Conflict</c> if the brewery was modified since <c>command.RowVersion</c> was read.
/// </returns>
[HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(

View File

@@ -1,51 +1,35 @@
namespace Features.Breweries.Dtos;
/// <summary>Location details of a brewery, as returned by the API.</summary>
public class BreweryLocationDto
{
public Guid BreweryPostLocationId { get; set; }
public Guid CityId { get; set; }
public string CityName { get; set; } = string.Empty;
/// <param name="Coordinates">The brewery's geographic coordinates, in a raw binary representation.</param>
public record BreweryLocationDto(
Guid BreweryPostLocationId,
Guid CityId,
string CityName,
string StateProvinceName,
string StateProvinceCode,
string CountryName,
string CountryCode,
string AddressLine1,
string? AddressLine2,
string PostalCode,
byte[]? Coordinates
);
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;
public string? AddressLine2 { get; set; }
public string PostalCode { get; set; } = string.Empty;
/// <summary>Gets or sets the brewery's geographic coordinates, in a raw binary representation.</summary>
public byte[]? Coordinates { get; set; }
}
public record BeerPreviewDto(Guid BeerPostId, string Name, decimal ABV, int IBU);
/// <summary>A brewery post, as returned by the API.</summary>
public class BreweryDto
{
public Guid BreweryPostId { get; set; }
public Guid PostedById { get; set; }
public string BreweryName { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
/// <summary>Gets or sets the date and time of the last edit, or <see langword="null"/> if never edited.</summary>
public DateTime? UpdatedAt { get; set; }
/// <summary>Gets or sets the row-version/concurrency token used to detect conflicting concurrent updates.</summary>
public byte[]? Timer { get; set; }
/// <summary>Gets or sets the brewery's location, or <see langword="null"/> if none has been set.</summary>
public BreweryLocationDto? Location { get; set; }
}
/// <param name="UpdatedAt">The date and time of the last edit, or <see langword="null"/> if never edited.</param>
/// <param name="RowVersion">The row-version/concurrency token used to detect conflicting concurrent updates.</param>
/// <param name="Location">The brewery's location, or <see langword="null"/> if none has been set.</param>
public record BreweryDto(
Guid BreweryPostId,
Guid PostedById,
string BreweryName,
string Description,
DateTime CreatedAt,
DateTime? UpdatedAt,
byte[]? RowVersion,
BreweryLocationDto? Location,
List<BeerPreviewDto> BeerPosts
);

View File

@@ -11,31 +11,42 @@ public static class BreweryDtoMapper
/// </summary>
public static BreweryDto ToDto(this BreweryPost brewery)
{
return new BreweryDto
{
BreweryPostId = brewery.BreweryPostId,
PostedById = brewery.PostedById,
BreweryName = brewery.BreweryName,
Description = brewery.Description,
CreatedAt = brewery.CreatedAt,
UpdatedAt = brewery.UpdatedAt,
Timer = brewery.Timer,
Location = brewery.Location is null
return new BreweryDto(
BreweryPostId: brewery.BreweryPostId,
PostedById: brewery.PostedById,
BreweryName: brewery.BreweryName,
Description: brewery.Description,
CreatedAt: brewery.CreatedAt,
UpdatedAt: brewery.UpdatedAt,
RowVersion: brewery.RowVersion,
Location: brewery.Location is null
? null
: new BreweryLocationDto
{
BreweryPostLocationId = brewery.Location.BreweryPostLocationId,
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,
Coordinates = brewery.Location.Coordinates,
},
};
: new BreweryLocationDto(
BreweryPostLocationId: brewery.Location.BreweryPostLocationId,
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,
Coordinates: brewery.Location.Coordinates
),
BeerPosts:
[
.. brewery.BeerPosts.Select(beer => new BeerPreviewDto(
BeerPostId: beer.BeerPostId,
Name: beer.Name,
ABV: beer.ABV,
IBU: beer.IBU
)),
]
);
}
}

View File

@@ -22,29 +22,28 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: Repository<BreweryPost>(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,
c.CityName,
c.StateProvinceID,
sp.StateProvinceName,
sp.ISO3166_2,
sp.CountryID,
co.CountryName,
co.ISO3166_1
""";
private const string SelectColumns = """
bp.BreweryPostID,
bp.PostedByID,
bp.BreweryName,
bp.Description,
bp.CreatedAt,
bp.UpdatedAt,
bp.RowVersion,
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
@@ -52,22 +51,20 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <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
""";
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/>
public async Task<BreweryPost?> GetByIdAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText =
$"""
command.CommandText = $"""
SELECT {SelectColumns}
FROM {FromJoins}
WHERE bp.BreweryPostID = @BreweryPostId
@@ -83,8 +80,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText =
$"""
command.CommandText = $"""
SELECT {SelectColumns}
FROM {FromJoins}
ORDER BY bp.CreatedAt DESC
@@ -117,7 +113,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
new { brewery.BreweryPostId },
transaction
)
) is not null;
)
is not null;
if (!breweryExists)
throw new NotFoundException("Brewery not found.");
@@ -131,7 +128,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
new { brewery.Location.CityId },
transaction
)
) is not null;
)
is not null;
if (!cityExists)
throw new NotFoundException("City not found.");
@@ -142,14 +140,14 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
"""
UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName, Description = @Description, UpdatedAt = GETDATE()
WHERE BreweryPostID = @BreweryPostId AND Timer = @Timer
WHERE BreweryPostID = @BreweryPostId AND RowVersion = @RowVersion
""",
new
{
brewery.BreweryPostId,
brewery.BreweryName,
brewery.Description,
brewery.Timer,
brewery.RowVersion,
},
transaction
)
@@ -180,7 +178,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
new { brewery.BreweryPostId },
transaction
)
) is not null;
)
is not null;
if (locationExists)
await connection.ExecuteAsync(
@@ -208,8 +207,21 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
new CommandDefinition(
"""
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (@BreweryPostLocationId, @BreweryPostId, @CityId, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates)
(BreweryPostLocationID,
BreweryPostID,
CityID,
AddressLine1,
AddressLine2,
PostalCode,
Coordinates)
VALUES
(@BreweryPostLocationId,
@BreweryPostId,
@CityId,
@AddressLine1,
@AddressLine2,
@PostalCode,
@Coordinates)
""",
new
{
@@ -271,7 +283,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
new { brewery.PostedById },
transaction
)
) is not null;
)
is not null;
if (!userExists)
throw new NotFoundException("User not found.");
@@ -283,14 +296,26 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
new { brewery.Location.CityId },
transaction
)
) is not null;
)
is not null;
if (!cityExists)
throw new NotFoundException("City not found.");
await connection.ExecuteAsync(
new CommandDefinition(
"INSERT INTO dbo.BreweryPost (BreweryPostID, BreweryName, Description, PostedByID) VALUES (@BreweryPostId, @BreweryName, @Description, @PostedById)",
"""
INSERT INTO dbo.BreweryPost
(BreweryPostID,
BreweryName,
Description,
PostedByID)
VALUES
(@BreweryPostId,
@BreweryName,
@Description,
@PostedById)
""",
new
{
brewery.BreweryPostId,
@@ -344,7 +369,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
int ordDescription = reader.GetOrdinal("Description");
int ordCreatedAt = reader.GetOrdinal("CreatedAt");
int ordUpdatedAt = reader.GetOrdinal("UpdatedAt");
int ordTimer = reader.GetOrdinal("Timer");
int ordRowVersion = reader.GetOrdinal("RowVersion");
BreweryPost brewery = new()
{
@@ -354,7 +379,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
Description = reader.GetString(ordDescription),
CreatedAt = reader.GetDateTime(ordCreatedAt),
UpdatedAt = reader.IsDBNull(ordUpdatedAt) ? null : reader.GetDateTime(ordUpdatedAt),
Timer = reader.IsDBNull(ordTimer) ? null : reader.GetFieldValue<byte[]>(ordTimer),
RowVersion = reader.IsDBNull(ordRowVersion)
? null
: reader.GetFieldValue<byte[]>(ordRowVersion),
};
int ordLocationId = reader.GetOrdinal("BreweryPostLocationId");

View File

@@ -23,17 +23,17 @@ public interface IBreweryRepository
/// <summary>
/// Updates a brewery post's name and description, and upserts or clears its location, enforcing
/// optimistic concurrency via <paramref name="brewery" />'s <c>Timer</c>: the update is rejected
/// if the row was modified since <c>Timer</c> was read. When <paramref name="brewery" />'s
/// optimistic concurrency via <paramref name="brewery" />'s <c>RowVersion</c>: the update is rejected
/// if the row was modified since <c>RowVersion</c> was read. When <paramref name="brewery" />'s
/// <c>Location</c> is <c>null</c>, any existing location for the brewery is removed.
/// Returns the freshly persisted brewery, including its new <c>Timer</c>.
/// Returns the freshly persisted brewery, including its new <c>RowVersion</c>.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with the given ID, or the given location's <c>CityId</c> does not
/// exist.
/// </exception>
/// <exception cref="Domain.Exceptions.ConflictException">
/// Thrown when <paramref name="brewery" />'s <c>Timer</c> no longer matches the stored row (it was
/// Thrown when <paramref name="brewery" />'s <c>RowVersion</c> no longer matches the stored row (it was
/// modified by another request since it was last read).
/// </exception>
Task<BreweryPost> UpdateAsync(BreweryPost brewery);