blob: 7722664f9bc12d1d4dd071d942a9e0f51ea09610 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.solr.search;
import java.text.ParseException;
import java.util.Arrays;
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
import org.apache.solr.SolrTestCaseJ4;
import org.apache.solr.common.SolrException;
import org.apache.solr.core.SolrCore;
import org.apache.solr.legacy.BBoxStrategy;
import org.apache.solr.schema.BBoxField;
import org.apache.solr.schema.IndexSchema;
import org.apache.solr.schema.SchemaField;
import org.apache.solr.util.SpatialUtils;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.locationtech.spatial4j.context.SpatialContext;
import org.locationtech.spatial4j.distance.DistanceUtils;
import org.locationtech.spatial4j.shape.Point;
import org.locationtech.spatial4j.shape.Rectangle;
/**
* Test Solr 4's new spatial capabilities from the new Lucene spatial module. Don't thoroughly test it here because
* Lucene spatial has its own tests. Some of these tests were ported from Solr 3 spatial tests.
*/
public class TestSolr4Spatial extends SolrTestCaseJ4 {
private final String fieldName;
private final boolean canCalcDistance;
public TestSolr4Spatial(String fieldName) {
this.fieldName = fieldName;
this.canCalcDistance = !fieldName.equals("llp_idx");
}
@ParametersFactory
public static Iterable<Object[]> parameters() {
return Arrays.asList(new Object[][]{
{"llp"}, {"llp_idx"}, {"llp_dv"}, {"srpt_geohash"}, {"srpt_quad"}, {"srpt_packedquad"}, {"stqpt_geohash"}, {"pointvector"}, {"bbox"}, {"pbbox"}, {"bbox_ndv"}
});
}
@BeforeClass
public static void beforeClass() throws Exception {
initCore("solrconfig-basic.xml", "schema-spatial.xml");
}
@Override
@Before
public void setUp() throws Exception {
super.setUp();
clearIndex();
assertU(commit());
}
@Test
public void testBadShapeParse400() {
assertQEx(null, req(
"fl", "id," + fieldName, "q", "*:*", "rows", "1000",
"fq", "{!field f=" + fieldName + "}Intersects(NonexistentShape(89.9,-130 d=9))"), 400);
assertQEx(null, req(
"fl", "id," + fieldName, "q", "*:*", "rows", "1000",
"fq", "{!field f=" + fieldName + "}Intersects(NonexistentShape(89.9,-130 d=9"), 400);//missing parens
assertQEx(null, req(
"fl", "id," + fieldName, "q", "*:*", "rows", "1000",
"fq", "{!field f=" + fieldName + "}Intersectssss"), 400);
ignoreException("NonexistentShape");
SolrException e = expectThrows(SolrException.class, "should throw exception on non existent shape",
() -> assertU(adoc("id", "-1", fieldName, "NonexistentShape"))
);
assertEquals(400, e.code());
unIgnoreException("NonexistentShape");
}
private void setupDocs() {
assertU(adoc("id", "1", fieldName, "32.7693246, -79.9289094"));
assertU(adoc("id", "2", fieldName, "33.7693246, -80.9289094"));
assertU(adoc("id", "3", fieldName, "-32.7693246, 50.9289094"));
assertU(adoc("id", "4", fieldName, "-50.7693246, 60.9289094"));
assertU(adoc("id", "5", fieldName, "0,0"));
assertU(adoc("id", "6", fieldName, "0.1,0.1"));
assertU(adoc("id", "7", fieldName, "-0.1,-0.1"));
assertU(adoc("id", "8", fieldName, "0,179.9"));
assertU(adoc("id", "9", fieldName, "0,-179.9"));
assertU(adoc("id", "10", fieldName, "89.9,50"));
assertU(adoc("id", "11", fieldName, "89.9,-130"));
assertU(adoc("id", "12", fieldName, "-89.9,50"));
assertU(adoc("id", "13", fieldName, "-89.9,-130"));
if (random().nextBoolean()) {
assertU(commit());
}
assertU(adoc("id", "99"));//blank
assertU(commit());
}
@Test
public void testIntersectFilter() throws Exception {
setupDocs();
//Try some edge cases
checkHits(fieldName, "1,1", 175, DistanceUtils.EARTH_MEAN_RADIUS_KM, 3, 5, 6, 7);
checkHits(fieldName, "0,179.8", 200, DistanceUtils.EARTH_MEAN_RADIUS_KM, 2, 8, 9);
checkHits(fieldName, "89.8, 50", 200, DistanceUtils.EARTH_MEAN_RADIUS_KM, 2, 10, 11);//this goes over the north pole
checkHits(fieldName, "-89.8, 50", 200, DistanceUtils.EARTH_MEAN_RADIUS_KM, 2, 12, 13);//this goes over the south pole
//try some normal cases
checkHits(fieldName, "33.0,-80.0", 300, DistanceUtils.EARTH_MEAN_RADIUS_KM, 2);
//large distance
checkHits(fieldName, "1,1", 5000, DistanceUtils.EARTH_MEAN_RADIUS_KM, 3, 5, 6, 7);
//Because we are generating a box based on the west/east longitudes and the south/north latitudes, which then
//translates to a range query, which is slightly more inclusive. Thus, even though 0.0 is 15.725 kms away,
//it will be included, b/c of the box calculation.
checkHits(fieldName, false, "0.1,0.1", 15, DistanceUtils.EARTH_MEAN_RADIUS_KM, 2, 5, 6);
//try some more
clearIndex();
assertU(adoc("id", "14", fieldName, "0,5"));
assertU(adoc("id", "15", fieldName, "0,15"));
//3000KM from 0,0, see http://www.movable-type.co.uk/scripts/latlong.html
assertU(adoc("id", "16", fieldName, "18.71111,19.79750"));
assertU(adoc("id", "17", fieldName, "44.043900,-95.436643"));
assertU(commit());
checkHits(fieldName, "0,0", 1000, DistanceUtils.EARTH_MEAN_RADIUS_KM, 1, 14);
checkHits(fieldName, "0,0", 2000, DistanceUtils.EARTH_MEAN_RADIUS_KM, 2, 14, 15);
checkHits(fieldName, false, "0,0", 3000, DistanceUtils.EARTH_MEAN_RADIUS_KM, 3, 14, 15, 16);
checkHits(fieldName, "0,0", 3001, DistanceUtils.EARTH_MEAN_RADIUS_KM, 3, 14, 15, 16);
checkHits(fieldName, "0,0", 3000.1, DistanceUtils.EARTH_MEAN_RADIUS_KM, 3, 14, 15, 16);
//really fine grained distance and reflects some of the vagaries of how we are calculating the box
checkHits(fieldName, "43.517030,-96.789603", 109, DistanceUtils.EARTH_MEAN_RADIUS_KM, 0);
//falls outside of the real distance, but inside the bounding box
checkHits(fieldName, true, "43.517030,-96.789603", 110, DistanceUtils.EARTH_MEAN_RADIUS_KM, 0);
checkHits(fieldName, false, "43.517030,-96.789603", 110, DistanceUtils.EARTH_MEAN_RADIUS_KM, 1, 17);
}
@Test
public void checkResultFormat() throws Exception {
//Check input and output format is the same
String IN = "89.9,-130";//lat,lon
String OUT = IN;//IDENTICAL!
assertU(adoc("id", "11", fieldName, IN));
assertU(commit());
assertQ(req(
"fl", "id," + fieldName, "q", "*:*", "rows", "1000",
"fq", "{!bbox sfield=" + fieldName + " pt=" + IN + " d=9}"),
"//result/doc/*[@name='" + fieldName + "']//text()='" + OUT + "'");
}
@Test
public void checkQueryEmptyIndex() throws ParseException {
checkHits(fieldName, "0,0", 100, DistanceUtils.EARTH_MEAN_RADIUS_KM, 0);//doesn't error
}
private void checkHits(String fieldName, String pt, double distKM, double sphereRadius, int count, int ... docIds) throws ParseException {
checkHits(fieldName, true, pt, distKM, sphereRadius, count, docIds);
}
private boolean isBBoxField(String fieldName) {
return fieldName.equalsIgnoreCase("bbox")
|| fieldName.equalsIgnoreCase("pbbox")
|| fieldName.equalsIgnoreCase("bbox_ndv");
}
private void checkHits(String fieldName, boolean exact, String ptStr, double distKM, double sphereRadius, int count, int ... docIds) throws ParseException {
if (exact && isBBoxField(fieldName)) {
return; // bbox field only supports rectangular query
}
String [] tests = new String[docIds != null && docIds.length > 0 ? docIds.length + 1 : 1];
//test for presence of required ids first
int i = 0;
if (docIds != null && docIds.length > 0) {
for (int docId : docIds) {
tests[i++] = "//result/doc/*[@name='id'][.='" + docId + "']";
}
}
//check total length last; maybe response includes ids it shouldn't. Nicer to check this last instead of first so
// that there may be a more specific detailed id to investigate.
tests[i++] = "*[count(//doc)=" + count + "]";
//Test using the Lucene spatial syntax
{
//never actually need the score but lets test
String score = randomScoreMode();
double distDEG = DistanceUtils.dist2Degrees(distKM, DistanceUtils.EARTH_MEAN_RADIUS_KM);
Point point = SpatialUtils.parsePoint(ptStr, SpatialContext.GEO);
String circleStr = "BUFFER(POINT(" + point.getX()+" "+point.getY()+")," + distDEG + ")";
String shapeStr;
if (exact) {
shapeStr = circleStr;
} else {//bbox
//the GEO is an assumption
SpatialContext ctx = SpatialContext.GEO;
Rectangle bbox = ctx.readShapeFromWkt(circleStr).getBoundingBox();
shapeStr = "ENVELOPE(" + bbox.getMinX() + ", " + bbox.getMaxX() +
", " + bbox.getMaxY() + ", " + bbox.getMinY() + ")";
}
//FYI default distErrPct=0.025 works with the tests in this file
assertQ(req(
"fl", "id", "q","*:*", "rows", "1000",
"fq", "{!field f=" + fieldName + (score==null?"":" score="+score)
+ "}Intersects(" + shapeStr + ")"),
tests);
}
//Test using geofilt
{
assertQ(req(
"fl", "id", "q", "*:*", "rows", "1000",
"fq", "{!" + (exact ? "geofilt" : "bbox") + " sfield=" + fieldName + " pt='" + ptStr + "' d=" + distKM + " sphere_radius=" + sphereRadius + "}"),
tests);
}
}
private String randomScoreMode() {
return canCalcDistance ? new String[]{null, "none","distance","recipDistance"}[random().nextInt(4)] : "none";
}
@Test
public void testRangeSyntax() {
setupDocs();
//match docId 1
int docId = 1;
int count = 1;
String score = randomScoreMode();//never actually need the score but lets test
assertQ(req(
"fl", "id", "q","*:*", "rows", "1000", // testing quotes in range too
"fq", "{! "+(score==null?"":" score="+score)+" df="+fieldName+"}[32,-80 TO \"33 , -79\"]"),//lower-left to upper-right
"//result/doc/*[@name='id'][.='" + docId + "']",
"*[count(//doc)=" + count + "]");
}
@Test
public void testSort() throws Exception {
assumeTrue("dist sorting not supported on field " + fieldName, canCalcDistance);
assertU(adoc("id", "100", fieldName, "1,2"));
assertU(adoc("id", "101", fieldName, "4,-1"));
if (random().nextBoolean()) {
assertU(commit()); // new segment
}
if (random().nextBoolean()) {
assertU(adoc("id", "999", fieldName, "70,70"));//far away from these queries; we filter it out
} else {
assertU(adoc("id", "999")); // no data
}
assertU(commit());
// geodist asc
assertJQ(req(
"q", radiusQuery(3, 4, 9, null, null),
"fl","id",
"sort","geodist() asc",
"sfield", fieldName, "pt", "3,4")
, 1e-3
, "/response/docs/[0]/id=='100'"
, "/response/docs/[1]/id=='101'"
);
// geodist desc (simply reverse the assertions)
assertJQ(req(
"q", radiusQuery(3, 4, 9, null, null),
"fl","id",
"sort","geodist() desc", // DESC
"sfield", fieldName, "pt", "3,4")
, 1e-3
, "/response/docs/[0]/id=='101'" // FLIPPED
, "/response/docs/[1]/id=='100'" // FLIPPED
);
//
// NOTE: the rest work via the score of the spatial query. Generally, you should use geodist() instead.
//
//test absence of score=distance means it doesn't score
assertJQ(req(
"q", radiusQuery(3, 4, 9, null, null),
"fl","id,score")
, 1e-9
, "/response/docs/[0]/score==1.0"
, "/response/docs/[1]/score==1.0"
);
//score by distance
assertJQ(req(
"q", radiusQuery(3, 4, 9, "distance", null),
"fl","id,score",
"sort","score asc")//want ascending due to increasing distance
, 1e-3
, "/response/docs/[0]/id=='100'"
, "/response/docs/[0]/score==2.827493"
, "/response/docs/[1]/id=='101'"
, "/response/docs/[1]/score==5.089807"
);
//score by recipDistance
assertJQ(req(
"q", radiusQuery(3, 4, 9, "recipDistance", null),
"fl","id,score",
"sort","score desc")//want descending
, 1e-3
, "/response/docs/[0]/id=='100'"
, "/response/docs/[0]/score==0.3099695"
, "/response/docs/[1]/id=='101'"
, "/response/docs/[1]/score==0.19970943"
);
//score by distance and don't filter
assertJQ(req(
//circle radius is small and shouldn't match either, but we disable filtering
"q", radiusQuery(3, 4, 0.000001, "distance", "false"),
"fl","id,score",
"sort","score asc")//want ascending due to increasing distance
, 1e-3
, "/response/docs/[0]/id=='100'"
, "/response/docs/[0]/score==2.827493"
, "/response/docs/[1]/id=='101'"
, "/response/docs/[1]/score==5.089807"
);
//query again with the query point closer to #101, and check the new ordering
assertJQ(req(
"q", radiusQuery(4, 0, 9, "distance", null),
"fl","id,score",
"sort","score asc")//want ascending due to increasing distance
, 1e-4
, "/response/docs/[0]/id=='101'"
, "/response/docs/[1]/id=='100'"
);
//use sort=query(...)
assertJQ(req(
"q","-id:999",//exclude that doc
"fl","id,score",
"sort","query($sortQuery) asc", //want ascending due to increasing distance
"sortQuery", radiusQuery(3, 4, 9, "distance", null))
, 1e-4
, "/response/docs/[0]/id=='100'"
, "/response/docs/[1]/id=='101'" );
//check reversed direction with query point closer to #101
assertJQ(req(
"q","-id:999",//exclude that doc
"fl","id,score",
"sort","query($sortQuery) asc", //want ascending due to increasing distance
"sortQuery", radiusQuery(4, 0, 9, "distance", null))
, 1e-4
, "/response/docs/[0]/id=='101'"
, "/response/docs/[1]/id=='100'" );
}
private String radiusQuery(double lat, double lon, double dDEG, String score, String filter) {
//Choose between the Solr/Geofilt syntax, and the Lucene spatial module syntax
if (isBBoxField(fieldName) || random().nextBoolean()) {
//we cheat for bbox strategy which doesn't do radius, only rect.
final String qparser = isBBoxField(fieldName) ? "bbox" : "geofilt";
return "{!" + qparser + " " +
"sfield=" + fieldName + " "
+ (score != null ? "score="+score : "") + " "
+ (filter != null ? "filter="+filter : "") + " "
+ "pt=" + lat + "," + lon + " d=" + (dDEG /* DistanceUtils.DEG_TO_KM*/) + "}";
} else {
return "{! "
+ (score != null ? "score="+score : "") + " "
+ (filter != null ? "filter="+filter : "") + " "
+ "}" + fieldName + ":\"Intersects(BUFFER(POINT(" + lon + " " + lat + ")," + dDEG + "))\"";
}
}
@Test
public void testSortMultiVal() throws Exception {
assumeTrue("dist sorting not supported on field " + fieldName, canCalcDistance);
assumeFalse("Multivalue not supported for this field",
fieldName.equals("pointvector") || isBBoxField(fieldName));
assertU(adoc("id", "100", fieldName, "1,2"));//1 point
assertU(adoc("id", "101", fieldName, "4,-1", fieldName, "3,5"));//2 points, 2nd is pretty close to query point
assertU(commit());
assertJQ(req(
"q", radiusQuery(3, 4, 9, "distance", null),
"fl","id,score",
"sort","score asc")//want ascending due to increasing distance
, 1e-4
, "/response/docs/[0]/id=='101'"
, "/response/docs/[0]/score==0.99862987"//dist to 3,5
);
}
@Test
public void testBadScoreParam() throws Exception {
assertQEx("expect friendly error message",
"none",
req(radiusQuery(0, 0, 0, "bogus", "false")),
SolrException.ErrorCode.BAD_REQUEST);
}
@Test
public void testSpatialConfig() throws Exception {
try (SolrCore core = h.getCoreInc()) {
IndexSchema schema = core.getLatestSchema();
// BBox Config
// Make sure the subfields are not stored
SchemaField sub = schema.getField("bbox"+BBoxStrategy.SUFFIX_MINX);
assertFalse(sub.stored());
// Make sure solr field type is also not stored
BBoxField bbox = (BBoxField)schema.getField("bbox").getType();
BBoxStrategy strategy = bbox.getStrategy("bbox");
assertFalse(strategy.getFieldType().stored());
}
}
}