Files
the-biergarten-app/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs

623 lines
24 KiB
C#

using System.Data.Common;
using Dapper;
using Database.Connection;
using Domain.Entities;
using Domain.Exceptions;
namespace Features.Breweries.Repository;
/// <summary>
/// Dapper-based implementation of <see cref="IBreweryRepository" />.
/// </summary>
/// <remarks>
/// <c>BreweryPostLocation.Coordinates</c> is a SQL Server <c>GEOGRAPHY</c> column. Dapper's generic
/// <c>Query&lt;T&gt;</c> deserializer reads columns via <c>IDataRecord.GetValue</c>, which for a UDT
/// column returns a CLR UDT instance rather than raw bytes and cannot be coerced to <c>byte[]</c>.
/// The read queries below therefore select <c>CONVERT(varbinary(max), bpl.Coordinates)</c>, which
/// yields the UDT's serialized bytes as a plain <c>varbinary</c> value that Dapper can bind directly.
/// </remarks>
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: DapperRepository(connectionFactory),
IBreweryRepository
{
private const int GeographySrid = 4326;
static BreweryRepository()
{
SqlMapper.AddTypeHandler(new CoordinateDataTypeHandler());
}
/// <inheritdoc/>
public async Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
await using DbConnection connection = await CreateConnection();
IEnumerable<BreweryPost> results = await connection.QueryAsync<
BreweryPost,
BreweryPostLocation,
City,
StateProvince,
Country,
BreweryPost
>(
new CommandDefinition(
"""
SELECT
bp.BreweryPostID,
bp.PostedByID,
bp.BreweryName,
bp.Description,
bp.CreatedAt,
bp.UpdatedAt,
bp.RowVersion,
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 Brewery.BreweryPost bp
LEFT JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID
LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID
WHERE bp.BreweryPostID = @BreweryPostId
""",
new { BreweryPostId = id },
cancellationToken: cancellationToken
),
MapBreweryRow,
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
);
return results.SingleOrDefault();
}
/// <inheritdoc/>
public async Task<Guid?> GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
await using DbConnection connection = await CreateConnection();
return await connection.ExecuteScalarAsync<Guid?>(
new CommandDefinition(
"SELECT PostedByID FROM Brewery.BreweryPost WHERE BreweryPostID = @BreweryPostId",
new { BreweryPostId = id },
cancellationToken: cancellationToken
)
);
}
/// <inheritdoc/>
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
{
await using DbConnection connection = await CreateConnection();
return await connection.QueryAsync<
BreweryPost,
BreweryPostLocation,
City,
StateProvince,
Country,
BreweryPost
>(
"""
SELECT
bp.BreweryPostID,
bp.PostedByID,
bp.BreweryName,
bp.Description,
bp.CreatedAt,
bp.UpdatedAt,
bp.RowVersion,
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 Brewery.BreweryPost bp
LEFT JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID
LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID
ORDER BY bp.CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
""",
MapBreweryRow,
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue },
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
);
}
/// <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 Brewery.BreweryPost bp
INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID
LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
LEFT JOIN Geolocation.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 Brewery.BreweryPost bp
INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID
LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
LEFT JOIN Geolocation.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, CancellationToken cancellationToken = default)
{
await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
bool breweryExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1 FROM
Brewery.BreweryPost
WHERE
BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction,
cancellationToken: cancellationToken
)
)
is not null;
if (!breweryExists)
throw new NotFoundException("Brewery not found.");
if (brewery.Location is not null)
{
bool cityExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"SELECT 1 FROM Geolocation.City WHERE CityID = @CityId",
new { brewery.Location.CityId },
transaction,
cancellationToken: cancellationToken
)
)
is not null;
if (!cityExists)
throw new NotFoundException("City not found.");
}
int updatedRows = await connection.ExecuteAsync(
new CommandDefinition(
"""
UPDATE Brewery.BreweryPost
SET BreweryName = @BreweryName,
Description = @Description,
UpdatedAt = GETDATE()
WHERE
BreweryPostID = @BreweryPostId
AND RowVersion = @RowVersion
""",
new
{
brewery.BreweryPostId,
brewery.BreweryName,
brewery.Description,
brewery.RowVersion,
},
transaction,
cancellationToken: cancellationToken
)
);
if (updatedRows == 0)
throw new ConflictException(
"Brewery was modified by another request. Reload and try again."
);
if (brewery.Location is null)
{
// No location supplied: clear any existing location for this brewery.
await connection.ExecuteAsync(
new CommandDefinition(
"""
DELETE FROM
Brewery.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction,
cancellationToken: cancellationToken
)
);
}
else
{
bool locationExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1
FROM Brewery.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction,
cancellationToken: cancellationToken
)
)
is not null;
if (locationExists)
await connection.ExecuteAsync(
new CommandDefinition(
"""
UPDATE Brewery.BreweryPostLocation
SET CityID = @CityId,
AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2,
PostalCode = @PostalCode,
Coordinates = CASE
WHEN @Latitude IS NULL THEN NULL
ELSE geography::Point(@Latitude, @Longitude, @Srid)
END
WHERE BreweryPostID = @BreweryPostId
""",
new
{
brewery.BreweryPostId,
brewery.Location.CityId,
brewery.Location.AddressLine1,
brewery.Location.AddressLine2,
brewery.Location.PostalCode,
Latitude = brewery.Location.Coordinates?.Latitude,
Longitude = brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid,
},
transaction,
cancellationToken: cancellationToken
)
);
else
await connection.ExecuteAsync(
new CommandDefinition(
"""
INSERT INTO Brewery.BreweryPostLocation
(BreweryPostLocationID,
BreweryPostID,
CityID,
AddressLine1,
AddressLine2,
PostalCode,
Coordinates)
VALUES
(@BreweryPostLocationId,
@BreweryPostId,
@CityId,
@AddressLine1,
@AddressLine2,
@PostalCode,
CASE
WHEN @Latitude IS NULL THEN NULL
ELSE geography::Point(@Latitude, @Longitude, @Srid)
END)
""",
new
{
brewery.Location.BreweryPostLocationId,
brewery.BreweryPostId,
brewery.Location.CityId,
brewery.Location.AddressLine1,
brewery.Location.AddressLine2,
brewery.Location.PostalCode,
Latitude = brewery.Location.Coordinates?.Latitude,
Longitude = brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid,
},
transaction,
cancellationToken: cancellationToken
)
);
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await RollbackQuietlyAsync(transaction);
throw;
}
return await GetByIdAsync(brewery.BreweryPostId, cancellationToken)
?? throw new InvalidOperationException(
$"Brewery '{brewery.BreweryPostId}' was not found after a successful update."
);
}
/// <inheritdoc/>
public async Task DeleteAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
int rows = await connection.ExecuteAsync(
"""
DELETE FROM
Brewery.BreweryPost
WHERE
BreweryPostID = @BreweryPostId
""",
new { BreweryPostId = id }
);
if (rows == 0)
throw new NotFoundException("Brewery not found.");
}
/// <inheritdoc/>
public async Task CreateAsync(BreweryPost brewery)
{
if (brewery.Location is null)
throw new ArgumentException("Location must be provided when creating a brewery.");
await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction = await connection.BeginTransactionAsync();
try
{
bool userExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1
FROM
Auth.UserAccount
WHERE
UserAccountID = @PostedById
""",
new { brewery.PostedById },
transaction
)
)
is not null;
if (!userExists)
throw new NotFoundException("User not found.");
bool cityExists =
await connection.ExecuteScalarAsync<int?>(
new CommandDefinition(
"""
SELECT 1
FROM
Geolocation.City
WHERE
CityID = @CityId
""",
new { brewery.Location.CityId },
transaction
)
)
is not null;
if (!cityExists)
throw new NotFoundException("City not found.");
await connection.ExecuteAsync(
new CommandDefinition(
"""
INSERT INTO Brewery.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID)
VALUES
(@BreweryPostId, @BreweryName, @Description, @PostedById)
""",
new
{
brewery.BreweryPostId,
brewery.BreweryName,
brewery.Description,
brewery.PostedById,
},
transaction
)
);
await connection.ExecuteAsync(
new CommandDefinition(
"""
INSERT INTO Brewery.BreweryPostLocation
(BreweryPostLocationID,
BreweryPostID,
CityID,
AddressLine1,
AddressLine2,
PostalCode,
Coordinates)
VALUES (@BreweryPostLocationId,
@BreweryPostId,
@CityId,
@AddressLine1,
@AddressLine2,
@PostalCode,
CASE
WHEN @Latitude IS NULL THEN NULL
ELSE geography::Point(@Latitude, @Longitude, @Srid)
END)
""",
new
{
brewery.Location.BreweryPostLocationId,
brewery.BreweryPostId,
brewery.Location.CityId,
brewery.Location.AddressLine1,
brewery.Location.AddressLine2,
brewery.Location.PostalCode,
Latitude = brewery.Location.Coordinates?.Latitude,
Longitude = brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid,
},
transaction
)
);
await transaction.CommitAsync();
}
catch
{
await RollbackQuietlyAsync(transaction);
throw;
}
}
/// <summary>
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,
/// <see cref="City" />, <see cref="StateProvince" /> and <see cref="Country" /> fragments into a
/// single populated <see cref="BreweryPost" />. When the row has no location (a left-joined
/// brewery with no address on file), Dapper passes <see langword="null" /> for
/// <paramref name="location" /> rather than a blank instance, since its identity column
/// (<c>BreweryPostLocationID</c>) is <c>NULL</c>; the schema guarantees that whenever a location
/// row was joined in, its City/StateProvince/Country chain was too.
/// </summary>
private static BreweryPost MapBreweryRow(
BreweryPost post,
BreweryPostLocation? location,
City? city,
StateProvince? stateProvince,
Country? country
)
{
if (location is null)
return post;
// The schema guarantees that whenever a location row was joined in, its City/StateProvince/
// Country chain was too, so these are never null alongside a non-null location.
city!.StateProvinceId = stateProvince!.StateProvinceId;
stateProvince.CountryId = country!.CountryId;
stateProvince.Country = country;
city.StateProvince = stateProvince;
location.BreweryPostId = post.BreweryPostId;
location.CityId = city.CityId;
location.City = city;
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;
}
}