mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Add brewery discovery & detail pages with location-based search (#253)
This commit is contained in:
@@ -41,14 +41,10 @@ CREATE
|
||||
NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||
ON BreweryPostLocation(CityID);
|
||||
|
||||
-- To assess when the time comes:
|
||||
|
||||
-- This would allow for efficient spatial queries to find breweries within a certain distance of a location, but it adds overhead to insert/update operations.
|
||||
|
||||
-- CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
|
||||
-- ON BreweryPostLocation(Coordinates)
|
||||
-- USING GEOGRAPHY_GRID
|
||||
-- WITH (
|
||||
-- GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),
|
||||
-- CELLS_PER_OBJECT = 16
|
||||
-- );
|
||||
CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
|
||||
ON BreweryPostLocation(Coordinates)
|
||||
USING GEOGRAPHY_GRID
|
||||
WITH (
|
||||
GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),
|
||||
CELLS_PER_OBJECT = 16
|
||||
);
|
||||
|
||||
@@ -21,4 +21,7 @@ public sealed class BreweryPost
|
||||
// Navigation Properties
|
||||
public BreweryPostLocation? Location { get; set; }
|
||||
public List<BeerPost> BeerPosts { get; set; } = [];
|
||||
|
||||
// Query-computed
|
||||
public DistanceInformation? Distance { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ namespace Domain.Entities;
|
||||
|
||||
public sealed record CoordinateData(double Latitude, double Longitude);
|
||||
|
||||
/// <summary>The distance, as computed by the database, from a query's origin point to a brewery.</summary>
|
||||
public sealed record DistanceInformation(CoordinateData From, double DistanceMetres);
|
||||
|
||||
public sealed class BreweryPostLocation
|
||||
{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Domain.Entities;
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Queries.GetAllBreweryLocations;
|
||||
using Features.Breweries.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Breweries.Tests.Queries;
|
||||
|
||||
public class GetAllBreweryLocationsHandlerTests
|
||||
{
|
||||
private readonly GetAllBreweryLocationsHandler _handler;
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
|
||||
public GetAllBreweryLocationsHandlerTests()
|
||||
{
|
||||
_handler = new GetAllBreweryLocationsHandler(_repoMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEmpty_WhenNoBreweriesHaveALocation()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(Array.Empty<BreweryPost>());
|
||||
|
||||
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
|
||||
new GetAllBreweryLocationsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
_repoMock.Verify(r => r.GetAllLocations(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsAllLocatedBreweries_FromRepository()
|
||||
{
|
||||
BreweryPost[] breweries =
|
||||
[
|
||||
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" },
|
||||
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" },
|
||||
];
|
||||
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries);
|
||||
|
||||
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
|
||||
new GetAllBreweryLocationsQuery(),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result
|
||||
.Select(b => b.BreweryPostId)
|
||||
.Should()
|
||||
.BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Domain.Entities;
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Queries.GetBreweryLocationsWithinRange;
|
||||
using Features.Breweries.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Breweries.Tests.Queries;
|
||||
|
||||
public class GetBreweryLocationsWithinRangeHandlerTests
|
||||
{
|
||||
private readonly GetBreweryLocationsWithinRangeHandler _handler;
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
|
||||
public GetBreweryLocationsWithinRangeHandlerTests()
|
||||
{
|
||||
_handler = new GetBreweryLocationsWithinRangeHandler(_repoMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PassesCoordinatesAndRange_ToRepository()
|
||||
{
|
||||
_repoMock
|
||||
.Setup(r =>
|
||||
r.GetAllLocationsWithinRange(
|
||||
It.Is<CoordinateData>(c => c.Latitude == 43.6532 && c.Longitude == -79.3832),
|
||||
1000
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(Array.Empty<BreweryPost>());
|
||||
|
||||
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
|
||||
new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
_repoMock.Verify(
|
||||
r =>
|
||||
r.GetAllLocationsWithinRange(
|
||||
It.Is<CoordinateData>(c => c.Latitude == 43.6532 && c.Longitude == -79.3832),
|
||||
1000
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsNearbyBreweries_FromRepository()
|
||||
{
|
||||
BreweryPost[] breweries =
|
||||
[
|
||||
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby A" },
|
||||
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby B" },
|
||||
];
|
||||
_repoMock
|
||||
.Setup(r => r.GetAllLocationsWithinRange(It.IsAny<CoordinateData>(), It.IsAny<double>()))
|
||||
.ReturnsAsync(breweries);
|
||||
|
||||
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
|
||||
new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result
|
||||
.Select(b => b.BreweryPostId)
|
||||
.Should()
|
||||
.BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ using Features.Breweries.Commands.DeleteBrewery;
|
||||
using Features.Breweries.Commands.UpdateBrewery;
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Queries.GetAllBreweries;
|
||||
using Features.Breweries.Queries.GetAllBreweryLocations;
|
||||
using Features.Breweries.Queries.GetBreweryById;
|
||||
using Features.Breweries.Queries.GetBreweryLocationsWithinRange;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -64,6 +66,49 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Retrieves all brewery posts that have a set location.</summary>
|
||||
/// <remarks>Allows anonymous access.</remarks>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("locations")]
|
||||
public async Task<ActionResult<ResponseBody<IEnumerable<SimplifiedBreweryDto>>>> GetAllLocations()
|
||||
{
|
||||
IEnumerable<SimplifiedBreweryDto> breweries = await mediator.Send(
|
||||
new GetAllBreweryLocationsQuery()
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<IEnumerable<SimplifiedBreweryDto>>
|
||||
{
|
||||
Message = "Brewery locations retrieved successfully.",
|
||||
Payload = breweries,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Retrieves brewery posts with a set location within range of the given coordinates, nearest first.</summary>
|
||||
/// <param name="latitude">The origin latitude, in decimal degrees.</param>
|
||||
/// <param name="longitude">The origin longitude, in decimal degrees.</param>
|
||||
/// <param name="rangeInMetres">The maximum distance, in metres, from the origin coordinates.</param>
|
||||
/// <remarks>Allows anonymous access.</remarks>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("locations/nearby")]
|
||||
public async Task<ActionResult<ResponseBody<IEnumerable<SimplifiedBreweryDto>>>> GetLocationsWithinRange(
|
||||
[FromQuery] double latitude,
|
||||
[FromQuery] double longitude,
|
||||
[FromQuery] double rangeInMetres
|
||||
)
|
||||
{
|
||||
IEnumerable<SimplifiedBreweryDto> breweries = await mediator.Send(
|
||||
new GetBreweryLocationsWithinRangeQuery(latitude, longitude, rangeInMetres)
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<IEnumerable<SimplifiedBreweryDto>>
|
||||
{
|
||||
Message = "Brewery locations retrieved successfully.",
|
||||
Payload = breweries,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Creates a new brewery post.</summary>
|
||||
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
|
||||
[HttpPost]
|
||||
|
||||
@@ -32,3 +32,13 @@ public record BreweryDto(
|
||||
byte[]? RowVersion,
|
||||
BreweryLocationDto? Location
|
||||
);
|
||||
/// <param name="DistanceMetres">
|
||||
/// The distance, in metres, from a query's origin point, or <see langword="null"/> for queries with no
|
||||
/// origin point (e.g. <c>GET /api/brewery/locations</c>).
|
||||
/// </param>
|
||||
public record SimplifiedBreweryDto(
|
||||
Guid BreweryPostId,
|
||||
string BreweryName,
|
||||
BreweryLocationDto? Location,
|
||||
double? DistanceMetres
|
||||
);
|
||||
|
||||
@@ -40,4 +40,36 @@ public static class BreweryDtoMapper
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a <see cref="BreweryPost" /> to its simplified DTO representation, used for
|
||||
/// location-based queries that only need identifying and location details.
|
||||
/// </summary>
|
||||
public static SimplifiedBreweryDto ToSimplifiedDto(this BreweryPost brewery)
|
||||
{
|
||||
return new SimplifiedBreweryDto(
|
||||
BreweryPostId: brewery.BreweryPostId,
|
||||
BreweryName: brewery.BreweryName,
|
||||
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?.Iso31662
|
||||
?? string.Empty,
|
||||
CountryName: brewery.Location.City?.StateProvince?.Country?.CountryName
|
||||
?? string.Empty,
|
||||
CountryCode: brewery.Location.City?.StateProvince?.Country?.Iso31661
|
||||
?? string.Empty,
|
||||
AddressLine1: brewery.Location.AddressLine1,
|
||||
AddressLine2: brewery.Location.AddressLine2,
|
||||
PostalCode: brewery.Location.PostalCode,
|
||||
Coordinates: brewery.Location.Coordinates
|
||||
),
|
||||
DistanceMetres: brewery.Distance?.DistanceMetres
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Domain.Entities;
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Queries.GetAllBreweryLocations;
|
||||
|
||||
/// <summary>Handles <see cref="GetAllBreweryLocationsQuery" /> by retrieving all located brewery posts.</summary>
|
||||
public class GetAllBreweryLocationsHandler(IBreweryRepository repository)
|
||||
: IRequestHandler<GetAllBreweryLocationsQuery, IEnumerable<SimplifiedBreweryDto>>
|
||||
{
|
||||
/// <summary>Retrieves every brewery post that has a set location.</summary>
|
||||
public async Task<IEnumerable<SimplifiedBreweryDto>> Handle(
|
||||
GetAllBreweryLocationsQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
IEnumerable<BreweryPost> breweries = await repository.GetAllLocations();
|
||||
return breweries.Select(b => b.ToSimplifiedDto());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Features.Breweries.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Queries.GetAllBreweryLocations;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all brewery posts that have a set location. Bound directly from
|
||||
/// <c>GET /api/brewery/locations</c>.
|
||||
/// </summary>
|
||||
public record GetAllBreweryLocationsQuery : IRequest<IEnumerable<SimplifiedBreweryDto>>;
|
||||
@@ -0,0 +1,26 @@
|
||||
using Domain.Entities;
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="GetBreweryLocationsWithinRangeQuery" /> by retrieving nearby located brewery posts.
|
||||
/// </summary>
|
||||
public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository)
|
||||
: IRequestHandler<GetBreweryLocationsWithinRangeQuery, IEnumerable<SimplifiedBreweryDto>>
|
||||
{
|
||||
/// <summary>Retrieves brewery posts within range of the requested coordinates, nearest first.</summary>
|
||||
public async Task<IEnumerable<SimplifiedBreweryDto>> Handle(
|
||||
GetBreweryLocationsWithinRangeQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
IEnumerable<BreweryPost> breweries = await repository.GetAllLocationsWithinRange(
|
||||
new CoordinateData(request.Latitude, request.Longitude),
|
||||
request.RangeInMetres
|
||||
);
|
||||
return breweries.Select(b => b.ToSimplifiedDto());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Features.Breweries.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves brewery posts with a set location within <paramref name="RangeInMetres" /> of the given
|
||||
/// coordinates, nearest first. Bound directly from the query string of
|
||||
/// <c>GET /api/brewery/locations/nearby</c>.
|
||||
/// </summary>
|
||||
/// <param name="Latitude">The origin latitude, in decimal degrees.</param>
|
||||
/// <param name="Longitude">The origin longitude, in decimal degrees.</param>
|
||||
/// <param name="RangeInMetres">The maximum distance, in metres, from the origin coordinates.</param>
|
||||
public record GetBreweryLocationsWithinRangeQuery(double Latitude, double Longitude, double RangeInMetres)
|
||||
: IRequest<IEnumerable<SimplifiedBreweryDto>>;
|
||||
@@ -0,0 +1,23 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
|
||||
|
||||
/// <summary>Validates <see cref="GetBreweryLocationsWithinRangeQuery" />.</summary>
|
||||
public class GetBreweryLocationsWithinRangeValidator
|
||||
: AbstractValidator<GetBreweryLocationsWithinRangeQuery>
|
||||
{
|
||||
public GetBreweryLocationsWithinRangeValidator()
|
||||
{
|
||||
RuleFor(x => x.Latitude)
|
||||
.InclusiveBetween(-90, 90)
|
||||
.WithMessage("Latitude must be between -90 and 90 degrees.");
|
||||
|
||||
RuleFor(x => x.Longitude)
|
||||
.InclusiveBetween(-180, 180)
|
||||
.WithMessage("Longitude must be between -180 and 180 degrees.");
|
||||
|
||||
RuleFor(x => x.RangeInMetres)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("RangeInMetres must be greater than zero.");
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,102 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(CoordinateData coords, double rangeInMetres)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryAsync<
|
||||
BreweryPost,
|
||||
BreweryPostLocation,
|
||||
City,
|
||||
StateProvince,
|
||||
Country,
|
||||
DistanceRow,
|
||||
BreweryPost
|
||||
> (
|
||||
"""
|
||||
DECLARE @Origin geography = geography::Point(@Latitude, @Longitude, 4326);
|
||||
|
||||
SELECT
|
||||
bp.BreweryPostID,
|
||||
bp.BreweryName,
|
||||
bpl.BreweryPostLocationID,
|
||||
bpl.AddressLine1,
|
||||
bpl.AddressLine2,
|
||||
bpl.PostalCode,
|
||||
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
|
||||
c.CityID,
|
||||
c.CityName,
|
||||
sp.StateProvinceID,
|
||||
sp.StateProvinceName,
|
||||
sp.ISO3166_2 AS Iso31662,
|
||||
co.CountryID,
|
||||
co.CountryName,
|
||||
co.ISO3166_1 AS Iso31661,
|
||||
bpl.Coordinates.STDistance(@Origin) AS DistanceMetres
|
||||
FROM dbo.BreweryPost bp
|
||||
INNER 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
|
||||
WHERE bpl.Coordinates IS NOT NULL
|
||||
AND bpl.Coordinates.STDistance(@Origin) <= @RangeInMetres
|
||||
ORDER BY bpl.Coordinates.STDistance(@Origin) ASC
|
||||
""",
|
||||
(post, location, city, stateProvince, country, distance) =>
|
||||
MapBreweryRowWithDistance(post, location, city, stateProvince, country, distance, coords),
|
||||
param: new
|
||||
{
|
||||
coords.Latitude,
|
||||
coords.Longitude,
|
||||
RangeInMetres = rangeInMetres
|
||||
},
|
||||
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID,DistanceMetres"
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<BreweryPost>> GetAllLocations()
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryAsync<
|
||||
BreweryPost,
|
||||
BreweryPostLocation,
|
||||
City,
|
||||
StateProvince,
|
||||
Country,
|
||||
BreweryPost
|
||||
> (
|
||||
"""
|
||||
SELECT
|
||||
bp.BreweryPostID,
|
||||
bp.BreweryName,
|
||||
bpl.BreweryPostLocationID,
|
||||
bpl.AddressLine1,
|
||||
bpl.AddressLine2,
|
||||
bpl.PostalCode,
|
||||
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
|
||||
c.CityID,
|
||||
c.CityName,
|
||||
sp.StateProvinceID,
|
||||
sp.StateProvinceName,
|
||||
sp.ISO3166_2 AS Iso31662,
|
||||
co.CountryID,
|
||||
co.CountryName,
|
||||
co.ISO3166_1 AS Iso31661
|
||||
FROM dbo.BreweryPost bp
|
||||
INNER 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
|
||||
WHERE bpl.Coordinates IS NOT NULL
|
||||
ORDER BY bp.BreweryPostID ASC
|
||||
""",
|
||||
MapBreweryRow,
|
||||
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery)
|
||||
{
|
||||
@@ -134,18 +230,18 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
bool breweryExists =
|
||||
await connection.ExecuteScalarAsync<int?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT 1 FROM
|
||||
dbo.BreweryPost
|
||||
WHERE
|
||||
BreweryPostID = @BreweryPostId
|
||||
""",
|
||||
new { brewery.BreweryPostId },
|
||||
transaction
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT 1 FROM
|
||||
dbo.BreweryPost
|
||||
WHERE
|
||||
BreweryPostID = @BreweryPostId
|
||||
""",
|
||||
new { brewery.BreweryPostId },
|
||||
transaction
|
||||
)
|
||||
)
|
||||
)
|
||||
is not null;
|
||||
is not null;
|
||||
|
||||
if (!breweryExists)
|
||||
throw new NotFoundException("Brewery not found.");
|
||||
@@ -154,13 +250,13 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
bool cityExists =
|
||||
await connection.ExecuteScalarAsync<int?>(
|
||||
new CommandDefinition(
|
||||
"SELECT 1 FROM dbo.City WHERE CityID = @CityId",
|
||||
new { brewery.Location.CityId },
|
||||
transaction
|
||||
new CommandDefinition(
|
||||
"SELECT 1 FROM dbo.City WHERE CityID = @CityId",
|
||||
new { brewery.Location.CityId },
|
||||
transaction
|
||||
)
|
||||
)
|
||||
)
|
||||
is not null;
|
||||
is not null;
|
||||
|
||||
if (!cityExists)
|
||||
throw new NotFoundException("City not found.");
|
||||
@@ -212,17 +308,17 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
bool locationExists =
|
||||
await connection.ExecuteScalarAsync<int?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM dbo.BreweryPostLocation
|
||||
WHERE BreweryPostID = @BreweryPostId
|
||||
""",
|
||||
new { brewery.BreweryPostId },
|
||||
transaction
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM dbo.BreweryPostLocation
|
||||
WHERE BreweryPostID = @BreweryPostId
|
||||
""",
|
||||
new { brewery.BreweryPostId },
|
||||
transaction
|
||||
)
|
||||
)
|
||||
)
|
||||
is not null;
|
||||
is not null;
|
||||
|
||||
if (locationExists)
|
||||
await connection.ExecuteAsync(
|
||||
@@ -303,9 +399,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
}
|
||||
|
||||
return await GetByIdAsync(brewery.BreweryPostId)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Brewery '{brewery.BreweryPostId}' was not found after a successful update."
|
||||
);
|
||||
?? throw new InvalidOperationException(
|
||||
$"Brewery '{brewery.BreweryPostId}' was not found after a successful update."
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -339,38 +435,38 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
bool userExists =
|
||||
await connection.ExecuteScalarAsync<int?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM
|
||||
dbo.UserAccount
|
||||
WHERE
|
||||
UserAccountID = @PostedById
|
||||
""",
|
||||
new { brewery.PostedById },
|
||||
transaction
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM
|
||||
dbo.UserAccount
|
||||
WHERE
|
||||
UserAccountID = @PostedById
|
||||
""",
|
||||
new { brewery.PostedById },
|
||||
transaction
|
||||
)
|
||||
)
|
||||
)
|
||||
is not null;
|
||||
is not null;
|
||||
|
||||
if (!userExists)
|
||||
throw new NotFoundException("User not found.");
|
||||
|
||||
bool cityExists =
|
||||
await connection.ExecuteScalarAsync<int?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM
|
||||
dbo.City
|
||||
WHERE
|
||||
CityID = @CityId
|
||||
""",
|
||||
new { brewery.Location.CityId },
|
||||
transaction
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM
|
||||
dbo.City
|
||||
WHERE
|
||||
CityID = @CityId
|
||||
""",
|
||||
new { brewery.Location.CityId },
|
||||
transaction
|
||||
)
|
||||
)
|
||||
)
|
||||
is not null;
|
||||
is not null;
|
||||
|
||||
if (!cityExists)
|
||||
throw new NotFoundException("City not found.");
|
||||
@@ -490,4 +586,29 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
post.Location = location;
|
||||
return post;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Dapper multi-mapping row for the <c>DistanceMetres</c> column computed by a range query.</summary>
|
||||
private sealed class DistanceRow
|
||||
{
|
||||
public double DistanceMetres { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes a joined row via <see cref="MapBreweryRow" />, then attaches the DB-computed distance
|
||||
/// from <paramref name="origin" /> as the result's <see cref="BreweryPost.Distance" />.
|
||||
/// </summary>
|
||||
private static BreweryPost MapBreweryRowWithDistance(
|
||||
BreweryPost post,
|
||||
BreweryPostLocation location,
|
||||
City city,
|
||||
StateProvince stateProvince,
|
||||
Country country,
|
||||
DistanceRow distance,
|
||||
CoordinateData origin
|
||||
)
|
||||
{
|
||||
BreweryPost mapped = MapBreweryRow(post, location, city, stateProvince, country);
|
||||
mapped.Distance = new DistanceInformation(origin, distance.DistanceMetres);
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,20 @@ public interface IBreweryRepository
|
||||
/// <param name="offset">Number of rows to skip. Treated as zero if <see langword="null" />.</param>
|
||||
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves brewery posts with a set location within <paramref name="rangeInMetres" /> of
|
||||
/// <paramref name="coords" />, nearest first. Posts without coordinates are excluded.
|
||||
/// </summary>
|
||||
/// <param name="coords">The origin point to measure distance from.</param>
|
||||
/// <param name="rangeInMetres">The maximum distance, in metres, from <paramref name="coords" />.</param>
|
||||
Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(CoordinateData coords, double rangeInMetres);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all brewery posts that have a set location, ordered by <c>BreweryPostId</c>. Posts
|
||||
/// without coordinates are excluded.
|
||||
/// </summary>
|
||||
Task<IEnumerable<BreweryPost>> GetAllLocations();
|
||||
|
||||
/// <summary>
|
||||
/// Updates a brewery post's name and description, and upserts or clears its location, enforcing
|
||||
/// optimistic concurrency via <paramref name="brewery" />'s <c>RowVersion</c>: the update is rejected
|
||||
@@ -48,4 +62,4 @@ public interface IBreweryRepository
|
||||
/// Thrown when <paramref name="brewery" />'s <c>PostedById</c> or <c>Location.CityId</c> does not exist.
|
||||
/// </exception>
|
||||
Task CreateAsync(BreweryPost brewery);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user