View Javadoc
1   /*
2    * Copyright (C) 2024 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.solr;
7   
8   import static org.tailormap.api.scheduling.IndexTask.INDEX_KEY;
9   
10  import jakarta.validation.constraints.NotNull;
11  import jakarta.validation.constraints.Positive;
12  import java.io.IOException;
13  import java.lang.invoke.MethodHandles;
14  import java.math.BigDecimal;
15  import java.time.Duration;
16  import java.time.Instant;
17  import java.time.OffsetDateTime;
18  import java.time.ZoneId;
19  import java.util.ArrayList;
20  import java.util.HashMap;
21  import java.util.HashSet;
22  import java.util.List;
23  import java.util.Map;
24  import java.util.Set;
25  import java.util.UUID;
26  import java.util.function.Consumer;
27  import org.apache.solr.client.solrj.RemoteSolrException;
28  import org.apache.solr.client.solrj.SolrClient;
29  import org.apache.solr.client.solrj.SolrResponse;
30  import org.apache.solr.client.solrj.SolrServerException;
31  import org.apache.solr.client.solrj.request.SolrQuery;
32  import org.apache.solr.client.solrj.request.schema.FieldTypeDefinition;
33  import org.apache.solr.client.solrj.request.schema.SchemaRequest;
34  import org.apache.solr.client.solrj.response.QueryResponse;
35  import org.apache.solr.client.solrj.response.UpdateResponse;
36  import org.apache.solr.client.solrj.response.schema.SchemaResponse;
37  import org.apache.solr.common.SolrDocumentList;
38  import org.apache.solr.common.SolrException;
39  import org.geotools.api.data.Query;
40  import org.geotools.api.data.SimpleFeatureSource;
41  import org.geotools.api.feature.simple.SimpleFeature;
42  import org.geotools.api.referencing.FactoryException;
43  import org.geotools.api.referencing.operation.MathTransform;
44  import org.geotools.data.simple.SimpleFeatureCollection;
45  import org.geotools.data.simple.SimpleFeatureIterator;
46  import org.jspecify.annotations.NonNull;
47  import org.jspecify.annotations.Nullable;
48  import org.locationtech.jts.geom.Geometry;
49  import org.slf4j.Logger;
50  import org.slf4j.LoggerFactory;
51  import org.tailormap.api.admin.model.SearchIndexSummary;
52  import org.tailormap.api.admin.model.TaskProgressEvent;
53  import org.tailormap.api.geotools.TransformationUtil;
54  import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
55  import org.tailormap.api.geotools.processing.GeometryProcessor;
56  import org.tailormap.api.persistence.SearchIndex;
57  import org.tailormap.api.persistence.TMFeatureType;
58  import org.tailormap.api.persistence.helper.GeoToolsHelper;
59  import org.tailormap.api.repository.SearchIndexRepository;
60  import org.tailormap.api.scheduling.TaskType;
61  import org.tailormap.api.util.Constants;
62  import org.tailormap.api.viewer.model.SearchDocument;
63  import org.tailormap.api.viewer.model.SearchResponse;
64  
65  /**
66   * Solr utility/wrapper class. This class provides methods to add or update a full-text feature type index for a layer,
67   * find in the index for a layer, and clear the index for a layer. It also provides a method to close the Solr client as
68   * well as automatically closing the client when used in a try-with-resources.
69   */
70  public class SolrHelper implements AutoCloseable, Constants {
71    private static final Logger logger =
72        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
73  
74    /** the Solr field type name geometry fields: {@value #SOLR_SPATIAL_FIELDNAME}. */
75    private static final String SOLR_SPATIAL_FIELDNAME = "tm_geometry_rpt";
76  
77    private final SolrClient solrClient;
78  
79    /** the Solr search field definition requests for Tailormap. */
80    private final Map<String, SchemaRequest.AddField> solrSearchFields = Map.of(
81        SEARCH_LAYER,
82            new SchemaRequest.AddField(Map.of(
83                "name", SEARCH_LAYER,
84                "type", "string",
85                "indexed", true,
86                "stored", true,
87                "multiValued", false,
88                "required", true,
89                "uninvertible", false)),
90        INDEX_GEOM_FIELD,
91            new SchemaRequest.AddField(
92                Map.of("name", INDEX_GEOM_FIELD, "type", SOLR_SPATIAL_FIELDNAME, "stored", true)),
93        INDEX_SEARCH_FIELD,
94            new SchemaRequest.AddField(Map.of(
95                "name", INDEX_SEARCH_FIELD,
96                "type", "text_general",
97                "indexed", true,
98                "stored", true,
99                "multiValued", true,
100               "required", true,
101               "uninvertible", false)),
102       INDEX_DISPLAY_FIELD,
103           new SchemaRequest.AddField(Map.of(
104               "name", INDEX_DISPLAY_FIELD,
105               "type", "text_general",
106               "indexed", false,
107               "stored", true,
108               "multiValued", true,
109               "required", true,
110               "uninvertible", false)));
111 
112   private int solrQueryTimeout = 7000;
113   private int solrBatchSize = 1000;
114   private String solrGeometryValidationRule = "repairBuffer0";
115 
116   /**
117    * Create a configured {@code SolrHelper} object.
118    *
119    * @param solrClient the Solr client, this will be closed when this class is closed
120    */
121   public SolrHelper(@NotNull SolrClient solrClient) {
122     this.solrClient = solrClient;
123   }
124 
125   /**
126    * Configure this {@code SolrHelper} with a query timeout .
127    *
128    * @param solrQueryTimeout the query timeout in seconds
129    */
130   public SolrHelper withQueryTimeout(
131       @Positive(message = "Must use a positive integer for query timeout") int solrQueryTimeout) {
132     this.solrQueryTimeout = solrQueryTimeout * 1000;
133     return this;
134   }
135 
136   /**
137    * Configure this {@code SolrHelper} with a batch size for submitting documents to the Solr instance.
138    *
139    * @param solrBatchSize the batch size for indexing, must be greater than 0
140    */
141   public SolrHelper withBatchSize(@Positive(message = "Must use a positive integer for batching") int solrBatchSize) {
142     this.solrBatchSize = solrBatchSize;
143     return this;
144   }
145 
146   /**
147    * Configure this {@code SolrHelper} to create a geometry field in Solr using the specified validation rule.
148    *
149    * @see <a
150    *     href="https://locationtech.github.io/spatial4j/apidocs/org/locationtech/spatial4j/context/jts/ValidationRule.html">ValidationRule</a>
151    * @param solrGeometryValidationRule any of {@code "error", "none", "repairBuffer0", "repairConvexHull"}
152    */
153   public SolrHelper withGeometryValidationRule(@NonNull String solrGeometryValidationRule) {
154     if (List.of("error", "none", "repairBuffer0", "repairConvexHull").contains(solrGeometryValidationRule)) {
155       logger.trace("Setting geometry validation rule for Solr geometry field to {}", solrGeometryValidationRule);
156       this.solrGeometryValidationRule = solrGeometryValidationRule;
157     }
158     return this;
159   }
160 
161   /**
162    * Add or update a feature type index for a layer.
163    *
164    * @param searchIndex the search index config
165    * @param tmFeatureType the feature type
166    * @param featureSourceFactoryHelper the feature source factory helper
167    * @param searchIndexRepository the search index repository, so we can save the {@code searchIndex}
168    * @throws IOException if an I/O error occurs
169    * @throws SolrServerException if a Solr error occurs
170    * @return the possibly updated {@code searchIndex} object
171    */
172   @SuppressWarnings("FromTemporalAccessor")
173   public SearchIndex addFeatureTypeIndex(
174       @NotNull SearchIndex searchIndex,
175       @NotNull TMFeatureType tmFeatureType,
176       @NotNull FeatureSourceFactoryHelper featureSourceFactoryHelper,
177       @NotNull SearchIndexRepository searchIndexRepository)
178       throws IOException, SolrServerException {
179     // use a dummy/logging listener when not given
180     Consumer<TaskProgressEvent> progressListener = (event) -> logger.debug("Progress event: {}", event);
181 
182     return this.addFeatureTypeIndex(
183         searchIndex, tmFeatureType, featureSourceFactoryHelper, searchIndexRepository, progressListener, null);
184   }
185 
186   /**
187    * Add or update a feature type index for a layer.
188    *
189    * @param searchIndex the search index config
190    * @param tmFeatureType the feature type
191    * @param featureSourceFactoryHelper the feature source factory helper
192    * @param searchIndexRepository the search index repository, so we can save the {@code searchIndex}
193    * @param progressListener the progress listener callback
194    * @param taskUuid the task UUID, when {@code null} we will attempt to use the UUID from the
195    *     {@code searchIndex#getSchedule()}
196    * @throws IOException if an I/O error occurs
197    * @throws SolrServerException if a Solr error occurs
198    * @return the possibly updated {@code searchIndex} object
199    */
200   @SuppressWarnings("FromTemporalAccessor")
201   public SearchIndex addFeatureTypeIndex(
202       @NotNull SearchIndex searchIndex,
203       @NotNull TMFeatureType tmFeatureType,
204       @NotNull FeatureSourceFactoryHelper featureSourceFactoryHelper,
205       @NotNull SearchIndexRepository searchIndexRepository,
206       @NotNull Consumer<TaskProgressEvent> progressListener,
207       @Nullable UUID taskUuid)
208       throws IOException, SolrServerException {
209 
210     createSchemaIfNotExists();
211 
212     final Instant startedAt = Instant.now();
213     final OffsetDateTime startedAtOffset =
214         startedAt.atOffset(ZoneId.systemDefault().getRules().getOffset(startedAt));
215 
216     if (null == taskUuid && null != searchIndex.getSchedule()) {
217       // this can be the case when this method is called directly such as when creating
218       // the test data. This in itself is not a big problem; it just means that the uuid
219       // in any progress events will be null (for that call).
220       taskUuid = searchIndex.getSchedule().getUuid();
221     }
222 
223     SearchIndexSummary summary =
224         new SearchIndexSummary().startedAt(startedAtOffset).total(0).duration(0.0);
225 
226     if (null == searchIndex.getSearchFieldsUsed()) {
227       logger.warn(
228           "No search fields configured for search index: {}, skipping index {}.",
229           tmFeatureType.getName(),
230           searchIndex.getName());
231       return searchIndexRepository.save(searchIndex
232           .setStatus(SearchIndex.Status.ERROR)
233           .setSummary(summary.errorMessage("No search fields configured")));
234     }
235 
236     TaskProgressEvent taskProgressEvent = new TaskProgressEvent()
237         .type(TaskType.INDEX.getValue())
238         .uuid(taskUuid)
239         .startedAt(startedAtOffset)
240         .progress(0)
241         .taskData(Map.of(INDEX_KEY, searchIndex.getId()));
242     progressListener.accept(taskProgressEvent);
243 
244     // set fields while filtering out hidden fields
245     List<String> searchFields = searchIndex.getSearchFieldsUsed().stream()
246         .filter(s -> !tmFeatureType.getSettings().getHideAttributes().contains(s))
247         .toList();
248     List<String> displayFields = searchIndex.getSearchDisplayFieldsUsed().stream()
249         .filter(s -> !tmFeatureType.getSettings().getHideAttributes().contains(s))
250         .toList();
251 
252     if (searchFields.isEmpty()) {
253       logger.warn(
254           "No valid search fields configured for feature type: {}, skipping index {}.",
255           tmFeatureType.getName(),
256           searchIndex.getName());
257       return searchIndexRepository.save(searchIndex
258           .setStatus(SearchIndex.Status.ERROR)
259           .setSummary(summary.errorMessage("No search fields configured")));
260     }
261 
262     // add search and display properties to query
263     Set<String> propertyNames = new HashSet<>();
264 
265     // always try to add primary key and default geometry to geotools query
266     if (null == tmFeatureType.getPrimaryKeyAttribute()) {
267       logger.error(
268           "No primary key attribute configured for feature type: {}, skipping index {}.",
269           tmFeatureType.getName(),
270           searchIndex.getName());
271       return searchIndexRepository.save(searchIndex
272           .setStatus(SearchIndex.Status.ERROR)
273           .setSummary(summary.errorMessage("No primary key attribute configured")));
274     }
275     propertyNames.add(tmFeatureType.getPrimaryKeyAttribute());
276     if (null == tmFeatureType.getDefaultGeometryAttribute()) {
277       logger.error(
278           "No default geometry attribute configured for feature type: {}, skipping index {}.",
279           tmFeatureType.getName(),
280           searchIndex.getName());
281       return searchIndexRepository.save(searchIndex
282           .setStatus(SearchIndex.Status.ERROR)
283           .setSummary(summary.errorMessage("No default geometry attribute configured")));
284     }
285     propertyNames.add(tmFeatureType.getDefaultGeometryAttribute());
286     propertyNames.addAll(searchFields);
287 
288     if (!displayFields.isEmpty()) {
289       propertyNames.addAll(displayFields);
290     }
291 
292     clearIndexForLayer(searchIndex.getId());
293 
294     logger.info("Indexing started for index: {}, feature type: {}", searchIndex.getName(), tmFeatureType.getName());
295     searchIndex = searchIndexRepository.save(searchIndex.setStatus(SearchIndex.Status.INDEXING));
296 
297     // collect features to index
298     SimpleFeatureSource fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
299 
300     Query q = new Query(fs.getName().toString());
301     // filter out any hidden properties (there should be none though)
302     tmFeatureType.getSettings().getHideAttributes().forEach(propertyNames::remove);
303     if (propertyNames.isEmpty()) {
304       logger.warn(
305           "No valid properties to index for feature type: {}, skipping index {}.",
306           tmFeatureType.getName(),
307           searchIndex.getName());
308       return searchIndexRepository.save(searchIndex
309           .setStatus(SearchIndex.Status.ERROR)
310           .setSummary(
311               summary.errorMessage("No valid properties to index. Check if any properties are hidden.")));
312     }
313     q.setPropertyNames(List.copyOf(propertyNames));
314     q.setStartIndex(0);
315     // TODO: make maxFeatures configurable?
316     // q.setMaxFeatures(Integer.MAX_VALUE);
317     logger.trace("Indexing query: {}", q);
318     SimpleFeatureCollection simpleFeatureCollection = fs.getFeatures(q);
319     final int total = simpleFeatureCollection.size();
320     List<FeatureIndexingDocument> docsBatch = new ArrayList<>(solrBatchSize);
321     // TODO this does not currently batch/page the feature source query, this doesn't seem to be an
322     //   issue for now but could be if the feature source is very, very large or slow
323     UpdateResponse updateResponse;
324     int indexCounter = 0;
325     int indexSkippedCounter = 0;
326     try (SimpleFeatureIterator iterator = simpleFeatureCollection.features()) {
327       searchIndex.setSourceCrs(GeoToolsHelper.crsToString(fs.getSchema().getCoordinateReferenceSystem()));
328       while (iterator.hasNext()) {
329         indexCounter++;
330         SimpleFeature feature = iterator.next();
331         // note that this will create a unique document
332         FeatureIndexingDocument doc = new FeatureIndexingDocument(feature.getID(), searchIndex.getId());
333         List<String> searchValues = new ArrayList<>();
334         List<String> displayValues = new ArrayList<>();
335         propertyNames.forEach(propertyName -> {
336           Object value = feature.getAttribute(propertyName);
337           if (value != null) {
338             if (value instanceof Geometry
339                 && propertyName.equals(tmFeatureType.getDefaultGeometryAttribute())) {
340               // We could use GeoJSON, but WKT is more compact and that would also incur a
341               // change to the API
342               doc.setGeometry(GeometryProcessor.processGeometry(value, true, true, null));
343             } else {
344               if (searchFields.contains(propertyName)) {
345                 searchValues.add(value.toString());
346               }
347               if (displayFields.contains(propertyName)) {
348                 displayValues.add(value.toString());
349               }
350             }
351           }
352         });
353         if (searchValues.isEmpty() || displayValues.isEmpty()) {
354           // this is a record/document that can either not be found or not be displayed
355           logger.trace(
356               "No search or display values found for feature: {} in feature type: {}, skipped for indexing",
357               feature.getID(),
358               tmFeatureType.getName());
359           indexSkippedCounter++;
360         } else if (doc.getGeometry() == null || doc.getGeometry().matches("^[A-Z]+\\s*EMPTY\\s*$")) {
361           logger.trace(
362               "No or empty geometry found for feature: {} in feature type: {}, skipped for indexing",
363               feature.getID(),
364               tmFeatureType.getName());
365           indexSkippedCounter++;
366         } else {
367           doc.setSearchFields(searchValues.toArray(new String[0]));
368           doc.setDisplayFields(displayValues.toArray(new String[0]));
369           docsBatch.add(doc);
370         }
371 
372         if (indexCounter % solrBatchSize == 0) {
373           updateResponse = solrClient.addBeans(docsBatch, solrQueryTimeout);
374           logger.info(
375               "Added {} documents of {} to index, result status: {}",
376               indexCounter - indexSkippedCounter,
377               total,
378               updateResponse.getStatus());
379           progressListener.accept(
380               taskProgressEvent.total(total).progress((indexCounter - indexSkippedCounter)));
381           docsBatch.clear();
382         }
383       }
384     } finally {
385       if (fs.getDataStore() != null) fs.getDataStore().dispose();
386     }
387 
388     if (!docsBatch.isEmpty()) {
389       solrClient.addBeans(docsBatch, solrQueryTimeout);
390       logger.info("Added last {} documents of {} to index", docsBatch.size(), total);
391       progressListener.accept(taskProgressEvent
392           .progress((indexCounter - indexSkippedCounter))
393           .total(total));
394     }
395     final Instant finishedAt = Instant.now();
396     final OffsetDateTime finishedAtOffset =
397         finishedAt.atOffset(ZoneId.systemDefault().getRules().getOffset(finishedAt));
398     Duration processTime = Duration.between(startedAt, finishedAt).abs();
399     logger.info(
400         "Indexing finished for index: {}, feature type: {} at {} in {}",
401         searchIndex.getName(),
402         tmFeatureType.getName(),
403         finishedAtOffset,
404         processTime);
405     updateResponse = this.solrClient.commit();
406     logger.trace("Update response commit status: {}", updateResponse.getStatus());
407 
408     if (indexSkippedCounter > 0) {
409       logger.warn(
410           "{} features were skipped because no search or display values were found.", indexSkippedCounter);
411     }
412 
413     return searchIndexRepository.save(searchIndex
414         .setLastIndexed(finishedAtOffset)
415         .setStatus(SearchIndex.Status.INDEXED)
416         .setSummary(summary.total(total)
417             .skippedCounter(indexSkippedCounter)
418             .duration(BigDecimal.valueOf(processTime.getSeconds())
419                 .add(BigDecimal.valueOf(processTime.getNano(), 9))
420                 .doubleValue())
421             .errorMessage(null)));
422   }
423 
424   /**
425    * Clear the index for a layer.
426    *
427    * @param searchLayerId the layer id
428    * @throws IOException if an I/O error occurs
429    * @throws SolrServerException if a Solr error occurs
430    */
431   public void clearIndexForLayer(@NotNull Long searchLayerId) throws IOException, SolrServerException {
432 
433     QueryResponse response =
434         solrClient.query(new SolrQuery("exists(query(" + SEARCH_LAYER + ":" + searchLayerId + "))"));
435     if (response.getResults().getNumFound() > 0) {
436       logger.info("Clearing index for searchLayer {}", searchLayerId);
437       UpdateResponse updateResponse = solrClient.deleteByQuery(SEARCH_LAYER + ":" + searchLayerId);
438       logger.trace("Delete response status: {}", updateResponse.getStatus());
439       updateResponse = solrClient.commit();
440       logger.trace("Commit response status: {}", updateResponse.getStatus());
441     } else {
442       logger.info("No index to clear for layer {}", searchLayerId);
443     }
444   }
445 
446   /**
447    * Search in the index for a layer. The given query is augmented to filter on the {@code solrLayerId}.
448    *
449    * @param searchIndex the search index
450    * @param solrQuery the query, when {@code null} or empty, the query is set to {@code *} (match all)
451    * @param solrPoint the point to search around, in (x y) format
452    * @param solrDistance the distance to search around the point in Solr distance units (kilometers)
453    * @param start the start index, starting at 0
454    * @param numResultsToReturn the number of results to return
455    * @return the documents
456    * @throws IOException if an I/O error occurs
457    * @throws SolrServerException if a Solr error occurs
458    */
459   public SearchResponse findInIndex(
460       @NotNull SearchIndex searchIndex,
461       String solrQuery,
462       String solrFilterQuery,
463       String solrPoint,
464       Double solrDistance,
465       int start,
466       int numResultsToReturn,
467       String applicationCrs)
468       throws IOException, SolrServerException, SolrException {
469 
470     if (null == solrQuery || solrQuery.isBlank()) {
471       solrQuery = "*";
472     }
473 
474     logger.info("Query index for '{}' in {} (id {})", solrQuery, searchIndex.getName(), searchIndex.getId());
475 
476     MathTransform toApplication = null;
477     MathTransform toSource = null;
478 
479     if (searchIndex.getSourceCrs() != null && applicationCrs != null) {
480       try {
481         toApplication = TransformationUtil.getTransformation(searchIndex.getSourceCrs(), applicationCrs);
482         toSource = TransformationUtil.getTransformation(applicationCrs, searchIndex.getSourceCrs());
483       } catch (FactoryException e) {
484         logger.error(
485             "Could not create transformations between search index CRS {} and application CRS {}",
486             searchIndex.getSourceCrs(),
487             applicationCrs,
488             e);
489       }
490     }
491 
492     final MathTransform resultTransform = toApplication;
493 
494     // TODO We could escape special/syntax characters, but that also prevents using
495     //      keys like ~ and *
496     // solrQuery = ClientUtils.escapeQueryChars(solrQuery);
497 
498     final SolrQuery query = new SolrQuery(INDEX_SEARCH_FIELD + ":" + solrQuery)
499         .setShowDebugInfo(logger.isDebugEnabled())
500         .setTimeAllowed(solrQueryTimeout)
501         .setIncludeScore(true)
502         .setFields(SEARCH_ID_FIELD, INDEX_DISPLAY_FIELD, INDEX_GEOM_FIELD)
503         .addFilterQuery(SEARCH_LAYER + ":" + searchIndex.getId())
504         .setSort("score", SolrQuery.ORDER.desc)
505         .addSort(SEARCH_ID_FIELD, SolrQuery.ORDER.asc)
506         .setRows(numResultsToReturn)
507         .setStart(start);
508 
509     if (null != solrFilterQuery && !solrFilterQuery.isBlank()) {
510       query.addFilterQuery(solrFilterQuery);
511     }
512     if (null != solrPoint && null != solrDistance) {
513       if (null == solrFilterQuery
514           || !(solrFilterQuery.startsWith("{!geofilt") || solrFilterQuery.startsWith("{!bbox"))) {
515         query.addFilterQuery("{!geofilt sfield=" + INDEX_GEOM_FIELD + "}");
516       }
517 
518       String transformedSolrPoint = solrPoint;
519 
520       if (toSource != null) {
521         Geometry pointGeometry = GeometryProcessor.wktToGeometry("POINT (" + solrPoint + ")");
522         if (pointGeometry != null) {
523           Geometry transformedPoint = GeometryProcessor.transformGeometry(pointGeometry, toSource);
524           transformedSolrPoint =
525               transformedPoint.getCoordinate().x + " " + transformedPoint.getCoordinate().y;
526         }
527       }
528 
529       query.add("pt", transformedSolrPoint);
530       query.add("d", solrDistance.toString());
531     }
532     query.set("q.op", "AND");
533     logger.debug("Solr query: {}", query);
534 
535     final QueryResponse response = solrClient.query(query);
536     logger.trace("response: {}", response);
537 
538     final SolrDocumentList solrDocumentList = response.getResults();
539     logger.debug("Found {} solr documents", solrDocumentList.getNumFound());
540     final SearchResponse searchResponse = new SearchResponse()
541         .total(solrDocumentList.getNumFound())
542         .start(response.getResults().getStart())
543         .maxScore(solrDocumentList.getMaxScore());
544     response.getResults().forEach(solrDocument -> {
545       List<String> displayValues = solrDocument.getFieldValues(INDEX_DISPLAY_FIELD).stream()
546           .map(Object::toString)
547           .toList();
548       Object geom = solrDocument.getFieldValue(INDEX_GEOM_FIELD);
549       if (geom != null) {
550         String geometry = geom.toString();
551 
552         if (resultTransform != null) {
553           Geometry parsedGeometry = GeometryProcessor.wktToGeometry(geometry);
554           if (parsedGeometry != null) {
555             geometry = GeometryProcessor.geometryToWKT(
556                 GeometryProcessor.transformGeometry(parsedGeometry, resultTransform));
557           }
558         }
559 
560         searchResponse.addDocumentsItem(new SearchDocument()
561             .fid(solrDocument.getFieldValue(SEARCH_ID_FIELD).toString())
562             .geometry(geometry)
563             .displayValues(displayValues));
564       }
565     });
566 
567     return searchResponse;
568   }
569 
570   /**
571    * Close the wrapped Solr client.
572    *
573    * @throws IOException if an I/O error occurs
574    */
575   @Override
576   public void close() throws IOException {
577     if (null != this.solrClient) this.solrClient.close();
578   }
579 
580   private boolean checkSchemaIfFieldExists(String fieldName) {
581     SchemaRequest.Field fieldCheck = new SchemaRequest.Field(fieldName);
582     try {
583       SchemaResponse.FieldResponse isField = fieldCheck.process(solrClient);
584       logger.debug("Field {} exists", isField.getField());
585       return true;
586     } catch (SolrServerException | RemoteSolrException e) {
587       logger.debug("Field {} does not exist or could not be retrieved. Assuming it does not exist.", fieldName);
588     } catch (IOException e) {
589       logger.error("Tried getting field: {}, but failed.", fieldName, e);
590     }
591     return false;
592   }
593 
594   /**
595    * @param fieldName the name of the field to create
596    * @throws SolrServerException if a Solr error occurs
597    * @throws IOException if an I/O error occurs
598    */
599   private void createSchemaFieldIfNotExists(String fieldName) throws SolrServerException, IOException {
600     if (!checkSchemaIfFieldExists(fieldName)) {
601       logger.info("Creating Solr field {}.", fieldName);
602       SchemaRequest.AddField schemaRequest = solrSearchFields.get(fieldName);
603       SolrResponse response = schemaRequest.process(solrClient);
604       logger.debug("Field type {} created", response);
605       solrClient.commit();
606     }
607   }
608 
609   /** Programmatically create the schema if it does not exist. */
610   private void createSchemaIfNotExists() {
611     solrSearchFields.forEach((key, value) -> {
612       try {
613         if (key.equals(INDEX_GEOM_FIELD)) {
614           createGeometryFieldTypeIfNotExists();
615         }
616         createSchemaFieldIfNotExists(key);
617       } catch (SolrServerException | IOException e) {
618         logger.error(
619             "Error creating schema field: {} indexing may fail. Details: {}",
620             key,
621             e.getLocalizedMessage(),
622             e);
623       }
624     });
625   }
626 
627   private void createGeometryFieldTypeIfNotExists() throws SolrServerException, IOException {
628     SchemaRequest.FieldType fieldTypeCheck = new SchemaRequest.FieldType(SOLR_SPATIAL_FIELDNAME);
629     try {
630       SchemaResponse.FieldTypeResponse isFieldType = fieldTypeCheck.process(solrClient);
631       logger.debug("Field type {} exists", isFieldType.getFieldType());
632       return;
633     } catch (SolrServerException | RemoteSolrException e) {
634       logger.debug(
635           "Field type {} does not exist or could not be retrieved. Assuming it does not exist.",
636           SOLR_SPATIAL_FIELDNAME);
637     } catch (IOException e) {
638       logger.error("Tried getting field type: {}, but failed.", SOLR_SPATIAL_FIELDNAME, e);
639     }
640 
641     logger.info(
642         "Creating Solr field type for {} with validation rule {}",
643         SOLR_SPATIAL_FIELDNAME,
644         solrGeometryValidationRule);
645     FieldTypeDefinition spatialFieldTypeDef = new FieldTypeDefinition();
646     Map<String, Object> spatialFieldAttributes = new HashMap<>(Map.of(
647         "name", SOLR_SPATIAL_FIELDNAME,
648         "class", "solr.SpatialRecursivePrefixTreeFieldType",
649         "spatialContextFactory", "JTS",
650         "geo", false,
651         "distanceUnits", "kilometers",
652         "distCalculator", "cartesian",
653         "format", "WKT",
654         "autoIndex", true,
655         "distErrPct", "0.025",
656         "maxDistErr", "0.001"));
657     spatialFieldAttributes.putAll(Map.of(
658         "prefixTree",
659         "packedQuad",
660         // see
661         // https://locationtech.github.io/spatial4j/apidocs/org/locationtech/spatial4j/context/jts/ValidationRule.html
662         "validationRule",
663         this.solrGeometryValidationRule,
664         // NOTE THE ODDITY in coordinate order of "worldBounds",
665         // "ENVELOPE(minX, maxX, maxY, minY)"
666         "worldBounds",
667         // webmercator / EPSG:3857 projected bounds
668         "ENVELOPE(-20037508.34, 20037508.34, 20048966.1, -20048966.1)"
669         // Amersfoort/RD new / EPSG:28992 projected bounds
670         // "ENVELOPE(482.06, 284182.97, 637049.52, 306602.42)"
671         ));
672     spatialFieldTypeDef.setAttributes(spatialFieldAttributes);
673     SchemaRequest.AddFieldType spatialFieldType = new SchemaRequest.AddFieldType(spatialFieldTypeDef);
674     spatialFieldType.process(solrClient);
675     solrClient.commit();
676   }
677 }