/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch 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.elasticsearch.search.suggest.completion.context;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DocValuesType;
import org.apache.lucene.index.IndexableField;
import org.elasticsearch.ElasticsearchParseException;
import org.elasticsearch.common.geo.GeoPoint;
import org.elasticsearch.common.geo.GeoUtils;
import org.elasticsearch.common.unit.DistanceUnit;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.common.xcontent.XContentParser.Token;
import org.elasticsearch.index.mapper.GeoPointFieldMapper;
import org.elasticsearch.index.mapper.FieldMapper;
import org.elasticsearch.index.mapper.ParseContext;
import org.elasticsearch.index.mapper.ParseContext.Document;
import org.elasticsearch.index.query.QueryParseContext;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import static org.elasticsearch.common.geo.GeoHashUtils.addNeighbors;
import static org.elasticsearch.common.geo.GeoHashUtils.stringEncode;
/**
* A {@link ContextMapping} that uses a geo location/area as a
* criteria.
* The suggestions can be boosted and/or filtered depending on
* whether it falls within an area, represented by a query geo hash
* with a specified precision
*
* {@link GeoQueryContext} defines the options for constructing
* a unit of query context for this context type
*/
public class GeoContextMapping extends ContextMapping<GeoQueryContext> {
public static final String FIELD_PRECISION = "precision";
public static final String FIELD_FIELDNAME = "path";
public static final int DEFAULT_PRECISION = 6;
static final String CONTEXT_VALUE = "context";
static final String CONTEXT_BOOST = "boost";
static final String CONTEXT_PRECISION = "precision";
static final String CONTEXT_NEIGHBOURS = "neighbours";
private final int precision;
private final String fieldName;
private GeoContextMapping(String name, String fieldName, int precision) {
super(Type.GEO, name);
this.precision = precision;
this.fieldName = fieldName;
}
public String getFieldName() {
return fieldName;
}
public int getPrecision() {
return precision;
}
protected static GeoContextMapping load(String name, Map<String, Object> config) {
final GeoContextMapping.Builder builder = new GeoContextMapping.Builder(name);
if (config != null) {
final Object configPrecision = config.get(FIELD_PRECISION);
if (configPrecision != null) {
if (configPrecision instanceof Integer) {
builder.precision((Integer) configPrecision);
} else if (configPrecision instanceof Long) {
builder.precision((Long) configPrecision);
} else if (configPrecision instanceof Double) {
builder.precision((Double) configPrecision);
} else if (configPrecision instanceof Float) {
builder.precision((Float) configPrecision);
} else {
builder.precision(configPrecision.toString());
}
config.remove(FIELD_PRECISION);
}
final Object fieldName = config.get(FIELD_FIELDNAME);
if (fieldName != null) {
builder.field(fieldName.toString());
config.remove(FIELD_FIELDNAME);
}
}
return builder.build();
}
@Override
protected XContentBuilder toInnerXContent(XContentBuilder builder, Params params) throws IOException {
builder.field(FIELD_PRECISION, precision);
if (fieldName != null) {
builder.field(FIELD_FIELDNAME, fieldName);
}
return builder;
}
/**
* Parse a set of {@link CharSequence} contexts at index-time.
* Acceptable formats:
*
* <ul>
* <li>Array: <pre>[<i><GEO POINT></i>, ..]</pre></li>
* <li>String/Object/Array: <pre>"GEO POINT"</pre></li>
* </ul>
*
* see {@link GeoUtils#parseGeoPoint(String, GeoPoint)} for GEO POINT
*/
@Override
public Set<CharSequence> parseContext(ParseContext parseContext, XContentParser parser) throws IOException, ElasticsearchParseException {
if (fieldName != null) {
FieldMapper mapper = parseContext.docMapper().mappers().getMapper(fieldName);
if (!(mapper instanceof GeoPointFieldMapper)) {
throw new ElasticsearchParseException("referenced field must be mapped to geo_point");
}
}
final Set<CharSequence> contexts = new HashSet<>();
Token token = parser.currentToken();
if (token == Token.START_ARRAY) {
token = parser.nextToken();
// Test if value is a single point in <code>[lon, lat]</code> format
if (token == Token.VALUE_NUMBER) {
double lon = parser.doubleValue();
if (parser.nextToken() == Token.VALUE_NUMBER) {
double lat = parser.doubleValue();
if (parser.nextToken() == Token.END_ARRAY) {
contexts.add(stringEncode(lon, lat, precision));
} else {
throw new ElasticsearchParseException("only two values [lon, lat] expected");
}
} else {
throw new ElasticsearchParseException("latitude must be a numeric value");
}
} else {
while (token != Token.END_ARRAY) {
GeoPoint point = GeoUtils.parseGeoPoint(parser);
contexts.add(stringEncode(point.getLon(), point.getLat(), precision));
token = parser.nextToken();
}
}
} else if (token == Token.VALUE_STRING) {
final String geoHash = parser.text();
final CharSequence truncatedGeoHash = geoHash.subSequence(0, Math.min(geoHash.length(), precision));
contexts.add(truncatedGeoHash);
} else {
// or a single location
GeoPoint point = GeoUtils.parseGeoPoint(parser);
contexts.add(stringEncode(point.getLon(), point.getLat(), precision));
}
return contexts;
}
@Override
public Set<CharSequence> parseContext(Document document) {
final Set<CharSequence> geohashes = new HashSet<>();
if (fieldName != null) {
IndexableField[] fields = document.getFields(fieldName);
GeoPoint spare = new GeoPoint();
if (fields.length == 0) {
IndexableField[] lonFields = document.getFields(fieldName + ".lon");
IndexableField[] latFields = document.getFields(fieldName + ".lat");
if (lonFields.length > 0 && latFields.length > 0) {
for (int i = 0; i < lonFields.length; i++) {
IndexableField lonField = lonFields[i];
IndexableField latField = latFields[i];
assert lonField.fieldType().docValuesType() == latField.fieldType().docValuesType();
// we write doc values fields differently: one field for all values, so we need to only care about indexed fields
if (lonField.fieldType().docValuesType() == DocValuesType.NONE) {
spare.reset(latField.numericValue().doubleValue(), lonField.numericValue().doubleValue());
geohashes.add(stringEncode(spare.getLon(), spare.getLat(), precision));
}
}
}
} else {
for (IndexableField field : fields) {
if (field instanceof StringField) {
spare.resetFromString(field.stringValue());
} else {
// todo return this to .stringValue() once LatLonPoint implements it
spare.resetFromIndexableField(field);
}
geohashes.add(spare.geohash());
}
}
}
Set<CharSequence> locations = new HashSet<>();
for (CharSequence geohash : geohashes) {
int precision = Math.min(this.precision, geohash.length());
CharSequence truncatedGeohash = geohash.subSequence(0, precision);
locations.add(truncatedGeohash);
}
return locations;
}
@Override
protected GeoQueryContext fromXContent(QueryParseContext context) throws IOException {
return GeoQueryContext.fromXContent(context);
}
/**
* Parse a list of {@link GeoQueryContext}
* using <code>parser</code>. A QueryContexts accepts one of the following forms:
*
* <ul>
* <li>Object: GeoQueryContext</li>
* <li>String: GeoQueryContext value with boost=1 precision=PRECISION neighbours=[PRECISION]</li>
* <li>Array: <pre>[GeoQueryContext, ..]</pre></li>
* </ul>
*
* A GeoQueryContext has one of the following forms:
* <ul>
* <li>Object:
* <ul>
* <li><pre>GEO POINT</pre></li>
* <li><pre>{"lat": <i><double></i>, "lon": <i><double></i>, "precision": <i><int></i>, "neighbours": <i><[int, ..]></i>}</pre></li>
* <li><pre>{"context": <i><string></i>, "boost": <i><int></i>, "precision": <i><int></i>, "neighbours": <i><[int, ..]></i>}</pre></li>
* <li><pre>{"context": <i><GEO POINT></i>, "boost": <i><int></i>, "precision": <i><int></i>, "neighbours": <i><[int, ..]></i>}</pre></li>
* </ul>
* <li>String: <pre>GEO POINT</pre></li>
* </ul>
* see {@link GeoUtils#parseGeoPoint(String, GeoPoint)} for GEO POINT
*/
@Override
public List<InternalQueryContext> toInternalQueryContexts(List<GeoQueryContext> queryContexts) {
List<InternalQueryContext> internalQueryContextList = new ArrayList<>();
for (GeoQueryContext queryContext : queryContexts) {
int minPrecision = Math.min(this.precision, queryContext.getPrecision());
GeoPoint point = queryContext.getGeoPoint();
final Collection<String> locations = new HashSet<>();
String geoHash = stringEncode(point.getLon(), point.getLat(), minPrecision);
locations.add(geoHash);
if (queryContext.getNeighbours().isEmpty() && geoHash.length() == this.precision) {
addNeighbors(geoHash, locations);
} else if (queryContext.getNeighbours().isEmpty() == false) {
queryContext.getNeighbours().stream()
.filter(neighbourPrecision -> neighbourPrecision < geoHash.length())
.forEach(neighbourPrecision -> {
String truncatedGeoHash = geoHash.substring(0, neighbourPrecision);
locations.add(truncatedGeoHash);
addNeighbors(truncatedGeoHash, locations);
});
}
internalQueryContextList.addAll(
locations.stream()
.map(location -> new InternalQueryContext(location, queryContext.getBoost(), location.length() < this.precision))
.collect(Collectors.toList()));
}
return internalQueryContextList;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
GeoContextMapping that = (GeoContextMapping) o;
if (precision != that.precision) return false;
return !(fieldName != null ? !fieldName.equals(that.fieldName) : that.fieldName != null);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), precision, fieldName);
}
public static class Builder extends ContextBuilder<GeoContextMapping> {
private int precision = DEFAULT_PRECISION;
private String fieldName = null;
public Builder(String name) {
super(name);
}
/**
* Set the precision use o make suggestions
*
* @param precision
* precision as distance with {@link DistanceUnit}. Default:
* meters
* @return this
*/
public Builder precision(String precision) {
return precision(DistanceUnit.parse(precision, DistanceUnit.METERS, DistanceUnit.METERS));
}
/**
* Set the precision use o make suggestions
*
* @param precision
* precision value
* @param unit
* {@link DistanceUnit} to use
* @return this
*/
public Builder precision(double precision, DistanceUnit unit) {
return precision(unit.toMeters(precision));
}
/**
* Set the precision use o make suggestions
*
* @param meters
* precision as distance in meters
* @return this
*/
public Builder precision(double meters) {
int level = GeoUtils.geoHashLevelsForPrecision(meters);
// Ceiling precision: we might return more results
if (GeoUtils.geoHashCellSize(level) < meters) {
level = Math.max(1, level - 1);
}
return precision(level);
}
/**
* Set the precision use o make suggestions
*
* @param level
* maximum length of geohashes
* @return this
*/
public Builder precision(int level) {
this.precision = level;
return this;
}
/**
* Set the name of the field containing a geolocation to use
* @param fieldName name of the field
* @return this
*/
public Builder field(String fieldName) {
this.fieldName = fieldName;
return this;
}
@Override
public GeoContextMapping build() {
return new GeoContextMapping(name, fieldName, precision);
}
}
}