using System.Data.Common;
using Dapper;
using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.Sql;
namespace Features.Breweries.Repository;
///
/// Dapper-based implementation of .
///
///
/// BreweryPostLocation.Coordinates is a SQL Server GEOGRAPHY column. Dapper's generic
/// Query<T> deserializer reads columns via IDataRecord.GetValue, which for a UDT
/// column returns a CLR UDT instance rather than raw bytes and cannot be coerced to byte[].
/// The read queries below therefore select CONVERT(varbinary(max), bpl.Coordinates), which
/// yields the UDT's serialized bytes as a plain varbinary value that Dapper can bind directly.
///
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: Repository(connectionFactory),
IBreweryRepository
{
///
public async Task GetByIdAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
IEnumerable results = 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 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
WHERE bp.BreweryPostID = @BreweryPostId
""",
MapBreweryRow,
new { BreweryPostId = id },
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
);
return results.SingleOrDefault();
}
///
public async Task> 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 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
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"
);
}
///
public async Task UpdateAsync(BreweryPost brewery)
{
await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction =
await connection.BeginTransactionAsync();
try
{
bool breweryExists =
await connection.ExecuteScalarAsync(
new CommandDefinition(
"""
SELECT 1 FROM
dbo.BreweryPost
WHERE
BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction
)
)
is not null;
if (!breweryExists)
throw new NotFoundException("Brewery not found.");
if (brewery.Location is not null)
{
bool cityExists =
await connection.ExecuteScalarAsync(
new CommandDefinition(
"SELECT 1 FROM dbo.City WHERE CityID = @CityId",
new { brewery.Location.CityId },
transaction
)
)
is not null;
if (!cityExists)
throw new NotFoundException("City not found.");
}
int updatedRows = await connection.ExecuteAsync(
new CommandDefinition(
"""
UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName,
Description = @Description,
UpdatedAt = GETDATE()
WHERE
BreweryPostID = @BreweryPostId
AND RowVersion = @RowVersion
""",
new
{
brewery.BreweryPostId,
brewery.BreweryName,
brewery.Description,
brewery.RowVersion,
},
transaction
)
);
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
dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction
)
);
}
else
{
bool locationExists =
await connection.ExecuteScalarAsync(
new CommandDefinition(
"""
SELECT 1
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction
)
)
is not null;
if (locationExists)
await connection.ExecuteAsync(
new CommandDefinition(
"""
UPDATE dbo.BreweryPostLocation
SET CityID = @CityId,
AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2,
PostalCode = @PostalCode,
Coordinates = @Coordinates
WHERE BreweryPostID = @BreweryPostId
""",
new
{
brewery.BreweryPostId,
brewery.Location.CityId,
brewery.Location.AddressLine1,
brewery.Location.AddressLine2,
brewery.Location.PostalCode,
brewery.Location.Coordinates,
},
transaction
)
);
else
await connection.ExecuteAsync(
new CommandDefinition(
"""
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID,
BreweryPostID,
CityID,
AddressLine1,
AddressLine2,
PostalCode,
Coordinates)
VALUES
(@BreweryPostLocationId,
@BreweryPostId,
@CityId,
@AddressLine1,
@AddressLine2,
@PostalCode,
@Coordinates)
""",
new
{
brewery.Location.BreweryPostLocationId,
brewery.BreweryPostId,
brewery.Location.CityId,
brewery.Location.AddressLine1,
brewery.Location.AddressLine2,
brewery.Location.PostalCode,
brewery.Location.Coordinates,
},
transaction
)
);
}
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
return await GetByIdAsync(brewery.BreweryPostId)
?? throw new InvalidOperationException(
$"Brewery '{brewery.BreweryPostId}' was not found after a successful update."
);
}
///
public async Task DeleteAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
int rows = await connection.ExecuteAsync(
"""
DELETE FROM
dbo.BreweryPost
WHERE
BreweryPostID = @BreweryPostId
""",
new { BreweryPostId = id }
);
if (rows == 0)
throw new NotFoundException("Brewery not found.");
}
///
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(
new CommandDefinition(
"""
SELECT 1
FROM
dbo.UserAccount
WHERE
UserAccountID = @PostedById
""",
new { brewery.PostedById },
transaction
)
)
is not null;
if (!userExists)
throw new NotFoundException("User not found.");
bool cityExists =
await connection.ExecuteScalarAsync(
new CommandDefinition(
"""
SELECT 1
FROM
dbo.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 dbo.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 dbo.BreweryPostLocation
(BreweryPostLocationID,
BreweryPostID,
CityID,
AddressLine1,
AddressLine2,
PostalCode,
Coordinates)
VALUES (@BreweryPostLocationId,
@BreweryPostId,
@CityId,
@AddressLine1,
@AddressLine2,
@PostalCode,
@Coordinates)
""",
new
{
brewery.Location.BreweryPostLocationId,
brewery.BreweryPostId,
brewery.Location.CityId,
brewery.Location.AddressLine1,
brewery.Location.AddressLine2,
brewery.Location.PostalCode,
brewery.Location.Coordinates,
},
transaction
)
);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
///
/// Composes a joined row's , ,
/// , and fragments into a
/// single populated . When the row has no location (a left-joined
/// brewery with no address on file), is a blank instance produced
/// from all-NULL columns; the schema guarantees that whenever a location row was joined
/// in, its City/StateProvince/Country chain was too.
///
private static BreweryPost MapBreweryRow(
BreweryPost post,
BreweryPostLocation location,
City city,
StateProvince stateProvince,
Country country
)
{
if (location.BreweryPostLocationId == Guid.Empty)
return post;
stateProvince.CountryId = country.CountryId;
stateProvince.Country = country;
city.StateProvinceId = stateProvince.StateProvinceId;
city.StateProvince = stateProvince;
location.BreweryPostId = post.BreweryPostId;
location.CityId = city.CityId;
location.City = city;
post.Location = location;
return post;
}
}