Compute nearby-brewery distance in SQL instead of client-side Haversine, and add the spatial index it relies on.

This commit is contained in:
Aaron Po
2026-08-27 02:40:58 -04:00
parent dab7aa2458
commit 7330eabebe
10 changed files with 244 additions and 121 deletions

View File

@@ -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
);

View File

@@ -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; }
}

View File

@@ -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
{

View File

@@ -32,8 +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
BreweryLocationDto? Location,
double? DistanceMetres
);

View File

@@ -68,7 +68,8 @@ public static class BreweryDtoMapper
AddressLine2: brewery.Location.AddressLine2,
PostalCode: brewery.Location.PostalCode,
Coordinates: brewery.Location.Coordinates
)
),
DistanceMetres: brewery.Distance?.DistanceMetres
);
}
}

View File

@@ -134,6 +134,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
City,
StateProvince,
Country,
DistanceRow,
BreweryPost
> (
"""
@@ -154,7 +155,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
sp.ISO3166_2 AS Iso31662,
co.CountryID,
co.CountryName,
co.ISO3166_1 AS Iso31661
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
@@ -164,14 +166,15 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
AND bpl.Coordinates.STDistance(@Origin) <= @RangeInMetres
ORDER BY bpl.Coordinates.STDistance(@Origin) ASC
""",
MapBreweryRow,
(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"
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID,DistanceMetres"
);
}
@@ -583,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;
}
}