Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,14 @@ public ResponseEntity<Serializable> search(
try (SolrClient solrClient = solrService.getSolrClientForSearching();
SolrHelper solrHelper = new SolrHelper(solrClient).withQueryTimeout(solrQueryTimeout)) {
final SearchResponse searchResponse = solrHelper.findInIndex(
searchIndex, solrQuery, solrFilterQuery, solrPoint, solrDistance, start, numResultsToReturn);
searchIndex,
solrQuery,
solrFilterQuery,
solrPoint,
solrDistance,
start,
numResultsToReturn,
application.getCrs());
return (null == searchResponse.getDocuments()
|| searchResponse.getDocuments().isEmpty())
? ResponseEntity.noContent().build()
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/org/tailormap/api/geotools/TransformationUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ private TransformationUtil() {
// utility class
}

@Nullable public static MathTransform getTransformation(@NonNull String sourceCrs, @NonNull String targetCrs)
throws FactoryException {
final CoordinateReferenceSystem sourceCRS = CRS.decode(sourceCrs, true);
final CoordinateReferenceSystem targetCRS = CRS.decode(targetCrs, true);

if (!CRS.isEquivalent(sourceCRS, targetCRS)) {
return CRS.findMathTransform(sourceCRS, targetCRS);
}

return null;
}

/**
* Determine whether we need to transform geometries to the application CRS. Note that this uses the "default
* geometry" attribute of the feature source, in cases where a feature source has multiple geometry attributes (with
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/org/tailormap/api/persistence/SearchIndex.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public class SearchIndex extends AuditMetadata implements Serializable {
@NotNull private String name;

private Long featureTypeId;
private String sourceCrs;

/** List of attribute names that were used when building the search index. */
@JsonProperty("searchFieldsUsed")
Expand Down Expand Up @@ -100,6 +101,15 @@ public SearchIndex setFeatureTypeId(Long featureTypeId) {
return this;
}

public String getSourceCrs() {
return sourceCrs;
}

public SearchIndex setSourceCrs(String sourceCrs) {
this.sourceCrs = sourceCrs;
return this;
}

public List<String> getSearchFieldsUsed() {
return searchFieldsUsed;
}
Expand Down
54 changes: 51 additions & 3 deletions src/main/java/org/tailormap/api/solr/SolrHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
import org.geotools.api.data.Query;
import org.geotools.api.data.SimpleFeatureSource;
import org.geotools.api.feature.simple.SimpleFeature;
import org.geotools.api.referencing.FactoryException;
import org.geotools.api.referencing.operation.MathTransform;
import org.geotools.data.simple.SimpleFeatureCollection;
import org.geotools.data.simple.SimpleFeatureIterator;
import org.jspecify.annotations.NonNull;
Expand All @@ -48,10 +50,12 @@
import org.slf4j.LoggerFactory;
import org.tailormap.api.admin.model.SearchIndexSummary;
import org.tailormap.api.admin.model.TaskProgressEvent;
import org.tailormap.api.geotools.TransformationUtil;
import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
import org.tailormap.api.geotools.processing.GeometryProcessor;
import org.tailormap.api.persistence.SearchIndex;
import org.tailormap.api.persistence.TMFeatureType;
import org.tailormap.api.persistence.helper.GeoToolsHelper;
import org.tailormap.api.repository.SearchIndexRepository;
import org.tailormap.api.scheduling.TaskType;
import org.tailormap.api.util.Constants;
Expand Down Expand Up @@ -292,6 +296,9 @@ public SearchIndex addFeatureTypeIndex(

// collect features to index
SimpleFeatureSource fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
String sourceCrs = GeoToolsHelper.crsToString(fs.getSchema().getCoordinateReferenceSystem());
searchIndex = searchIndexRepository.save(searchIndex.setSourceCrs(sourceCrs));

Query q = new Query(fs.getName().toString());
// filter out any hidden properties (there should be none though)
tmFeatureType.getSettings().getHideAttributes().forEach(propertyNames::remove);
Expand Down Expand Up @@ -457,7 +464,8 @@ public SearchResponse findInIndex(
String solrPoint,
Double solrDistance,
int start,
int numResultsToReturn)
int numResultsToReturn,
String applicationCrs)
throws IOException, SolrServerException, SolrException {

if (null == solrQuery || solrQuery.isBlank()) {
Expand All @@ -466,6 +474,24 @@ public SearchResponse findInIndex(

logger.info("Query index for '{}' in {} (id {})", solrQuery, searchIndex.getName(), searchIndex.getId());

MathTransform toApplication = null;
MathTransform toSource = null;

if (searchIndex.getSourceCrs() != null && applicationCrs != null) {
try {
toApplication = TransformationUtil.getTransformation(searchIndex.getSourceCrs(), applicationCrs);
toSource = TransformationUtil.getTransformation(applicationCrs, searchIndex.getSourceCrs());
} catch (FactoryException e) {
logger.error(
"Could not create transformations between search index CRS {} and application CRS {}",
searchIndex.getSourceCrs(),
applicationCrs,
e);
}
}

final MathTransform resultTransform = toApplication;

// TODO We could escape special/syntax characters, but that also prevents using
// keys like ~ and *
// solrQuery = ClientUtils.escapeQueryChars(solrQuery);
Expand All @@ -489,7 +515,19 @@ public SearchResponse findInIndex(
|| !(solrFilterQuery.startsWith("{!geofilt") || solrFilterQuery.startsWith("{!bbox"))) {
query.addFilterQuery("{!geofilt sfield=" + INDEX_GEOM_FIELD + "}");
}
query.add("pt", solrPoint);

String transformedSolrPoint = solrPoint;

if (toSource != null) {
Geometry pointGeometry = GeometryProcessor.wktToGeometry("POINT (" + solrPoint + ")");
if (pointGeometry != null) {
Geometry transformedPoint = GeometryProcessor.transformGeometry(pointGeometry, toSource);
transformedSolrPoint =
transformedPoint.getCoordinate().x + " " + transformedPoint.getCoordinate().y;
}
}

query.add("pt", transformedSolrPoint);
query.add("d", solrDistance.toString());
}
query.set("q.op", "AND");
Expand All @@ -510,9 +548,19 @@ public SearchResponse findInIndex(
.toList();
Object geom = solrDocument.getFieldValue(INDEX_GEOM_FIELD);
if (geom != null) {
String geometry = geom.toString();

if (resultTransform != null) {
Geometry parsedGeometry = GeometryProcessor.wktToGeometry(geometry);
if (parsedGeometry != null) {
geometry = GeometryProcessor.geometryToWKT(
GeometryProcessor.transformGeometry(parsedGeometry, resultTransform));
}
}

searchResponse.addDocumentsItem(new SearchDocument()
.fid(solrDocument.getFieldValue(SEARCH_ID_FIELD).toString())
.geometry(geom.toString())
.geometry(geometry)
.displayValues(displayValues));
}
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE search_index
ADD COLUMN source_crs varchar(255);
205 changes: 205 additions & 0 deletions src/test/java/org/tailormap/api/solr/SolrHelperTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/*
* Copyright (C) 2026 B3Partners B.V.
*
* SPDX-License-Identifier: MIT
*/
package org.tailormap.api.solr;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.util.List;
import org.apache.solr.client.solrj.SolrClient;
import org.apache.solr.client.solrj.request.SolrQuery;
import org.apache.solr.client.solrj.response.QueryResponse;
import org.apache.solr.common.SolrDocument;
import org.apache.solr.common.SolrDocumentList;
import org.junit.jupiter.api.Test;
import org.locationtech.jts.geom.Geometry;
import org.mockito.ArgumentCaptor;
import org.tailormap.api.geotools.processing.GeometryProcessor;
import org.tailormap.api.persistence.SearchIndex;
import org.tailormap.api.viewer.model.SearchResponse;

class SolrHelperTest {

@Test
void find_in_index_transforms_spatial_query_and_result_geometry() throws Exception {
SolrClient solrClient = mock(SolrClient.class);
QueryResponse queryResponse = mock(QueryResponse.class);

String sourceGeometryWkt = "POINT (3.7651071548461914 51.742008209228516)";

SolrDocument document = new SolrDocument();
document.setField("id", "lgstations.108");
document.setField("displayFields", List.of("Jan van Renesseweg"));
document.setField("geometry", sourceGeometryWkt);

SolrDocumentList documents = new SolrDocumentList();
documents.add(document);
documents.setNumFound(1);
documents.setStart(0);

when(queryResponse.getResults()).thenReturn(documents);
when(solrClient.query(any(SolrQuery.class))).thenReturn(queryResponse);

SearchIndex searchIndex =
new SearchIndex().setId(7L).setName("Reddingposten").setSourceCrs("EPSG:4326");

Geometry sourcePoint = GeometryProcessor.wktToGeometry(sourceGeometryWkt);
assertNotNull(sourcePoint);

/*
* Known EPSG:3857 coordinates for longitude 3.7651071548461914,
* latitude 51.742008209228516. Keep these values independent from
* TransformationUtil so axis-order regressions are detected.
*/
String applicationPoint = "419129.8112595891 6753610.983561384";
Geometry applicationPointGeometry = GeometryProcessor.wktToGeometry("POINT (" + applicationPoint + ")");
assertNotNull(applicationPointGeometry);

double distance = 0.005;

SearchResponse response;
try (SolrHelper solrHelper = new SolrHelper(solrClient)) {
response = solrHelper.findInIndex(searchIndex, "*", null, applicationPoint, distance, 0, 10, "EPSG:3857");
}

ArgumentCaptor<SolrQuery> queryCaptor = ArgumentCaptor.forClass(SolrQuery.class);
verify(solrClient).query(queryCaptor.capture());

SolrQuery sentQuery = queryCaptor.getValue();

/*
* The point supplied by the application must be transformed back to the
* source CRS before it is sent to Solr.
*/
String transformedPoint = sentQuery.get("pt");
assertNotNull(transformedPoint);

Geometry transformedPointGeometry = GeometryProcessor.wktToGeometry("POINT (" + transformedPoint + ")");

assertNotNull(transformedPointGeometry);
assertEquals(sourcePoint.getCoordinate().x, transformedPointGeometry.getCoordinate().x, 0.000001);
assertEquals(sourcePoint.getCoordinate().y, transformedPointGeometry.getCoordinate().y, 0.000001);

/*
* Solr distance is already expressed in its configured distance unit and
* must not be transformed together with the point coordinates.
*/
assertEquals("0.005", sentQuery.get("d"));

assertEquals(1, response.getDocuments().size());

String geometry = response.getDocuments().getFirst().getGeometry();
assertNotNull(geometry);

/*
* Geometry stored in Solr uses the source CRS. The geometry returned by
* the API must use the application CRS.
*/
Geometry transformedGeometry = GeometryProcessor.wktToGeometry(geometry);
assertNotNull(transformedGeometry);

assertEquals(applicationPointGeometry.getCoordinate().x, transformedGeometry.getCoordinate().x, 0.001);
assertEquals(applicationPointGeometry.getCoordinate().y, transformedGeometry.getCoordinate().y, 0.001);
}

@Test
void find_in_index_does_not_transform_when_source_and_application_crs_are_equal() throws Exception {
SolrClient solrClient = mock(SolrClient.class);
QueryResponse queryResponse = mock(QueryResponse.class);

String geometryWkt = "POINT (3.7651071548461914 51.742008209228516)";

SolrDocument document = new SolrDocument();
document.setField("id", "lgstations.108");
document.setField("displayFields", List.of("Jan van Renesseweg"));
document.setField("geometry", geometryWkt);

SolrDocumentList documents = new SolrDocumentList();
documents.add(document);
documents.setNumFound(1);
documents.setStart(0);

when(queryResponse.getResults()).thenReturn(documents);
when(solrClient.query(any(SolrQuery.class))).thenReturn(queryResponse);

SearchIndex searchIndex =
new SearchIndex().setId(7L).setName("Reddingposten").setSourceCrs("EPSG:4326");

String point = "3.7651071548461914 51.742008209228516";
double distance = 0.005;

SearchResponse response;
try (SolrHelper solrHelper = new SolrHelper(solrClient)) {
response = solrHelper.findInIndex(searchIndex, "*", null, point, distance, 0, 10, "EPSG:4326");
}

ArgumentCaptor<SolrQuery> queryCaptor = ArgumentCaptor.forClass(SolrQuery.class);
verify(solrClient).query(queryCaptor.capture());

SolrQuery sentQuery = queryCaptor.getValue();

assertEquals(point, sentQuery.get("pt"));
assertEquals("0.005", sentQuery.get("d"));

assertEquals(1, response.getDocuments().size());
assertEquals(geometryWkt, response.getDocuments().getFirst().getGeometry());
}

@Test
void find_in_index_without_source_crs_preserves_existing_behavior() throws Exception {
SolrClient solrClient = mock(SolrClient.class);
QueryResponse queryResponse = mock(QueryResponse.class);

String geometryWkt = "POINT (3.7651071548461914 51.742008209228516)";

SolrDocument document = new SolrDocument();
document.setField("id", "lgstations.108");
document.setField("displayFields", List.of("Jan van Renesseweg"));
document.setField("geometry", geometryWkt);

SolrDocumentList documents = new SolrDocumentList();
documents.add(document);
documents.setNumFound(1);
documents.setStart(0);

when(queryResponse.getResults()).thenReturn(documents);
when(solrClient.query(any(SolrQuery.class))).thenReturn(queryResponse);

/*
* Existing search_index records will initially have a null sourceCrs after
* the database migration, until the index has been rebuilt.
*/
SearchIndex searchIndex = new SearchIndex().setId(7L).setName("Existing search index");

String point = "419121.8 6750463.6";
double distance = 0.005;

SearchResponse response;
try (SolrHelper solrHelper = new SolrHelper(solrClient)) {
response = solrHelper.findInIndex(searchIndex, "*", null, point, distance, 0, 10, "EPSG:3857");
}

ArgumentCaptor<SolrQuery> queryCaptor = ArgumentCaptor.forClass(SolrQuery.class);
verify(solrClient).query(queryCaptor.capture());

SolrQuery sentQuery = queryCaptor.getValue();

/*
* Without a stored source CRS Tailormap cannot safely transform either the
* spatial query point or the returned geometry, so the previous behaviour
* is retained.
*/
assertEquals(point, sentQuery.get("pt"));
assertEquals("0.005", sentQuery.get("d"));

assertEquals(1, response.getDocuments().size());
assertEquals(geometryWkt, response.getDocuments().getFirst().getGeometry());
}
}