@@ -3,6 +3,7 @@ package POI
33import (
44 "fmt"
55 "math"
6+ "sort"
67 "strings"
78)
89
@@ -33,34 +34,137 @@ type LocationType string
3334
3435const (
3536 // LocationTypeAny leaves the Google Maps place type unset, used by keyword (brand) searches
36- LocationTypeAny = LocationType ("" )
37- LocationTypeCafe = LocationType ("cafe" )
38- LocationTypeRestaurant = LocationType ("restaurant" )
39- LocationTypeBar = LocationType ("bar" )
40- LocationTypeBakery = LocationType ("bakery" )
41- LocationTypeMealTakeaway = LocationType ("meal_takeaway" )
42- LocationTypeMuseum = LocationType ("museum" )
43- LocationTypeGallery = LocationType ("art_gallery" )
44- LocationTypeAmusementPark = LocationType ("amusement_park" )
45- LocationTypePark = LocationType ("park" )
37+ LocationTypeAny = LocationType ("" )
38+ // Eatery place types
39+ LocationTypeCafe = LocationType ("cafe" )
40+ LocationTypeRestaurant = LocationType ("restaurant" )
41+ LocationTypeBar = LocationType ("bar" )
42+ LocationTypeBakery = LocationType ("bakery" )
43+ LocationTypeMealTakeaway = LocationType ("meal_takeaway" )
44+ LocationTypeMealDelivery = LocationType ("meal_delivery" )
45+ LocationTypeNightClub = LocationType ("night_club" )
46+ // Visit place types
47+ LocationTypeMuseum = LocationType ("museum" )
48+ LocationTypeGallery = LocationType ("art_gallery" )
49+ LocationTypeAmusementPark = LocationType ("amusement_park" )
50+ LocationTypePark = LocationType ("park" )
51+ LocationTypeTouristAttraction = LocationType ("tourist_attraction" )
52+ LocationTypeZoo = LocationType ("zoo" )
53+ LocationTypeAquarium = LocationType ("aquarium" )
54+ LocationTypeMovieTheater = LocationType ("movie_theater" )
55+ LocationTypeStadium = LocationType ("stadium" )
56+ LocationTypeBowlingAlley = LocationType ("bowling_alley" )
4657 // Shopping place types
4758 LocationTypeShoppingMall = LocationType ("shopping_mall" )
4859 LocationTypeDepartmentStore = LocationType ("department_store" )
4960 LocationTypeSupermarket = LocationType ("supermarket" )
5061 LocationTypeClothingStore = LocationType ("clothing_store" )
5162 LocationTypeStore = LocationType ("store" )
63+ // LocationTypeGroceryOrSupermarket is a types[]-only value: it appears in a place's
64+ // Types list but is NOT a legal ?type= value for the legacy Nearby Search
65+ // (maps.ParsePlaceType rejects it). Never pass it to CreateMapSearchRequest or add it to
66+ // GetPlaceTypes; it is matched only via PrimaryLocationType/GetPlaceCategory.
67+ LocationTypeGroceryOrSupermarket = LocationType ("grocery_or_supermarket" )
68+ // Any type that is a strict specialization of the already-mapped `store` goes to Shopping.
69+ LocationTypeConvenienceStore = LocationType ("convenience_store" )
70+ LocationTypeHardwareStore = LocationType ("hardware_store" )
71+ LocationTypeHomeGoodsStore = LocationType ("home_goods_store" )
72+ LocationTypeElectronicsStore = LocationType ("electronics_store" )
73+ LocationTypeFurnitureStore = LocationType ("furniture_store" )
74+ LocationTypeBookStore = LocationType ("book_store" )
75+ LocationTypeShoeStore = LocationType ("shoe_store" )
76+ LocationTypeJewelryStore = LocationType ("jewelry_store" )
77+ LocationTypePetStore = LocationType ("pet_store" )
78+ LocationTypeBicycleStore = LocationType ("bicycle_store" )
79+ LocationTypeFlorist = LocationType ("florist" )
80+ LocationTypeLiquorStore = LocationType ("liquor_store" )
81+ LocationTypeGasStation = LocationType ("gas_station" )
5282 // Lodging place types
5383 LocationTypeLodging = LocationType ("lodging" )
5484 // Wellness place types
55- LocationTypeGym = LocationType ("gym" )
56- LocationTypeSpa = LocationType ("spa" )
57- LocationTypePharmacy = LocationType ("pharmacy" )
85+ LocationTypeGym = LocationType ("gym" )
86+ LocationTypeSpa = LocationType ("spa" )
87+ LocationTypePharmacy = LocationType ("pharmacy" )
88+ LocationTypeDrugstore = LocationType ("drugstore" )
89+ LocationTypeBeautySalon = LocationType ("beauty_salon" )
90+ LocationTypeHairCare = LocationType ("hair_care" )
5891)
5992
93+ // placeTypeToCategory is the reverse map from a Google place type to its category. It backs
94+ // both GetPlaceCategory (the write path / classification rule) and ReclassifyForCategory (the
95+ // read filter) below, so there is exactly one table that decides "what category does this
96+ // Google type belong to" anywhere in the service.
97+ //
98+ // It is a SUPERSET of GetPlaceTypes' inverse: it covers every Google primary type (see
99+ // PrimaryLocationType) this service knows how to classify, not only the types the nearby-search
100+ // endpoints actively query for (GetPlaceTypes' 18 searched types are all present here too).
101+ // Widening this map only ever makes ReclassifyForCategory keep MORE places, never fewer — see
102+ // TestReclassifyForCategoryKeepsAllFormerlySearchedTypes.
103+ var placeTypeToCategory = map [LocationType ]PlaceCategory {
104+ // Eatery
105+ LocationTypeCafe : PlaceCategoryEatery ,
106+ LocationTypeRestaurant : PlaceCategoryEatery ,
107+ LocationTypeBar : PlaceCategoryEatery ,
108+ LocationTypeBakery : PlaceCategoryEatery ,
109+ LocationTypeMealTakeaway : PlaceCategoryEatery ,
110+ LocationTypeMealDelivery : PlaceCategoryEatery ,
111+ LocationTypeNightClub : PlaceCategoryEatery ,
112+
113+ // Visit
114+ LocationTypePark : PlaceCategoryVisit ,
115+ LocationTypeAmusementPark : PlaceCategoryVisit ,
116+ LocationTypeGallery : PlaceCategoryVisit ,
117+ LocationTypeMuseum : PlaceCategoryVisit ,
118+ LocationTypeTouristAttraction : PlaceCategoryVisit ,
119+ LocationTypeZoo : PlaceCategoryVisit ,
120+ LocationTypeAquarium : PlaceCategoryVisit ,
121+ LocationTypeMovieTheater : PlaceCategoryVisit ,
122+ LocationTypeStadium : PlaceCategoryVisit ,
123+ LocationTypeBowlingAlley : PlaceCategoryVisit ,
124+
125+ // Shopping. Any type that is a strict specialization of the already-mapped `store` goes
126+ // to Shopping.
127+ LocationTypeShoppingMall : PlaceCategoryShopping ,
128+ LocationTypeDepartmentStore : PlaceCategoryShopping ,
129+ LocationTypeSupermarket : PlaceCategoryShopping ,
130+ LocationTypeClothingStore : PlaceCategoryShopping ,
131+ LocationTypeStore : PlaceCategoryShopping ,
132+ LocationTypeGroceryOrSupermarket : PlaceCategoryShopping ,
133+ LocationTypeConvenienceStore : PlaceCategoryShopping ,
134+ LocationTypeHardwareStore : PlaceCategoryShopping ,
135+ LocationTypeHomeGoodsStore : PlaceCategoryShopping ,
136+ LocationTypeElectronicsStore : PlaceCategoryShopping ,
137+ LocationTypeFurnitureStore : PlaceCategoryShopping ,
138+ LocationTypeBookStore : PlaceCategoryShopping ,
139+ LocationTypeShoeStore : PlaceCategoryShopping ,
140+ LocationTypeJewelryStore : PlaceCategoryShopping ,
141+ LocationTypePetStore : PlaceCategoryShopping ,
142+ LocationTypeBicycleStore : PlaceCategoryShopping ,
143+ LocationTypeFlorist : PlaceCategoryShopping ,
144+ LocationTypeLiquorStore : PlaceCategoryShopping ,
145+ LocationTypeGasStation : PlaceCategoryShopping ,
146+
147+ // Lodging
148+ LocationTypeLodging : PlaceCategoryLodging ,
149+
150+ // Wellness
151+ LocationTypeGym : PlaceCategoryWellness ,
152+ LocationTypeSpa : PlaceCategoryWellness ,
153+ LocationTypePharmacy : PlaceCategoryWellness ,
154+ LocationTypeDrugstore : PlaceCategoryWellness ,
155+ LocationTypeBeautySalon : PlaceCategoryWellness ,
156+ LocationTypeHairCare : PlaceCategoryWellness ,
157+
158+ // LocationTypeAny ("") is deliberately NOT a key: GetPlaceCategory("") must stay
159+ // ("", false), the same as any other unmapped type.
160+ }
161+
60162// GetPlaceCategory maps a Google Maps place type back to its category, reporting whether
61- // the type is mapped at all. It is the inverse of GetPlaceTypes and MUST stay consistent
62- // with it: the nearby-search cache writes each place under
63- // EncodeNearbySearchRedisKey(GetPlaceCategory(place.LocationType), ...), so a type that
163+ // the type is mapped at all. placeTypeToCategory (above) is now a SUPERSET of GetPlaceTypes'
164+ // inverse, not its exact inverse — it classifies every primary type this service recognizes,
165+ // while GetPlaceTypes still only lists the subset each category's Nearby Search issues as
166+ // ?type=. The write path relies on the shared subset: the nearby-search cache writes each place
167+ // under EncodeNearbySearchRedisKey(GetPlaceCategory(place.LocationType), ...), so a type that
64168// resolves to a different category than the one it was searched under would never cache-hit.
65169//
66170// It deliberately has NO default category. An earlier version defaulted to Eatery, which
@@ -69,24 +173,34 @@ const (
69173// GetPlaceTypes(Eatery), Google ignored the unenforceable filter, and prominence-ranked
70174// hotels were written into the eatery geo buckets. Returning ok=false forces every caller
71175// to decide what an unmapped type means, and makes TestPlaceCategoryRoundTrip able to fail.
176+ //
177+ // DO NOT ADD: fast_food_restaurant, food_court. Both are Places API (New)-only values that
178+ // never appear in a legacy Nearby Search result's types[], so adding them re-opens the exact
179+ // guard class TestGetPlaceCategoryRejectsUnknownTypes exists for.
72180func GetPlaceCategory (placeType LocationType ) (PlaceCategory , bool ) {
73- switch placeType {
74- case LocationTypePark , LocationTypeAmusementPark , LocationTypeGallery , LocationTypeMuseum :
75- return PlaceCategoryVisit , true
76- case LocationTypeCafe , LocationTypeRestaurant , LocationTypeBar , LocationTypeBakery , LocationTypeMealTakeaway :
77- return PlaceCategoryEatery , true
78- case LocationTypeShoppingMall , LocationTypeDepartmentStore , LocationTypeSupermarket , LocationTypeClothingStore , LocationTypeStore :
79- return PlaceCategoryShopping , true
80- case LocationTypeLodging :
81- return PlaceCategoryLodging , true
82- case LocationTypeGym , LocationTypeSpa , LocationTypePharmacy :
83- return PlaceCategoryWellness , true
84- default :
85- return PlaceCategory ("" ), false
181+ cat , ok := placeTypeToCategory [placeType ]
182+ return cat , ok
183+ }
184+
185+ // MappedLocationTypes returns every LocationType classified by GetPlaceCategory, sorted for
186+ // deterministic iteration. Exported for tests (e.g. TestGetPlaceCategoryKeysAreGoogleTypes),
187+ // which need to walk the full map without depending on Go's randomized map iteration order.
188+ func MappedLocationTypes () []LocationType {
189+ keys := make ([]LocationType , 0 , len (placeTypeToCategory ))
190+ for t := range placeTypeToCategory {
191+ keys = append (keys , t )
86192 }
193+ sort .Slice (keys , func (i , j int ) bool { return keys [i ] < keys [j ] })
194+ return keys
87195}
88196
89- // GetPlaceTypes returns a set of types defined in Google Maps API given a location type
197+ // GetPlaceTypes returns a set of types defined in Google Maps API given a location type. This
198+ // is the SEARCHED subset: the exact place types each category's Nearby Search issues as ?type=.
199+ // It is intentionally unchanged by the classification-map expansion above — widening it changes
200+ // the outbound Google query for every existing search, not just how a result gets classified,
201+ // and is how the fast_food_restaurant incident happened (a type the legacy API doesn't
202+ // understand, silently ignored by Google, poisoning the eatery cache). Add new types to
203+ // placeTypeToCategory / GetPlaceCategory instead of here.
90204func GetPlaceTypes (placeCat PlaceCategory ) (placeTypes []LocationType ) {
91205 switch placeCat {
92206 case PlaceCategoryVisit :
@@ -161,25 +275,35 @@ func PrimaryLocationType(types []string) LocationType {
161275 return LocationType ("" )
162276}
163277
164- // ReclassifyForCategory decides whether a place belongs in cat based on its
165- // PRIMARY function, and returns the place re-tagged with that primary type.
278+ // ReclassifyForCategory decides whether a place belongs in cat based on its PRIMARY function,
279+ // and returns the place re-tagged with that primary type. It now keys on the same
280+ // placeTypeToCategory map as GetPlaceCategory — the same map the nearby-search write path
281+ // (SetPlacesAddGeoLocations) and the bucket-cleanup migration
282+ // (RemoveMisclassifiedPlacesFromCategoryBuckets) key on — so there is one rule everywhere for
283+ // "does this place belong in this category":
166284//
167- // - primary type is one of cat's search types → keep, LocationType := primary
285+ // - primary type maps to cat → keep, LocationType := primary
168286// (e.g. a "cafe"-searched result that is really a restaurant is re-tagged).
169- // - primary type is known but NOT in cat → drop (keep=false): its main
287+ // - primary type maps to a DIFFERENT category → drop (keep=false): its main
170288// function is something else (a supermarket the food search returned).
171- // - no Types on the place (older cached records) → keep unchanged, so coverage
172- // never regresses on data written before Types was captured.
289+ // - no Types at all (empty primary) → keep unchanged (older cached records), so
290+ // coverage never regresses on data written before Types was captured.
291+ // - primary type is present but unmapped → drop (keep=false), same as the old rule:
292+ // an unmapped primary was never a member of GetPlaceTypes(cat) either, so this is not a
293+ // behavior change from before the placeTypeToCategory unification.
294+ //
295+ // Because placeTypeToCategory is a strict superset of GetPlaceTypes' searched types (see
296+ // GetPlaceCategory's docstring), this keeps every place the old primary-in-GetPlaceTypes(cat)
297+ // rule kept, plus more — never fewer. TestReclassifyForCategoryKeepsAllFormerlySearchedTypes
298+ // pins that monotonicity.
173299func ReclassifyForCategory (place Place , cat PlaceCategory ) (Place , bool ) {
174300 primary := PrimaryLocationType (place .Types )
175301 if primary == LocationType ("" ) {
176- return place , true
302+ return place , true // records with no Types stay kept (older cache entries)
177303 }
178- for _ , t := range GetPlaceTypes (cat ) {
179- if t == primary {
180- place .LocationType = primary
181- return place , true
182- }
304+ if c , ok := GetPlaceCategory (primary ); ok && c == cat {
305+ place .LocationType = primary // re-tag with the true type
306+ return place , true
183307 }
184308 return place , false
185309}
0 commit comments