Order Location searches using near in closest distance to center of the box (#4687)

* All working

* CHangelog tweak

* Address review comments
This commit is contained in:
James Agnew 2023-03-28 23:19:34 -04:00 committed by GitHub
parent 2e70b351a2
commit 05b3bff89f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 493 additions and 90 deletions

View File

@ -140,6 +140,7 @@ ca.uhn.fhir.jpa.search.SearchCoordinatorSvcImpl.invalidInclude=Invalid {0} param
ca.uhn.fhir.jpa.search.builder.predicate.NumberPredicateBuilder.invalidQuantityPrefix=Unable to handle quantity prefix "{0}" for value: {1}
ca.uhn.fhir.jpa.search.builder.predicate.NumberPredicateBuilder.invalidNumberPrefix=Unable to handle number prefix "{0}" for value: {1}
ca.uhn.fhir.jpa.search.builder.QueryStack.sourceParamDisabled=The _source parameter is disabled on this server
ca.uhn.fhir.jpa.search.builder.QueryStack.cantSortOnCoordParamWithoutValues=Can not sort on coordinate parameter "{0}" unless this parameter is also specified as a search parameter with a latitude/longitude value
ca.uhn.fhir.jpa.search.builder.predicate.TokenPredicateBuilder.invalidCodeMissingSystem=Invalid token specified for parameter {0} - No system specified: {1}|{2}
ca.uhn.fhir.jpa.search.builder.predicate.TokenPredicateBuilder.invalidCodeMissingCode=Invalid token specified for parameter {0} - No code specified: {1}|{2}

View File

@ -0,0 +1,6 @@
---
type: fix
issue: 4650
title: "When processing the Location:near Search Parameter, if a distance unit was supplied in the
parameter value it was ignored and the distance was always assumed to be km. This has been
corrected."

View File

@ -0,0 +1,7 @@
---
type: add
issue: 4650
title: "When performing a search using the Location:near search parameter, it is now possible
to include this parameter in a `_sort` expression as well. This will result in locations being
sorted by their proximity to the coordinates in the parameter value. Thanks to
Jens Kristian Villadsen for the suggestion and algorithm design!"

View File

@ -254,6 +254,14 @@ public class HapiFhirJpaMigrationTasks extends BaseMigrationTasks<VersionEnum> {
uriTable.dropIndex("20230324.6", "IDX_SP_URI_HASH_URI");
uriTable.dropIndex("20230324.7", "IDX_SP_URI_HASH_IDENTITY");
}
version.onTable("HFJ_SPIDX_COORDS")
.dropIndex("20230325.1", "IDX_SP_COORDS_HASH");
version.onTable("HFJ_SPIDX_COORDS")
.addIndex("20230325.2", "IDX_SP_COORDS_HASH_V2")
.unique(true)
.online(true)
.withColumns("HASH_IDENTITY", "SP_LATITUDE", "SP_LONGITUDE", "RES_ID", "PARTITION_ID");
}
protected void init640() {

View File

@ -46,6 +46,7 @@ import ca.uhn.fhir.jpa.search.builder.predicate.DatePredicateBuilder;
import ca.uhn.fhir.jpa.search.builder.predicate.ForcedIdPredicateBuilder;
import ca.uhn.fhir.jpa.search.builder.predicate.ICanMakeMissingParamPredicate;
import ca.uhn.fhir.jpa.search.builder.predicate.NumberPredicateBuilder;
import ca.uhn.fhir.jpa.search.builder.predicate.ParsedLocationParam;
import ca.uhn.fhir.jpa.search.builder.predicate.ResourceIdPredicateBuilder;
import ca.uhn.fhir.jpa.search.builder.predicate.ResourceLinkPredicateBuilder;
import ca.uhn.fhir.jpa.search.builder.predicate.ResourceTablePredicateBuilder;
@ -137,6 +138,7 @@ import static org.apache.commons.lang3.StringUtils.split;
public class QueryStack {
private static final Logger ourLog = LoggerFactory.getLogger(QueryStack.class);
public static final String LOCATION_POSITION = "Location.position";
private final FhirContext myFhirContext;
private final SearchQueryBuilder mySqlBuilder;
@ -146,6 +148,7 @@ public class QueryStack {
private final JpaStorageSettings myStorageSettings;
private final EnumSet<PredicateBuilderTypeEnum> myReusePredicateBuilderTypes;
private Map<PredicateBuilderCacheKey, BaseJoiningPredicateBuilder> myJoinMap;
private Map<String, BaseJoiningPredicateBuilder> myParamNameToPredicateBuilderMap;
// used for _offset queries with sort, should be removed once the fix is applied to the async path too.
private boolean myUseAggregate;
@ -174,6 +177,32 @@ public class QueryStack {
myReusePredicateBuilderTypes = theReusePredicateBuilderTypes;
}
public void addSortOnCoordsNear(String theParamName, boolean theAscending, SearchParameterMap theParams) {
boolean handled = false;
if (myParamNameToPredicateBuilderMap != null) {
BaseJoiningPredicateBuilder builder = myParamNameToPredicateBuilderMap.get(theParamName);
if (builder instanceof CoordsPredicateBuilder) {
CoordsPredicateBuilder coordsBuilder = (CoordsPredicateBuilder) builder;
List<List<IQueryParameterType>> params = theParams.get(theParamName);
if (params.size() > 0 && params.get(0).size() > 0) {
IQueryParameterType param = params.get(0).get(0);
ParsedLocationParam location = ParsedLocationParam.from(theParams, param);
double latitudeValue = location.getLatitudeValue();
double longitudeValue = location.getLongitudeValue();
mySqlBuilder.addSortCoordsNear(coordsBuilder, latitudeValue, longitudeValue, theAscending);
handled = true;
}
}
}
if (!handled) {
String msg = myFhirContext.getLocalizer().getMessageSanitized(QueryStack.class, "cantSortOnCoordParamWithoutValues", theParamName);
throw new InvalidRequestException(Msg.code(2307) + msg);
}
}
public void addSortOnDate(String theResourceName, String theParamName, boolean theAscending) {
BaseJoiningPredicateBuilder firstPredicateBuilder = mySqlBuilder.getOrCreateFirstPredicateBuilder();
DatePredicateBuilder datePredicateBuilder = mySqlBuilder.createDatePredicateBuilder();
@ -302,7 +331,7 @@ public class QueryStack {
chainedPredicateBuilder = datePredicateBuilder;
break;
/*
/*
* Note that many of the options below aren't implemented because they
* don't seem useful to me, but they could theoretically be implemented
* if someone ever needed them. I'm not sure why you'd want to do a chained
@ -409,6 +438,14 @@ public class QueryStack {
} else {
retVal = theFactoryMethod.get();
}
if (theType == PredicateBuilderTypeEnum.COORDS) {
if (myParamNameToPredicateBuilderMap == null) {
myParamNameToPredicateBuilderMap = new HashMap<>();
}
myParamNameToPredicateBuilderMap.put(theParamName, retVal);
}
return new PredicateBuilderCacheLookupResult<>(cacheHit, (T) retVal);
}
@ -1716,7 +1753,7 @@ public class QueryStack {
break;
case TOKEN:
for (List<? extends IQueryParameterType> nextAnd : theAndOrParams) {
if ("Location.position".equals(nextParamDef.getPath())) {
if (LOCATION_POSITION.equals(nextParamDef.getPath())) {
andPredicates.add(createPredicateCoords(theSourceJoinColumn, theResourceName, null, nextParamDef, nextAnd, theRequestPartitionId, mySqlBuilder));
} else {
andPredicates.add(createPredicateToken(theSourceJoinColumn, theResourceName, null, nextParamDef, nextAnd, null, theRequestPartitionId));
@ -1741,7 +1778,7 @@ public class QueryStack {
case HAS:
case SPECIAL:
for (List<? extends IQueryParameterType> nextAnd : theAndOrParams) {
if ("Location.position".equals(nextParamDef.getPath())) {
if (LOCATION_POSITION.equals(nextParamDef.getPath())) {
andPredicates.add(createPredicateCoords(theSourceJoinColumn, theResourceName, null, nextParamDef, nextAnd, theRequestPartitionId, mySqlBuilder));
}
}

View File

@ -125,6 +125,8 @@ import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import static ca.uhn.fhir.jpa.search.builder.QueryStack.LOCATION_POSITION;
import static org.apache.commons.lang3.StringUtils.countMatches;
import static org.apache.commons.lang3.StringUtils.defaultString;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
@ -644,7 +646,7 @@ public class SearchBuilder implements ISearchBuilder<JpaPid> {
if (sort != null) {
assert !theCountOnlyFlag;
createSort(queryStack3, sort);
createSort(queryStack3, sort, theParams);
}
@ -731,7 +733,7 @@ public class SearchBuilder implements ISearchBuilder<JpaPid> {
}
}
private void createSort(QueryStack theQueryStack, SortSpec theSort) {
private void createSort(QueryStack theQueryStack, SortSpec theSort, SearchParameterMap theParams) {
if (theSort == null || isBlank(theSort.getParamName())) {
return;
}
@ -864,6 +866,12 @@ public class SearchBuilder implements ISearchBuilder<JpaPid> {
break;
case SPECIAL:
if (LOCATION_POSITION.equals(param.getPath())) {
theQueryStack.addSortOnCoordsNear(paramName, ascending, theParams);
break;
}
throw new InvalidRequestException(Msg.code(2306) + "This server does not support _sort specifications of type " + param.getParamType() + " - Can't serve _sort=" + paramName);
case HAS:
default:
throw new InvalidRequestException(Msg.code(1197) + "This server does not support _sort specifications of type " + param.getParamType() + " - Can't serve _sort=" + paramName);
@ -872,7 +880,7 @@ public class SearchBuilder implements ISearchBuilder<JpaPid> {
}
// Recurse
createSort(theQueryStack, theSort.getChain());
createSort(theQueryStack, theSort.getChain(), theParams);
}

View File

@ -27,17 +27,12 @@ import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
import ca.uhn.fhir.jpa.util.CoordCalculator;
import ca.uhn.fhir.model.api.IQueryParameterType;
import ca.uhn.fhir.model.dstu2.resource.Location;
import ca.uhn.fhir.rest.param.QuantityParam;
import ca.uhn.fhir.rest.param.SpecialParam;
import ca.uhn.fhir.rest.param.TokenParam;
import com.healthmarketscience.sqlbuilder.BinaryCondition;
import com.healthmarketscience.sqlbuilder.ComboCondition;
import com.healthmarketscience.sqlbuilder.Condition;
import com.healthmarketscience.sqlbuilder.dbspec.basic.DbColumn;
import org.hibernate.search.engine.spatial.GeoBoundingBox;
import static org.apache.commons.lang3.StringUtils.isBlank;
public class CoordsPredicateBuilder extends BaseSearchParamPredicateBuilder {
private final DbColumn myColumnLatitude;
@ -53,6 +48,13 @@ public class CoordsPredicateBuilder extends BaseSearchParamPredicateBuilder {
myColumnLongitude = getTable().addColumn("SP_LONGITUDE");
}
public DbColumn getColumnLatitude() {
return myColumnLatitude;
}
public DbColumn getColumnLongitude() {
return myColumnLongitude;
}
public Condition createPredicateCoords(SearchParameterMap theParams,
IQueryParameterType theParam,
@ -60,47 +62,11 @@ public class CoordsPredicateBuilder extends BaseSearchParamPredicateBuilder {
RuntimeSearchParam theSearchParam,
CoordsPredicateBuilder theFrom,
RequestPartitionId theRequestPartitionId) {
String latitudeValue;
String longitudeValue;
double distanceKm = 0.0;
if (theParam instanceof TokenParam) { // DSTU3
TokenParam param = (TokenParam) theParam;
String value = param.getValue();
String[] parts = value.split(":");
if (parts.length != 2) {
throw new IllegalArgumentException(Msg.code(1228) + "Invalid position format '" + value + "'. Required format is 'latitude:longitude'");
}
latitudeValue = parts[0];
longitudeValue = parts[1];
if (isBlank(latitudeValue) || isBlank(longitudeValue)) {
throw new IllegalArgumentException(Msg.code(1229) + "Invalid position format '" + value + "'. Both latitude and longitude must be provided.");
}
QuantityParam distanceParam = theParams.getNearDistanceParam();
if (distanceParam != null) {
distanceKm = distanceParam.getValue().doubleValue();
}
} else if (theParam instanceof SpecialParam) { // R4
SpecialParam param = (SpecialParam) theParam;
String value = param.getValue();
String[] parts = value.split("\\|");
if (parts.length < 2 || parts.length > 4) {
throw new IllegalArgumentException(Msg.code(1230) + "Invalid position format '" + value + "'. Required format is 'latitude|longitude' or 'latitude|longitude|distance' or 'latitude|longitude|distance|units'");
}
latitudeValue = parts[0];
longitudeValue = parts[1];
if (isBlank(latitudeValue) || isBlank(longitudeValue)) {
throw new IllegalArgumentException(Msg.code(1231) + "Invalid position format '" + value + "'. Both latitude and longitude must be provided.");
}
if (parts.length >= 3) {
String distanceString = parts[2];
if (!isBlank(distanceString)) {
distanceKm = Double.parseDouble(distanceString);
}
}
} else {
throw new IllegalArgumentException(Msg.code(1232) + "Invalid position type: " + theParam.getClass());
}
ParsedLocationParam params = ParsedLocationParam.from(theParams, theParam);
double distanceKm = params.getDistanceKm();
double latitudeValue = params.getLatitudeValue();
double longitudeValue = params.getLongitudeValue();
Condition latitudePredicate;
Condition longitudePredicate;
@ -112,10 +78,7 @@ public class CoordsPredicateBuilder extends BaseSearchParamPredicateBuilder {
} else if (distanceKm > CoordCalculator.MAX_SUPPORTED_DISTANCE_KM) {
throw new IllegalArgumentException(Msg.code(1234) + "Invalid " + Location.SP_NEAR_DISTANCE + " parameter '" + distanceKm + "' must be <= " + CoordCalculator.MAX_SUPPORTED_DISTANCE_KM);
} else {
double latitudeDegrees = Double.parseDouble(latitudeValue);
double longitudeDegrees = Double.parseDouble(longitudeValue);
GeoBoundingBox box = CoordCalculator.getBox(latitudeDegrees, longitudeDegrees, distanceKm);
GeoBoundingBox box = CoordCalculator.getBox(latitudeValue, longitudeValue, distanceKm);
latitudePredicate = theFrom.createLatitudePredicateFromBox(box);
longitudePredicate = theFrom.createLongitudePredicateFromBox(box);
}
@ -124,11 +87,11 @@ public class CoordsPredicateBuilder extends BaseSearchParamPredicateBuilder {
}
public Condition createPredicateLatitudeExact(String theLatitudeValue) {
public Condition createPredicateLatitudeExact(double theLatitudeValue) {
return BinaryCondition.equalTo(myColumnLatitude, generatePlaceholder(theLatitudeValue));
}
public Condition createPredicateLongitudeExact(String theLongitudeValue) {
public Condition createPredicateLongitudeExact(double theLongitudeValue) {
return BinaryCondition.equalTo(myColumnLongitude, generatePlaceholder(theLongitudeValue));
}

View File

@ -0,0 +1,101 @@
package ca.uhn.fhir.jpa.search.builder.predicate;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.jpa.model.util.UcumServiceUtil;
import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
import ca.uhn.fhir.model.api.IQueryParameterType;
import ca.uhn.fhir.rest.param.QuantityParam;
import ca.uhn.fhir.rest.param.SpecialParam;
import ca.uhn.fhir.rest.param.TokenParam;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import ca.uhn.fhir.util.UrlUtil;
import org.apache.commons.lang3.StringUtils;
import static org.apache.commons.lang3.StringUtils.defaultString;
import static org.apache.commons.lang3.StringUtils.isBlank;
public class ParsedLocationParam {
private final double myLatitudeValue;
private final double myLongitudeValue;
private final double myDistanceKm;
private ParsedLocationParam(String theLatitudeValue, String theLongitudeValue, double theDistanceKm) {
myLatitudeValue = parseLatLonParameter(theLatitudeValue);
myLongitudeValue = parseLatLonParameter(theLongitudeValue);
myDistanceKm = theDistanceKm;
}
private static double parseLatLonParameter(String theValue) {
try {
return Double.parseDouble(defaultString(theValue));
} catch (NumberFormatException e) {
throw new InvalidRequestException(Msg.code(2308) + "Invalid lat/lon parameter value: " + UrlUtil.sanitizeUrlPart(theValue));
}
}
public double getLatitudeValue() {
return myLatitudeValue;
}
public double getLongitudeValue() {
return myLongitudeValue;
}
public double getDistanceKm() {
return myDistanceKm;
}
public static ParsedLocationParam from(SearchParameterMap theParams, IQueryParameterType theParam) {
String latitudeValue;
String longitudeValue;
double distanceKm = 0.0;
if (theParam instanceof TokenParam) { // DSTU3
TokenParam param = (TokenParam) theParam;
String value = param.getValue();
String[] parts = value.split(":");
if (parts.length != 2) {
throw new IllegalArgumentException(Msg.code(1228) + "Invalid position format '" + value + "'. Required format is 'latitude:longitude'");
}
latitudeValue = parts[0];
longitudeValue = parts[1];
if (isBlank(latitudeValue) || isBlank(longitudeValue)) {
throw new IllegalArgumentException(Msg.code(1229) + "Invalid position format '" + value + "'. Both latitude and longitude must be provided.");
}
QuantityParam distanceParam = theParams.getNearDistanceParam();
if (distanceParam != null) {
distanceKm = parseLatLonParameter(distanceParam.getValueAsString());
}
} else if (theParam instanceof SpecialParam) { // R4
SpecialParam param = (SpecialParam) theParam;
String value = param.getValue();
String[] parts = StringUtils.split(value, '|');
if (parts.length < 2 || parts.length > 4) {
throw new IllegalArgumentException(Msg.code(1230) + "Invalid position format '" + value + "'. Required format is 'latitude|longitude' or 'latitude|longitude|distance' or 'latitude|longitude|distance|units'");
}
latitudeValue = parts[0];
longitudeValue = parts[1];
if (isBlank(latitudeValue) || isBlank(longitudeValue)) {
throw new IllegalArgumentException(Msg.code(1231) + "Invalid position format '" + value + "'. Both latitude and longitude must be provided.");
}
if (parts.length >= 3) {
String distanceString = parts[2];
if (!isBlank(distanceString)) {
distanceKm = parseLatLonParameter(distanceString);
}
if (parts.length >= 4) {
String distanceUnits = parts[3];
distanceKm = UcumServiceUtil.convert(distanceKm, distanceUnits, "km");
}
}
} else {
throw new IllegalArgumentException(Msg.code(1232) + "Invalid position type: " + theParam.getClass());
}
return new ParsedLocationParam(latitudeValue, longitudeValue, distanceKm);
}
}

View File

@ -50,6 +50,7 @@ import ca.uhn.fhir.rest.param.DateRangeParam;
import ca.uhn.fhir.rest.param.ParamPrefixEnum;
import com.healthmarketscience.sqlbuilder.BinaryCondition;
import com.healthmarketscience.sqlbuilder.ComboCondition;
import com.healthmarketscience.sqlbuilder.ComboExpression;
import com.healthmarketscience.sqlbuilder.Condition;
import com.healthmarketscience.sqlbuilder.FunctionCall;
import com.healthmarketscience.sqlbuilder.InCondition;
@ -108,6 +109,7 @@ public class SearchQueryBuilder {
private boolean dialectIsMsSql;
private boolean dialectIsMySql;
private boolean myNeedResourceTableRoot;
private int myNextNearnessColumnId = 0;
/**
* Constructor
@ -722,6 +724,30 @@ public class SearchQueryBuilder {
return myHaveAtLeastOnePredicate;
}
public void addSortCoordsNear(CoordsPredicateBuilder theCoordsBuilder, double theLatitudeValue, double theLongitudeValue, boolean theAscending) {
FunctionCall absLatitude = new FunctionCall("ABS");
String latitudePlaceholder = generatePlaceholder(theLatitudeValue);
ComboExpression absLatitudeMiddle = new ComboExpression(ComboExpression.Op.SUBTRACT, theCoordsBuilder.getColumnLatitude(), latitudePlaceholder);
absLatitude = absLatitude.addCustomParams(absLatitudeMiddle);
FunctionCall absLongitude = new FunctionCall("ABS");
String longitudePlaceholder = generatePlaceholder(theLongitudeValue);
ComboExpression absLongitudeMiddle = new ComboExpression(ComboExpression.Op.SUBTRACT, theCoordsBuilder.getColumnLongitude(), longitudePlaceholder);
absLongitude = absLongitude.addCustomParams(absLongitudeMiddle);
ComboExpression sum = new ComboExpression(ComboExpression.Op.ADD, absLatitude, absLongitude);
String ordering;
if (theAscending) {
ordering = "";
} else {
ordering = " DESC";
}
String columnName = "MHD" + (myNextNearnessColumnId++);
mySelect.addAliasedColumn(sum, columnName);
mySelect.addCustomOrderings(columnName + ordering);
}
public void addSortString(DbColumn theColumnValueNormalized, boolean theAscending) {
addSortString(theColumnValueNormalized, theAscending, false);
}
@ -787,26 +813,26 @@ public class SearchQueryBuilder {
return sortColumnName;
}
public void addSortNumeric(DbColumn theTheColumnValueNormalized, boolean theTheAscending, OrderObject.NullOrder theNullOrder, boolean theUseAggregate) {
public void addSortNumeric(DbColumn theTheColumnValueNormalized, boolean theAscending, OrderObject.NullOrder theNullOrder, boolean theUseAggregate) {
if ((dialectIsMySql || dialectIsMsSql)) {
// MariaDB, MySQL and MSSQL do not support "NULLS FIRST" and "NULLS LAST" syntax.
// Null values are always treated as less than non-null values.
// As such special handling is required here.
String direction;
String sortColumnName = theTheColumnValueNormalized.getTable().getAlias() + "." + theTheColumnValueNormalized.getName();
if ((theTheAscending && theNullOrder == OrderObject.NullOrder.LAST)
|| (!theTheAscending && theNullOrder == OrderObject.NullOrder.FIRST)) {
if ((theAscending && theNullOrder == OrderObject.NullOrder.LAST)
|| (!theAscending && theNullOrder == OrderObject.NullOrder.FIRST)) {
// Negating the numeric column value and reversing the sort order will ensure that the rows appear
// in the correct order with nulls appearing first or last as needed.
direction = theTheAscending ? " DESC" : " ASC";
direction = theAscending ? " DESC" : " ASC";
sortColumnName = "-" + sortColumnName;
} else {
direction = theTheAscending ? " ASC" : " DESC";
direction = theAscending ? " ASC" : " DESC";
}
sortColumnName = formatColumnNameForAggregate(theTheAscending, theUseAggregate, sortColumnName);
sortColumnName = formatColumnNameForAggregate(theAscending, theUseAggregate, sortColumnName);
mySelect.addCustomOrderings(sortColumnName + direction);
} else {
addSort(theTheColumnValueNormalized, theTheAscending, theNullOrder, theUseAggregate);
addSort(theTheColumnValueNormalized, theAscending, theNullOrder, theUseAggregate);
}
}

View File

@ -43,7 +43,7 @@ import javax.persistence.Table;
@Embeddable
@Entity
@Table(name = "HFJ_SPIDX_COORDS", indexes = {
@Index(name = "IDX_SP_COORDS_HASH", columnList = "HASH_IDENTITY,SP_LATITUDE,SP_LONGITUDE"),
@Index(name = "IDX_SP_COORDS_HASH_V2", columnList = "HASH_IDENTITY,SP_LATITUDE,SP_LONGITUDE,RES_ID,PARTITION_ID"),
@Index(name = "IDX_SP_COORDS_UPDATED", columnList = "SP_UPDATED"),
@Index(name = "IDX_SP_COORDS_RESID", columnList = "RES_ID")
})

View File

@ -24,7 +24,9 @@ import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.rest.param.QuantityParam;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import org.fhir.ucum.Decimal;
import org.fhir.ucum.Pair;
import org.fhir.ucum.UcumEssenceService;
@ -172,4 +174,16 @@ public class UcumServiceUtil {
return null;
}
}
public static double convert(double theDistanceKm, String theSourceUnits, String theTargetUnits) {
init();
try {
Decimal distance = new Decimal(Double.toString(theDistanceKm));
Decimal output = myUcumEssenceService.convert(distance, theSourceUnits, theTargetUnits);
String decimal = output.asDecimal();
return Double.parseDouble(decimal);
} catch (UcumException e) {
throw new InvalidRequestException(Msg.code(2309) + e.getMessage());
}
}
}

View File

@ -1,15 +1,28 @@
package ca.uhn.fhir.jpa.provider.r4;
import ca.uhn.fhir.jpa.provider.BaseResourceProviderR4Test;
import ca.uhn.fhir.jpa.search.builder.QueryStack;
import ca.uhn.fhir.jpa.util.CoordCalculatorTestUtil;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Enumerations;
import org.hl7.fhir.r4.model.Location;
import org.hl7.fhir.r4.model.PractitionerRole;
import org.hl7.fhir.r4.model.SearchParameter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.List;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
public class ResourceProviderR4DistanceTest extends BaseResourceProviderR4Test {
@ -38,8 +51,6 @@ public class ResourceProviderR4DistanceTest extends BaseResourceProviderR4Test {
Bundle actual = myClient
.search()
.byUrl(myServerBase + "/" + url)
.encodedJson()
.prettyPrint()
.returnBundle(Bundle.class)
.execute();
@ -56,8 +67,6 @@ public class ResourceProviderR4DistanceTest extends BaseResourceProviderR4Test {
Bundle actual = myClient
.search()
.byUrl(myServerBase + "/" + url)
.encodedJson()
.prettyPrint()
.returnBundle(Bundle.class)
.execute();
myCaptureQueriesListener.logSelectQueries();
@ -85,8 +94,6 @@ public class ResourceProviderR4DistanceTest extends BaseResourceProviderR4Test {
Bundle actual = myClient
.search()
.byUrl(myServerBase + "/" + url)
.encodedJson()
.prettyPrint()
.returnBundle(Bundle.class)
.execute();
@ -102,12 +109,12 @@ public class ResourceProviderR4DistanceTest extends BaseResourceProviderR4Test {
Location.LocationPositionComponent position = new Location.LocationPositionComponent().setLatitude(latitude).setLongitude(longitude);
loc.setPosition(position);
myCaptureQueriesListener.clear();
IIdType locId = myLocationDao.create(loc).getId().toUnqualifiedVersionless();
IIdType locId = myLocationDao.create(loc, mySrd).getId().toUnqualifiedVersionless();
myCaptureQueriesListener.logInsertQueries();
PractitionerRole pr = new PractitionerRole();
pr.addLocation().setReference(locId.getValue());
IIdType prId = myPractitionerRoleDao.create(pr).getId().toUnqualifiedVersionless();
IIdType prId = myPractitionerRoleDao.create(pr, mySrd).getId().toUnqualifiedVersionless();
{ // In the box
double bigEnoughDistance = CoordCalculatorTestUtil.DISTANCE_KM_CHIN_TO_UHN * 2;
String url = "PractitionerRole?location." +
@ -118,8 +125,6 @@ public class ResourceProviderR4DistanceTest extends BaseResourceProviderR4Test {
Bundle actual = myClient
.search()
.byUrl(myServerBase + "/" + url)
.encodedJson()
.prettyPrint()
.returnBundle(Bundle.class)
.execute();
myCaptureQueriesListener.logSelectQueries();
@ -138,8 +143,6 @@ public class ResourceProviderR4DistanceTest extends BaseResourceProviderR4Test {
Bundle actual = myClient
.search()
.byUrl(myServerBase + "/" + url)
.encodedJson()
.prettyPrint()
.returnBundle(Bundle.class)
.execute();
myCaptureQueriesListener.logSelectQueries();
@ -147,4 +150,209 @@ public class ResourceProviderR4DistanceTest extends BaseResourceProviderR4Test {
assertEquals(0, actual.getEntry().size());
}
}
@Test
public void testNearSearchDistanceNotInKm() {
createFourCityLocations();
String url = "Location?" +
Location.SP_NEAR +
"=" +
CoordCalculatorTestUtil.LATITUDE_CHIN +
"|" +
CoordCalculatorTestUtil.LONGITUDE_CHIN +
"|" +
"300000" +
"|" +
"m";
Bundle actual = myClient
.search()
.byUrl(myServerBase + "/" + url)
.returnBundle(Bundle.class)
.execute();
List<String> ids = toUnqualifiedVersionlessIdValues(actual);
assertThat(ids.toString(), ids, contains(
"Location/toronto",
"Location/belleville",
"Location/kingston"
));
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
public void testSortNear(boolean theAscending) {
createFourCityLocations();
String url = "Location?" +
Location.SP_NEAR +
"=" +
CoordCalculatorTestUtil.LATITUDE_CHIN +
"|" +
CoordCalculatorTestUtil.LONGITUDE_CHIN +
"|" +
"300" +
"|" +
"km" +
"&_sort=" +
(theAscending ? "" : "-") +
Location.SP_NEAR;
logAllCoordsIndexes();
myCaptureQueriesListener.clear();
Bundle actual = myClient
.search()
.byUrl(myServerBase + "/" + url)
.returnBundle(Bundle.class)
.execute();
List<String> ids = toUnqualifiedVersionlessIdValues(actual);
myCaptureQueriesListener.logSelectQueries();
if (theAscending) {
assertThat(ids.toString(), ids, contains(
"Location/toronto",
"Location/belleville",
"Location/kingston"
));
} else {
assertThat(ids.toString(), ids, contains(
"Location/kingston",
"Location/belleville",
"Location/toronto"
));
}
}
/**
* This is kind of a contrived test where we create a second search parameter that
* also has the {@literal Location.position} path, so that we can make sure we don't crash with
* two nearness search parameters in the sort expression
*/
@Test
public void testSortNearWithTwoParameters() {
SearchParameter sp = new SearchParameter();
sp.setCode("near2");
sp.setName("near2");
sp.setStatus(Enumerations.PublicationStatus.ACTIVE);
sp.setExpression(QueryStack.LOCATION_POSITION);
sp.setType(Enumerations.SearchParamType.SPECIAL);
sp.addBase("Location");
mySearchParameterDao.create(sp, mySrd);
mySearchParamRegistry.forceRefresh();
createFourCityLocations();
String url = "Location?" +
"near2=" +
CoordCalculatorTestUtil.LATITUDE_CHIN + "|" +
CoordCalculatorTestUtil.LONGITUDE_CHIN + "|" +
"300" + "|" + "km" +
"&near=" +
CoordCalculatorTestUtil.LATITUDE_CHIN + "|" +
CoordCalculatorTestUtil.LONGITUDE_CHIN + "|" +
"300" + "|" + "km" +
"&_sort=near2,near";
logAllCoordsIndexes();
myCaptureQueriesListener.clear();
Bundle actual = myClient
.search()
.byUrl(myServerBase + "/" + url)
.returnBundle(Bundle.class)
.execute();
List<String> ids = toUnqualifiedVersionlessIdValues(actual);
myCaptureQueriesListener.logSelectQueries();
assertThat(ids.toString(), ids, contains(
"Location/toronto",
"Location/belleville",
"Location/kingston"
));
}
@Test
public void testSortNearWithNoNearParameter() {
String url = "Location?_sort=near";
try {
myClient
.search()
.byUrl(myServerBase + "/" + url)
.returnBundle(Bundle.class)
.execute();
fail();
} catch (InvalidRequestException e) {
assertThat(e.getMessage(), containsString("Can not sort on coordinate parameter \"near\" unless this parameter is also specified as a search parameter"));
}
}
private void createFourCityLocations() {
createLocation("Location/toronto", CoordCalculatorTestUtil.LATITUDE_TORONTO, CoordCalculatorTestUtil.LONGITUDE_TORONTO);
createLocation("Location/belleville", CoordCalculatorTestUtil.LATITUDE_BELLEVILLE, CoordCalculatorTestUtil.LONGITUDE_BELLEVILLE);
createLocation("Location/kingston", CoordCalculatorTestUtil.LATITUDE_KINGSTON, CoordCalculatorTestUtil.LONGITUDE_KINGSTON);
createLocation("Location/ottawa", CoordCalculatorTestUtil.LATITUDE_OTTAWA, CoordCalculatorTestUtil.LONGITUDE_OTTAWA);
}
@Test
public void testInvalid_SortWithNoParameter() {
String url = "Location?" +
"_sort=" +
Location.SP_NEAR;
try {
myClient
.search()
.byUrl(myServerBase + "/" + url)
.returnBundle(Bundle.class)
.execute();
fail();
} catch (InvalidRequestException e) {
assertThat(e.getMessage(), containsString("Can not sort on coordinate parameter \"near\" unless this parameter is also specified"));
}
}
@ParameterizedTest
@CsvSource({
"foo, -79.4170007, 300, km, Invalid lat/lon parameter value: foo",
"43.65513, foo, 300, km, Invalid lat/lon parameter value: foo",
"43.65513, -79.4170007, foo, km, Invalid lat/lon parameter value: foo",
"43.65513, -79.4170007, 300, foo, The unit 'foo' is unknown"
})
public void testInvalid_InvalidLatitude(String theLatitude, String theLongitude, String theDistance, String theDistanceUnits, String theExpectedErrorMessageContains) {
String url = "Location?" +
Location.SP_NEAR +
"=" +
theLatitude +
"|" +
theLongitude +
"|" +
theDistance +
"|" +
theDistanceUnits;
try {
myClient
.search()
.byUrl(myServerBase + "/" + url)
.returnBundle(Bundle.class)
.execute();
fail();
} catch (InvalidRequestException e) {
assertThat(e.getMessage(), containsString(theExpectedErrorMessageContains));
}
}
private void createLocation(String id, double latitude, double longitude) {
Location loc = new Location();
loc.setId(id);
loc.getPosition()
.setLatitude(latitude)
.setLongitude(longitude);
myLocationDao.update(loc, mySrd);
}
}

View File

@ -37,6 +37,7 @@ import ca.uhn.fhir.jpa.dao.JpaPersistedResourceValidationSupport;
import ca.uhn.fhir.jpa.dao.data.IForcedIdDao;
import ca.uhn.fhir.jpa.dao.data.IResourceHistoryTableDao;
import ca.uhn.fhir.jpa.dao.data.IResourceIndexedComboTokensNonUniqueDao;
import ca.uhn.fhir.jpa.dao.data.IResourceIndexedSearchParamCoordsDao;
import ca.uhn.fhir.jpa.dao.data.IResourceIndexedSearchParamDateDao;
import ca.uhn.fhir.jpa.dao.data.IResourceIndexedSearchParamNumberDao;
import ca.uhn.fhir.jpa.dao.data.IResourceIndexedSearchParamStringDao;
@ -60,6 +61,13 @@ import ca.uhn.fhir.jpa.entity.TermValueSetConcept;
import ca.uhn.fhir.jpa.entity.TermValueSetConceptDesignation;
import ca.uhn.fhir.jpa.model.entity.ForcedId;
import ca.uhn.fhir.jpa.model.entity.ResourceHistoryTable;
import ca.uhn.fhir.jpa.model.entity.ResourceIndexedComboTokenNonUnique;
import ca.uhn.fhir.jpa.model.entity.ResourceIndexedSearchParamCoords;
import ca.uhn.fhir.jpa.model.entity.ResourceIndexedSearchParamDate;
import ca.uhn.fhir.jpa.model.entity.ResourceIndexedSearchParamNumber;
import ca.uhn.fhir.jpa.model.entity.ResourceIndexedSearchParamToken;
import ca.uhn.fhir.jpa.model.entity.ResourceIndexedSearchParamUri;
import ca.uhn.fhir.jpa.model.entity.ResourceLink;
import ca.uhn.fhir.jpa.model.entity.ResourceTable;
import ca.uhn.fhir.jpa.model.util.JpaConstants;
import ca.uhn.fhir.jpa.partition.IPartitionLookupSvc;
@ -211,6 +219,8 @@ public abstract class BaseJpaTest extends BaseTest {
@Autowired
protected IResourceIndexedSearchParamDateDao myResourceIndexedSearchParamDateDao;
@Autowired
protected IResourceIndexedSearchParamCoordsDao myResourceIndexedSearchParamCoordsDao;
@Autowired
protected IResourceIndexedComboTokensNonUniqueDao myResourceIndexedComboTokensNonUniqueDao;
@Autowired(required = false)
protected IFulltextSearchSvc myFulltestSearchSvc;
@ -351,14 +361,14 @@ public abstract class BaseJpaTest extends BaseTest {
protected void logAllResourceLinks() {
runInTransaction(() -> {
ourLog.info("Resource Links:\n * {}", myResourceLinkDao.findAll().stream().map(t -> t.toString()).collect(Collectors.joining("\n * ")));
ourLog.info("Resource Links:\n * {}", myResourceLinkDao.findAll().stream().map(ResourceLink::toString).collect(Collectors.joining("\n * ")));
});
}
protected int logAllResources() {
return runInTransaction(() -> {
List<ResourceTable> resources = myResourceTableDao.findAll();
ourLog.info("Resources:\n * {}", resources.stream().map(t -> t.toString()).collect(Collectors.joining("\n * ")));
ourLog.info("Resources:\n * {}", resources.stream().map(ResourceTable::toString).collect(Collectors.joining("\n * ")));
return resources.size();
});
}
@ -366,7 +376,7 @@ public abstract class BaseJpaTest extends BaseTest {
protected int logAllConceptDesignations() {
return runInTransaction(() -> {
List<TermConceptDesignation> resources = myTermConceptDesignationDao.findAll();
ourLog.info("Concept Designations:\n * {}", resources.stream().map(t -> t.toString()).collect(Collectors.joining("\n * ")));
ourLog.info("Concept Designations:\n * {}", resources.stream().map(TermConceptDesignation::toString).collect(Collectors.joining("\n * ")));
return resources.size();
});
}
@ -374,7 +384,7 @@ public abstract class BaseJpaTest extends BaseTest {
protected int logAllConceptProperties() {
return runInTransaction(() -> {
List<TermConceptProperty> resources = myTermConceptPropertyDao.findAll();
ourLog.info("Concept Designations:\n * {}", resources.stream().map(t -> t.toString()).collect(Collectors.joining("\n * ")));
ourLog.info("Concept Designations:\n * {}", resources.stream().map(TermConceptProperty::toString).collect(Collectors.joining("\n * ")));
return resources.size();
});
}
@ -382,7 +392,7 @@ public abstract class BaseJpaTest extends BaseTest {
protected int logAllConcepts() {
return runInTransaction(() -> {
List<TermConcept> resources = myTermConceptDao.findAll();
ourLog.info("Concepts:\n * {}", resources.stream().map(t -> t.toString()).collect(Collectors.joining("\n * ")));
ourLog.info("Concepts:\n * {}", resources.stream().map(TermConcept::toString).collect(Collectors.joining("\n * ")));
return resources.size();
});
}
@ -390,7 +400,7 @@ public abstract class BaseJpaTest extends BaseTest {
protected int logAllValueSetConcepts() {
return runInTransaction(() -> {
List<TermValueSetConcept> resources = myTermValueSetConceptDao.findAll();
ourLog.info("Concepts:\n * {}", resources.stream().map(t -> t.toString()).collect(Collectors.joining("\n * ")));
ourLog.info("Concepts:\n * {}", resources.stream().map(TermValueSetConcept::toString).collect(Collectors.joining("\n * ")));
return resources.size();
});
}
@ -398,7 +408,7 @@ public abstract class BaseJpaTest extends BaseTest {
protected int logAllValueSets() {
return runInTransaction(() -> {
List<TermValueSet> valueSets = myTermValueSetDao.findAll();
ourLog.info("ValueSets:\n * {}", valueSets.stream().map(t -> t.toString()).collect(Collectors.joining("\n * ")));
ourLog.info("ValueSets:\n * {}", valueSets.stream().map(TermValueSet::toString).collect(Collectors.joining("\n * ")));
return valueSets.size();
});
}
@ -406,38 +416,44 @@ public abstract class BaseJpaTest extends BaseTest {
protected int logAllForcedIds() {
return runInTransaction(() -> {
List<ForcedId> forcedIds = myForcedIdDao.findAll();
ourLog.info("Resources:\n * {}", forcedIds.stream().map(t -> t.toString()).collect(Collectors.joining("\n * ")));
ourLog.info("Resources:\n * {}", forcedIds.stream().map(ForcedId::toString).collect(Collectors.joining("\n * ")));
return forcedIds.size();
});
}
protected void logAllDateIndexes() {
runInTransaction(() -> {
ourLog.info("Date indexes:\n * {}", myResourceIndexedSearchParamDateDao.findAll().stream().map(t -> t.toString()).collect(Collectors.joining("\n * ")));
ourLog.info("Date indexes:\n * {}", myResourceIndexedSearchParamDateDao.findAll().stream().map(ResourceIndexedSearchParamDate::toString).collect(Collectors.joining("\n * ")));
});
}
protected void logAllNonUniqueIndexes() {
runInTransaction(() -> {
ourLog.info("Non unique indexes:\n * {}", myResourceIndexedComboTokensNonUniqueDao.findAll().stream().map(t -> t.toString()).collect(Collectors.joining("\n * ")));
ourLog.info("Non unique indexes:\n * {}", myResourceIndexedComboTokensNonUniqueDao.findAll().stream().map(ResourceIndexedComboTokenNonUnique::toString).collect(Collectors.joining("\n * ")));
});
}
protected void logAllTokenIndexes() {
runInTransaction(() -> {
ourLog.info("Token indexes:\n * {}", myResourceIndexedSearchParamTokenDao.findAll().stream().map(t -> t.toString()).collect(Collectors.joining("\n * ")));
ourLog.info("Token indexes:\n * {}", myResourceIndexedSearchParamTokenDao.findAll().stream().map(ResourceIndexedSearchParamToken::toString).collect(Collectors.joining("\n * ")));
});
}
protected void logAllCoordsIndexes() {
runInTransaction(() -> {
ourLog.info("Coords indexes:\n * {}", myResourceIndexedSearchParamCoordsDao.findAll().stream().map(ResourceIndexedSearchParamCoords::toString).collect(Collectors.joining("\n * ")));
});
}
protected void logAllNumberIndexes() {
runInTransaction(() -> {
ourLog.info("Number indexes:\n * {}", myResourceIndexedSearchParamNumberDao.findAll().stream().map(t -> t.toString()).collect(Collectors.joining("\n * ")));
ourLog.info("Number indexes:\n * {}", myResourceIndexedSearchParamNumberDao.findAll().stream().map(ResourceIndexedSearchParamNumber::toString).collect(Collectors.joining("\n * ")));
});
}
protected void logAllUriIndexes() {
runInTransaction(() -> {
ourLog.info("URI indexes:\n * {}", myResourceIndexedSearchParamUriDao.findAll().stream().map(t -> t.toString()).collect(Collectors.joining("\n * ")));
ourLog.info("URI indexes:\n * {}", myResourceIndexedSearchParamUriDao.findAll().stream().map(ResourceIndexedSearchParamUri::toString).collect(Collectors.joining("\n * ")));
});
}

View File

@ -33,6 +33,14 @@ public final class CoordCalculatorTestUtil {
public static final double LONGITIDE_TAVEUNI = 179.889793;
// enough distance from point to cross anti-meridian
public static final double DISTANCE_TAVEUNI = 100.0;
public static final double LATITUDE_TORONTO = 43.741667;
public static final double LONGITUDE_TORONTO = -79.373333;
public static final double LATITUDE_BELLEVILLE = 44.166667;
public static final double LONGITUDE_BELLEVILLE = -77.383333;
public static final double LATITUDE_KINGSTON = 44.234722;
public static final double LONGITUDE_KINGSTON = -76.510833;
public static final double LATITUDE_OTTAWA = 45.424722;
public static final double LONGITUDE_OTTAWA = -75.695;
private CoordCalculatorTestUtil() {}
}