Migrate Breweries to a vertical slice (Features.Breweries)

Moves brewery CRUD from the Service.Breweries/Infrastructure.Repository
layered split into a single Features.Breweries project: MediatR
commands/queries replace BreweryService, and the repository moves in
alongside its own controller. Extracts Infrastructure.Sql out of
Infrastructure.Repository (just the generic ADO.NET connection/base-repo
plumbing) since Breweries no longer needs the rest of that project, while
Auth and UserAccount repos still do until their own migration.

Also fixes the API.Core and Infrastructure.Repository.Tests Dockerfiles,
which were restoring against COPY destinations that didn't match the
projects' actual relative paths (silently working only because of the
COPY . . that followed); both now restore against Core.slnx with correctly
nested paths, verified with a real docker build.
This commit is contained in:
Aaron Po
2026-06-19 23:54:25 -04:00
parent 62d682472e
commit a8c1b17095
46 changed files with 803 additions and 789 deletions

View File

@@ -0,0 +1,25 @@
using Features.Breweries.Dtos;
using MediatR;
namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>
/// Location data required to create a new brewery post, supplied as part of <see cref="CreateBreweryCommand"/>.
/// </summary>
public record CreateBreweryLocation(
Guid CityId,
string AddressLine1,
string? AddressLine2,
string PostalCode,
byte[]? Coordinates
);
/// <summary>
/// Creates a new brewery post. Bound directly from the request body of <c>POST /api/brewery</c>.
/// </summary>
public record CreateBreweryCommand(
Guid PostedById,
string BreweryName,
string Description,
CreateBreweryLocation Location
) : IRequest<BreweryDto>;

View File

@@ -0,0 +1,44 @@
using Domain.Entities;
using Features.Breweries.Dtos;
using Features.Breweries.Repository;
using MediatR;
namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>
/// Handles <see cref="CreateBreweryCommand"/> by persisting a new brewery post and its location.
/// </summary>
/// <param name="repository">Repository used to persist the new brewery post.</param>
public class CreateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<CreateBreweryCommand, BreweryDto>
{
/// <summary>
/// Creates a new brewery post, generating new identifiers for the post and its location.
/// </summary>
/// <param name="request">The details of the brewery post to create.</param>
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
/// <returns>The newly created brewery post.</returns>
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken)
{
var entity = new BreweryPost
{
BreweryPostId = Guid.NewGuid(),
PostedById = request.PostedById,
BreweryName = request.BreweryName,
Description = request.Description,
CreatedAt = DateTime.UtcNow,
Location = new BreweryPostLocation
{
BreweryPostLocationId = Guid.NewGuid(),
CityId = request.Location.CityId,
AddressLine1 = request.Location.AddressLine1,
AddressLine2 = request.Location.AddressLine2,
PostalCode = request.Location.PostalCode,
Coordinates = request.Location.Coordinates,
},
};
await repository.CreateAsync(entity);
return entity.ToDto();
}
}

View File

@@ -0,0 +1,59 @@
using FluentValidation;
namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>
/// Validates <see cref="CreateBreweryCommand"/> instances before they are processed.
/// </summary>
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
{
/// <summary>
/// Configures validation rules requiring <see cref="CreateBreweryCommand.PostedById"/>,
/// <see cref="CreateBreweryCommand.BreweryName"/>, <see cref="CreateBreweryCommand.Description"/>, and
/// <see cref="CreateBreweryCommand.Location"/> to be present, with length limits on the name, description,
/// address line 1, and postal code fields.
/// </summary>
public CreateBreweryValidator()
{
RuleFor(x => x.PostedById)
.NotEmpty()
.WithMessage("PostedById is required.");
RuleFor(x => x.BreweryName)
.NotEmpty()
.WithMessage("Brewery name is required.")
.MaximumLength(256)
.WithMessage("Brewery name cannot exceed 256 characters.");
RuleFor(x => x.Description)
.NotEmpty()
.WithMessage("Description is required.")
.MaximumLength(512)
.WithMessage("Description cannot exceed 512 characters.");
RuleFor(x => x.Location)
.NotNull()
.WithMessage("Location is required.");
RuleFor(x => x.Location.CityId)
.NotEmpty()
.When(x => x.Location is not null)
.WithMessage("CityId is required.");
RuleFor(x => x.Location.AddressLine1)
.NotEmpty()
.When(x => x.Location is not null)
.WithMessage("Address line 1 is required.")
.MaximumLength(256)
.When(x => x.Location is not null)
.WithMessage("Address line 1 cannot exceed 256 characters.");
RuleFor(x => x.Location.PostalCode)
.NotEmpty()
.When(x => x.Location is not null)
.WithMessage("Postal code is required.")
.MaximumLength(20)
.When(x => x.Location is not null)
.WithMessage("Postal code cannot exceed 20 characters.");
}
}

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>
/// Deletes a brewery post by its unique identifier.
/// </summary>
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;

View File

@@ -0,0 +1,15 @@
using Features.Breweries.Repository;
using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>
/// Handles <see cref="DeleteBreweryCommand"/> by deleting the matching brewery post.
/// </summary>
/// <param name="repository">Repository used to delete the brewery post.</param>
public class DeleteBreweryHandler(IBreweryRepository repository)
: IRequestHandler<DeleteBreweryCommand>
{
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken) =>
repository.DeleteAsync(request.BreweryPostId);
}

View File

@@ -0,0 +1,28 @@
using Features.Breweries.Dtos;
using MediatR;
namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>
/// Location data for an existing brewery post, supplied as part of <see cref="UpdateBreweryCommand"/>.
/// </summary>
public record UpdateBreweryLocation(
Guid BreweryPostLocationId,
Guid CityId,
string AddressLine1,
string? AddressLine2,
string PostalCode,
byte[]? Coordinates
);
/// <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.
/// </summary>
public record UpdateBreweryCommand(
Guid BreweryPostId,
Guid PostedById,
string BreweryName,
string Description,
UpdateBreweryLocation? Location
) : IRequest<BreweryDto>;

View File

@@ -0,0 +1,46 @@
using Domain.Entities;
using Features.Breweries.Dtos;
using Features.Breweries.Repository;
using MediatR;
namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>
/// Handles <see cref="UpdateBreweryCommand"/> by persisting changes to an existing brewery post.
/// </summary>
/// <param name="repository">Repository used to persist the updated brewery post.</param>
public class UpdateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<UpdateBreweryCommand, BreweryDto>
{
/// <summary>
/// Updates an existing brewery post. If <paramref name="request"/> has no <c>Location</c>,
/// the brewery's location is cleared.
/// </summary>
/// <param name="request">The updated details of the brewery post.</param>
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
/// <returns>The updated brewery post.</returns>
public async Task<BreweryDto> Handle(UpdateBreweryCommand request, CancellationToken cancellationToken)
{
var entity = new BreweryPost
{
BreweryPostId = request.BreweryPostId,
PostedById = request.PostedById,
BreweryName = request.BreweryName,
Description = request.Description,
UpdatedAt = DateTime.UtcNow,
Location = request.Location is null ? null : new BreweryPostLocation
{
BreweryPostLocationId = request.Location.BreweryPostLocationId,
BreweryPostId = request.BreweryPostId,
CityId = request.Location.CityId,
AddressLine1 = request.Location.AddressLine1,
AddressLine2 = request.Location.AddressLine2,
PostalCode = request.Location.PostalCode,
Coordinates = request.Location.Coordinates,
},
};
await repository.UpdateAsync(entity);
return entity.ToDto();
}
}

View File

@@ -0,0 +1,123 @@
using Features.Breweries.Commands.CreateBrewery;
using Features.Breweries.Commands.DeleteBrewery;
using Features.Breweries.Commands.UpdateBrewery;
using Features.Breweries.Dtos;
using Features.Breweries.Queries.GetAllBreweries;
using Features.Breweries.Queries.GetBreweryById;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Shared.Contracts;
namespace Features.Breweries.Controllers;
/// <summary>
/// Provides CRUD endpoints for managing brewery posts.
/// </summary>
/// <remarks>
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default; read endpoints
/// (<see cref="GetById"/> and <see cref="GetAll"/>) opt out via <c>[AllowAnonymous]</c>.
/// </remarks>
/// <param name="mediator">Used to dispatch brewery commands and queries to their handlers.</param>
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class BreweryController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Retrieves a single brewery post by its unique identifier.
/// </summary>
/// <remarks>Allows anonymous access.</remarks>
/// <param name="id">The unique identifier of the brewery post to retrieve.</param>
/// <returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="BreweryDto"/> if found;
/// otherwise a <c>404 Not Found</c> result wrapping a <see cref="ResponseBody"/> error message.
/// </returns>
[AllowAnonymous]
[HttpGet("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
{
var brewery = await mediator.Send(new GetBreweryByIdQuery(id));
if (brewery is null)
return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." });
return Ok(new ResponseBody<BreweryDto>
{
Message = "Brewery retrieved successfully.",
Payload = brewery,
});
}
/// <summary>
/// Retrieves a paginated list of brewery posts.
/// </summary>
/// <remarks>Allows anonymous access.</remarks>
/// <param name="limit">The maximum number of brewery posts to return, or <c>null</c> for no limit.</param>
/// <param name="offset">The number of brewery posts to skip before returning results, or <c>null</c> for no offset.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of a collection of <see cref="BreweryDto"/>.</returns>
[AllowAnonymous]
[HttpGet]
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
[FromQuery] int? limit,
[FromQuery] int? offset)
{
var breweries = await mediator.Send(new GetAllBreweriesQuery(limit, offset));
return Ok(new ResponseBody<IEnumerable<BreweryDto>>
{
Message = "Breweries retrieved successfully.",
Payload = breweries,
});
}
/// <summary>
/// Creates a new brewery post.
/// </summary>
/// <param name="command">The brewery details to create, including the posting user, name, description, and location.</param>
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}"/> of the newly created <see cref="BreweryDto"/>.</returns>
[HttpPost]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] CreateBreweryCommand command)
{
var brewery = await mediator.Send(command);
return Created($"/api/brewery/{brewery.BreweryPostId}", new ResponseBody<BreweryDto>
{
Message = "Brewery created successfully.",
Payload = brewery,
});
}
/// <summary>
/// Updates an existing brewery post.
/// </summary>
/// <param name="id">The unique identifier of the brewery post from the route, which must match <paramref name="command"/>'s ID.</param>
/// <param name="command">The updated brewery details, including the posting user, name, description, and optional location.</param>
/// <returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of the updated <see cref="BreweryDto"/> if the
/// update succeeds; otherwise a <c>400 Bad Request</c> result wrapping a <see cref="ResponseBody"/> error message
/// when the route ID does not match the payload ID.
/// </returns>
[HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(Guid id, [FromBody] UpdateBreweryCommand command)
{
if (command.BreweryPostId != id)
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
var brewery = await mediator.Send(command);
return Ok(new ResponseBody<BreweryDto>
{
Message = "Brewery updated successfully.",
Payload = brewery,
});
}
/// <summary>
/// Deletes a brewery post.
/// </summary>
/// <param name="id">The unique identifier of the brewery post to delete.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody"/> confirming the deletion.</returns>
[HttpDelete("{id:guid}")]
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
{
await mediator.Send(new DeleteBreweryCommand(id));
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
}
}

View File

@@ -0,0 +1,16 @@
using Features.Breweries.Repository;
using Microsoft.Extensions.DependencyInjection;
namespace Features.Breweries.DependencyInjection;
/// <summary>
/// Registers the services owned by the Breweries feature slice.
/// </summary>
public static class FeaturesBreweriesServiceCollectionExtensions
{
public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services)
{
services.AddScoped<IBreweryRepository, BreweryRepository>();
return services;
}
}

View File

@@ -0,0 +1,89 @@
namespace Features.Breweries.Dtos;
/// <summary>
/// Represents the location details of an existing brewery, as returned in <see cref="BreweryDto"/>.
/// </summary>
public class BreweryLocationDto
{
/// <summary>
/// The unique identifier of the brewery's location record.
/// </summary>
public Guid BreweryPostLocationId { get; set; }
/// <summary>
/// The unique identifier of the brewery post that this location belongs to.
/// </summary>
public Guid BreweryPostId { get; set; }
/// <summary>
/// The unique identifier of the city in which the brewery is located.
/// </summary>
public Guid CityId { get; set; }
/// <summary>
/// The primary street address line of the brewery.
/// </summary>
public string AddressLine1 { get; set; } = string.Empty;
/// <summary>
/// An optional secondary address line (e.g. suite or unit number).
/// </summary>
public string? AddressLine2 { get; set; }
/// <summary>
/// The postal/ZIP code of the brewery's address.
/// </summary>
public string PostalCode { get; set; } = string.Empty;
/// <summary>
/// The optional geographic coordinates of the brewery, in a raw binary representation.
/// </summary>
public byte[]? Coordinates { get; set; }
}
/// <summary>
/// Represents a brewery post as returned by the brewery endpoints, including its metadata and
/// optional location.
/// </summary>
public class BreweryDto
{
/// <summary>
/// The unique identifier of the brewery post.
/// </summary>
public Guid BreweryPostId { get; set; }
/// <summary>
/// The unique identifier of the user account that created the brewery post.
/// </summary>
public Guid PostedById { get; set; }
/// <summary>
/// The name of the brewery.
/// </summary>
public string BreweryName { get; set; } = string.Empty;
/// <summary>
/// A description of the brewery.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// The date and time at which the brewery post was created.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// The date and time at which the brewery post was last updated, or <c>null</c> if it has never been updated.
/// </summary>
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// A row-version/concurrency token used to detect conflicting concurrent updates to the brewery post.
/// </summary>
public byte[]? Timer { get; set; }
/// <summary>
/// The location details of the brewery, or <c>null</c> if no location is associated with it.
/// </summary>
public BreweryLocationDto? Location { get; set; }
}

View File

@@ -0,0 +1,36 @@
using Domain.Entities;
namespace Features.Breweries.Dtos;
/// <summary>
/// Maps <see cref="BreweryPost"/> domain entities to their <see cref="BreweryDto"/> wire representation.
/// </summary>
public static class BreweryDtoMapper
{
/// <summary>
/// Maps a <see cref="BreweryPost"/> domain entity to its <see cref="BreweryDto"/> representation,
/// including its location, if present.
/// </summary>
/// <param name="brewery">The brewery post entity to map.</param>
/// <returns>The mapped <see cref="BreweryDto"/>.</returns>
public static BreweryDto ToDto(this BreweryPost brewery) => new()
{
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 ? null : new BreweryLocationDto
{
BreweryPostLocationId = brewery.Location.BreweryPostLocationId,
BreweryPostId = brewery.Location.BreweryPostId,
CityId = brewery.Location.CityId,
AddressLine1 = brewery.Location.AddressLine1,
AddressLine2 = brewery.Location.AddressLine2,
PostalCode = brewery.Location.PostalCode,
Coordinates = brewery.Location.Coordinates,
},
};
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Features.Breweries</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,19 @@
using Features.Breweries.Dtos;
using Features.Breweries.Repository;
using MediatR;
namespace Features.Breweries.Queries.GetAllBreweries;
/// <summary>
/// Handles <see cref="GetAllBreweriesQuery"/> by retrieving a paginated list of brewery posts.
/// </summary>
/// <param name="repository">Repository used to query brewery post data.</param>
public class GetAllBreweriesHandler(IBreweryRepository repository)
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
{
public async Task<IEnumerable<BreweryDto>> Handle(GetAllBreweriesQuery request, CancellationToken cancellationToken)
{
var breweries = await repository.GetAllAsync(request.Limit, request.Offset);
return breweries.Select(b => b.ToDto());
}
}

View File

@@ -0,0 +1,9 @@
using Features.Breweries.Dtos;
using MediatR;
namespace Features.Breweries.Queries.GetAllBreweries;
/// <summary>
/// Retrieves a paginated list of brewery posts.
/// </summary>
public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<BreweryDto>>;

View File

@@ -0,0 +1,19 @@
using Features.Breweries.Dtos;
using Features.Breweries.Repository;
using MediatR;
namespace Features.Breweries.Queries.GetBreweryById;
/// <summary>
/// Handles <see cref="GetBreweryByIdQuery"/> by looking up the matching brewery post.
/// </summary>
/// <param name="repository">Repository used to query brewery post data.</param>
public class GetBreweryByIdHandler(IBreweryRepository repository)
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
{
public async Task<BreweryDto?> Handle(GetBreweryByIdQuery request, CancellationToken cancellationToken)
{
var brewery = await repository.GetByIdAsync(request.BreweryPostId);
return brewery?.ToDto();
}
}

View File

@@ -0,0 +1,9 @@
using Features.Breweries.Dtos;
using MediatR;
namespace Features.Breweries.Queries.GetBreweryById;
/// <summary>
/// Retrieves a single brewery post by its unique identifier.
/// </summary>
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;

View File

@@ -0,0 +1,238 @@
using System.Data.Common;
using Domain.Entities;
using Infrastructure.Sql;
namespace Features.Breweries.Repository;
/// <summary>
/// ADO.NET-based implementation of <see cref="IBreweryRepository"/> backed by SQL Server stored
/// procedures.
/// </summary>
/// <param name="connectionFactory">The factory used to create database connections.</param>
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: Repository<BreweryPost>(connectionFactory), IBreweryRepository
{
/// <summary>
/// Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.
/// </summary>
/// <param name="id">The unique identifier of the brewery post.</param>
/// <returns>The matching <see cref="BreweryPost"/>, or <c>null</c> if not found.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<BreweryPost?> GetByIdAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandType = System.Data.CommandType.StoredProcedure;
command.CommandText = "USP_GetBreweryById";
AddParameter(command, "@BreweryPostID", id);
await using var reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
return MapToEntity(reader);
}
return null;
}
/// <summary>
/// Retrieves all brewery posts, optionally paginated, using the <c>USP_GetAllBreweries</c>
/// stored procedure. The <c>@Limit</c> and <c>@Offset</c> parameters are only added when their
/// corresponding argument has a value.
/// </summary>
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
/// <returns>The collection of matching <see cref="BreweryPost"/> records.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_GetAllBreweries";
command.CommandType = System.Data.CommandType.StoredProcedure;
if (limit.HasValue)
AddParameter(command, "@Limit", limit.Value);
if (offset.HasValue)
AddParameter(command, "@Offset", offset.Value);
await using var reader = await command.ExecuteReaderAsync();
var breweries = new List<BreweryPost>();
while (await reader.ReadAsync())
{
breweries.Add(MapToEntity(reader));
}
return breweries;
}
/// <summary>
/// Updates a brewery post's name and description, and upserts or clears its location, using the
/// <c>USP_UpdateBrewery</c> stored procedure. When <paramref name="brewery"/>.<c>Location</c> is
/// <c>null</c>, any existing location for the brewery is removed.
/// </summary>
/// <param name="brewery">The brewery post containing updated values.</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task UpdateAsync(BreweryPost brewery)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_UpdateBrewery";
command.CommandType = System.Data.CommandType.StoredProcedure;
AddParameter(command, "@BreweryPostID", brewery.BreweryPostId);
AddParameter(command, "@BreweryName", brewery.BreweryName);
AddParameter(command, "@Description", brewery.Description);
AddParameter(command, "@BreweryPostLocationID", brewery.Location?.BreweryPostLocationId);
AddParameter(command, "@CityID", brewery.Location?.CityId);
AddParameter(command, "@AddressLine1", brewery.Location?.AddressLine1);
AddParameter(command, "@AddressLine2", brewery.Location?.AddressLine2);
AddParameter(command, "@PostalCode", brewery.Location?.PostalCode);
AddParameter(command, "@Coordinates", brewery.Location?.Coordinates);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Deletes a brewery post by ID using the <c>USP_DeleteBrewery</c> stored procedure. Its location
/// and photos are removed via cascading foreign keys.
/// </summary>
/// <param name="id">The unique identifier of the brewery post to delete.</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task DeleteAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_DeleteBrewery";
command.CommandType = System.Data.CommandType.StoredProcedure;
AddParameter(command, "@BreweryPostID", id);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Creates a new brewery post and its location using the <c>USP_CreateBrewery</c> stored procedure.
/// </summary>
/// <param name="brewery">The brewery post to create. Must have a non-null <c>Location</c>.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery"/>.<c>Location</c> is <c>null</c>.</exception>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task CreateAsync(BreweryPost brewery)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_CreateBrewery";
command.CommandType = System.Data.CommandType.StoredProcedure;
if (brewery.Location is null)
{
throw new ArgumentException("Location must be provided when creating a brewery.");
}
AddParameter(command, "@BreweryName", brewery.BreweryName);
AddParameter(command, "@Description", brewery.Description);
AddParameter(command, "@PostedByID", brewery.PostedById);
AddParameter(command, "@CityID", brewery.Location?.CityId);
AddParameter(command, "@AddressLine1", brewery.Location?.AddressLine1);
AddParameter(command, "@AddressLine2", brewery.Location?.AddressLine2);
AddParameter(command, "@PostalCode", brewery.Location?.PostalCode);
AddParameter(command, "@Coordinates", brewery.Location?.Coordinates);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Maps the current row of a data reader to a <see cref="BreweryPost"/> entity, including its
/// rowversion <c>Timer</c> field and, if location columns are present in the result set, its
/// associated <see cref="BreweryPostLocation"/>.
/// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="BreweryPost"/> instance.</returns>
protected override BreweryPost MapToEntity(DbDataReader reader)
{
var brewery = new BreweryPost();
var ordBreweryPostId = reader.GetOrdinal("BreweryPostId");
var ordPostedById = reader.GetOrdinal("PostedById");
var ordBreweryName = reader.GetOrdinal("BreweryName");
var ordDescription = reader.GetOrdinal("Description");
var ordCreatedAt = reader.GetOrdinal("CreatedAt");
var ordUpdatedAt = reader.GetOrdinal("UpdatedAt");
var ordTimer = reader.GetOrdinal("Timer");
brewery.BreweryPostId = reader.GetGuid(ordBreweryPostId);
brewery.PostedById = reader.GetGuid(ordPostedById);
brewery.BreweryName = reader.GetString(ordBreweryName);
brewery.Description = reader.GetString(ordDescription);
brewery.CreatedAt = reader.GetDateTime(ordCreatedAt);
brewery.UpdatedAt = reader.IsDBNull(ordUpdatedAt) ? null : reader.GetDateTime(ordUpdatedAt);
// Read timer (varbinary/rowversion) robustly
if (reader.IsDBNull(ordTimer))
{
brewery.Timer = null;
}
else
{
try
{
brewery.Timer = reader.GetFieldValue<byte[]>(ordTimer);
}
catch
{
var length = reader.GetBytes(ordTimer, 0, null, 0, 0);
var buffer = new byte[length];
reader.GetBytes(ordTimer, 0, buffer, 0, (int)length);
brewery.Timer = buffer;
}
}
// Map BreweryPostLocation if columns are present
try
{
var ordLocationId = reader.GetOrdinal("BreweryPostLocationId");
if (!reader.IsDBNull(ordLocationId))
{
var location = new BreweryPostLocation
{
BreweryPostLocationId = reader.GetGuid(ordLocationId),
BreweryPostId = reader.GetGuid(reader.GetOrdinal("BreweryPostId")),
CityId = reader.GetGuid(reader.GetOrdinal("CityId")),
AddressLine1 = reader.GetString(reader.GetOrdinal("AddressLine1")),
AddressLine2 = reader.IsDBNull(reader.GetOrdinal("AddressLine2")) ? null : reader.GetString(reader.GetOrdinal("AddressLine2")),
PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")),
Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates")) ? null : reader.GetFieldValue<byte[]>(reader.GetOrdinal("Coordinates"))
};
brewery.Location = location;
}
}
catch (IndexOutOfRangeException)
{
// Location columns not present, skip mapping location
}
return brewery;
}
/// <summary>
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
/// <see cref="DBNull.Value"/>.
/// </summary>
/// <param name="command">The command to add the parameter to.</param>
/// <param name="name">The parameter name (including any prefix, e.g. "@BreweryName").</param>
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value"/>.</param>
private static void AddParameter(
DbCommand command,
string name,
object? value
)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}

View File

@@ -0,0 +1,43 @@
using Domain.Entities;
namespace Features.Breweries.Repository;
/// <summary>
/// Repository for CRUD operations on brewery post records.
/// </summary>
public interface IBreweryRepository
{
/// <summary>
/// Retrieves a brewery post by its unique identifier.
/// </summary>
/// <param name="id">The unique identifier of the brewery post.</param>
/// <returns>The matching <see cref="BreweryPost"/>, or <c>null</c> if not found.</returns>
Task<BreweryPost?> GetByIdAsync(Guid id);
/// <summary>
/// Retrieves all brewery posts, optionally paginated.
/// </summary>
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
/// <returns>The collection of matching <see cref="BreweryPost"/> records.</returns>
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
/// <summary>
/// Updates an existing brewery post.
/// </summary>
/// <param name="brewery">The brewery post containing updated values.</param>
Task UpdateAsync(BreweryPost brewery);
/// <summary>
/// Deletes a brewery post by its unique identifier.
/// </summary>
/// <param name="id">The unique identifier of the brewery post to delete.</param>
Task DeleteAsync(Guid id);
/// <summary>
/// Creates a new brewery post, including its location details.
/// </summary>
/// <param name="brewery">The brewery post to create. Must have a non-null <c>Location</c>.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery"/> has no <c>Location</c>.</exception>
Task CreateAsync(BreweryPost brewery);
}