mcp接口增加分页
This commit is contained in:
1 parent
7243319bbb
commit
7f4adad7b1
16 files changed
+1157
-281
No files matched your search
+196
-67
@@ -233,21 +233,22 @@ FROM public.%s`, quoteIdentifier(table))
|
||||
}
|
||||
|
||||
// SearchPlaceCandidates returns bounded name matches from the fixed fire-safety source tables.
|
||||
func (p *PostGIS) SearchPlaceCandidates(ctx context.Context, query domain.PlaceSearchQuery) ([]domain.PlaceCandidate, error) {
|
||||
func (p *PostGIS) SearchPlaceCandidates(ctx context.Context, query domain.PlaceSearchQuery) (domain.Page[domain.PlaceCandidate], error) {
|
||||
rows, err := p.pool.Query(ctx, placeCandidateSearchSQL,
|
||||
query.PlaceName,
|
||||
query.Scope.AllTowns,
|
||||
query.Scope.AllowedTowns,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return domain.Page[domain.PlaceCandidate]{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.PlaceCandidate, 0, query.Limit)
|
||||
page := domain.Page[domain.PlaceCandidate]{Items: make([]domain.PlaceCandidate, 0, query.Limit)}
|
||||
for rows.Next() {
|
||||
var item domain.PlaceCandidate
|
||||
var totalCount int64
|
||||
if err := rows.Scan(
|
||||
&item.PlaceType,
|
||||
&item.SourceRecordID,
|
||||
@@ -260,35 +261,65 @@ func (p *PostGIS) SearchPlaceCandidates(ctx context.Context, query domain.PlaceS
|
||||
&item.Location.Longitude,
|
||||
&item.Location.Latitude,
|
||||
&item.LocationKind,
|
||||
&totalCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
rows.Close()
|
||||
return domain.Page[domain.PlaceCandidate]{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
page.Items = append(page.Items, item)
|
||||
page.TotalCount = totalCount
|
||||
}
|
||||
return items, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return domain.Page[domain.PlaceCandidate]{}, err
|
||||
}
|
||||
rows.Close()
|
||||
return recoverEmptyOffsetPageTotal(ctx, query.Offset, page, func(ctx context.Context) (domain.Page[domain.PlaceCandidate], error) {
|
||||
query.Offset = 0
|
||||
query.Limit = 1
|
||||
return p.SearchPlaceCandidates(ctx, query)
|
||||
})
|
||||
}
|
||||
|
||||
// ResolveIncidentContext returns fire grids that cover the supplied point.
|
||||
func (p *PostGIS) ResolveIncidentContext(ctx context.Context, point domain.Coordinate, scope domain.SpatialScope) ([]domain.IncidentContext, error) {
|
||||
rows, err := p.pool.Query(ctx, resolveIncidentContextSQL, point.Longitude, point.Latitude, scope.AllTowns, scope.AllowedTowns)
|
||||
func (p *PostGIS) ResolveIncidentContext(ctx context.Context, query domain.PointQuery) (domain.Page[domain.IncidentContext], error) {
|
||||
rows, err := p.pool.Query(ctx, resolveIncidentContextSQL,
|
||||
query.Point.Longitude,
|
||||
query.Point.Latitude,
|
||||
query.Scope.AllTowns,
|
||||
query.Scope.AllowedTowns,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return domain.Page[domain.IncidentContext]{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.IncidentContext, 0)
|
||||
page := domain.Page[domain.IncidentContext]{Items: make([]domain.IncidentContext, 0, query.Limit)}
|
||||
for rows.Next() {
|
||||
var item domain.IncidentContext
|
||||
if err := rows.Scan(&item.GridID, &item.Town, &item.AreaLabel); err != nil {
|
||||
return nil, err
|
||||
var totalCount int64
|
||||
if err := rows.Scan(&item.GridID, &item.Town, &item.AreaLabel, &totalCount); err != nil {
|
||||
rows.Close()
|
||||
return domain.Page[domain.IncidentContext]{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
page.Items = append(page.Items, item)
|
||||
page.TotalCount = totalCount
|
||||
}
|
||||
return items, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return domain.Page[domain.IncidentContext]{}, err
|
||||
}
|
||||
rows.Close()
|
||||
return recoverEmptyOffsetPageTotal(ctx, query.Offset, page, func(ctx context.Context) (domain.Page[domain.IncidentContext], error) {
|
||||
query.Offset = 0
|
||||
query.Limit = 1
|
||||
return p.ResolveIncidentContext(ctx, query)
|
||||
})
|
||||
}
|
||||
|
||||
// FindNearbyWaterSources returns water-source and storage-pool candidates.
|
||||
func (p *PostGIS) FindNearbyWaterSources(ctx context.Context, query domain.NearbyQuery) ([]domain.WaterSource, error) {
|
||||
func (p *PostGIS) FindNearbyWaterSources(ctx context.Context, query domain.NearbyQuery) (domain.Page[domain.WaterSource], error) {
|
||||
rows, err := p.pool.Query(ctx, nearbyWaterSourcesSQL,
|
||||
query.Point.Longitude,
|
||||
query.Point.Latitude,
|
||||
@@ -296,18 +327,19 @@ func (p *PostGIS) FindNearbyWaterSources(ctx context.Context, query domain.Nearb
|
||||
query.Scope.AllowedTowns,
|
||||
query.RadiusMeters,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return domain.Page[domain.WaterSource]{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.WaterSource, 0, query.Limit)
|
||||
page := domain.Page[domain.WaterSource]{Items: make([]domain.WaterSource, 0, query.Limit)}
|
||||
for rows.Next() {
|
||||
var item domain.WaterSource
|
||||
var name, village, resourceType, reportedStatus pgtype.Text
|
||||
var capacity pgtype.Float8
|
||||
var sourceTimestamp pgtype.Int8
|
||||
var totalCount int64
|
||||
if err := rows.Scan(
|
||||
&item.Category,
|
||||
&item.SourceRecordID,
|
||||
@@ -321,8 +353,10 @@ func (p *PostGIS) FindNearbyWaterSources(ctx context.Context, query domain.Nearb
|
||||
&resourceType,
|
||||
&reportedStatus,
|
||||
&sourceTimestamp,
|
||||
&totalCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
rows.Close()
|
||||
return domain.Page[domain.WaterSource]{}, err
|
||||
}
|
||||
item.Name = optionalText(name)
|
||||
item.Village = optionalText(village)
|
||||
@@ -330,13 +364,23 @@ func (p *PostGIS) FindNearbyWaterSources(ctx context.Context, query domain.Nearb
|
||||
item.ResourceType = optionalText(resourceType)
|
||||
item.ReportedStatus = optionalText(reportedStatus)
|
||||
item.SourceTimestampRaw = optionalInt64(sourceTimestamp)
|
||||
items = append(items, item)
|
||||
page.Items = append(page.Items, item)
|
||||
page.TotalCount = totalCount
|
||||
}
|
||||
return items, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return domain.Page[domain.WaterSource]{}, err
|
||||
}
|
||||
rows.Close()
|
||||
return recoverEmptyOffsetPageTotal(ctx, query.Offset, page, func(ctx context.Context) (domain.Page[domain.WaterSource], error) {
|
||||
query.Offset = 0
|
||||
query.Limit = 1
|
||||
return p.FindNearbyWaterSources(ctx, query)
|
||||
})
|
||||
}
|
||||
|
||||
// FindCommandPostCandidates returns nearby check stations and lookout posts.
|
||||
func (p *PostGIS) FindCommandPostCandidates(ctx context.Context, query domain.NearbyQuery) ([]domain.CommandPostCandidate, error) {
|
||||
func (p *PostGIS) FindCommandPostCandidates(ctx context.Context, query domain.NearbyQuery) (domain.Page[domain.CommandPostCandidate], error) {
|
||||
rows, err := p.pool.Query(ctx, commandPostCandidatesSQL,
|
||||
query.Point.Longitude,
|
||||
query.Point.Latitude,
|
||||
@@ -344,16 +388,17 @@ func (p *PostGIS) FindCommandPostCandidates(ctx context.Context, query domain.Ne
|
||||
query.Scope.AllowedTowns,
|
||||
query.RadiusMeters,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return domain.Page[domain.CommandPostCandidate]{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.CommandPostCandidate, 0, query.Limit)
|
||||
page := domain.Page[domain.CommandPostCandidate]{Items: make([]domain.CommandPostCandidate, 0, query.Limit)}
|
||||
for rows.Next() {
|
||||
var item domain.CommandPostCandidate
|
||||
var name, village, reportedStatus, managementUnit pgtype.Text
|
||||
var totalCount int64
|
||||
if err := rows.Scan(
|
||||
&item.FacilityType,
|
||||
&item.SourceRecordID,
|
||||
@@ -365,20 +410,32 @@ func (p *PostGIS) FindCommandPostCandidates(ctx context.Context, query domain.Ne
|
||||
&item.DistanceMeters,
|
||||
&reportedStatus,
|
||||
&managementUnit,
|
||||
&totalCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
rows.Close()
|
||||
return domain.Page[domain.CommandPostCandidate]{}, err
|
||||
}
|
||||
item.Name = optionalText(name)
|
||||
item.Village = optionalText(village)
|
||||
item.ReportedStatus = optionalText(reportedStatus)
|
||||
item.ManagementUnit = optionalText(managementUnit)
|
||||
items = append(items, item)
|
||||
page.Items = append(page.Items, item)
|
||||
page.TotalCount = totalCount
|
||||
}
|
||||
return items, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return domain.Page[domain.CommandPostCandidate]{}, err
|
||||
}
|
||||
rows.Close()
|
||||
return recoverEmptyOffsetPageTotal(ctx, query.Offset, page, func(ctx context.Context) (domain.Page[domain.CommandPostCandidate], error) {
|
||||
query.Offset = 0
|
||||
query.Limit = 1
|
||||
return p.FindCommandPostCandidates(ctx, query)
|
||||
})
|
||||
}
|
||||
|
||||
// ListNearbyAccessLines returns nearby fire access lines and closest access points.
|
||||
func (p *PostGIS) ListNearbyAccessLines(ctx context.Context, query domain.NearbyQuery) ([]domain.AccessLine, error) {
|
||||
func (p *PostGIS) ListNearbyAccessLines(ctx context.Context, query domain.NearbyQuery) (domain.Page[domain.AccessLine], error) {
|
||||
rows, err := p.pool.Query(ctx, nearbyAccessLinesSQL,
|
||||
query.Point.Longitude,
|
||||
query.Point.Latitude,
|
||||
@@ -386,17 +443,18 @@ func (p *PostGIS) ListNearbyAccessLines(ctx context.Context, query domain.Nearby
|
||||
query.Scope.AllowedTowns,
|
||||
query.RadiusMeters,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return domain.Page[domain.AccessLine]{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.AccessLine, 0, query.Limit)
|
||||
page := domain.Page[domain.AccessLine]{Items: make([]domain.AccessLine, 0, query.Limit)}
|
||||
for rows.Next() {
|
||||
var item domain.AccessLine
|
||||
var name, sourceUpdated pgtype.Text
|
||||
var length pgtype.Float8
|
||||
var totalCount int64
|
||||
if err := rows.Scan(
|
||||
&item.SourceRecordID,
|
||||
&item.Town,
|
||||
@@ -406,38 +464,68 @@ func (p *PostGIS) ListNearbyAccessLines(ctx context.Context, query domain.Nearby
|
||||
&item.NearestPoint.Latitude,
|
||||
&length,
|
||||
&sourceUpdated,
|
||||
&totalCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
rows.Close()
|
||||
return domain.Page[domain.AccessLine]{}, err
|
||||
}
|
||||
item.Name = optionalText(name)
|
||||
item.LengthMeters = optionalFloat64(length)
|
||||
item.SourceUpdatedRaw = optionalText(sourceUpdated)
|
||||
items = append(items, item)
|
||||
page.Items = append(page.Items, item)
|
||||
page.TotalCount = totalCount
|
||||
}
|
||||
return items, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return domain.Page[domain.AccessLine]{}, err
|
||||
}
|
||||
rows.Close()
|
||||
return recoverEmptyOffsetPageTotal(ctx, query.Offset, page, func(ctx context.Context) (domain.Page[domain.AccessLine], error) {
|
||||
query.Offset = 0
|
||||
query.Limit = 1
|
||||
return p.ListNearbyAccessLines(ctx, query)
|
||||
})
|
||||
}
|
||||
|
||||
// GetResponsibleUnits returns non-personal responsibility details from covering grids.
|
||||
func (p *PostGIS) GetResponsibleUnits(ctx context.Context, point domain.Coordinate, scope domain.SpatialScope) ([]domain.ResponsibleUnit, error) {
|
||||
rows, err := p.pool.Query(ctx, responsibleUnitsSQL, point.Longitude, point.Latitude, scope.AllTowns, scope.AllowedTowns)
|
||||
func (p *PostGIS) GetResponsibleUnits(ctx context.Context, query domain.PointQuery) (domain.Page[domain.ResponsibleUnit], error) {
|
||||
rows, err := p.pool.Query(ctx, responsibleUnitsSQL,
|
||||
query.Point.Longitude,
|
||||
query.Point.Latitude,
|
||||
query.Scope.AllTowns,
|
||||
query.Scope.AllowedTowns,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return domain.Page[domain.ResponsibleUnit]{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.ResponsibleUnit, 0)
|
||||
page := domain.Page[domain.ResponsibleUnit]{Items: make([]domain.ResponsibleUnit, 0, query.Limit)}
|
||||
for rows.Next() {
|
||||
var item domain.ResponsibleUnit
|
||||
if err := rows.Scan(&item.GridID, &item.Town, &item.AreaLabel, &item.FireTeam); err != nil {
|
||||
return nil, err
|
||||
var totalCount int64
|
||||
if err := rows.Scan(&item.GridID, &item.Town, &item.AreaLabel, &item.FireTeam, &totalCount); err != nil {
|
||||
rows.Close()
|
||||
return domain.Page[domain.ResponsibleUnit]{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
page.Items = append(page.Items, item)
|
||||
page.TotalCount = totalCount
|
||||
}
|
||||
return items, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return domain.Page[domain.ResponsibleUnit]{}, err
|
||||
}
|
||||
rows.Close()
|
||||
return recoverEmptyOffsetPageTotal(ctx, query.Offset, page, func(ctx context.Context) (domain.Page[domain.ResponsibleUnit], error) {
|
||||
query.Offset = 0
|
||||
query.Limit = 1
|
||||
return p.GetResponsibleUnits(ctx, query)
|
||||
})
|
||||
}
|
||||
|
||||
// FindNearbyRiskAreas returns nearby cemetery and forest-enterprise polygons.
|
||||
func (p *PostGIS) FindNearbyRiskAreas(ctx context.Context, query domain.NearbyQuery) ([]domain.RiskArea, error) {
|
||||
func (p *PostGIS) FindNearbyRiskAreas(ctx context.Context, query domain.NearbyQuery) (domain.Page[domain.RiskArea], error) {
|
||||
rows, err := p.pool.Query(ctx, nearbyRiskAreasSQL,
|
||||
query.Point.Longitude,
|
||||
query.Point.Latitude,
|
||||
@@ -445,16 +533,17 @@ func (p *PostGIS) FindNearbyRiskAreas(ctx context.Context, query domain.NearbyQu
|
||||
query.Scope.AllowedTowns,
|
||||
query.RadiusMeters,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return domain.Page[domain.RiskArea]{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.RiskArea, 0, query.Limit)
|
||||
page := domain.Page[domain.RiskArea]{Items: make([]domain.RiskArea, 0, query.Limit)}
|
||||
for rows.Next() {
|
||||
var item domain.RiskArea
|
||||
var name, village, direction pgtype.Text
|
||||
var totalCount int64
|
||||
if err := rows.Scan(
|
||||
&item.RiskType,
|
||||
&item.SourceRecordID,
|
||||
@@ -464,15 +553,44 @@ func (p *PostGIS) FindNearbyRiskAreas(ctx context.Context, query domain.NearbyQu
|
||||
&direction,
|
||||
&item.CoversPoint,
|
||||
&item.DistanceMeters,
|
||||
&totalCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
rows.Close()
|
||||
return domain.Page[domain.RiskArea]{}, err
|
||||
}
|
||||
item.Name = optionalText(name)
|
||||
item.Village = optionalText(village)
|
||||
item.Direction = optionalText(direction)
|
||||
items = append(items, item)
|
||||
page.Items = append(page.Items, item)
|
||||
page.TotalCount = totalCount
|
||||
}
|
||||
return items, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return domain.Page[domain.RiskArea]{}, err
|
||||
}
|
||||
rows.Close()
|
||||
return recoverEmptyOffsetPageTotal(ctx, query.Offset, page, func(ctx context.Context) (domain.Page[domain.RiskArea], error) {
|
||||
query.Offset = 0
|
||||
query.Limit = 1
|
||||
return p.FindNearbyRiskAreas(ctx, query)
|
||||
})
|
||||
}
|
||||
|
||||
func recoverEmptyOffsetPageTotal[T any](
|
||||
ctx context.Context,
|
||||
offset int,
|
||||
page domain.Page[T],
|
||||
loadFirstPage func(context.Context) (domain.Page[T], error),
|
||||
) (domain.Page[T], error) {
|
||||
if offset <= 0 || len(page.Items) > 0 {
|
||||
return page, nil
|
||||
}
|
||||
firstPage, err := loadFirstPage(ctx)
|
||||
if err != nil {
|
||||
return domain.Page[T]{}, err
|
||||
}
|
||||
page.TotalCount = firstPage.TotalCount
|
||||
return page, nil
|
||||
}
|
||||
|
||||
type spatialTableSpec struct {
|
||||
@@ -727,16 +845,19 @@ SELECT
|
||||
CASE WHEN match_rank <= 2 THEN 'exact' ELSE 'partial' END,
|
||||
round(ST_X(location)::numeric, 6)::float8,
|
||||
round(ST_Y(location)::numeric, 6)::float8,
|
||||
location_kind
|
||||
location_kind,
|
||||
COUNT(*) OVER()::bigint AS total_count
|
||||
FROM matched
|
||||
ORDER BY match_rank, town, village, name, place_type, source_record_id
|
||||
LIMIT $4`
|
||||
LIMIT $4
|
||||
OFFSET $5`
|
||||
|
||||
const resolveIncidentContextSQL = `
|
||||
WITH incident AS (
|
||||
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
||||
)
|
||||
SELECT g.gid::text, LEFT(COALESCE(g.auth, ''), 100), LEFT(COALESCE(g.name, ''), 255)
|
||||
SELECT g.gid::text, LEFT(COALESCE(g.auth, ''), 100), LEFT(COALESCE(g.name, ''), 255),
|
||||
COUNT(*) OVER()::bigint AS total_count
|
||||
FROM public.st_2_fanghuowangge AS g
|
||||
CROSS JOIN incident AS i
|
||||
WHERE ($3::boolean OR g.auth = ANY($4::text[]))
|
||||
@@ -747,7 +868,8 @@ WHERE ($3::boolean OR g.auth = ANY($4::text[]))
|
||||
AND NOT ST_IsEmpty(g.geom)
|
||||
AND ST_Covers(g.geom, i.geom)
|
||||
ORDER BY ST_Area(g.geom::geography), g.gid
|
||||
LIMIT 20`
|
||||
LIMIT $5
|
||||
OFFSET $6`
|
||||
|
||||
const nearbyWaterSourcesSQL = `
|
||||
WITH incident AS (
|
||||
@@ -806,10 +928,11 @@ WITH incident AS (
|
||||
SELECT category, source_record_id, name, town, village,
|
||||
round(longitude::numeric, 6)::float8, round(latitude::numeric, 6)::float8,
|
||||
round(distance_meters::numeric, 1)::float8, capacity_cubic_meters, resource_type, reported_status,
|
||||
source_timestamp_raw
|
||||
source_timestamp_raw, COUNT(*) OVER()::bigint AS total_count
|
||||
FROM candidates
|
||||
ORDER BY distance_meters, category, source_record_id
|
||||
LIMIT $6`
|
||||
LIMIT $6
|
||||
OFFSET $7`
|
||||
|
||||
const commandPostCandidatesSQL = `
|
||||
WITH incident AS (
|
||||
@@ -863,10 +986,12 @@ WITH incident AS (
|
||||
)
|
||||
SELECT facility_type, source_record_id, name, town, village,
|
||||
round(longitude::numeric, 6)::float8, round(latitude::numeric, 6)::float8,
|
||||
round(distance_meters::numeric, 1)::float8, reported_status, management_unit
|
||||
round(distance_meters::numeric, 1)::float8, reported_status, management_unit,
|
||||
COUNT(*) OVER()::bigint AS total_count
|
||||
FROM candidates
|
||||
ORDER BY distance_meters, facility_type, source_record_id
|
||||
LIMIT $6`
|
||||
LIMIT $6
|
||||
OFFSET $7`
|
||||
|
||||
const nearbyAccessLinesSQL = `
|
||||
WITH incident AS (
|
||||
@@ -894,16 +1019,18 @@ WITH incident AS (
|
||||
SELECT source_record_id, town, name, round(distance_meters::numeric, 1)::float8,
|
||||
round(ST_X(nearest_point)::numeric, 6)::float8,
|
||||
round(ST_Y(nearest_point)::numeric, 6)::float8,
|
||||
length_meters, source_updated_raw
|
||||
length_meters, source_updated_raw, COUNT(*) OVER()::bigint AS total_count
|
||||
FROM candidates
|
||||
ORDER BY distance_meters, source_record_id
|
||||
LIMIT $6`
|
||||
LIMIT $6
|
||||
OFFSET $7`
|
||||
|
||||
const responsibleUnitsSQL = `
|
||||
WITH incident AS (
|
||||
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
||||
)
|
||||
SELECT g.gid::text, LEFT(COALESCE(g.auth, ''), 100), LEFT(COALESCE(g.name, ''), 255), LEFT(COALESCE(g.fhzd, ''), 255)
|
||||
SELECT g.gid::text, LEFT(COALESCE(g.auth, ''), 100), LEFT(COALESCE(g.name, ''), 255), LEFT(COALESCE(g.fhzd, ''), 255),
|
||||
COUNT(*) OVER()::bigint AS total_count
|
||||
FROM public.st_2_fanghuowangge AS g
|
||||
CROSS JOIN incident AS i
|
||||
WHERE ($3::boolean OR g.auth = ANY($4::text[]))
|
||||
@@ -914,7 +1041,8 @@ WHERE ($3::boolean OR g.auth = ANY($4::text[]))
|
||||
AND NOT ST_IsEmpty(g.geom)
|
||||
AND ST_Covers(g.geom, i.geom)
|
||||
ORDER BY ST_Area(g.geom::geography), g.gid
|
||||
LIMIT 20`
|
||||
LIMIT $5
|
||||
OFFSET $6`
|
||||
|
||||
const nearbyRiskAreasSQL = `
|
||||
WITH incident AS (
|
||||
@@ -963,7 +1091,8 @@ WITH incident AS (
|
||||
AND ST_DWithin(e.geom::geography, i.geom::geography, $5)
|
||||
)
|
||||
SELECT risk_type, source_record_id, name, town, village, direction, covers_point,
|
||||
round(distance_meters::numeric, 1)::float8
|
||||
round(distance_meters::numeric, 1)::float8, COUNT(*) OVER()::bigint AS total_count
|
||||
FROM candidates
|
||||
ORDER BY covers_point DESC, distance_meters, risk_type, source_record_id
|
||||
LIMIT $6`
|
||||
LIMIT $6
|
||||
OFFSET $7`
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"fire-safety-ymd/internal/domain"
|
||||
)
|
||||
|
||||
func TestOpenPostGISDoesNotExposeInvalidDSN(t *testing.T) {
|
||||
@@ -126,7 +128,7 @@ func TestSpatialQueriesKeepScopeAndBoundsParameterized(t *testing.T) {
|
||||
}
|
||||
for _, tt := range nearbyQueries {
|
||||
query := tt.query
|
||||
for _, required := range []string{"$3::boolean", "ANY($4::text[])", "$5", "LIMIT $6", "ST_SRID", "ST_IsValid", "ST_IsEmpty", "ST_Expand"} {
|
||||
for _, required := range []string{"$3::boolean", "ANY($4::text[])", "$5", "LIMIT $6", "OFFSET $7", "COUNT(*) OVER()::bigint", "ST_SRID", "ST_IsValid", "ST_IsEmpty", "ST_Expand"} {
|
||||
if !strings.Contains(query, required) {
|
||||
t.Fatalf("%s query lacks %q", tt.name, required)
|
||||
}
|
||||
@@ -139,7 +141,7 @@ func TestSpatialQueriesKeepScopeAndBoundsParameterized(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for index, query := range []string{resolveIncidentContextSQL, responsibleUnitsSQL} {
|
||||
for _, required := range []string{"$3::boolean", "ANY($4::text[])", "ST_SRID", "ST_IsValid", "ST_Covers"} {
|
||||
for _, required := range []string{"$3::boolean", "ANY($4::text[])", "LIMIT $5", "OFFSET $6", "COUNT(*) OVER()::bigint", "ST_SRID", "ST_IsValid", "ST_IsEmpty", "ST_Covers"} {
|
||||
if !strings.Contains(query, required) {
|
||||
t.Fatalf("cover query %d lacks %q", index, required)
|
||||
}
|
||||
@@ -156,6 +158,8 @@ func TestPlaceCandidateSearchKeepsTermScopeAndLimitParameterized(t *testing.T) {
|
||||
"($2::boolean OR",
|
||||
"ANY($3::text[])",
|
||||
"LIMIT $4",
|
||||
"OFFSET $5",
|
||||
"COUNT(*) OVER()::bigint",
|
||||
"strpos",
|
||||
"recorded_point",
|
||||
"representative_point",
|
||||
@@ -179,6 +183,74 @@ func TestPlaceCandidateSearchKeepsTermScopeAndLimitParameterized(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpatialPaginationQueriesUseOneWindowCountAndStableOrdering(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantLimit string
|
||||
wantOffset string
|
||||
orderBy string
|
||||
}{
|
||||
{name: "place candidates", query: placeCandidateSearchSQL, wantLimit: "LIMIT $4", wantOffset: "OFFSET $5", orderBy: "ORDER BY match_rank, town, village, name, place_type, source_record_id"},
|
||||
{name: "incident context", query: resolveIncidentContextSQL, wantLimit: "LIMIT $5", wantOffset: "OFFSET $6", orderBy: "ORDER BY ST_Area(g.geom::geography), g.gid"},
|
||||
{name: "water sources", query: nearbyWaterSourcesSQL, wantLimit: "LIMIT $6", wantOffset: "OFFSET $7", orderBy: "ORDER BY distance_meters, category, source_record_id"},
|
||||
{name: "command posts", query: commandPostCandidatesSQL, wantLimit: "LIMIT $6", wantOffset: "OFFSET $7", orderBy: "ORDER BY distance_meters, facility_type, source_record_id"},
|
||||
{name: "access lines", query: nearbyAccessLinesSQL, wantLimit: "LIMIT $6", wantOffset: "OFFSET $7", orderBy: "ORDER BY distance_meters, source_record_id"},
|
||||
{name: "responsible units", query: responsibleUnitsSQL, wantLimit: "LIMIT $5", wantOffset: "OFFSET $6", orderBy: "ORDER BY ST_Area(g.geom::geography), g.gid"},
|
||||
{name: "risk areas", query: nearbyRiskAreasSQL, wantLimit: "LIMIT $6", wantOffset: "OFFSET $7", orderBy: "ORDER BY covers_point DESC, distance_meters, risk_type, source_record_id"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := strings.Count(tt.query, "COUNT(*) OVER()::bigint"); got != 1 {
|
||||
t.Fatalf("window count occurrences = %d, want 1", got)
|
||||
}
|
||||
orderIndex := strings.Index(tt.query, tt.orderBy)
|
||||
limitIndex := strings.Index(tt.query, tt.wantLimit)
|
||||
offsetIndex := strings.Index(tt.query, tt.wantOffset)
|
||||
if orderIndex < 0 || limitIndex < 0 || offsetIndex < 0 {
|
||||
t.Fatalf("query lacks stable order or parameterized pagination: order=%d limit=%d offset=%d", orderIndex, limitIndex, offsetIndex)
|
||||
}
|
||||
if !(orderIndex < limitIndex && limitIndex < offsetIndex) {
|
||||
t.Fatalf("pagination order is not ORDER BY, LIMIT, OFFSET")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverEmptyOffsetPageTotalProbesOnlyEmptyLaterPages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
offset int
|
||||
page domain.Page[string]
|
||||
wantCalls int
|
||||
wantTotal int64
|
||||
}{
|
||||
{name: "first page empty", offset: 0, page: domain.Page[string]{Items: []string{}}, wantTotal: 0},
|
||||
{name: "later page has items", offset: 10, page: domain.Page[string]{Items: []string{"item"}, TotalCount: 11}, wantTotal: 11},
|
||||
{name: "later page empty", offset: 20, page: domain.Page[string]{Items: []string{}}, wantCalls: 1, wantTotal: 12},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
calls := 0
|
||||
got, err := recoverEmptyOffsetPageTotal(context.Background(), tt.offset, tt.page, func(context.Context) (domain.Page[string], error) {
|
||||
calls++
|
||||
return domain.Page[string]{Items: []string{"first"}, TotalCount: 12}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("recoverEmptyOffsetPageTotal() error = %v", err)
|
||||
}
|
||||
if calls != tt.wantCalls {
|
||||
t.Fatalf("probe calls = %d, want %d", calls, tt.wantCalls)
|
||||
}
|
||||
if got.TotalCount != tt.wantTotal {
|
||||
t.Fatalf("total count = %d, want %d", got.TotalCount, tt.wantTotal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpatialQueriesDoNotSelectSensitiveContactColumns(t *testing.T) {
|
||||
queries := strings.Join([]string{
|
||||
placeCandidateSearchSQL,
|
||||
|
||||
Reference in new issue
Block a user