Repository: komoot/photon
Pull request #742 (2023-07-25 16:12:03)

Reverse Geocoding with osm tag
This is an implementation of the osm tags filters for reverse geocoding.
 As proposed here #703 by @lonvia 



Patch:
diff --git a/README.md b/README.md
index aac13fc..872c734 100644
--- a/README.md
+++ b/README.md
@@ -180,6 +180,12 @@ Or, just by they key
 http://localhost:2322/api?q=berlin&osm_tag=tourism
 ```
 
+You can also use this feature for reverse geocoding. Want to see the 5 pharmacies closest to a location ?
+
+```
+http://localhost:2322/reverse?lon=10&lat=52&osm_tag=amenity:pharmacy&limit=5
+```
+
 #### Filter results by layer
 
 List of available layers:
diff --git a/src/main/java/de/komoot/photon/elasticsearch/ElasticsearchReverseHandler.java b/src/main/java/de/komoot/photon/elasticsearch/ElasticsearchReverseHandler.java
index 70b8a14..a915e4a 100644
--- a/src/main/java/de/komoot/photon/elasticsearch/ElasticsearchReverseHandler.java
+++ b/src/main/java/de/komoot/photon/elasticsearch/ElasticsearchReverseHandler.java
@@ -62,8 +62,8 @@ public class ElasticsearchReverseHandler implements ReverseHandler {
     }
 
     private ReverseQueryBuilder buildQuery(ReverseRequest photonRequest) {
-        return ReverseQueryBuilder.builder(photonRequest.getLocation(), photonRequest.getRadius(),
-                photonRequest.getQueryStringFilter(), photonRequest.getLayerFilters());
+        return ReverseQueryBuilder.
+                builder(photonRequest.getLocation(), photonRequest.getRadius(), photonRequest.getQueryStringFilter(), photonRequest.getLayerFilters()).
+                withOsmTagFilters(photonRequest.getOsmTagFilters());
     }
-
 }
diff --git a/src/main/java/de/komoot/photon/elasticsearch/ReverseQueryBuilder.java b/src/main/java/de/komoot/photon/elasticsearch/ReverseQueryBuilder.java
index 734fd89..e6a874e 100644
--- a/src/main/java/de/komoot/photon/elasticsearch/ReverseQueryBuilder.java
+++ b/src/main/java/de/komoot/photon/elasticsearch/ReverseQueryBuilder.java
@@ -1,12 +1,15 @@
 package de.komoot.photon.elasticsearch;
 
 import com.vividsolutions.jts.geom.Point;
+import de.komoot.photon.searcher.TagFilter;
+import de.komoot.photon.searcher.TagFilterKind;
 import org.elasticsearch.common.unit.DistanceUnit;
 import org.elasticsearch.index.query.BoolQueryBuilder;
 import org.elasticsearch.index.query.QueryBuilder;
 import org.elasticsearch.index.query.QueryBuilders;
 import org.elasticsearch.index.query.TermsQueryBuilder;
 
+import java.util.List;
 import java.util.Set;
 
 /**
@@ -18,6 +21,9 @@ public class ReverseQueryBuilder {
     private String queryStringFilter;
     private Set<String> layerFilter;
 
+    private BoolQueryBuilder orQueryBuilderForIncludeTagFiltering = null;
+    private BoolQueryBuilder andQueryBuilderForExcludeTagFiltering = null;
+
     private ReverseQueryBuilder(Point location, Double radius, String queryStringFilter, Set<String> layerFilter) {
         this.location = location;
         this.radius = radius;
@@ -42,10 +48,68 @@ public class ReverseQueryBuilder {
             finalQuery.must(new TermsQueryBuilder("type", layerFilter));
         }
 
+        if (orQueryBuilderForIncludeTagFiltering != null || andQueryBuilderForExcludeTagFiltering != null) {
+            BoolQueryBuilder tagFilters = QueryBuilders.boolQuery();
+            if (orQueryBuilderForIncludeTagFiltering != null)
+                tagFilters.must(orQueryBuilderForIncludeTagFiltering);
+            if (andQueryBuilderForExcludeTagFiltering != null)
+                tagFilters.mustNot(andQueryBuilderForExcludeTagFiltering);
+            finalQuery.filter(tagFilters);
+        }
+
         if (finalQuery.must().size() == 0) {
             finalQuery.must(QueryBuilders.matchAllQuery());
         }
 
         return finalQuery.filter(fb);
     }
+
+    public ReverseQueryBuilder withOsmTagFilters(List<TagFilter> filters) {
+        for (TagFilter filter : filters) {
+            addOsmTagFilter(filter);
+        }
+        return this;
+    }
+
+    public ReverseQueryBuilder addOsmTagFilter(TagFilter filter) {
+        if (filter.getKind() == TagFilterKind.EXCLUDE_VALUE) {
+            appendIncludeTermQuery(QueryBuilders.boolQuery()
+                    .must(QueryBuilders.termQuery("osm_key", filter.getKey()))
+                    .mustNot(QueryBuilders.termQuery("osm_value", filter.getValue())));
+        } else {
+            QueryBuilder builder;
+            if (filter.isKeyOnly()) {
+                builder = QueryBuilders.termQuery("osm_key", filter.getKey());
+            } else if (filter.isValueOnly()) {
+                builder = QueryBuilders.termQuery("osm_value", filter.getValue());
+            } else {
+                builder = QueryBuilders.boolQuery()
+                        .must(QueryBuilders.termQuery("osm_key", filter.getKey()))
+                        .must(QueryBuilders.termQuery("osm_value", filter.getValue()));
+            }
+            if (filter.getKind() == TagFilterKind.INCLUDE) {
+                appendIncludeTermQuery(builder);
+            } else {
+                appendExcludeTermQuery(builder);
+            }
+        }
+        return this;
+    }
+
+    private void appendIncludeTermQuery(QueryBuilder termQuery) {
+
+        if (orQueryBuilderForIncludeTagFiltering == null)
+            orQueryBuilderForIncludeTagFiltering = QueryBuilders.boolQuery();
+
+        orQueryBuilderForIncludeTagFiltering.should(termQuery);
+    }
+
+
+    private void appendExcludeTermQuery(QueryBuilder termQuery) {
+
+        if (andQueryBuilderForExcludeTagFiltering == null)
+            andQueryBuilderForExcludeTagFiltering = QueryBuilders.boolQuery();
+
+        andQueryBuilderForExcludeTagFiltering.should(termQuery);
+    }
 }
diff --git a/src/main/java/de/komoot/photon/query/ReverseRequest.java b/src/main/java/de/komoot/photon/query/ReverseRequest.java
index 06314bc..d1c834a 100644
--- a/src/main/java/de/komoot/photon/query/ReverseRequest.java
+++ b/src/main/java/de/komoot/photon/query/ReverseRequest.java
@@ -2,8 +2,10 @@ package de.komoot.photon.query;
 
 import com.vividsolutions.jts.geom.Point;
 
+import de.komoot.photon.searcher.TagFilter;
+
 import java.io.Serializable;
-import java.util.Set;
+import java.util.*;
 
 /**
  * @author svantulden
@@ -16,6 +18,7 @@ public class ReverseRequest implements Serializable {
     private String queryStringFilter;
     private Boolean locationDistanceSort = true;
     private Set<String> layerFilters;
+    private final List<TagFilter> osmTagFilters = new ArrayList<>(1);
     private boolean debug;
 
     public ReverseRequest(Point location, String language, Double radius, String queryStringFilter, Integer limit,
@@ -58,7 +61,16 @@ public class ReverseRequest implements Serializable {
         return layerFilters;
     }
 
+    public List<TagFilter> getOsmTagFilters() {
+        return osmTagFilters;
+    }
+
     public boolean getDebug() {
         return debug;
     }
+
+    ReverseRequest addOsmTagFilter(TagFilter filter) {
+        osmTagFilters.add(filter);
+        return this;
+    }
 }
diff --git a/src/main/java/de/komoot/photon/query/ReverseRequestFactory.java b/src/main/java/de/komoot/photon/query/ReverseRequestFactory.java
index 56664d1..2c72b6d 100644
--- a/src/main/java/de/komoot/photon/query/ReverseRequestFactory.java
+++ b/src/main/java/de/komoot/photon/query/ReverseRequestFactory.java
@@ -1,6 +1,8 @@
 package de.komoot.photon.query;
 
 import com.vividsolutions.jts.geom.Point;
+
+import de.komoot.photon.searcher.TagFilter;
 import spark.QueryParamsMap;
 import spark.Request;
 
@@ -18,7 +20,7 @@ public class ReverseRequestFactory {
     private final LayerParamValidator layerParamValidator;
 
     private static final HashSet<String> REQUEST_QUERY_PARAMS = new HashSet<>(Arrays.asList("lang", "lon", "lat", "radius",
-            "query_string_filter", "distance_sort", "limit", "layer", "debug"));
+            "query_string_filter", "distance_sort", "limit", "layer", "osm_tag", "debug"));
 
     public ReverseRequestFactory(List<String> supportedLanguages, String defaultLanguage) {
         this.languageResolver = new RequestLanguageResolver(supportedLanguages, defaultLanguage);
@@ -83,6 +85,19 @@ public class ReverseRequestFactory {
         }
 
         String queryStringFilter = webRequest.queryParams("query_string_filter");
-        return new ReverseRequest(location, language, radius, queryStringFilter, limit, locationDistanceSort, layerFilter, enableDebug);
+        ReverseRequest request = new ReverseRequest(location, language, radius, queryStringFilter, limit, locationDistanceSort, layerFilter, enableDebug);
+
+        QueryParamsMap tagFiltersQueryMap = webRequest.queryMap("osm_tag");
+        if (tagFiltersQueryMap.hasValue()) {
+            for (String filter : tagFiltersQueryMap.values()) {
+                TagFilter tagFilter = TagFilter.buildOsmTagFilter(filter);
+                if (tagFilter == null) {
+                    throw new BadRequestException(400, String.format("Invalid parameter 'osm_tag=%s': bad syntax for tag filter.", filter));
+                }
+                request.addOsmTagFilter(TagFilter.buildOsmTagFilter(filter));
+            }
+        }
+
+        return request;
     }
 }


Test patch:
diff --git a/src/test/java/de/komoot/photon/query/QueryReverseFilterTagValueTest.java b/src/test/java/de/komoot/photon/query/QueryReverseFilterTagValueTest.java
new file mode 100644
index 0000000..16fed61
--- /dev/null
+++ b/src/test/java/de/komoot/photon/query/QueryReverseFilterTagValueTest.java
@@ -0,0 +1,116 @@
+package de.komoot.photon.query;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.junit.jupiter.api.Assertions.*;
+import  static org.junit.jupiter.params.provider.Arguments.arguments;
+
+import com.vividsolutions.jts.geom.Coordinate;
+import com.vividsolutions.jts.geom.Point;
+
+import de.komoot.photon.ESBaseTester;
+import de.komoot.photon.Importer;
+import de.komoot.photon.PhotonDoc;
+import de.komoot.photon.searcher.PhotonResult;
+import de.komoot.photon.searcher.TagFilter;
+
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public class QueryReverseFilterTagValueTest extends ESBaseTester {
+    @TempDir
+    private static Path instanceTestDirectory;
+
+    private static final String[] TAGS = new String[]{"tourism", "attraction",
+                                                      "tourism", "hotel",
+                                                      "tourism", "museum",
+                                                      "tourism", "information",
+                                                      "amenity", "parking",
+                                                      "amenity", "restaurant",
+                                                      "amenity", "information",
+                                                      "food", "information",
+                                                      "railway", "station"};
+
+    @BeforeAll
+    public void setup() throws IOException {
+        setUpES(instanceTestDirectory, "en", "de", "fr");
+        Importer instance = makeImporter();
+        double lon = 13.38886;
+        double lat = 52.51704;
+        for (int i = 0; i < TAGS.length; i++) {
+            String key = TAGS[i];
+            String value = TAGS[++i];
+            PhotonDoc doc = this.createDoc(lon, lat, i, i, key, value);
+            instance.add(doc);
+            lon += 0.00004;
+            lat += 0.00006;
+            doc = this.createDoc(lon, lat, i + 1, i + 1, key, value);
+            instance.add(doc);
+            lon += 0.00004;
+            lat += 0.00006;
+        }
+        instance.finish();
+        refresh();
+    }
+
+    @AfterAll
+    @Override
+    public void tearDown() {
+        super.tearDown();
+    }
+
+    private List<PhotonResult> reverseWithTags(String[] params) {
+        Point pt = FACTORY.createPoint(new Coordinate(13.38886, 52.51704));
+        ReverseRequest request = new ReverseRequest(pt, "en", 1.0, "", 50, true, new HashSet<>(), false);
+        for (String param : params) {
+            request.addOsmTagFilter(TagFilter.buildOsmTagFilter(param));
+        }
+        return getServer().createReverseHandler().reverse(request);
+    }
+
+    @ParameterizedTest
+    @MethodSource("simpleTagFilterProvider")
+    public void testSingleTagFilter(String filter, int expectedResults) {
+        assertEquals(expectedResults, reverseWithTags(new String[]{filter}).size());
+    }
+
+    static Stream<Arguments> simpleTagFilterProvider() {
+        return Stream.of(
+                arguments("tourism:attraction", 2),
+                arguments(":attraction", 2),
+                arguments(":information", 6),
+                arguments("tourism", 8),
+                arguments("!tourism:attraction", 16),
+                arguments(":!information", 12),
+                arguments("!tourism", 10),
+                arguments("tourism:!information", 6)
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("combinedTagFilterProvider")
+    public void testCombinedTagFilter(String[] filters, int expectedResults) {
+        assertEquals(expectedResults, reverseWithTags(filters).size());
+    }
+
+    static Stream<Arguments> combinedTagFilterProvider() {
+        return Stream.of(
+                arguments(new String[]{"food", "amenity"}, 8),
+                arguments(new String[]{":parking", ":museum"}, 4),
+                arguments(new String[]{"food", ":information"}, 6),
+                arguments(new String[]{"!tourism", "!amenity"}, 4),
+                arguments(new String[]{"tourism", "!amenity"}, 8),
+                arguments(new String[]{":information", "!amenity"}, 4),
+                arguments(new String[]{"tourism:!information", "food"}, 8),
+                arguments(new String[]{"tourism:!information", "tourism:!hotel"}, 8),
+                arguments(new String[]{"tourism", "!:information", "food"}, 6)
+        );
+    }
+}
diff --git a/src/test/java/de/komoot/photon/query/ReverseRequestFactoryTest.java b/src/test/java/de/komoot/photon/query/ReverseRequestFactoryTest.java
index 80f78dd..640aa6d 100644
--- a/src/test/java/de/komoot/photon/query/ReverseRequestFactoryTest.java
+++ b/src/test/java/de/komoot/photon/query/ReverseRequestFactoryTest.java
@@ -7,12 +7,15 @@ package de.komoot.photon.query;
 
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
+
+import de.komoot.photon.searcher.TagFilter;
 import spark.QueryParamsMap;
 import spark.Request;
 
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.*;
 
@@ -31,11 +34,21 @@ public class ReverseRequestFactoryTest {
         Mockito.when(mockRequest.queryParamOrDefault("distance_sort", "true")).thenReturn("true");
 
         QueryParamsMap mockEmptyQueryParamsMap = Mockito.mock(QueryParamsMap.class);
+        Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockEmptyQueryParamsMap);
         Mockito.when(mockRequest.queryMap("layer")).thenReturn(mockEmptyQueryParamsMap);
 
         return mockRequest;
     }
 
+    public void requestWithOsmFilters(Request mockRequest, String... filterParams) {
+        Mockito.when(mockRequest.queryParams("q")).thenReturn("new york");
+
+        QueryParamsMap mockQueryParamsMap = Mockito.mock(QueryParamsMap.class);
+        Mockito.when(mockQueryParamsMap.hasValue()).thenReturn(true);
+        Mockito.when(mockQueryParamsMap.values()).thenReturn(filterParams);
+        Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockQueryParamsMap);
+    }
+
     public void requestWithLayers(Request mockRequest, String... layers) {
         QueryParamsMap mockQueryParamsMap = Mockito.mock(QueryParamsMap.class);
         Mockito.when(mockQueryParamsMap.hasValue()).thenReturn(true);
@@ -211,6 +224,32 @@ public class ReverseRequestFactoryTest {
         assertBadRequest(mockRequest, "Invalid layer 'bad'. Allowed layers are: house,street,locality,district,city,county,state,country");
     }
 
+    @Test
+    public void testTagFilters() throws Exception {
+        Request mockRequest = createRequestWithLongitudeLatitude(-87d, 41d);
+        requestWithOsmFilters(mockRequest, "foo", ":!bar");
+        ReverseRequestFactory reverseRequestFactory = new ReverseRequestFactory(Collections.singletonList("en"), "en");
+        reverseRequest = reverseRequestFactory.create(mockRequest);
+
+        List<TagFilter> result = reverseRequest.getOsmTagFilters();
+
+        assertEquals(2, result.size());
+
+        assertAll("filterlist",
+                () -> assertNotNull(result.get(0)),
+                () -> assertNotNull(result.get(1))
+        );
+    }
+
+    @Test
+    public void testBadTagFilters() throws BadRequestException {
+        Request mockRequest = createRequestWithLongitudeLatitude(-87d, 41d);
+        requestWithOsmFilters(mockRequest, "good", "bad:bad:bad");
+        ReverseRequestFactory reverseRequestFactory = new ReverseRequestFactory(Collections.singletonList("en"), "en");
+
+        assertThrows(BadRequestException.class, () -> reverseRequestFactory.create(mockRequest));
+    }
+
     @Test
     public void testWithDebug() throws Exception {
         Request mockRequest = createRequestWithLongitudeLatitude(-87d, 41d);